Skip to content

Commit

Permalink
Seek and speed features (#2402)
Browse files Browse the repository at this point in the history
* Adds seeking and playback speed support.

* Update example_options.ini

* Add relative seek.
  • Loading branch information
itsTheFae committed May 15, 2024
1 parent 20e674d commit e2ad0fe
Show file tree
Hide file tree
Showing 5 changed files with 292 additions and 43 deletions.
5 changes: 5 additions & 0 deletions config/example_options.ini
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,11 @@ DeleteNowPlaying = no
# The volume of the bot's audio output, between 0.01 and 1.0.
DefaultVolume = 0.25

# The playback speed used by default for every song played by MusicBot.
# Must be a value between 0.5 and 100.0, inclusive.
# Default is 1.0, for normal playback speed.
DefaultSpeed = 1.0

# The number of people voting to skip in order for a song to be skipped successfully,
# whichever value is lower will be used. Ratio refers to the percentage of undefeaned, non-
# owner users in the channel.
Expand Down
156 changes: 154 additions & 2 deletions musicbot/bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -1199,9 +1199,12 @@ async def on_player_pause(
"""
log.debug("Running on_player_pause")
await self.update_now_playing_status()

# save current entry progress, if it played "enough" to merit saving.
if player.session_progress > 1:
await self.serialize_queue(player.voice_client.channel.guild)

self.loop.create_task(self.handle_player_inactivity(player))
# TODO: if we manage to add seek functionality this might be wise.
# await self.serialize_queue(player.voice_client.channel.guild)

async def on_player_stop(self, player: MusicPlayer, **_: Any) -> None:
"""
Expand Down Expand Up @@ -3304,6 +3307,93 @@ async def cmd_playnow(
skip_playing=True,
)

async def cmd_seek(
self, guild: discord.Guild, _player: Optional[MusicPlayer], seek_time: str = ""
) -> CommandResponse:
"""
Usage:
{command_prefix}seek [time]
Restarts the current song at the given time.
If time starts with + or - seek will be relative to current playback time.
Time should be given in seconds, fractional seconds are accepted.
Due to codec specifics in ffmpeg, this may not be accurate.
"""
if not _player or not _player.current_entry:
raise exceptions.CommandError(
"Cannot use seek if there is nothing playing.",
expire_in=30,
)

if _player.current_entry.duration is None:
raise exceptions.CommandError(
"Cannot use seek on current track, it has an unknown duration.",
expire_in=30,
)

if not isinstance(
_player.current_entry, (URLPlaylistEntry, LocalFilePlaylistEntry)
):
raise exceptions.CommandError(
"Seeking is not supported for streams.",
expire_in=30,
)

if not seek_time:
raise exceptions.CommandError(
"Cannot use seek without a time to position playback.",
expire_in=30,
)

relative_seek: int = 0
f_seek_time: float = 0
if "." in seek_time:
try:
if seek_time.startswith("-"):
relative_seek = -1
if seek_time.startswith("+"):
relative_seek = 1

p1, p2 = seek_time.rsplit(".", maxsplit=1)
i_seek_time = format_time_to_seconds(p1)
f_seek_time = float(f"0.{p2}")
f_seek_time += i_seek_time
except (ValueError, TypeError) as e:
raise exceptions.CommandError(
f"Could not convert `{seek_time}` to a valid time in seconds.",
expire_in=30,
) from e
else:
f_seek_time = 0.0 + format_time_to_seconds(seek_time)

if relative_seek != 0:
f_seek_time = _player.progress + (relative_seek * f_seek_time)

if f_seek_time > _player.current_entry.duration or f_seek_time < 0:
td = format_song_duration(_player.current_entry.duration_td)
raise exceptions.CommandError(
f"Cannot seek to `{seek_time}` in the current track with a length of `{td}`",
expire_in=30,
)

entry = _player.current_entry
entry.set_start_time(f_seek_time)
_player.playlist.insert_entry_at_index(0, entry)

# handle history playlist updates.
if (
self.config.enable_queue_history_global
or self.config.enable_queue_history_guilds
):
self.server_data[guild.id].current_playing_url = ""

_player.skip()

return Response(
f"Seeking to time `{seek_time}` (`{f_seek_time}` seconds) in the current song.",
delete_after=30,
)

async def cmd_repeat(
self, guild: discord.Guild, option: str = ""
) -> CommandResponse:
Expand Down Expand Up @@ -4914,6 +5004,68 @@ async def cmd_volume(
expire_in=20,
)

async def cmd_speed(
self, guild: discord.Guild, player: MusicPlayer, new_speed: str = ""
) -> CommandResponse:
"""
Usage:
{command_prefix}speed [rate]
Apply a speed to the currently playing track.
The rate must be between 0.5 and 100.0 due to ffmpeg limits.
Stream playback does not support speed adjustments.
"""
if not player.current_entry:
raise exceptions.CommandError(
"No track is playing, cannot set speed.\n"
"Use the config command to set a default playback speed.",
expire_in=30,
)

if not isinstance(
player.current_entry, (URLPlaylistEntry, LocalFilePlaylistEntry)
):
raise exceptions.CommandError(
"Speed cannot be applied to streamed media.",
expire_in=30,
)

if not new_speed:
raise exceptions.CommandError(
"You must provide a speed to set.",
expire_in=30,
)

try:
speed = float(new_speed)
if speed < 0.5 or speed > 100.0:
raise ValueError("Value out of range.")
except (ValueError, TypeError) as e:
raise exceptions.CommandError(
"The speed you proivded is invalid. Use a number between 0.5 and 100.",
expire_in=30,
) from e

# Set current playback progress and speed then restart playback.
entry = player.current_entry
entry.set_start_time(player.progress)
entry.set_playback_speed(speed)
player.playlist.insert_entry_at_index(0, entry)

# handle history playlist updates.
if (
self.config.enable_queue_history_global
or self.config.enable_queue_history_guilds
):
self.server_data[guild.id].current_playing_url = ""

player.skip()

return Response(
f"Setting playback speed to `{speed:.3f}` for current track.",
delete_after=30,
)

@owner_only
async def cmd_config(
self,
Expand Down
22 changes: 22 additions & 0 deletions musicbot/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,17 @@ def __init__(self, config_file: pathlib.Path) -> None:
"Must be a value from 0 to 1 inclusive."
),
)
self.default_speed: float = self.register.init_option(
section="MusicBot",
option="DefaultSpeed",
dest="default_speed",
default=ConfigDefaults.default_speed,
getter="getfloat",
comment=(
"Sets the default speed MusicBot will play songs at.\n"
"Must be a value from 0.5 to 100.0 for ffmpeg to use it."
),
)
self.skips_required: int = self.register.init_option(
section="MusicBot",
option="SkipsRequired",
Expand Down Expand Up @@ -817,6 +828,16 @@ def run_checks(self) -> None:
if not self.footer_text:
self.footer_text = ConfigDefaults.footer_text

if self.default_speed < 0.5 or self.default_speed > 100.0:
log.warning(
"The default playback speed must be between 0.5 and 100.0. "
"The option value of %.3f will be limited instead."
)
self.default_speed = max(min(self.default_speed, 100.0), 0.5)

if self.enable_local_media and not self.media_file_dir.is_dir():
self.media_file_dir.mkdir(exist_ok=True)

async def async_validate(self, bot: "MusicBot") -> None:
"""
Validation logic for bot settings that depends on data from async services.
Expand Down Expand Up @@ -1060,6 +1081,7 @@ class ConfigDefaults:
delete_nowplaying: bool = True

default_volume: float = 0.15
default_speed: float = 1.0
skips_required: int = 4
skip_ratio_required: float = 0.5
save_videos: bool = True
Expand Down
67 changes: 57 additions & 10 deletions musicbot/entry.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,13 @@ def __init__(self) -> None:
self._is_downloaded: bool = False
self._waiting_futures: List[AsyncFuture] = []

@property
def start_time(self) -> float:
"""
Time in seconds that is passed to ffmpeg -ss flag.
"""
return 0

@property
def url(self) -> str:
"""
Expand Down Expand Up @@ -187,12 +194,12 @@ def __init__(
Create URL Playlist entry that will be downloaded for playback.
:param: playlist: The playlist object this entry should belong to.
:param: info: A YtdlResponseDict with from downloader.extract_info()
:param: from_apl: Flag this entry as automatic playback, not queued by a user.
:param: meta: a collection extra of key-values stored with the entry.
:param: info: A YtdlResponseDict from downloader.extract_info()
"""
super().__init__()

self._start_time: Optional[float] = None
self._playback_rate: Optional[float] = None
self.playlist: "Playlist" = playlist
self.downloader: "Downloader" = playlist.bot.downloader
self.filecache: "AudioFileCache" = playlist.bot.filecache
Expand All @@ -210,7 +217,31 @@ def __init__(
self.author: Optional["discord.Member"] = author
self.channel: Optional[GuildMessageableChannels] = channel

self.aoptions: str = "-vn"
self._aopt_eq: str = ""

@property
def aoptions(self) -> str:
"""After input options for ffmpeg to use with this entry."""
aopts = f"{self._aopt_eq}"
# Set playback speed options if needed.
if self._playback_rate is not None or self.playback_speed != 1.0:
# Append to the EQ options if they are set.
if self._aopt_eq:
aopts = f"{self._aopt_eq},atempo={self.playback_speed:.3f}"
else:
aopts = f"-af atempo={self.playback_speed:.3f}"

if aopts:
return f"{aopts} -vn"

return "-vn"

@property
def boptions(self) -> str:
"""Before input options for ffmpeg to use with this entry."""
if self._start_time is not None:
return f"-ss {self._start_time}"
return ""

@property
def from_auto_playlist(self) -> bool:
Expand Down Expand Up @@ -370,6 +401,27 @@ def _deserialize(

return None

@property
def start_time(self) -> float:
if self._start_time is not None:
return self._start_time
return 0

def set_start_time(self, start_time: float) -> None:
"""Sets a start time in seconds to use with the ffmpeg -ss flag."""
self._start_time = start_time

@property
def playback_speed(self) -> float:
"""Get the current playback speed if one was set, or return 1.0 for normal playback."""
if self._playback_rate is not None:
return self._playback_rate
return self.playlist.bot.config.default_speed or 1.0

def set_playback_speed(self, speed: float) -> None:
"""Set the playback speed to be used with ffmpeg -af:atempo filter."""
self._playback_rate = speed

async def _ensure_entry_info(self) -> None:
"""helper to ensure this entry object has critical information"""

Expand Down Expand Up @@ -457,7 +509,7 @@ async def _download(self) -> None:

if self.playlist.bot.config.use_experimental_equalization:
try:
aoptions = await self.get_mean_volume(self.filename)
self._aopt_eq = await self.get_mean_volume(self.filename)

# Unfortunate evil that we abide for now...
except Exception: # pylint: disable=broad-exception-caught
Expand All @@ -466,11 +518,6 @@ async def _download(self) -> None:
"This has not impacted the ability for the bot to work, but will mean your tracks will not be equalised.",
exc_info=True,
)
aoptions = "-vn"
else:
aoptions = "-vn"

self.aoptions = aoptions

# Trigger ready callbacks.
self._for_each_future(lambda future: future.set_result(self))
Expand Down
Loading

0 comments on commit e2ad0fe

Please sign in to comment.