mirror of
https://github.com/BluemediaDev/muse.git
synced 2025-06-27 09:12:43 +02:00
Add SponsorBlock support (#1013)
This commit is contained in:
parent
8e08919206
commit
a27598c50a
7 changed files with 379 additions and 344 deletions
|
@ -10,6 +10,9 @@ SPOTIFY_CLIENT_SECRET=
|
||||||
|
|
||||||
# CACHE_LIMIT=2GB
|
# CACHE_LIMIT=2GB
|
||||||
|
|
||||||
|
# ENABLE_SPONSORBLOCK=true
|
||||||
|
# SPONSORBLOCK_TIMEOUT=5 # Delay (in mn) before retrying when SponsorBlock server are unreachable.
|
||||||
|
|
||||||
# See the README for details on the below variables
|
# See the README for details on the below variables
|
||||||
# BOT_STATUS=
|
# BOT_STATUS=
|
||||||
# BOT_ACTIVITY_TYPE=
|
# BOT_ACTIVITY_TYPE=
|
||||||
|
|
|
@ -99,6 +99,11 @@ services:
|
||||||
|
|
||||||
By default, Muse limits the total cache size to around 2 GB. If you want to change this, set the environment variable `CACHE_LIMIT`. For example, `CACHE_LIMIT=512MB` or `CACHE_LIMIT=10GB`.
|
By default, Muse limits the total cache size to around 2 GB. If you want to change this, set the environment variable `CACHE_LIMIT`. For example, `CACHE_LIMIT=512MB` or `CACHE_LIMIT=10GB`.
|
||||||
|
|
||||||
|
### SponsorBlock
|
||||||
|
|
||||||
|
Muse can skip non-music segments at the beginning or end of a Youtube music video (Using [SponsorBlock](https://sponsor.ajay.app/)). It is disabled by default. If you want to enable it, set the environment variable `ENABLE_SPONSORBLOCK=true` or uncomment it in your .env.
|
||||||
|
Being a community project, the server may be down or overloaded. When it happen, Muse will skip requests to SponsorBlock for a few minutes. You can change the skip duration by setting the value of `SPONSORBLOCK_TIMEOUT`.
|
||||||
|
|
||||||
### Custom Bot Status
|
### Custom Bot Status
|
||||||
|
|
||||||
In the default state, Muse has the status "Online" and the text "Listening to Music". You can change the status through environment variables:
|
In the default state, Muse has the status "Online" and the text "Listening to Music". You can change the status through environment variables:
|
||||||
|
|
|
@ -112,6 +112,7 @@
|
||||||
"postinstall-postinstall": "^2.1.0",
|
"postinstall-postinstall": "^2.1.0",
|
||||||
"read-pkg": "7.1.0",
|
"read-pkg": "7.1.0",
|
||||||
"reflect-metadata": "^0.1.13",
|
"reflect-metadata": "^0.1.13",
|
||||||
|
"sponsorblock-api": "^0.2.4",
|
||||||
"spotify-uri": "^3.0.2",
|
"spotify-uri": "^3.0.2",
|
||||||
"spotify-web-api-node": "^5.0.2",
|
"spotify-web-api-node": "^5.0.2",
|
||||||
"sync-fetch": "^0.3.1",
|
"sync-fetch": "^0.3.1",
|
||||||
|
|
|
@ -5,15 +5,32 @@ import {inject, injectable} from 'inversify';
|
||||||
import shuffle from 'array-shuffle';
|
import shuffle from 'array-shuffle';
|
||||||
import {TYPES} from '../types.js';
|
import {TYPES} from '../types.js';
|
||||||
import GetSongs from '../services/get-songs.js';
|
import GetSongs from '../services/get-songs.js';
|
||||||
import {SongMetadata, STATUS} from './player.js';
|
import {MediaSource, SongMetadata, STATUS} from './player.js';
|
||||||
import PlayerManager from '../managers/player.js';
|
import PlayerManager from '../managers/player.js';
|
||||||
import {buildPlayingMessageEmbed} from '../utils/build-embed.js';
|
import {buildPlayingMessageEmbed} from '../utils/build-embed.js';
|
||||||
import {getMemberVoiceChannel, getMostPopularVoiceChannel} from '../utils/channels.js';
|
import {getMemberVoiceChannel, getMostPopularVoiceChannel} from '../utils/channels.js';
|
||||||
import {getGuildSettings} from '../utils/get-guild-settings.js';
|
import {getGuildSettings} from '../utils/get-guild-settings.js';
|
||||||
|
import {SponsorBlock} from 'sponsorblock-api';
|
||||||
|
import Config from './config';
|
||||||
|
import KeyValueCacheProvider from './key-value-cache';
|
||||||
|
import {ONE_HOUR_IN_SECONDS} from '../utils/constants';
|
||||||
|
|
||||||
@injectable()
|
@injectable()
|
||||||
export default class AddQueryToQueue {
|
export default class AddQueryToQueue {
|
||||||
constructor(@inject(TYPES.Services.GetSongs) private readonly getSongs: GetSongs, @inject(TYPES.Managers.Player) private readonly playerManager: PlayerManager) {
|
private readonly sponsorBlock?: SponsorBlock;
|
||||||
|
private sponsorBlockDisabledUntil?: Date;
|
||||||
|
private readonly sponsorBlockTimeoutDelay;
|
||||||
|
private readonly cache: KeyValueCacheProvider;
|
||||||
|
|
||||||
|
constructor(@inject(TYPES.Services.GetSongs) private readonly getSongs: GetSongs,
|
||||||
|
@inject(TYPES.Managers.Player) private readonly playerManager: PlayerManager,
|
||||||
|
@inject(TYPES.Config) private readonly config: Config,
|
||||||
|
@inject(TYPES.KeyValueCache) cache: KeyValueCacheProvider) {
|
||||||
|
this.sponsorBlockTimeoutDelay = config.SPONSORBLOCK_TIMEOUT;
|
||||||
|
this.sponsorBlock = config.ENABLE_SPONSORBLOCK
|
||||||
|
? new SponsorBlock('muse-sb-integration') // UserID matters only for submissions
|
||||||
|
: undefined;
|
||||||
|
this.cache = cache;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async addToQueue({
|
public async addToQueue({
|
||||||
|
@ -118,6 +135,10 @@ export default class AddQueryToQueue {
|
||||||
newSongs = shuffle(newSongs);
|
newSongs = shuffle(newSongs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.config.ENABLE_SPONSORBLOCK) {
|
||||||
|
newSongs = await Promise.all(newSongs.map(this.skipNonMusicSegments.bind(this)));
|
||||||
|
}
|
||||||
|
|
||||||
newSongs.forEach(song => {
|
newSongs.forEach(song => {
|
||||||
player.add({
|
player.add({
|
||||||
...song,
|
...song,
|
||||||
|
@ -167,4 +188,66 @@ export default class AddQueryToQueue {
|
||||||
await interaction.editReply(`u betcha, **${firstSong.title}** and ${newSongs.length - 1} other songs were added to the queue${extraMsg}`);
|
await interaction.editReply(`u betcha, **${firstSong.title}** and ${newSongs.length - 1} other songs were added to the queue${extraMsg}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async skipNonMusicSegments(song: SongMetadata) {
|
||||||
|
if (!this.sponsorBlock
|
||||||
|
|| (this.sponsorBlockDisabledUntil && new Date() < this.sponsorBlockDisabledUntil)
|
||||||
|
|| song.source !== MediaSource.Youtube
|
||||||
|
|| !song.url) {
|
||||||
|
return song;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const segments = await this.cache.wrap(
|
||||||
|
async () => this.sponsorBlock?.getSegments(song.url, ['music_offtopic']),
|
||||||
|
{
|
||||||
|
key: song.url, // Value is too short for hashing
|
||||||
|
expiresIn: ONE_HOUR_IN_SECONDS,
|
||||||
|
},
|
||||||
|
) ?? [];
|
||||||
|
const skipSegments = segments
|
||||||
|
.sort((a, b) => a.startTime - b.startTime)
|
||||||
|
.reduce((acc: Array<{startTime: number; endTime: number}>, {startTime, endTime}) => {
|
||||||
|
const previousSegment = acc[acc.length - 1];
|
||||||
|
// If segments overlap merge
|
||||||
|
if (previousSegment && previousSegment.endTime > startTime) {
|
||||||
|
acc[acc.length - 1].endTime = endTime;
|
||||||
|
} else {
|
||||||
|
acc.push({startTime, endTime});
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const intro = skipSegments[0];
|
||||||
|
const outro = skipSegments.at(-1);
|
||||||
|
if (outro && outro?.endTime >= song.length - 2) {
|
||||||
|
song.length -= outro.endTime - outro.startTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (intro?.startTime <= 2) {
|
||||||
|
song.offset = Math.floor(intro.endTime);
|
||||||
|
song.length -= song.offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
return song;
|
||||||
|
} catch (e) {
|
||||||
|
if (!(e instanceof Error)) {
|
||||||
|
console.error('Unexpected event occurred while fetching skip segments : ', e);
|
||||||
|
return song;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!e.message.includes('404')) {
|
||||||
|
// Don't log 404 response, it just means that there are no segments for given video
|
||||||
|
console.warn(`Could not fetch skip segments for "${song.url}" :`, e);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.message.includes('504')) {
|
||||||
|
// Stop fetching SponsorBlock data when servers are down
|
||||||
|
this.sponsorBlockDisabledUntil = new Date(new Date().getTime() + (this.sponsorBlockTimeoutDelay * 60_000));
|
||||||
|
}
|
||||||
|
|
||||||
|
return song;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -22,6 +22,8 @@ const CONFIG_MAP = {
|
||||||
BOT_ACTIVITY_TYPE: process.env.BOT_ACTIVITY_TYPE ?? 'LISTENING',
|
BOT_ACTIVITY_TYPE: process.env.BOT_ACTIVITY_TYPE ?? 'LISTENING',
|
||||||
BOT_ACTIVITY_URL: process.env.BOT_ACTIVITY_URL ?? '',
|
BOT_ACTIVITY_URL: process.env.BOT_ACTIVITY_URL ?? '',
|
||||||
BOT_ACTIVITY: process.env.BOT_ACTIVITY ?? 'music',
|
BOT_ACTIVITY: process.env.BOT_ACTIVITY ?? 'music',
|
||||||
|
ENABLE_SPONSORBLOCK: process.env.ENABLE_SPONSORBLOCK === 'true',
|
||||||
|
SPONSORBLOCK_TIMEOUT: process.env.ENABLE_SPONSORBLOCK ?? 5,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const BOT_ACTIVITY_TYPE_MAP = {
|
const BOT_ACTIVITY_TYPE_MAP = {
|
||||||
|
@ -45,6 +47,8 @@ export default class Config {
|
||||||
readonly BOT_ACTIVITY_TYPE!: Exclude<ActivityType, ActivityType.Custom>;
|
readonly BOT_ACTIVITY_TYPE!: Exclude<ActivityType, ActivityType.Custom>;
|
||||||
readonly BOT_ACTIVITY_URL!: string;
|
readonly BOT_ACTIVITY_URL!: string;
|
||||||
readonly BOT_ACTIVITY!: string;
|
readonly BOT_ACTIVITY!: string;
|
||||||
|
readonly ENABLE_SPONSORBLOCK!: boolean;
|
||||||
|
readonly SPONSORBLOCK_TIMEOUT!: number;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
for (const [key, value] of Object.entries(CONFIG_MAP)) {
|
for (const [key, value] of Object.entries(CONFIG_MAP)) {
|
||||||
|
|
|
@ -34,7 +34,7 @@ export interface QueuedPlaylist {
|
||||||
export interface SongMetadata {
|
export interface SongMetadata {
|
||||||
title: string;
|
title: string;
|
||||||
artist: string;
|
artist: string;
|
||||||
url: string;
|
url: string; // For YT, it's the video ID (not the full URI)
|
||||||
length: number;
|
length: number;
|
||||||
offset: number;
|
offset: number;
|
||||||
playlist: QueuedPlaylist | null;
|
playlist: QueuedPlaylist | null;
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue