Skip to content

Commit 52cbbd9

Browse files
author
timhauke
committed
feat: Per-Guild Playback Profiles, Playlist Persistence, Advanced Autoplay, Cross-Fade & Gapless Playback got implemented completely successfully running.
1 parent d4019dc commit 52cbbd9

17 files changed

+1081
-12
lines changed

.env.example

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,22 @@ LAVALINK_HTTPS=false
1111
LAVALINK_NAME=main
1212
LAVALINK_REGION=eu
1313

14+
# Redis settings for playlist persistence
15+
# Redis persistence (optional but required for /playlist features)
16+
REDIS_HOST=45.84.196.19
17+
REDIS_PORT=32768
18+
REDIS_PASSWORD=
19+
REDIS_DB=0
20+
21+
# Autoplay tuning
22+
AUTOPLAY_DISCOVERY_LIMIT=10
23+
AUTOPLAY_RANDOM_PICK=true
24+
25+
# Crossfade controls
26+
CROSSFADE_ENABLED=true
27+
CROSSFADE_DURATION_MS=2500
28+
CROSSFADE_STEPS=12
29+
CROSSFADE_FLOOR_VOLUME=20
30+
1431
# Optional: specify an alternate configuration file
1532
# CONFIG_PATH=config.yml

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,9 @@ marimo/_static/
206206
marimo/_lsp/
207207
__marimo__/
208208

209+
# Runtime data
210+
data/
211+
209212

210213
#Ignore vscode AI rules
211214
.github\instructions\codacy.instructions.md

FURTHER_DEVELOPMENT.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<div align="center">
22
<h1 style="margin-bottom: 0.4rem;">VectoBeat Development Path</h1>
33
<p style="max-width: 680px;">
4-
A staged implementation checklist that visualises the next waves of improvements for the VectoBeat music bot.
4+
An implementation checklist that visualises the next waves of improvements for the VectoBeat music bot.
55
Toggle each milestone as releases go out to keep the roadmap transparent for contributors and stakeholders.
66
</p>
77
</div>
@@ -13,25 +13,25 @@
1313
<ul style="list-style: none; padding-left: 0;">
1414
<li>
1515
<label>
16-
<input type="checkbox" disabled />
16+
<input type="checkbox" checked disabled />
1717
<strong>Per-Guild Playback Profiles</strong> &mdash; Allow server owners to customise default volume, autoplay, and announcement styles via slash configuration.
1818
</label>
1919
</li>
2020
<li>
2121
<label>
22-
<input type="checkbox" disabled />
22+
<input type="checkbox" checked disabled />
2323
<strong>Playlist Persistence</strong> &mdash; Back playlists with persistent storage (PostgreSQL or Redis) plus import/export tooling.
2424
</label>
2525
</li>
2626
<li>
2727
<label>
28-
<input type="checkbox" disabled />
28+
<input type="checkbox" checked disabled />
2929
<strong>Advanced Autoplay</strong> &mdash; Recommend tracks automatically using guild listening history and source metadata.
3030
</label>
3131
</li>
3232
<li>
3333
<label>
34-
<input type="checkbox" disabled />
34+
<input type="checkbox" checked disabled />
3535
<strong>Cross-Fade &amp; Gapless Playback</strong> &mdash; Employ Lavalink filters to deliver seamless transitions between songs.
3636
</label>
3737
</li>

README.md

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
<div align="center">
22
<img src="https://raw.githubusercontent.com/VectoDE/VectoBeat/main/assets/images/logo.png" alt="VectoBeat Logo" width="280" />
3-
<h1 style="margin-top: 1rem; font-size: 3rem;">VectoBeat</h1>
43
<p style="font-size: 1.2rem;"><strong>Music Bot for Discord</strong></p>
54
<p style="max-width: 640px;">
65
VectoBeat delivers premium audio playback, meticulous observability, and a polished operations toolchain built on
@@ -79,7 +78,7 @@
7978
<tbody>
8079
<tr>
8180
<td><strong>Playback</strong></td>
82-
<td>Randomised search results, queue autosync, manual seek, replay, loop modes, volume control, auto-resume protection</td>
81+
<td>Randomised search results, queue autosync, manual seek, replay, loop modes, volume control, auto-resume protection, Redis-backed playlists, history-aware autoplay</td>
8382
</tr>
8483
<tr>
8584
<td><strong>Queueing</strong></td>
@@ -131,7 +130,7 @@
131130
<p align="center" style="font-size: 0.9rem;"><em>High-level interaction map between Discord, the VectoBeat runtime, Lavalink, and upstream media sources.</em></p>
132131

