music play + now playing

This commit is contained in:
Yanis Rigaudeau 2023-05-17 01:04:16 +02:00
parent 0749f742c3
commit d09c2b7a89
Signed by: yanis
GPG Key ID: 4DD2841DF1C94D83
21 changed files with 410 additions and 143 deletions

1
.gitignore vendored
View File

@ -1,3 +1,4 @@
.mypy_cache
__pycache__
config.toml
download

2
.mypy.ini Normal file
View File

@ -0,0 +1,2 @@
[mypy]
ignore_missing_imports = True

View File

@ -1,3 +1,5 @@
{
"python.formatting.provider": "black"
"python.formatting.provider": "black",
"editor.formatOnSave": true,
"python.analysis.typeCheckingMode": "off"
}

View File

@ -5,9 +5,9 @@ from toml import TomlDecodeError
from cog import Greetings, Music
from config import Config
from framework import Downloader, Redis, Spotify, Youtube
from framework import DiscordPlayer, Downloader, Redis, Spotify, Youtube
from logger import Logger
from service import FileManager, QueueManager
from service import FileManager, PlaybackManager, QueueManager
from usecase import Sources
if __name__ == "__main__":
@ -30,6 +30,12 @@ if __name__ == "__main__":
# Redis Client
redis = Redis(logger, config.redis, config.appName)
# Discord Player
discordPlayer = DiscordPlayer()
# Playback Manager
playbackManager = PlaybackManager(discordPlayer)
# Queue Manager
queueManager = QueueManager(redis)
@ -53,7 +59,7 @@ if __name__ == "__main__":
# Add Cogs
bot.add_cog(Greetings(bot, logger, redis))
bot.add_cog(Music(bot, logger, queueManager, sources))
bot.add_cog(Music(bot, logger, queueManager, playbackManager, sources))
# Run
bot.run(config.discord.token)

View File

@ -15,7 +15,7 @@ class Greetings(Cog):
@default_permissions(administrator=True)
async def redis_set(self, context: ApplicationContext, key: str, value: str):
self.logger.info(f"redis set {value} at {key}")
await self.redis.set(key, value)
await self.redis.set("test", key, value)
await context.respond(f"redis set {value} at {key}")
@ -23,6 +23,6 @@ class Greetings(Cog):
@default_permissions(administrator=True)
async def redis_get(self, context: ApplicationContext, key: str):
self.logger.info(f"redis get {key}")
value = await self.redis.get(key)
value = await self.redis.get("test", key)
await context.respond(f"redis get {key}: {value}")

View File

@ -1,25 +1,91 @@
from logging import Logger
from discord import ApplicationContext, Bot, Cog, slash_command
from discord import ApplicationContext, Bot, Cog, Embed, Interaction, slash_command
from service import QueueManager
from service import PlaybackManager, QueueManager
from usecase import Sources
class Music(Cog):
def __init__(
self, bot: Bot, logger: Logger, queueManager: QueueManager, sources: Sources
self,
bot: Bot,
logger: Logger,
queueManager: QueueManager,
playbackManager: PlaybackManager,
sources: Sources,
):
self.bot = bot
self.logger = logger
self.queueManager = queueManager
self.playbackManager = playbackManager
self.sources = sources
@slash_command(name="play")
async def play(self, context: ApplicationContext, query: str):
async with self.queueManager(context.guild_id) as queue:
interaction = await context.respond(f"searching {query} ...")
entries = await self.sources.processQuery(interaction, query)
queue.add(entries)
if context.author.voice is None:
await context.respond("Not connected to a voice channel")
return
await interaction.edit_original_response(content=entries[0].title.name)
async with self.queueManager(context.guild_id) as queue:
interaction = await context.respond(f"Searching {query}...")
if not isinstance(interaction, Interaction):
return
entries = await self.sources.processQuery(interaction, query)
if entries is None:
await interaction.edit_original_response(content=f"{query} not found")
return
queue.add(entries)
await self.playbackManager.registerQueue(
context.guild_id,
queue,
context.channel,
context.author.voice.channel,
)
if entries[0].playlist is not None:
await interaction.edit_original_response(
content=f"{len(entries)} songs added to queue from playlist {entries[0].playlist.name}"
)
else:
await interaction.edit_original_response(
content=f"{entries[0].title.name} was added to queue"
)
@slash_command(name="nowplaying")
async def nowPlaying(self, context: ApplicationContext):
assert self.bot.user
async with self.queueManager(context.guild_id) as queue:
entry = queue.nowPlaying()
if entry is None:
await context.respond("No song playing")
return
requester = await self.bot.get_or_fetch_user(entry.requester)
assert requester is not None
embed = (
Embed(title=entry.title.name, url=entry.title.url)
.add_field(
name="Artist", value=f"[{entry.artist.name}]({entry.artist.url})"
)
.set_author(
name="Now Playing", icon_url=self.bot.user.display_avatar.url
)
.set_image(url=entry.thumbnail)
.set_footer(
text=requester.display_name, icon_url=requester.display_avatar.url
)
)
if entry.playlist is not None:
embed.add_field(
name="Playlist",
value=f"[{entry.playlist.name}]({entry.playlist.url})",
)
await context.respond(embed=embed)

