aboutsummaryrefslogtreecommitdiffstats
path: root/cogs/music/audio_playback.py
diff options
context:
space:
mode:
authorsalmonik <raisersoft@proton.me>2026-02-04 16:16:26 +0100
committersalmonik <raisersoft@proton.me>2026-02-04 16:16:26 +0100
commitce61bf6bd5949046d15f58609f572fedcb8a073e (patch)
treecafaa12f11a06337bda5de08881e395ac66c1c11 /cogs/music/audio_playback.py
parentbetter reply context (diff)
downloadsenator-sperma-ce61bf6bd5949046d15f58609f572fedcb8a073e.tar.gz
senator-sperma-ce61bf6bd5949046d15f58609f572fedcb8a073e.zip
refactor music
Diffstat (limited to 'cogs/music/audio_playback.py')
-rw-r--r--cogs/music/audio_playback.py179
1 files changed, 179 insertions, 0 deletions
diff --git a/cogs/music/audio_playback.py b/cogs/music/audio_playback.py
new file mode 100644
index 0000000..3d4f453
--- /dev/null
+++ b/cogs/music/audio_playback.py
@@ -0,0 +1,179 @@
1# This file is part of Senator Sperma
2# Licensed under the GPL-3.0 License
3# Copyright (C) 2026 Salmonik @ salmonik@tuta.io
4# https://codeberg.org/salmonik/senator-sperma
5
6import os
7import logging
8from pathlib import Path
9
10import discord
11import mutagen
12
13logger = logging.getLogger(__name__)
14
15class AudioPlaybackManager:
16 """Handles audio playback, voice connections, and source preparation."""
17
18 @staticmethod
19 async def cleanup_temp_file(source):
20 """Clean up temporary file if it exists."""
21 if source and os.path.exists(source):
22 try:
23 os.remove(source)
24 logger.info(f"Cleaned up temporary file: {source}")
25 except Exception as e:
26 logger.error(f"Error cleaning up file {source}: {str(e)}")
27
28 @staticmethod
29 async def validate_voice_channel(ctx):
30 """Validate user is in a voice channel. Returns channel or None."""
31 if not ctx.author.voice or not ctx.author.voice.channel:
32 await ctx.respond("Musisz być na kanale głosowym, żebym mógł dołączyć!", ephemeral=True)
33 return None
34 return ctx.author.voice.channel
35
36 @staticmethod
37 async def prepare_file_source(source):
38 """Prepare and validate local file source. Returns (display_name, absolute_path) or (None, None)."""
39 logger.info(f"Attempting to play local file: {source}")
40
41 # Get absolute path and verify file exists
42 if not os.path.isabs(source):
43 source = os.path.abspath(source)
44 logger.debug(f"Converted to absolute path: {source}")
45
46 if not os.path.exists(source):
47 logger.error(f"File not found: {source}")
48 return None, None
49
50 filename = os.path.basename(source)
51 display_name = os.path.splitext(filename)[0]
52 return display_name, source
53
54 @staticmethod
55 async def connect_to_voice_channel(ctx, vc, target_channel):
56 """Connect or move to voice channel. Returns voice client."""
57 try:
58 # Connect to voice channel if not connected
59 if vc is None:
60 logger.info(f"Connecting to voice channel: {target_channel.name}")
61 vc = await target_channel.connect()
62 logger.info(f"Connected to voice channel: {target_channel.name}")
63 # Move to user's channel if in a different one
64 elif vc.channel != target_channel:
65 logger.info(f"Moving to voice channel: {target_channel.name}")
66 await vc.move_to(target_channel)
67 logger.info(f"Moved to voice channel: {target_channel.name}")
68 return vc
69 except Exception as e:
70 logger.error(f"Error connecting to voice channel: {str(e)}")
71 raise
72
73 @staticmethod
74 async def prepare_audio_source(source, is_url, display_name):
75 """Prepare audio source for playback. Returns (source, final_display_name)."""
76 if is_url:
77 # For URLs, use FFmpeg with the direct URL
78 ffmpeg_options = {
79 'options': '-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5',
80 'before_options': '-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5'
81 }
82 audio_source = discord.FFmpegPCMAudio(source, **ffmpeg_options)
83 return audio_source, display_name
84 else:
85 # For local files, use default options
86 audio_source = discord.FFmpegPCMAudio(source=source)
87
88 # Try to get metadata for local files
89 try:
90 metadata = mutagen.File(source)
91 if metadata:
92 title = metadata.get('title', [None])[0] if hasattr(metadata, 'get') else None
93 if title:
94 display_name = title
95 logger.debug(f"Read metadata - title: {title}")
96 except Exception as e:
97 logger.warning(f"Could not read metadata from {source}: {str(e)}")
98
99 return audio_source, display_name
100
101 @staticmethod
102 async def cleanup_if_needed(source, delete_after_play, is_url):
103 """Clean up temporary file if needed."""
104 if delete_after_play and not is_url:
105 await AudioPlaybackManager.cleanup_temp_file(source)
106
107 @staticmethod
108 async def prepare_source(ctx, source):
109 """Prepare source and get display name. Returns (source, display_name, is_url) or (None, None, None)."""
110 is_url = source.startswith(('http://', 'https://'))
111
112 if is_url:
113 logger.info(f"Attempting to play stream from URL: {source}")
114 return source, source, is_url
115
116 display_name, absolute_source = await AudioPlaybackManager.prepare_file_source(source)
117 if not display_name:
118 return None, None, None
119
120 return absolute_source, display_name, is_url
121
122 @staticmethod
123 async def execute_playback(ctx, source, display_name, is_url):
124 """Execute the actual audio playback."""
125 vc = ctx.voice_client
126 target_channel = await AudioPlaybackManager.validate_voice_channel(ctx)
127 if not target_channel:
128 return False
129
130 try:
131 vc = await AudioPlaybackManager.connect_to_voice_channel(ctx, vc, target_channel)
132
133 # Stop any currently playing audio
134 if vc.is_playing() or vc.is_paused():
135 vc.stop()
136
137 # Prepare audio source
138 audio_source, final_display_name = await AudioPlaybackManager.prepare_audio_source(source, is_url, display_name)
139
140 # Play the audio
141 vc.play(audio_source)
142 logger.info(f"Playing: {final_display_name}")
143 await ctx.respond(f"grom: {final_display_name}")
144 return True
145
146 except Exception as e:
147 logger.error(f"Error in play: {str(e)}", exc_info=True)
148 await ctx.respond(f"eror: {str(e)}", ephemeral=True)
149 return False
150
151 @staticmethod
152 async def validate_and_prepare_source(ctx, source):
153 """Validate and prepare source for playback. Returns (source, display_name, is_url) or (None, None, None)."""
154 prepared_source, display_name, is_url = await AudioPlaybackManager.prepare_source(ctx, source)
155 if not prepared_source:
156 return None, None, None
157 return prepared_source, display_name, is_url
158
159 @staticmethod
160 async def play(ctx, source, delete_after_play=False):
161 """Play audio from a local file or URL.
162
163 Args:
164 ctx: Discord context
165 source: Either a local file path or a URL to stream from
166 delete_after_play: If True, delete the file after playing (only for local files)
167 """
168 # Validate and prepare source
169 prepared_source, display_name, is_url = await AudioPlaybackManager.validate_and_prepare_source(ctx, source)
170 if not prepared_source:
171 await AudioPlaybackManager.cleanup_if_needed(source, delete_after_play, False)
172 return
173
174 # Execute playback
175 success = await AudioPlaybackManager.execute_playback(ctx, prepared_source, display_name, is_url)
176
177 # Cleanup if needed
178 if not success:
179 await AudioPlaybackManager.cleanup_if_needed(prepared_source, delete_after_play, is_url)