133132
<p align="center">
134-
<img src="assets/images/architecture.png" alt="VectoBeat System Architecture" width="720" />
133+
<img src="https://raw.githubusercontent.com/VectoDE/VectoBeat/main/assets/images/architecture.png" alt="VectoBeat System Architecture" width="720" />
135134
</p>
136135
<p style="font-size: 0.85rem; text-align: center;">Source Mermaid definition lives at <code>docs/system_architecture.mmd</code>; regenerate the asset with:</p>
137136

@@ -155,18 +154,32 @@ pip install -r requirements.txt</code></pre>
155154
<li>Record host, port, password, SSL and region; update <code>.env</code> and <code>config.yml</code>.</li>
156155
</ul>
157156

158-
<h3>3. Bot Configuration</h3>
157+
<h3>3. Redis (Playlists & Autoplay)</h3>
158+
<ul>
159+
<li>Prepare a Redis instance (single node is sufficient). Default config assumes <code>45.84.196.19:32768</code>.</li>
160+
<li>Set network rules so only the bot host can access Redis.</li>
161+
<li>Populate <code>REDIS_HOST</code>/<code>PORT</code>/<code>PASSWORD</code>/<code>DB</code> in <code>.env</code> or adjust <code>config.yml</code>.</li>
162+
<li>Tune history-driven autoplay via <code>AUTOPLAY_DISCOVERY_LIMIT</code> and <code>AUTOPLAY_RANDOM_PICK</code> (controls recommendation breadth and randomness).</li>
163+
</ul>
164+
165+
<h3>4. Bot Configuration</h3>
159166
<pre><code>.env
160167
DISCORD_TOKEN=YOUR_BOT_TOKEN
161168
LAVALINK_HOST=example-host
162169
LAVALINK_PORT=2333
163170
LAVALINK_PASSWORD=supersecret
164171
LAVALINK_HTTPS=false
165172
LAVALINK_REGION=eu
173+
REDIS_HOST=45.84.196.19
174+
REDIS_PORT=32768
175+
REDIS_PASSWORD=
176+
REDIS_DB=0
177+
AUTOPLAY_DISCOVERY_LIMIT=10
178+
AUTOPLAY_RANDOM_PICK=true
166179
</code></pre>
167180
<p>Optional: adjust <code>config.yml</code> for intents, shard count, embed theme.</p>
168181

169-
<h3>4. Launch</h3>
182+
<h3>5. Launch</h3>
170183
<pre><code>python -m src.main</code></pre>
171184
<p>The bot registers slash commands and reports node health on startup. Review the logs for authentication or connectivity hints.</p>
172185

@@ -230,6 +243,22 @@ LAVALINK_REGION=eu
230243
<tr><td><code>/voiceinfo</code></td><td>See above; duplicated for quick reference.</td></tr>
231244
<tr><td><code>/guildinfo</code></td><td>Guild demographics and configuration (requires Manage Guild).</td></tr>
232245
<tr><td><code>/permissions</code></td><td>Audit the bot’s channel permissions with checkmarks.</td></tr>
246+
<tr>
247+
<td rowspan="4"><strong>Configuration</strong></td>
248+
<td><code>/profile show</code></td>
249+
<td>Display the guild’s playback profile (default volume, autoplay, announcement style).</td>
250+
</tr>
251+
<tr><td><code>/profile set-volume</code></td><td>Persist a default volume applied whenever the bot joins voice.</td></tr>
252+
<tr><td><code>/profile set-autoplay</code></td><td>Toggle automatic queue refill when playback finishes.</td></tr>
253+
<tr><td><code>/profile set-announcement</code></td><td>Switch between rich embeds and minimal now-playing text.</td></tr>
254+
<tr>
255+
<td rowspan="4"><strong>Playlists</strong></td>
256+
<td><code>/playlist save</code></td>
257+
<td>Persist the current queue (optionally including the active track) to Redis. Requires Manage Server.</td>
258+
</tr>
259+
<tr><td><code>/playlist load</code></td><td>Load a saved playlist into the queue, optionally replacing current items.</td></tr>
260+
<tr><td><code>/playlist list</code></td><td>Enumerate playlists stored for the guild.</td></tr>
261+
<tr><td><code>/playlist delete</code></td><td>Remove a playlist from storage. Requires Manage Server.</td></tr>
233262
</tbody>
234263
</table>
235264