View File

@ -2,46 +2,46 @@ import toml
class DiscordConfig:
def __init__(self, discord_config) -> None:
self.token: str = discord_config["token"]
self.timeout: int = discord_config["timeout"]
def __init__(self, discordConfig) -> None:
self.token: str = discordConfig["token"]
self.timeout: int = discordConfig["timeout"]
class YoutubeConfig:
def __init__(self, youtube_config) -> None:
self.language: str = youtube_config["language"]
self.region: str = youtube_config["region"]
def __init__(self, youtubeConfig) -> None:
self.language: str = youtubeConfig["language"]
self.region: str = youtubeConfig["region"]
class SpotifyConfig:
def __init__(self, spotify_config) -> None:
self.clientId: str = spotify_config["clientId"]
self.clientSecret: str = spotify_config["clientSecret"]
self.region: str = spotify_config["region"]
def __init__(self, spotifyConfig) -> None:
self.clientId: str = spotifyConfig["clientId"]
self.clientSecret: str = spotifyConfig["clientSecret"]
self.region: str = spotifyConfig["region"]
class LoggingConfig:
def __init__(self, logging_config) -> None:
self.level: str = logging_config["level"]
def __init__(self, loggingConfig) -> None:
self.level: str = loggingConfig["level"]
class RedisConfig:
def __init__(self, redis_config) -> None:
self.host: str = redis_config["host"]
self.port: int = redis_config["port"]
self.password: str = redis_config["password"]
def __init__(self, redisConfig) -> None:
self.host: str = redisConfig["host"]
self.port: int = redisConfig["port"]
self.password: str = redisConfig["password"]
class Config:
def __init__(self, config_path: str) -> None:
self._config = toml.load(config_path)
self.appName: str = self._config["appName"]
self.downloadDirectory: str = self._config["downloadDirectory"]
self.discord = DiscordConfig(self._config["discord"])
self.youtube = YoutubeConfig(self._config["youtube"])
self.spotify = SpotifyConfig(self._config["spotify"])
self.logging = LoggingConfig(self._config["logging"])
self.redis = RedisConfig(self._config["redis"])
def __init__(self, configPath: str) -> None:
self.config = toml.load(configPath)
self.appName: str = self.config["appName"]
self.downloadDirectory: str = self.config["downloadDirectory"]
self.discord = DiscordConfig(self.config["discord"])
self.youtube = YoutubeConfig(self.config["youtube"])
self.spotify = SpotifyConfig(self.config["spotify"])
self.logging = LoggingConfig(self.config["logging"])
self.redis = RedisConfig(self.config["redis"])
def __str__(self) -> str:
return str(self._config)
return str(self.config)

View File

