Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ A fully-featured Discord music bot built with Python. Supports YouTube and Sound
- 📋 **Queue system** — Full queue management with shuffle, loop, and history
- 🎛️ **Audio effects** — Bass boost, nightcore, vaporwave, 8D audio, echo, and more
- 🎤 **Live lyrics** — One message that follows the song line by line, karaoke-style
- 🎛️ **Buttons** — Pause, skip, previous, stop, loop, shuffle, queue and lyrics right under Now Playing
- ⚡ **Slash commands** — Every command also works as `/command`, with search suggestions in `/play`
- 🔊 **Volume control** — Per-server volume adjustment
- 🔁 **Loop modes** — Loop a single track or the entire queue
- ▶️ **Autoplay** — Automatically queue related tracks when the queue ends
Expand All @@ -20,6 +22,14 @@ A fully-featured Discord music bot built with Python. Supports YouTube and Sound

## 📋 Commands

Every command below also works as a slash command: `/play`, `/skip`, `/effect`…
Aliases (`!p`, `!q`, `!bass`…) are `!`-only.

Each **Now Playing** message carries buttons for anyone in the bot's voice
channel: ⏮ ⏯ ⏭ ⏹ on the first row, 🔁 (cycle loop) 🔀 (shuffle) 📜 (queue,
shown only to you) 🎤 (live lyrics) on the second. When the next song starts,
the previous message's buttons are disabled.

### ▶️ Playback

| Command | Description |
Expand Down Expand Up @@ -58,7 +68,7 @@ A fully-featured Discord music bot built with Python. Supports YouTube and Sound
| `!8d` | Apply 8D audio (use headphones!) |
| `!karaoke` | Remove center vocals |
| `!reset` | Remove all audio effects |
| `!effect` | Show which effect is currently active |
| `!effect [name]` | Show the active effect, or apply one by name (`/effect` lists them) |
| `!effects` | List all available effects |

### 🎤 Extras
Expand Down
28 changes: 28 additions & 0 deletions cogs/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import logging

from discord.ext import commands

from utils.context import GuildContext
from utils.embeds import success_embed

log = logging.getLogger("loopify.admin")


class Admin(commands.Cog, name="Admin"):
def __init__(self, bot: commands.Bot) -> None:
self.bot = bot

# Not run at startup: Discord rate-limits syncing, and the bot restarts on
# its own (systemd, network loss). Run it once after a deploy that adds,
# removes or changes a command.
@commands.command(hidden=True)
@commands.is_owner()
async def sync(self, ctx: GuildContext) -> None:
"""Register the slash commands with Discord."""
synced = await self.bot.tree.sync()
log.info("Synced %d slash commands", len(synced))
await ctx.send(embed=success_embed(f"Synced **{len(synced)}** slash commands."))


async def setup(bot: commands.Bot) -> None:
await bot.add_cog(Admin(bot))
48 changes: 34 additions & 14 deletions cogs/effects.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import time
from dataclasses import dataclass
from typing import Optional

import discord
from discord import app_commands
from discord.ext import commands

from config import COMMAND_PREFIX
from utils.checks import same_voice_channel
from utils.context import GuildContext
from utils.checks import in_same_voice_channel, same_voice_channel
from utils.context import GuildContext, hybrid_command
from utils.embeds import BLURPLE, error_embed, success_embed
from utils.player import players

Expand Down Expand Up @@ -75,13 +77,13 @@ async def _switch_to(self, ctx: GuildContext, name: str | None, filter_str: str,
if self._throttled(ctx):
await ctx.send(embed=error_embed(
f"Easy — wait {_EFFECT_COOLDOWN:.0f}s between effect changes."
))
), ephemeral=True)
return
player = players.get(ctx.guild.id)
if player and player.apply_effect(name, filter_str, rate):
await ctx.send(embed=success_embed(label))
else:
await ctx.send(embed=error_embed("Nothing is playing."))
await ctx.send(embed=error_embed("Nothing is playing."), ephemeral=True)