@@ -254,6 +283,7 @@ LAVALINK_REGION=eu
254283
<li>No audio? Verify Lavalink source managers (YouTube/Spotify) and logs for “requires login”.</li>
255284
<li>Bot silent? Confirm permissions with <code>/permissions</code> (connect, speak, view channel).</li>
256285
<li>Queue stuck? Use <code>/stop</code> followed by <code>/play</code> to reset the player.</li>
286+
<li>No autoplay? Check Redis reachability and <code>VectoBeat</code> logs for “Autoplay” warnings.</li>
257287
</ul>
258288
</td>
259289
</tr>
@@ -264,6 +294,7 @@ LAVALINK_REGION=eu
264294
<li>Capture <code>stdout</code> for VectoBeat; enable log shipping in production (ELK, CloudWatch, etc.).</li>
265295
<li>Monitor Lavalink metrics: player count, CPU, memory, frame deficit.</li>
266296
<li>Regularly patch yt-dlp for source compatibility.</li>
297+
<li>Monitor Redis availability (<code>INFO</code>/<code>PING</code>) if playlist persistence is enabled.</li>
267298
</ul>
268299

269300
<hr />

config.yml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,20 @@ limits:
4242
features:
4343
allow_loop: true
4444
allow_shuffle: true
45+
46+
autoplay:
47+
discovery_limit: 10
48+
random_pick: true
49+
50+
crossfade:
51+
enabled: true
52+
duration_ms: 2500
53+
fade_steps: 12
54+
floor_volume: 20
55+
56+
redis:
57+
# Redis playlist persistence backend
58+
host: "45.84.196.19"
59+
port: 32768
60+
password: ""
61+
db: 0

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@ PyYAML>=6.0.1
44
pydantic>=2.4.2
55
PyNaCl>=1.5.0
66
lavalink>=5.9.0
7+
redis>=5.0.1

src/commands/connection_commands.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,13 @@ async def connect(self, inter: discord.Interaction):
9898
player = self._find_player(self.bot, inter.guild.id)
9999
if player:
100100
player.text_channel_id = getattr(inter.channel, "id", None)
101+
manager = getattr(self.bot, "profile_manager", None)
102+
if manager:
103+
profile = manager.get(inter.guild.id)
104+
player.store("autoplay_enabled", profile.autoplay)
105+
player.store("announcement_style", profile.announcement_style)
106+
if player.volume != profile.default_volume:
107+
await player.set_volume(profile.default_volume)
101108

102109
embed = factory.success("Connected", f"Joined voice channel:\n{self._channel_info(channel)}")
103110
embed.add_field(name="Permissions", value=summary, inline=False)