@ -3,37 +3,60 @@ from .entry import Entry
class Queue:
def __init__(self) -> None:
self._entries: list[Entry] = []
self.entries: list[Entry] = []
self.playing = False
self.cursor = 0
self.seek = 0
def add(self, entries: list[Entry]) -> None:
for entry in entries:
self._entries.append(entry)
self.entries.append(entry)
def remove(self, index: int, recursive: bool) -> None:
if not 0 < index < len(self._entries):
if not 0 <= index < len(self.entries):
return
# if recursive and self[index].playlist is not None:
# first_entry = ""
# firstEntry = ""
# else:
self._entries.pop()
self.entries.pop()
def move(self, frm: int, to: int) -> None:
if (
not 0 < frm < len(self._entries)
or not 0 < to < len(self._entries)
not 0 <= frm < len(self.entries)
or not 0 <= to < len(self.entries)
or frm == to
):
return
return None
self._entries.insert(to, self._entries.pop(frm))
self.entries.insert(to, self.entries.pop(frm))
def moveCursor(self, position: int) -> None:
if not 0 <= position <= len(self.entries):
return None
self.cursor = position
def incrementCursor(self) -> None:
self.moveCursor(self.cursor + 1)
def nowPlaying(self) -> Entry | None:
if not 0 <= self.cursor < len(self.entries):
return None
return self.entries[self.cursor]
def startPlaying(self) -> None:
self.playing = True
def stopPlaying(self) -> None:
self.playing = False
def __getitem__(self, index: int) -> Entry | None:
if not 0 < index < len(self._entries):
return
if not 0 <= index < len(self.entries):
return None
return self._entries[index]
return self.entries[index]
def __len__(self) -> int:
return len(self._entries)
return len(self.entries)

View File

@ -1,3 +1,4 @@
from .discordPlayer import DiscordPlayer
from .downloader import Downloader
from .redis import Redis
from .spotify import Spotify

View File

@ -0,0 +1,40 @@
from discord import FFmpegOpusAudio, VoiceChannel, VoiceClient
from entity import File
class DiscordPlayer:
def __init__(self) -> None:
self.clients: dict[int, VoiceClient] = {}
self.timeout = 60
async def connect(self, guildId: int, voiceChannel: VoiceChannel) -> None:
if (
guildId not in self.clients
or self.clients[guildId] is None
or not self.clients[guildId].is_connected()
):
self.clients[guildId] = await voiceChannel.connect(timeout=self.timeout)
print(f"connect to {voiceChannel.name}")
async def disconnect(self, guildId: int) -> None:
if guildId in self.clients and self.clients[guildId].is_connected():
await self.clients[guildId].disconnect()
def play(
self, guildId: int, file: File | str, seekTime: int = 0, after=None
) -> None:
self.clients[guildId].play(
FFmpegOpusAudio(
file.name if isinstance(file, File) else file,
bitrate=256,
before_options="-ss %d" % seekTime,
options="-vn",
),
after=lambda error: self.clients[guildId].loop.create_task(after(error))
if after is not None
else None,
)
def stop(self, guildId) -> None:
self.clients[guildId].stop()

View File