@commands.command(name=_EFFECT_NAMES[0], aliases=_EFFECT_NAMES[1:],
help=f"Apply an audio effect. Use {COMMAND_PREFIX}effects to see "
Expand All @@ -93,27 +95,45 @@ async def apply_effect(self, ctx: GuildContext) -> None:
effect = EFFECTS[name]
await self._switch_to(ctx, name, effect.filter, effect.label, effect.rate)

@commands.command(name="reset", aliases=["fxreset", "noeffect"])
@hybrid_command(name="reset", aliases=["fxreset", "noeffect"])
@same_voice_channel()
async def reset_effect(self, ctx: GuildContext) -> None:
"""Remove all audio effects."""
await self._switch_to(ctx, None, "", "Audio effects removed ✅")

@commands.command(name="effect")
async def current_effect(self, ctx: GuildContext) -> None:
"""Show the active audio effect."""
player = players.get(ctx.guild.id)
name = (player.effect_name if player else None) or "none"
await ctx.send(embed=success_embed(f"Current effect: **{name}**"))
@hybrid_command(name="effect")
@app_commands.describe(name="The effect to apply; leave it out to see the active one")
@app_commands.choices(name=[app_commands.Choice(name=n, value=n) for n in EFFECTS])
async def effect(self, ctx: GuildContext, name: Optional[str] = None) -> None:
"""Show the active audio effect, or apply one by name."""
if name is None:
player = players.get(ctx.guild.id)
active = (player.effect_name if player else None) or "none"
await ctx.send(embed=success_embed(f"Current effect: **{active}**"))
return
name = name.lower()
chosen = EFFECTS.get(name)
if chosen is None:
await ctx.send(embed=error_embed(
f"Unknown effect `{name}`. See `{ctx.clean_prefix}effects`."), ephemeral=True)
return
if await in_same_voice_channel(ctx):
await self._switch_to(ctx, name, chosen.filter, chosen.label, chosen.rate)

@commands.command(name="effects")
@hybrid_command(name="effects")
async def list_effects(self, ctx: GuildContext) -> None:
"""List all available audio effects."""
prefix = ctx.clean_prefix
if ctx.interaction is None:
names = ", ".join(f"`{prefix}{name}`" for name in EFFECTS)
else:
# The per-effect shortcuts are aliases, and aliases do not exist
# under `/`; there every effect goes through /effect.
names = ("Pick one with `/effect`: "
+ ", ".join(f"`{name}`" for name in EFFECTS))
await ctx.send(embed=discord.Embed(
title="🎛️ Available Effects",
description=", ".join(f"`{prefix}{name}`" for name in EFFECTS)
+ f"\n\nUse `{prefix}reset` to remove all effects.",
description=f"{names}\n\nUse `{prefix}reset` to remove all effects.",
color=BLURPLE,
))

Expand Down
91 changes: 62 additions & 29 deletions cogs/lyrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@
# Genius fallback then built a Cog instead of a result and raised TypeError,
# which the command reported as nothing at all.
from services.synced_lyrics import Line, index_at, search_terms
from utils.context import GuildContext
from utils import now_playing_view
from utils.context import GuildContext, hybrid_command
from utils.embeds import (error_embed, info_embed, lyrics_embed, lyrics_pages,
synced_lyrics_embed)
success_embed, synced_lyrics_embed)
from utils.player import MusicPlayer, players

LyricsResult = synced_lyrics.Lyrics
Expand Down Expand Up @@ -178,8 +179,10 @@ def __init__(self, bot: commands.Bot) -> None:
async def cog_load(self) -> None:
# One session for the cog. Building one per request is what leaked in #34.
self._session = aiohttp.ClientSession()
now_playing_view.set_lyrics_provider(self.show_current)

async def cog_unload(self) -> None:
now_playing_view.set_lyrics_provider(None)
for guild_id in list(self._following):
self.stop_following(guild_id)
if self._session is not None:
Expand Down Expand Up @@ -241,21 +244,22 @@ def stop_following(self, guild_id: int) -> None:
if not task.done():
task.cancel()