src/commands/music_controls.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,13 @@ async def _player(self, inter: discord.Interaction) -> Optional[lavalink.Default
196196
await asyncio.sleep(0.1)
197197

198198
player.text_channel_id = getattr(inter.channel, "id", None)
199+
manager = getattr(self.bot, "profile_manager", None)
200+
if manager:
201+
profile = manager.get(inter.guild.id)
202+
player.store("autoplay_enabled", profile.autoplay)
203+
player.store("announcement_style", profile.announcement_style)
204+
if player.volume != profile.default_volume:
205+
await player.set_volume(profile.default_volume)
199206
return player
200207

201208
# ------------------------------------------------------------------ commands

src/commands/profile_commands.py

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
"""Slash commands for managing per-guild playback profiles."""
2+
3+
from __future__ import annotations
4+
5+
from typing import Optional
6+
7+
import discord
8+
from discord import app_commands
9+
from discord.ext import commands
10+
11+
from src.services.profile_service import GuildProfileManager
12+
from src.utils.embeds import EmbedFactory
13+
14+
15+
def _manager(bot: commands.Bot) -> GuildProfileManager:
16+
manager = getattr(bot, "profile_manager", None)
17+
if not manager:
18+
raise RuntimeError("GuildProfileManager not initialised on bot.")
19+
return manager
20+
21+
22+
class ProfileCommands(commands.Cog):
23+
"""Expose guild-level configuration toggles for playback behaviour."""
24+
25+
def __init__(self, bot: commands.Bot):
26+
self.bot = bot
27+
28+
profile = app_commands.Group(
29+
name="profile",
30+
description="Inspect and configure the guild playback profile.",
31+
guild_only=True,
32+
)
33+
34+
# ------------------------------------------------------------------ helpers
35+
@staticmethod
36+
def _ensure_manage_guild(inter: discord.Interaction) -> Optional[str]:
37+
"""Verify the invoker has manage_guild permissions."""
38+
if not inter.guild:
39+
return "This command can only be used inside a guild."
40+
member = inter.guild.get_member(inter.user.id) if isinstance(inter.user, discord.User) else inter.user
41+
if not isinstance(member, discord.Member):
42+
return "Unable to resolve invoking member."
43+
if not member.guild_permissions.manage_guild:
44+
return "You require the `Manage Server` permission to modify playback profiles."
45+
return None
46+
47+
@staticmethod
48+
def _profile_embed(inter: discord.Interaction, profile) -> discord.Embed:
49+
"""Build a concise embed representing the guild profile."""
50+
factory = EmbedFactory(inter.guild.id if inter.guild else None)
51+
embed = factory.primary("Playback Profile")
52+
embed.add_field(name="Default Volume", value=f"`{profile.default_volume}%`", inline=True)
53+
embed.add_field(name="Autoplay", value="✅ Enabled" if profile.autoplay else "❌ Disabled", inline=True)
54+
embed.add_field(name="Announcement Style", value=f"`{profile.announcement_style}`", inline=True)
55+
embed.set_footer(text="Use /profile commands to adjust these defaults.")
56+
return embed
57+
58+
# ------------------------------------------------------------------ slash commands
59+
@profile.command(name="show", description="Display the current playback profile for this guild.")
60+
async def show(self, inter: discord.Interaction):
61+
profile = _manager(self.bot).get(inter.guild.id) # type: ignore[union-attr]
62+
await inter.response.send_message(embed=self._profile_embed(inter, profile), ephemeral=True)
63+
64+
@profile.command(name="set-volume", description="Set the default playback volume for this guild.")
65+
@app_commands.describe(level="Volume percent to apply automatically (0-200).")
66+
async def set_volume(self, inter: discord.Interaction, level: app_commands.Range[int, 0, 200]):
67+
if (error := self._ensure_manage_guild(inter)) is not None:
68+
return await inter.response.send_message(error, ephemeral=True)
69+
manager = _manager(self.bot)
70+
profile = manager.update(inter.guild.id, volume=level) # type: ignore[union-attr]
71+
72+
player = self.bot.lavalink.player_manager.get(inter.guild.id) # type: ignore[union-attr]
73+
if player:
74+
await player.set_volume(profile.default_volume)
75+
76+
await inter.response.send_message(
77+
embed=self._profile_embed(inter, profile),
78+
ephemeral=True,
79+
)
80+
81+
@profile.command(name="set-autoplay", description="Enable or disable autoplay when the queue finishes.")
82+
async def set_autoplay(self, inter: discord.Interaction, enabled: bool):
83+
if (error := self._ensure_manage_guild(inter)) is not None:
84+
return await inter.response.send_message(error, ephemeral=True)
85+
manager = _manager(self.bot)
86+
profile = manager.update(inter.guild.id, autoplay=enabled) # type: ignore[union-attr]
87+
88+
player = self.bot.lavalink.player_manager.get(inter.guild.id) # type: ignore[union-attr]
89+
if player:
90+
player.store("autoplay_enabled", profile.autoplay)
91+
92+
await inter.response.send_message(
93+
embed=self._profile_embed(inter, profile),
94+
ephemeral=True,
95+
)
96+
97+
@profile.command(name="set-announcement", description="Choose how now-playing messages are displayed.")
98+
@app_commands.describe(style="Select between rich embeds or minimal text notifications.")
99+
@app_commands.choices(
100+
style=[
101+
app_commands.Choice(name="Rich Embed", value="rich"),
102+
app_commands.Choice(name="Minimal Text", value="minimal"),
103+
]
104+
)
105+
async def set_announcement(self, inter: discord.Interaction, style: app_commands.Choice[str]):
106+
if (error := self._ensure_manage_guild(inter)) is not None:
107+
return await inter.response.send_message(error, ephemeral=True)
108+
manager = _manager(self.bot)
109+
profile = manager.update(inter.guild.id, announcement_style=style.value) # type: ignore[union-attr]
110+
111+
player = self.bot.lavalink.player_manager.get(inter.guild.id) # type: ignore[union-attr]
112+
if player:
113+
player.store("announcement_style", profile.announcement_style)
114+
115+
await inter.response.send_message(
116+
embed=self._profile_embed(inter, profile),
117+
ephemeral=True,
118+
)
119+
120+
121+
async def setup(bot: commands.Bot):
122+
await bot.add_cog(ProfileCommands(bot))

0 commit comments

Comments
 (0)