@ -1,47 +1,34 @@
import pickle
from logging import Logger
import redis.asyncio as redis
from redis.asyncio.lock import Lock
from redis.asyncio import Redis as RedisClient
from config import RedisConfig
class Redis:
def __init__(self, logger: Logger, config: RedisConfig, rootKeyName: str) -> None:
self._client = redis.Redis(
self.client: RedisClient = RedisClient(
host=config.host,
port=config.port,
password=config.password,
auto_close_connection_pool=False,
)
self._locks: dict[str, Lock] = {}
self.rootKeyName = rootKeyName
self.logger = logger
async def _get_lock(self, key) -> Lock:
if key not in self._locks:
self._locks[key] = self._client.lock(key)
return self._locks[key]
async def acquire(self, key: str) -> None:
lock = await self._get_lock(f"{self.rootKeyName}:queue:{key}")
await lock.acquire()
async def release(self, key: str) -> None:
lock = await self._get_lock(f"{self.rootKeyName}:queue:{key}")
await lock.release()
async def get(self, key: str):
async def get(self, context: str, key: str):
self.logger.info(f"get value {key} from redis")
value = await self._client.get(f"{self.rootKeyName}:queue:{key}")
value = await self.client.get(f"{self.rootKeyName}:{context}:{key}")
if value:
return pickle.loads(value)
return None
async def set(self, key: str, value) -> None:
async def set(self, context: str, key: str, value) -> None:
self.logger.info(f"set value {key} to redis")
await self._client.set(f"{self.rootKeyName}:queue:{key}", pickle.dumps(value))
await self.client.set(
f"{self.rootKeyName}:{context}:{key}", pickle.dumps(value)
)
async def close(self) -> None:
await self._client.close()
await self.client.close()

View File

@ -21,5 +21,10 @@ class Spotify:
async def getTrack(self, url: str):
return await self.loop.run_in_executor(
None, lambda: self.client.track(url, self.region)
None, lambda: self.client.track(url, market=self.region)
)
async def getPlaylist(self, url: str):
return await self.loop.run_in_executor(
None, lambda: self.client.playlist(url, market=self.region)
)

View File

@ -17,6 +17,7 @@ class Youtube:
self.params = {
"format": "bestaudio/best",
"outtmpl": path.join(downloadDirectory, "%(id)s"),
"updatetime": False,
"restrictfilenames": True,
"noplaylist": True,
"ignoreerrors": True,
@ -37,7 +38,7 @@ class Youtube:
videosSearch = VideosSearch(
query, limit=1, language=self.language, region=self.region
)
return await videosSearch.next()
return (await videosSearch.next())["result"]
async def fetchData(self, url: str):
info = await self.loop.run_in_executor(
@ -66,7 +67,7 @@ class Youtube:
),
)
async def download(self, url: str, fileName: str, progress=None) -> None:
async def download(self, url: str, fileName: str, progress=None) -> File:
if progress is not None:
progress_hook = lambda status: self.downloadProgress(progress, status)
self.client.add_progress_hook(progress_hook)

2
lint.sh Executable file
View File

@ -0,0 +1,2 @@
#!/bin/sh
isort . && black . && flake8 . && mypy .

8
requirements-dev.txt Normal file
View File

@ -0,0 +1,8 @@
types-redis==4.5.5.0
types-requests==2.30.0.0
types-toml==0.10.8.6
black==23.3.0
flake8==6.0.0
isort==5.12.0
mypy==1.2.0

View File

@ -5,7 +5,3 @@ spotipy==2.23.0
toml==0.10.2
youtube-search-python==1.6.6
yt-dlp==2023.3.4
black==23.3.0
flake8==6.0.0
isort==5.12.0

View File

@ -1,2 +1,3 @@
from .fileManager import FileManager
from .playbackManager import PlaybackManager
from .queueManager import QueueManager

View File

@ -1,10 +1,11 @@
import re
from os import path
from collections.abc import AsyncIterator
from os import path, stat
from time import time
from discord import Interaction
from entity import Entry
from entity import Entry, File
from framework import Downloader, Youtube
@ -18,7 +19,7 @@ class FileManager:
self.youtubeVideoRegex = re.compile(
r"(https:\/\/)?(www|music)\.youtube\.com\/(watch\?v=|shorts\/)\w*"
)
self.progressLastUpdate = 0
self.progressLastUpdate = 0.0
def getFileName(self, entryId: str) -> str:
return path.join(self.downloadDirectory, entryId)
@ -27,24 +28,29 @@ class FileManager:
self,
interaction: Interaction,
entry: Entry,
current_size: int,
total_size: int,
current_size: float,
total_size: float,
):
if time() - self.progressLastUpdate > 1:
current_size = current_size / 1024 / 1024
total_size = total_size / 1024 / 1024
if time() - self.progressLastUpdate > 1 and total_size > 10:
self.progressLastUpdate = time()
await interaction.edit_original_response(
content=f"Downloading {entry.title.name} [%.2f/%.2f Mo]"
% (current_size / 1024 / 1024, total_size / 1024 / 1024)
% (current_size, total_size)
)
async def download(self, interaction: Interaction, entries: list[Entry]):
async def download(
self, interaction: Interaction, entries: list[Entry]
) -> AsyncIterator[File]:
for entry in entries:
entryId = self.youtube.getId(entry.title.url)
print("DOWNLOAD", entry.source)
entryId = self.youtube.getId(entry.source)
fileName = self.getFileName(entryId)
if not path.isfile(fileName) and isinstance(entry.source, str):
if self.youtubeVideoRegex.match(entry.source) is not None:
entry.source = await self.youtube.download(
yield await self.youtube.download(
entry.source,
fileName,
lambda current_size, total_size: self.downloadProgress(
@ -52,10 +58,12 @@ class FileManager:
),
)
else:
entry.source = await self.downloader.download(
yield await self.downloader.download(
entry.source,
fileName,
lambda current_size, total_size: self.downloadProgress(
interaction, entry, current_size, total_size
),
)
yield File(fileName, stat(fileName).st_size)

View File

@ -0,0 +1,59 @@
from discord import TextChannel, VoiceChannel
from entity import Queue
from framework import DiscordPlayer
class ActiveQueue:
def __init__(
self, queue: Queue, textChannel: TextChannel, voiceChannel: VoiceChannel
) -> None:
self.queue = queue
self.textChannel = textChannel
self.voiceChannel = voiceChannel
class PlaybackManager:
def __init__(self, player: DiscordPlayer) -> None:
self.player = player
self.queues: dict[int, ActiveQueue] = {}
async def nextTrack(self, error: Exception, guildId: int):
queue = self.queues[guildId].queue
textChannel = self.queues[guildId].textChannel
queue.incrementCursor()
if queue.cursor < len(queue):
self.startPlayback(guildId)
await textChannel.send(f"Playing {queue[queue.cursor].title.name}")
else:
self.stopPlayback(guildId)
def startPlayback(self, guildId: int, seekTime=0):
queue = self.queues[guildId].queue
if len(queue) > queue.cursor and not queue.playing:
queue.startPlaying()
self.player.play(
guildId,
queue[queue.cursor].source,
seekTime,
lambda error: self.nextTrack(error, guildId),
)
def stopPlayback(self, guildId: int):
queue = self.queues[guildId].queue
if queue.playing:
queue.stopPlaying()
self.player.stop(guildId)
async def registerQueue(
self,
guildId: int,
queue: Queue,
textChannel: TextChannel,
voiceChannel: VoiceChannel,
):
if guildId not in self.queues:
self.queues[guildId] = ActiveQueue(queue, textChannel, voiceChannel)
await self.player.connect(guildId, voiceChannel)
self.startPlayback(guildId)

View File

@ -1,3 +1,4 @@
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from entity import Queue
@ -10,21 +11,13 @@ class QueueManager:
self.queues: dict[int, Queue] = {}
@asynccontextmanager
async def __call__(self, guildId: int) -> Queue:
# await self.acquire(guildId)
async def __call__(self, guildId: int) -> AsyncIterator[Queue]:
queue = await self.get(guildId)
yield queue
await self.save(guildId)
# await self.release(guildId)
async def acquire(self, guildId: int) -> None:
await self.redis.acquire(guildId)
async def release(self, guildId: int) -> None:
await self.redis.release(guildId)
async def get(self, guildId: int) -> Queue:
queue: Queue | None = await self.redis.get(guildId)
queue: Queue | None = await self.redis.get("queue", str(guildId))
if queue is None:
queue = Queue()
self.queues[guildId] = queue
@ -32,4 +25,4 @@ class QueueManager:
return self.queues[guildId]
async def save(self, guildId: int) -> None:
await self.redis.set(guildId, self.queues[guildId])
await self.redis.set("queue", str(guildId), self.queues[guildId])

View File

@ -1,4 +1,3 @@
import json
import re
from discord import Interaction
@ -50,8 +49,8 @@ class Sources:
if data is None:
return None
with open("search_result.json", "w") as file:
file.write(json.dumps(data))
# with open("search_result.json", "w") as file:
# file.write(json.dumps(data))
entry = Entry(
title=Title(name=data["title"], url=data["webpage_url"]),
@ -62,71 +61,138 @@ class Sources:
source=data["webpage_url"],
)
await self.fileManager.download(interaction, [entry])
entry.source = await anext(self.fileManager.download(interaction, [entry]))
return [entry]
if self.youtube_playlist_regex.match(query) is not None:
data = await self.youtube.getData(query)
data = await self.youtube.fetchData(query)
if data is None:
return None
with open("search_result.json", "w") as file:
file.write(json.dumps(data))
# with open("search_result.json", "w") as file:
# file.write(json.dumps(data))
playlist = Playlist(
title=data["title"],
name=data["title"],
url=data["webpage_url"],
owner=Artist(name=data["channel"], url=data["channel_url"]),
)
return [
entries = [
Entry(
title=Title(name=data["title"], url=data["webpage_url"]),
artist=Artist(name=data["channel"], url=data["channel_url"]),
duration=data["duration"],
thumbnail=data["thumbnail"],
title=Title(
name=entry_data["title"], url=entry_data["webpage_url"]
),
artist=Artist(
name=entry_data["channel"], url=entry_data["channel_url"]
),
duration=entry_data["duration"],
thumbnail=entry_data["thumbnail"],
requesterId=interaction.user.id,
playlist=playlist,
source=data["url"],
source=entry_data["webpage_url"],
)
for data in data["entries"]
for entry_data in data["entries"]
]
index = 0
async for file in self.fileManager.download(interaction, entries):
entries[index].source = file
index += 1
return entries
if self.spotify_track_regex.match(query) is not None:
data = await self.spotify.getTrack(query)
with open("spotify_track_result.json", "w") as file:
file.write(json.dumps(data))
# with open("spotify_track_result.json", "w") as file:
# file.write(json.dumps(data))
return [
Entry(
title=Title(
name=data["name"], url=data["external_urls"]["spotify"]
),
artist=Artist(
name=data["artists"][0]["name"],
url=data["artists"][0]["external_urls"]["spotify"],
),
album=Album(
name=data["album"]["name"],
url=data["album"]["external_urls"]["spotify"],
),
duration=0,
thumbnail=data["album"]["images"][0]["url"],
requesterId=interaction.user.id,
)
]
entry = Entry(
title=Title(name=data["name"], url=data["external_urls"]["spotify"]),
artist=Artist(
name=data["artists"][0]["name"],
url=data["artists"][0]["external_urls"]["spotify"],
),
album=Album(
name=data["album"]["name"],
url=data["album"]["external_urls"]["spotify"],
),
duration=0,
thumbnail=data["album"]["images"][0]["url"],
requesterId=interaction.user.id,
)
entry.source = await self.search(f"{entry.title.name} {entry.artist.name}")
entry.source = await anext(self.fileManager.download(interaction, [entry]))
return [entry]
if (
self.spotify_playlist_regex.match(query) is not None
or self.spotify_album_regex.match(query) is not None
or self.spotify_artist_regex.match(query) is not None
):
return
data = await self.spotify.getPlaylist(query)
return await self.processQuery(interaction, await self.search(query))
# with open("spotify_playlist_result.json", "w") as file:
# file.write(json.dumps(data))
async def search(self, query) -> str:
playlist = Playlist(
name=data["name"],
url=data["external_urls"]["spotify"],
owner=Artist(
name=data["owner"]["display_name"],
url=data["owner"]["external_urls"]["spotify"],
),
)
entries = [
Entry(
title=Title(
name=entry_data["track"]["name"],
url=entry_data["track"]["external_urls"]["spotify"],
),
artist=Artist(
name=entry_data["track"]["artists"][0]["name"],
url=entry_data["track"]["artists"][0]["external_urls"][
"spotify"
],
),
album=Album(
name=entry_data["track"]["album"]["name"],
url=entry_data["track"]["album"]["external_urls"]["spotify"],
),
duration=0,
thumbnail=entry_data["track"]["album"]["images"][0]["url"],
requesterId=interaction.user.id,
)
for entry_data in data["tracks"]["items"]
]
for entry in entries:
entry.source = await self.search(
f"{entry.title.name} {entry.artist.name}"
)
print(entry.source)
index = 0
async for file in self.fileManager.download(interaction, entries):
print(entries[index].source)
entries[index].source = file
print(entries[index].source)
index += 1
return entries
searchResult = await self.search(query)
if searchResult is None:
return None
return await self.processQuery(interaction, searchResult)
async def search(self, query) -> str | None:
result = await self.youtube.searchVideo(query)
return result["result"][0]["link"]
if len(result) == 0:
return None
return result[0]["link"]