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 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
@@ -0,0 +1,7 @@
1
+ """Package metadata."""
2
+
3
+ __version__ = "0.1.0"
4
+ __version_info__: tuple[int, int, int, str, int] = (0, 1, 0, "final", 0)
5
+ __author__ = "FluxWave Contributors"
6
+ __license__ = "MIT"
7
+ __url__ = "https://github.com/NobitaDeveloper/fluxwave"
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