async def _follow(self, ctx: GuildContext, player: MusicPlayer,
async def _follow(self, dest: discord.abc.Messageable, player: MusicPlayer,
lyrics: LyricsResult) -> None:
"""Post the live message and start keeping it up to date."""
self.stop_following(ctx.guild.id) # one per guild; the newest wins
guild_id = player.guild.id
self.stop_following(guild_id) # one per guild; the newest wins
index = index_at(lyrics.lines, player.position)
message = await ctx.send(
message = await dest.send(
embed=synced_lyrics_embed(
lyrics.title, lyrics.artist, lyrics.lines, index,
player.position,
player.current.get("duration") if player.current else None),
view=FollowControls(lambda: self.stop_following(ctx.guild.id)),
view=FollowControls(lambda: self.stop_following(guild_id)),
)
follower = LyricsFollower(message, player, self._for_track)
task = self.bot.loop.create_task(self._run_follower(ctx.guild.id, follower))
self._following[ctx.guild.id] = (follower, task)
task = self.bot.loop.create_task(self._run_follower(guild_id, follower))
self._following[guild_id] = (follower, task)

async def _run_follower(self, guild_id: int, follower: LyricsFollower) -> None:
try:
Expand All @@ -267,13 +271,51 @@ async def _run_follower(self, guild_id: int, follower: LyricsFollower) -> None:
finally:
self._following.pop(guild_id, None)

# -- Showing what was found ---------------------------------------

async def _present(self, dest: discord.abc.Messageable, found: LyricsResult,
player: Optional[MusicPlayer]) -> None:
"""
Post lyrics that were found. ``player`` is given when they are the
playing track's, and only then can they be followed: a lyric needs a
clock, and a search result has none.
"""
if found.instrumental:
await dest.send(embed=info_embed(
"\U0001f3b5 Instrumental",
f"**{found.title}** has no lyrics to show."
))
return
if found.synced and player and player.current:
return await self._follow(dest, player, found)

note = "not synced - showing the full lyrics" if player else ""
view = LyricsPages(found.title, found.artist, found.plain, note)
if view.total > 1:
await dest.send(embed=view.embed(), view=view)
else:
await dest.send(embed=view.embed())

async def show_current(self, dest: discord.abc.Messageable,
player: MusicPlayer) -> bool:
"""Post the playing track's lyrics. False when there are none to post."""
if not player.current:
return False
found = await self._for_track(player.current)
if found is None:
return False
await self._present(dest, found, player)
return True

# -- The command ---------------------------------------------------

@commands.command(aliases=["ly"])
@hybrid_command(aliases=["ly"])
async def lyrics(self, ctx: GuildContext, *,
query: Optional[str] = None) -> None:
"""Follow the lyrics of the current song, or look a song up."""
async with ctx.typing():
# Ephemeral under `/`: the lyrics themselves go to the channel (below),
# so all this interaction ever shows is an error or an acknowledgement.
async with ctx.typing(ephemeral=True):
player = players.get(ctx.guild.id)
if query:
title = self._split_query(query)[0]
Expand All @@ -285,32 +327,23 @@ async def lyrics(self, ctx: GuildContext, *,
await ctx.send(embed=error_embed(
f"Nothing is playing. Provide a song name: "
f"`{ctx.clean_prefix}lyrics <title>`"
))
), ephemeral=True)
return

if found is None:
await ctx.send(embed=error_embed(
f"Couldn't find lyrics for **{title}**."
))
), ephemeral=True)
return
if found.instrumental:
await ctx.send(embed=info_embed(
"\U0001f3b5 Instrumental",
f"**{found.title}** has no lyrics to show."
))
if ctx.interaction is None:
await self._present(ctx, found, None if query else player)
return

# Only the playing track can be followed: a lyric needs a clock, and
# a search result has none.
if found.synced and not query and player and player.current:
return await self._follow(ctx, player, found)

note = "" if query else "not synced - showing the full lyrics"
view = LyricsPages(found.title, found.artist, found.plain, note)
if view.total > 1:
await ctx.send(embed=view.embed(), view=view)
else:
await ctx.send(embed=view.embed())
# A reply to an interaction can only be edited with its token, which
# expires after 15 minutes; the live message is edited for as long
# as the music plays. Posted to the channel, it is the bot's own.
await self._present(ctx.channel, found, None if query else player)
await ctx.send(embed=success_embed("🎤 Lyrics posted."),
ephemeral=True)


async def setup(bot: commands.Bot) -> None:
Expand Down
Loading
Loading