fluxwave 0.1.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.
- fluxwave/__init__.py +268 -0
- fluxwave/__main__.py +70 -0
- fluxwave/_libraries.py +100 -0
- fluxwave/_meta.py +7 -0
- fluxwave/autoplay.py +162 -0
- fluxwave/backoff.py +51 -0
- fluxwave/cache.py +86 -0
- fluxwave/events.py +321 -0
- fluxwave/exceptions.py +148 -0
- fluxwave/filters.py +682 -0
- fluxwave/formatting.py +136 -0
- fluxwave/metrics.py +114 -0
- fluxwave/node.py +1389 -0
- fluxwave/persistence.py +381 -0
- fluxwave/player.py +1870 -0
- fluxwave/plugins.py +243 -0
- fluxwave/pool.py +398 -0
- fluxwave/py.typed +1 -0
- fluxwave/queue.py +576 -0
- fluxwave/rest.py +360 -0
- fluxwave/results.py +71 -0
- fluxwave/routeplanner.py +34 -0
- fluxwave/router.py +127 -0
- fluxwave/search.py +136 -0
- fluxwave/tracing.py +135 -0
- fluxwave/tracks.py +1021 -0
- fluxwave/types.py +7 -0
- fluxwave/versioning.py +136 -0
- fluxwave/watchdog.py +209 -0
- fluxwave/websocket.py +416 -0
- fluxwave-0.1.0.dist-info/METADATA +456 -0
- fluxwave-0.1.0.dist-info/RECORD +34 -0
- fluxwave-0.1.0.dist-info/WHEEL +4 -0
- fluxwave-0.1.0.dist-info/licenses/LICENSE +21 -0
fluxwave/__init__.py
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
"""FluxWave — modern async Lavalink wrapper for Python Discord bots."""
|
|
2
|
+
|
|
3
|
+
from ._libraries import library as discord_library
|
|
4
|
+
from ._meta import ( # noqa: F401
|
|
5
|
+
__author__,
|
|
6
|
+
__license__,
|
|
7
|
+
__url__,
|
|
8
|
+
__version__,
|
|
9
|
+
__version_info__,
|
|
10
|
+
)
|
|
11
|
+
from .autoplay import AutoPlayMode, RecommendationProvider, SearchRecommendationProvider
|
|
12
|
+
from .backoff import Backoff
|
|
13
|
+
from .cache import LFUCache
|
|
14
|
+
from .events import (
|
|
15
|
+
EventDispatcher,
|
|
16
|
+
EventPayload,
|
|
17
|
+
EventType,
|
|
18
|
+
ExtraEvent,
|
|
19
|
+
InactivePlayerEvent,
|
|
20
|
+
NodeClosedEvent,
|
|
21
|
+
NodeDisconnectedEvent,
|
|
22
|
+
NodeReadyEvent,
|
|
23
|
+
PlayerUpdateEvent,
|
|
24
|
+
PluginEvent,
|
|
25
|
+
RawWebSocketEvent,
|
|
26
|
+
StatsUpdateEvent,
|
|
27
|
+
TrackEndEvent,
|
|
28
|
+
TrackExceptionEvent,
|
|
29
|
+
TrackStartEvent,
|
|
30
|
+
TrackStuckEvent,
|
|
31
|
+
WebSocketClosedEvent,
|
|
32
|
+
close_listeners,
|
|
33
|
+
dispatch,
|
|
34
|
+
listen,
|
|
35
|
+
remove_listener,
|
|
36
|
+
)
|
|
37
|
+
from .exceptions import (
|
|
38
|
+
AuthorizationError,
|
|
39
|
+
ChannelTimeoutError,
|
|
40
|
+
FluxWaveError,
|
|
41
|
+
InvalidChannelError,
|
|
42
|
+
InvalidNodeError,
|
|
43
|
+
LavalinkError,
|
|
44
|
+
LavalinkErrorResponse,
|
|
45
|
+
MultipleDiscordLibrariesError,
|
|
46
|
+
NodeConnectionError,
|
|
47
|
+
NodeError,
|
|
48
|
+
NoDiscordLibraryError,
|
|
49
|
+
PlayerError,
|
|
50
|
+
QueueEmpty,
|
|
51
|
+
QueueError,
|
|
52
|
+
QueueFull,
|
|
53
|
+
TrackLoadError,
|
|
54
|
+
UnsupportedLavalinkVersion,
|
|
55
|
+
)
|
|
56
|
+
from .filters import (
|
|
57
|
+
ChannelMixFilter,
|
|
58
|
+
DistortionFilter,
|
|
59
|
+
EqualizerFilter,
|
|
60
|
+
Filters,
|
|
61
|
+
KaraokeFilter,
|
|
62
|
+
LowPassFilter,
|
|
63
|
+
PluginFiltersComponent,
|
|
64
|
+
RotationFilter,
|
|
65
|
+
TimescaleFilter,
|
|
66
|
+
TremoloFilter,
|
|
67
|
+
VibratoFilter,
|
|
68
|
+
)
|
|
69
|
+
from .formatting import QueuePage, format_duration, paginate_queue, progress_bar
|
|
70
|
+
from .metrics import WrapperMetrics, metrics
|
|
71
|
+
from .node import (
|
|
72
|
+
DEFAULT_REGION_GROUPS,
|
|
73
|
+
Node,
|
|
74
|
+
NodePool,
|
|
75
|
+
NodeSelectionStrategy,
|
|
76
|
+
NodeStatus,
|
|
77
|
+
calculate_shard_id,
|
|
78
|
+
parse_voice_region,
|
|
79
|
+
)
|
|
80
|
+
from .persistence import FileStore, MemoryStore, PersistedState, PersistenceBackend, capture
|
|
81
|
+
from .player import FluxPlayer, Player
|
|
82
|
+
from .plugins import (
|
|
83
|
+
LavaSrcClient,
|
|
84
|
+
LyricsClient,
|
|
85
|
+
PluginClient,
|
|
86
|
+
PluginHelpers,
|
|
87
|
+
SponsorBlockClient,
|
|
88
|
+
)
|
|
89
|
+
from .pool import Pool
|
|
90
|
+
from .queue import Queue, QueueMode
|
|
91
|
+
from .rest import PlayerUpdate, RestClient, SessionUpdate
|
|
92
|
+
from .results import EnqueueResult, LyricsLine, LyricsResult
|
|
93
|
+
from .routeplanner import RoutePlannerStatus
|
|
94
|
+
from .router import SourceRoute, SourceRouter
|
|
95
|
+
from .search import SearchQuery, SearchSource, build_search_query
|
|
96
|
+
from .tracing import EventTracer, TraceCategory, TraceEvent, tracer
|
|
97
|
+
from .tracks import (
|
|
98
|
+
Album,
|
|
99
|
+
Artist,
|
|
100
|
+
ExtrasNamespace,
|
|
101
|
+
LavalinkPlayer,
|
|
102
|
+
LoadError,
|
|
103
|
+
LoadResult,
|
|
104
|
+
LoadType,
|
|
105
|
+
NodeInfo,
|
|
106
|
+
PlayerState,
|
|
107
|
+
Playlist,
|
|
108
|
+
Stats,
|
|
109
|
+
Track,
|
|
110
|
+
TrackInfo,
|
|
111
|
+
VoiceState,
|
|
112
|
+
)
|
|
113
|
+
from .versioning import (
|
|
114
|
+
LavalinkVersion,
|
|
115
|
+
LavalinkVersionCheck,
|
|
116
|
+
LavalinkVersionWarning,
|
|
117
|
+
check_lavalink_version,
|
|
118
|
+
parse_lavalink_version,
|
|
119
|
+
)
|
|
120
|
+
from .watchdog import VoiceWatchdog, WatchdogConfig, WatchdogStats
|
|
121
|
+
from .websocket import WebSocketClient
|
|
122
|
+
|
|
123
|
+
__all__ = (
|
|
124
|
+
"DEFAULT_REGION_GROUPS",
|
|
125
|
+
# models
|
|
126
|
+
"Album",
|
|
127
|
+
"Artist",
|
|
128
|
+
# exceptions
|
|
129
|
+
"AuthorizationError",
|
|
130
|
+
# autoplay
|
|
131
|
+
"AutoPlayMode",
|
|
132
|
+
"Backoff",
|
|
133
|
+
# filters
|
|
134
|
+
"ChannelMixFilter",
|
|
135
|
+
"ChannelTimeoutError",
|
|
136
|
+
"DistortionFilter",
|
|
137
|
+
# results
|
|
138
|
+
"EnqueueResult",
|
|
139
|
+
"EqualizerFilter",
|
|
140
|
+
# events
|
|
141
|
+
"EventDispatcher",
|
|
142
|
+
"EventPayload",
|
|
143
|
+
# tracing
|
|
144
|
+
"EventTracer",
|
|
145
|
+
"EventType",
|
|
146
|
+
"ExtraEvent",
|
|
147
|
+
"ExtrasNamespace",
|
|
148
|
+
# persistence backends
|
|
149
|
+
"FileStore",
|
|
150
|
+
"Filters",
|
|
151
|
+
# player
|
|
152
|
+
"FluxPlayer",
|
|
153
|
+
"FluxWaveError",
|
|
154
|
+
"InactivePlayerEvent",
|
|
155
|
+
"InvalidChannelError",
|
|
156
|
+
"InvalidNodeError",
|
|
157
|
+
"KaraokeFilter",
|
|
158
|
+
# cache
|
|
159
|
+
"LFUCache",
|
|
160
|
+
# plugins
|
|
161
|
+
"LavaSrcClient",
|
|
162
|
+
"LavalinkError",
|
|
163
|
+
"LavalinkErrorResponse",
|
|
164
|
+
"LavalinkPlayer",
|
|
165
|
+
"LavalinkVersion",
|
|
166
|
+
"LavalinkVersionCheck",
|
|
167
|
+
"LavalinkVersionWarning",
|
|
168
|
+
"LoadError",
|
|
169
|
+
"LoadResult",
|
|
170
|
+
"LoadType",
|
|
171
|
+
"LowPassFilter",
|
|
172
|
+
"LyricsClient",
|
|
173
|
+
"LyricsLine",
|
|
174
|
+
"LyricsResult",
|
|
175
|
+
# persistence
|
|
176
|
+
"MemoryStore",
|
|
177
|
+
"MultipleDiscordLibrariesError",
|
|
178
|
+
"NoDiscordLibraryError",
|
|
179
|
+
# node
|
|
180
|
+
"Node",
|
|
181
|
+
"NodeClosedEvent",
|
|
182
|
+
"NodeConnectionError",
|
|
183
|
+
"NodeDisconnectedEvent",
|
|
184
|
+
"NodeError",
|
|
185
|
+
"NodeInfo",
|
|
186
|
+
"NodePool",
|
|
187
|
+
"NodeReadyEvent",
|
|
188
|
+
"NodeSelectionStrategy",
|
|
189
|
+
"NodeStatus",
|
|
190
|
+
"PersistedState",
|
|
191
|
+
"PersistenceBackend",
|
|
192
|
+
"Player",
|
|
193
|
+
"PlayerError",
|
|
194
|
+
"PlayerState",
|
|
195
|
+
# REST
|
|
196
|
+
"PlayerUpdate",
|
|
197
|
+
"PlayerUpdateEvent",
|
|
198
|
+
"Playlist",
|
|
199
|
+
"PluginClient",
|
|
200
|
+
"PluginEvent",
|
|
201
|
+
"PluginFiltersComponent",
|
|
202
|
+
"PluginHelpers",
|
|
203
|
+
# pool
|
|
204
|
+
"Pool",
|
|
205
|
+
# queue
|
|
206
|
+
"Queue",
|
|
207
|
+
"QueueEmpty",
|
|
208
|
+
"QueueError",
|
|
209
|
+
"QueueFull",
|
|
210
|
+
"QueueMode",
|
|
211
|
+
"QueuePage",
|
|
212
|
+
"RawWebSocketEvent",
|
|
213
|
+
"RecommendationProvider",
|
|
214
|
+
"RestClient",
|
|
215
|
+
"RotationFilter",
|
|
216
|
+
"RoutePlannerStatus",
|
|
217
|
+
# search
|
|
218
|
+
"SearchQuery",
|
|
219
|
+
"SearchRecommendationProvider",
|
|
220
|
+
"SearchSource",
|
|
221
|
+
"SessionUpdate",
|
|
222
|
+
# routing
|
|
223
|
+
"SourceRoute",
|
|
224
|
+
"SourceRouter",
|
|
225
|
+
"SponsorBlockClient",
|
|
226
|
+
"Stats",
|
|
227
|
+
"StatsUpdateEvent",
|
|
228
|
+
"TimescaleFilter",
|
|
229
|
+
"TraceCategory",
|
|
230
|
+
"TraceEvent",
|
|
231
|
+
"Track",
|
|
232
|
+
"TrackEndEvent",
|
|
233
|
+
"TrackExceptionEvent",
|
|
234
|
+
"TrackInfo",
|
|
235
|
+
"TrackLoadError",
|
|
236
|
+
"TrackStartEvent",
|
|
237
|
+
"TrackStuckEvent",
|
|
238
|
+
"TremoloFilter",
|
|
239
|
+
"UnsupportedLavalinkVersion",
|
|
240
|
+
"VibratoFilter",
|
|
241
|
+
"VoiceState",
|
|
242
|
+
# watchdog (voice resilience)
|
|
243
|
+
"VoiceWatchdog",
|
|
244
|
+
"WatchdogConfig",
|
|
245
|
+
"WatchdogStats",
|
|
246
|
+
"WebSocketClient",
|
|
247
|
+
"WebSocketClosedEvent",
|
|
248
|
+
# metrics
|
|
249
|
+
"WrapperMetrics",
|
|
250
|
+
# meta
|
|
251
|
+
"__version__",
|
|
252
|
+
"build_search_query",
|
|
253
|
+
"calculate_shard_id",
|
|
254
|
+
"capture",
|
|
255
|
+
"check_lavalink_version",
|
|
256
|
+
"close_listeners",
|
|
257
|
+
"discord_library",
|
|
258
|
+
"dispatch",
|
|
259
|
+
"format_duration",
|
|
260
|
+
"listen",
|
|
261
|
+
"metrics",
|
|
262
|
+
"paginate_queue",
|
|
263
|
+
"parse_lavalink_version",
|
|
264
|
+
"parse_voice_region",
|
|
265
|
+
"progress_bar",
|
|
266
|
+
"remove_listener",
|
|
267
|
+
"tracer",
|
|
268
|
+
)
|
fluxwave/__main__.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Command line helpers for FluxWave."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import platform
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
import fluxwave
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def main(argv: list[str] | None = None) -> int:
|
|
14
|
+
"""Run the FluxWave command line interface."""
|
|
15
|
+
|
|
16
|
+
parser = argparse.ArgumentParser(prog="fluxwave")
|
|
17
|
+
parser.add_argument(
|
|
18
|
+
"--version",
|
|
19
|
+
action="store_true",
|
|
20
|
+
help="Show FluxWave version and debug information.",
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
args = parser.parse_args(argv)
|
|
24
|
+
if args.version:
|
|
25
|
+
print(debug_info())
|
|
26
|
+
return 0
|
|
27
|
+
|
|
28
|
+
parser.print_help()
|
|
29
|
+
return 0
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def debug_info() -> str:
|
|
33
|
+
"""Return version, Python, platform, and Java diagnostic information."""
|
|
34
|
+
|
|
35
|
+
python_info = "\n - ".join(sys.version.splitlines())
|
|
36
|
+
java_info = "\n - ".join(_java_version().splitlines())
|
|
37
|
+
return f"""fluxwave: {fluxwave.__version__}
|
|
38
|
+
|
|
39
|
+
Python:
|
|
40
|
+
- {python_info}
|
|
41
|
+
System:
|
|
42
|
+
- {platform.platform()}
|
|
43
|
+
Java:
|
|
44
|
+
- {java_info}
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _java_version() -> str:
|
|
49
|
+
try:
|
|
50
|
+
completed = subprocess.run(
|
|
51
|
+
["java", "-version"],
|
|
52
|
+
check=False,
|
|
53
|
+
capture_output=True,
|
|
54
|
+
text=True,
|
|
55
|
+
)
|
|
56
|
+
except FileNotFoundError:
|
|
57
|
+
return "Java executable not found"
|
|
58
|
+
|
|
59
|
+
output = (completed.stderr or completed.stdout).strip()
|
|
60
|
+
if not output:
|
|
61
|
+
return "Version not found"
|
|
62
|
+
|
|
63
|
+
if completed.returncode != 0:
|
|
64
|
+
return f"Command failed with exit code {completed.returncode}: {output}"
|
|
65
|
+
|
|
66
|
+
return output
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
if __name__ == "__main__":
|
|
70
|
+
raise SystemExit(main())
|
fluxwave/_libraries.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Discord-library compatibility layer.
|
|
2
|
+
|
|
3
|
+
FluxWave works with any discord.py-compatible library: discord.py, py-cord,
|
|
4
|
+
nextcord, and disnake. This module detects which one is installed at import
|
|
5
|
+
time and re-exports the small set of names FluxWave needs, so the rest of the
|
|
6
|
+
package never imports a specific library directly.
|
|
7
|
+
|
|
8
|
+
discord.py and py-cord both provide the top-level ``discord`` module and share
|
|
9
|
+
the same public API, so they are detected as a single ``discord`` backend.
|
|
10
|
+
|
|
11
|
+
The re-exported names are resolved dynamically, so type checkers see them as
|
|
12
|
+
``Any`` here. ``player.py`` imports the concrete discord.py types under
|
|
13
|
+
``TYPE_CHECKING`` for accurate static analysis while binding these at runtime.
|
|
14
|
+
|
|
15
|
+
Environment variables
|
|
16
|
+
---------------------
|
|
17
|
+
``FLUXWAVE_DISCORD_LIBRARY``
|
|
18
|
+
Force a backend (``discord``, ``nextcord``, or ``disnake``) when more than
|
|
19
|
+
one is installed, or to override auto-detection.
|
|
20
|
+
``FLUXWAVE_IGNORE_LIBRARY_CHECK``
|
|
21
|
+
Skip the "no/multiple libraries" guard and fall back to the first match
|
|
22
|
+
(or ``discord``). Mainly useful for documentation builds.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import importlib
|
|
28
|
+
import importlib.util
|
|
29
|
+
from os import getenv
|
|
30
|
+
from typing import TYPE_CHECKING, Any
|
|
31
|
+
|
|
32
|
+
from .exceptions import MultipleDiscordLibrariesError, NoDiscordLibraryError
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING:
|
|
35
|
+
from types import ModuleType
|
|
36
|
+
|
|
37
|
+
__all__ = (
|
|
38
|
+
"Client",
|
|
39
|
+
"Connectable",
|
|
40
|
+
"Guild",
|
|
41
|
+
"Snowflake",
|
|
42
|
+
"VoiceProtocol",
|
|
43
|
+
"library",
|
|
44
|
+
"version_info",
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
SUPPORTED_LIBRARIES = ("discord", "nextcord", "disnake")
|
|
48
|
+
|
|
49
|
+
_ALIASES = {
|
|
50
|
+
"discord.py": "discord",
|
|
51
|
+
"discordpy": "discord",
|
|
52
|
+
"py-cord": "discord",
|
|
53
|
+
"pycord": "discord",
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _detect_library() -> str:
|
|
58
|
+
forced = getenv("FLUXWAVE_DISCORD_LIBRARY")
|
|
59
|
+
if forced:
|
|
60
|
+
choice = forced.strip().lower()
|
|
61
|
+
choice = _ALIASES.get(choice, choice)
|
|
62
|
+
if choice not in SUPPORTED_LIBRARIES:
|
|
63
|
+
msg = (
|
|
64
|
+
f"FLUXWAVE_DISCORD_LIBRARY={forced!r} is not supported; choose one of "
|
|
65
|
+
f"{', '.join(SUPPORTED_LIBRARIES)}."
|
|
66
|
+
)
|
|
67
|
+
raise NoDiscordLibraryError(msg)
|
|
68
|
+
if importlib.util.find_spec(choice) is None:
|
|
69
|
+
msg = f"FLUXWAVE_DISCORD_LIBRARY={forced!r} is set but {choice!r} is not installed."
|
|
70
|
+
raise NoDiscordLibraryError(msg)
|
|
71
|
+
return choice
|
|
72
|
+
|
|
73
|
+
found = [name for name in SUPPORTED_LIBRARIES if importlib.util.find_spec(name) is not None]
|
|
74
|
+
|
|
75
|
+
if getenv("FLUXWAVE_IGNORE_LIBRARY_CHECK"):
|
|
76
|
+
return found[0] if found else "discord"
|
|
77
|
+
if not found:
|
|
78
|
+
raise NoDiscordLibraryError
|
|
79
|
+
if len(found) > 1:
|
|
80
|
+
raise MultipleDiscordLibrariesError(found)
|
|
81
|
+
return found[0]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
library: str = _detect_library()
|
|
85
|
+
|
|
86
|
+
_module: ModuleType = importlib.import_module(library)
|
|
87
|
+
_abc: ModuleType = importlib.import_module(f"{library}.abc")
|
|
88
|
+
|
|
89
|
+
Client: Any = _module.Client
|
|
90
|
+
Guild: Any = _module.Guild
|
|
91
|
+
VoiceProtocol: Any = _module.VoiceProtocol
|
|
92
|
+
version_info: Any = _module.version_info
|
|
93
|
+
|
|
94
|
+
Connectable: Any = _abc.Connectable
|
|
95
|
+
Snowflake: Any = _abc.Snowflake
|
|
96
|
+
|
|
97
|
+
if getattr(version_info, "major", 0) < 2:
|
|
98
|
+
_found_major = getattr(version_info, "major", "?")
|
|
99
|
+
_msg = f"FluxWave requires {library} v2.0 or newer, but found {_found_major}.x."
|
|
100
|
+
raise NoDiscordLibraryError(_msg)
|
fluxwave/_meta.py
ADDED
fluxwave/autoplay.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Autoplay and recommendation primitives."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from enum import StrEnum
|
|
7
|
+
from typing import Protocol
|
|
8
|
+
|
|
9
|
+
from .search import Search, SearchSource
|
|
10
|
+
from .tracks import Track
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SearchNode(Protocol):
|
|
14
|
+
"""Node subset needed by the default recommendation provider."""
|
|
15
|
+
|
|
16
|
+
async def search(
|
|
17
|
+
self,
|
|
18
|
+
query: str,
|
|
19
|
+
*,
|
|
20
|
+
source: SearchSource | str | None = SearchSource.YOUTUBE,
|
|
21
|
+
use_cache: bool = True,
|
|
22
|
+
) -> Search:
|
|
23
|
+
"""Search for tracks."""
|
|
24
|
+
...
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AutoPlayMode(StrEnum):
|
|
28
|
+
"""How a player should use generated recommendations."""
|
|
29
|
+
|
|
30
|
+
DISABLED = "disabled"
|
|
31
|
+
PARTIAL = "partial"
|
|
32
|
+
ENABLED = "enabled"
|
|
33
|
+
disabled = "disabled"
|
|
34
|
+
partial = "partial"
|
|
35
|
+
enabled = "enabled"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class RecommendationProvider(Protocol):
|
|
39
|
+
"""Interface for source-specific recommendation providers."""
|
|
40
|
+
|
|
41
|
+
async def recommendations(
|
|
42
|
+
self,
|
|
43
|
+
seed: Track,
|
|
44
|
+
*,
|
|
45
|
+
limit: int = 5,
|
|
46
|
+
) -> list[Track]:
|
|
47
|
+
"""Return recommendation tracks for a seed track."""
|
|
48
|
+
...
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(slots=True)
|
|
52
|
+
class SearchRecommendationProvider:
|
|
53
|
+
"""Simple recommendation provider using Lavalink search sources."""
|
|
54
|
+
|
|
55
|
+
node: SearchNode
|
|
56
|
+
source: SearchSource | str | None = None
|
|
57
|
+
|
|
58
|
+
async def recommendations(
|
|
59
|
+
self,
|
|
60
|
+
seed: Track,
|
|
61
|
+
*,
|
|
62
|
+
limit: int = 5,
|
|
63
|
+
) -> list[Track]:
|
|
64
|
+
tracks: list[Track] = []
|
|
65
|
+
seen = {_track_key(seed)}
|
|
66
|
+
for query, source in _queries_for_track(seed, limit=limit, source=self.source):
|
|
67
|
+
result = await self.node.search(query, source=source)
|
|
68
|
+
if not isinstance(result, list):
|
|
69
|
+
continue
|
|
70
|
+
|
|
71
|
+
for track in result:
|
|
72
|
+
key = _track_key(track)
|
|
73
|
+
if key in seen:
|
|
74
|
+
continue
|
|
75
|
+
|
|
76
|
+
seen.add(key)
|
|
77
|
+
tracks.append(track.as_recommended())
|
|
78
|
+
if len(tracks) >= limit:
|
|
79
|
+
return _rank_recommendations(seed, tracks)
|
|
80
|
+
|
|
81
|
+
return _rank_recommendations(seed, tracks)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _source_for_track(track: Track) -> SearchSource | str | None:
|
|
85
|
+
if track.source == "spotify":
|
|
86
|
+
return None
|
|
87
|
+
if track.source == "youtube":
|
|
88
|
+
return None
|
|
89
|
+
if track.source == "soundcloud":
|
|
90
|
+
return SearchSource.SOUNDCLOUD
|
|
91
|
+
return SearchSource.YOUTUBE
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _query_for_track(track: Track, *, limit: int) -> str:
|
|
95
|
+
if track.source == "spotify":
|
|
96
|
+
return f"sprec:seed_tracks={track.identifier}&limit={limit}"
|
|
97
|
+
|
|
98
|
+
if track.source == "youtube":
|
|
99
|
+
return f"https://music.youtube.com/watch?v={track.identifier}&list=RD{track.identifier}"
|
|
100
|
+
|
|
101
|
+
album = track.plugin_info.get("album") or track.plugin_info.get("albumName")
|
|
102
|
+
if isinstance(album, str) and album:
|
|
103
|
+
return f"{track.author} {album}"
|
|
104
|
+
|
|
105
|
+
return f"{track.author} {track.title}"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _queries_for_track(
|
|
109
|
+
track: Track,
|
|
110
|
+
*,
|
|
111
|
+
limit: int,
|
|
112
|
+
source: SearchSource | str | None,
|
|
113
|
+
) -> list[tuple[str, SearchSource | str | None]]:
|
|
114
|
+
selected_source = source if source is not None else _source_for_track(track)
|
|
115
|
+
queries: list[tuple[str, SearchSource | str | None]] = [
|
|
116
|
+
(_query_for_track(track, limit=limit), selected_source)
|
|
117
|
+
]
|
|
118
|
+
|
|
119
|
+
title_author = f"{track.author} {track.title}"
|
|
120
|
+
if track.source in {"spotify", "youtube"}:
|
|
121
|
+
queries.append((title_author, SearchSource.YOUTUBE_MUSIC))
|
|
122
|
+
queries.append((title_author, SearchSource.YOUTUBE))
|
|
123
|
+
elif track.source == "soundcloud":
|
|
124
|
+
queries.append((title_author, SearchSource.SOUNDCLOUD))
|
|
125
|
+
queries.append((title_author, SearchSource.YOUTUBE))
|
|
126
|
+
else:
|
|
127
|
+
queries.append((title_author, SearchSource.YOUTUBE_MUSIC))
|
|
128
|
+
queries.append((title_author, SearchSource.SOUNDCLOUD))
|
|
129
|
+
|
|
130
|
+
deduped: list[tuple[str, SearchSource | str | None]] = []
|
|
131
|
+
seen: set[tuple[str, str | None]] = set()
|
|
132
|
+
for query, query_source in queries:
|
|
133
|
+
key = (query, str(query_source) if query_source is not None else None)
|
|
134
|
+
if key in seen:
|
|
135
|
+
continue
|
|
136
|
+
seen.add(key)
|
|
137
|
+
deduped.append((query, query_source))
|
|
138
|
+
|
|
139
|
+
return deduped
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _track_key(track: Track) -> str:
|
|
143
|
+
title = " ".join(track.title.casefold().split())
|
|
144
|
+
author = " ".join(track.author.casefold().split())
|
|
145
|
+
return f"{track.source or ''}:{track.identifier}:{title}:{author}"
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _rank_recommendations(seed: Track, tracks: list[Track]) -> list[Track]:
|
|
149
|
+
return sorted(tracks, key=lambda track: _recommendation_score(seed, track), reverse=True)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _recommendation_score(seed: Track, track: Track) -> float:
|
|
153
|
+
score = 1.0
|
|
154
|
+
if track.source == seed.source:
|
|
155
|
+
score += 0.3
|
|
156
|
+
if track.author.casefold() == seed.author.casefold():
|
|
157
|
+
score += 0.4
|
|
158
|
+
if seed.title.casefold() in track.title.casefold():
|
|
159
|
+
score -= 0.2
|
|
160
|
+
if track.recommended:
|
|
161
|
+
score += 0.1
|
|
162
|
+
return score
|
fluxwave/backoff.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Jittered reconnect backoff helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import random
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(slots=True)
|
|
11
|
+
class Backoff:
|
|
12
|
+
"""Randomized exponential backoff for reconnect loops."""
|
|
13
|
+
|
|
14
|
+
base: float = 0.5
|
|
15
|
+
maximum_time: float = 30.0
|
|
16
|
+
maximum_tries: int | None = None
|
|
17
|
+
jitter: bool = True
|
|
18
|
+
_retries: int = field(default=1, init=False)
|
|
19
|
+
_last_wait: float = field(default=0.0, init=False)
|
|
20
|
+
_random: Callable[[float, float], float] = field(
|
|
21
|
+
default_factory=lambda: random.Random().uniform,
|
|
22
|
+
init=False,
|
|
23
|
+
repr=False,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
def calculate(self) -> float:
|
|
27
|
+
"""Return the next delay and advance internal retry state."""
|
|
28
|
+
|
|
29
|
+
if self.base <= 0:
|
|
30
|
+
return 0.0
|
|
31
|
+
|
|
32
|
+
exponent = min(float(self._retries * self._retries), self.maximum_time)
|
|
33
|
+
ceiling = min(self.maximum_time, self.base * 2 * exponent)
|
|
34
|
+
wait = self._random(0.0, ceiling) if self.jitter else ceiling
|
|
35
|
+
|
|
36
|
+
if self.jitter and wait <= self._last_wait:
|
|
37
|
+
wait = min(self.maximum_time, self._last_wait * 2 if self._last_wait else ceiling)
|
|
38
|
+
|
|
39
|
+
self._last_wait = wait
|
|
40
|
+
if self.maximum_tries is not None and self._retries >= self.maximum_tries:
|
|
41
|
+
self.reset()
|
|
42
|
+
else:
|
|
43
|
+
self._retries += 1
|
|
44
|
+
|
|
45
|
+
return wait
|
|
46
|
+
|
|
47
|
+
def reset(self) -> None:
|
|
48
|
+
"""Reset retry counters."""
|
|
49
|
+
|
|
50
|
+
self._retries = 1
|
|
51
|
+
self._last_wait = 0.0
|