Skip to content

Commit

Permalink
Merge pull request #1 from ag-av/pl-fix
Browse files Browse the repository at this point in the history
YouTube Playlist fix
  • Loading branch information
ag-av authored Feb 23, 2025
2 parents d7f0a95 + 7256531 commit 303749c
Show file tree
Hide file tree
Showing 9 changed files with 140 additions and 19 deletions.
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
DISCORD_TOKEN=
DATA_DIR=./data
YOUTUBE_API_KEY=
IP_COUNTRY_CODE=
SPOTIFY_CLIENT_ID=
SPOTIFY_CLIENT_SECRET=

Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]
- Check YouTube playlists for unplayable videos (i.e. private / deleted / region restricted) while queuing them
- Add `IP_COUNTRY_CODE` environment variable

## [2.10.1] - 2025-01-28
- Remove Spotify requirement
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ Muse is written in TypeScript. You can either run Muse with Docker (recommended)
- `SPOTIFY_CLIENT_ID` and `SPOTIFY_CLIENT_SECRET` can be acquired [here](https://developer.spotify.com/dashboard/applications) with 'Create a Client ID' (Optional).
- `YOUTUBE_API_KEY` can be acquired by [creating a new project](https://console.developers.google.com) in Google's Developer Console, enabling the YouTube API, and creating an API key under credentials.

While preparing to play a playlist from YouTube, Muse will check the region restictions of its videos. For this reason you need to set `IP_COUNTRY_CODE` to the two character country code (like 'US', 'IT', 'DE', 'ES', [etc](https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2#AD)) of the country you are running Muse in.

Muse will log a URL when run. Open this URL in a browser to invite Muse to your server. Muse will DM the server owner after it's added with setup instructions.

A 64-bit OS is required to run Muse.
Expand Down Expand Up @@ -76,6 +78,7 @@ services:
- YOUTUBE_API_KEY=
- SPOTIFY_CLIENT_ID=
- SPOTIFY_CLIENT_SECRET=
- IP_COUNTRY_CODE=
```
### Node.js
Expand Down
19 changes: 13 additions & 6 deletions src/services/add-query-to-queue.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
/* eslint-disable complexity */
import {ChatInputCommandInteraction, GuildMember} from 'discord.js';
import {ChatInputCommandInteraction, EmbedBuilder, GuildMember} from 'discord.js';
import {inject, injectable} from 'inversify';
import shuffle from 'array-shuffle';
import {TYPES} from '../types.js';
import GetSongs from '../services/get-songs.js';
import {MediaSource, SongMetadata, STATUS} from './player.js';
import PlayerManager from '../managers/player.js';
import {buildPlayingMessageEmbed} from '../utils/build-embed.js';
import {buildPlayingMessageEmbed, buildUnplayableSongsEmbed} from '../utils/build-embed.js';
import {getMemberVoiceChannel, getMostPopularVoiceChannel} from '../utils/channels.js';
import {getGuildSettings} from '../utils/get-guild-settings.js';
import {SponsorBlock} from 'sponsorblock-api';
Expand Down Expand Up @@ -59,7 +59,12 @@ export default class AddQueryToQueue {

await interaction.deferReply({ephemeral: queueAddResponseEphemeral});

let [newSongs, extraMsg] = await this.getSongs.getSongs(query, playlistLimit, shouldSplitChapters);
let [newSongs, extraMsg, unplayableSongs] = await this.getSongs.getSongs(query, playlistLimit, shouldSplitChapters);

const embeds: EmbedBuilder[] = [];
if (unplayableSongs !== null) {
embeds.push(buildUnplayableSongsEmbed(unplayableSongs));
}

if (newSongs.length === 0) {
throw new Error('no songs found');
Expand Down Expand Up @@ -95,9 +100,7 @@ export default class AddQueryToQueue {
statusMsg = 'resuming playback';
}

await interaction.editReply({
embeds: [buildPlayingMessageEmbed(player)],
});
embeds.push(buildPlayingMessageEmbed(player));
} else if (player.status === STATUS.IDLE) {
// Player is idle, start playback instead
await player.play();
Expand All @@ -124,6 +127,10 @@ export default class AddQueryToQueue {
extraMsg = ` (${extraMsg})`;
}

if (embeds.length > 0) {
await interaction.editReply({embeds});
}

if (newSongs.length === 1) {
await interaction.editReply(`u betcha, **${firstSong.title}** added to the${addToFrontOfQueue ? ' front of the' : ''} queue${skipCurrentTrack ? 'and current track skipped' : ''}${extraMsg}`);
} else {
Expand Down
7 changes: 7 additions & 0 deletions src/services/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const CONFIG_MAP = {
YOUTUBE_API_KEY: process.env.YOUTUBE_API_KEY,
SPOTIFY_CLIENT_ID: process.env.SPOTIFY_CLIENT_ID ?? '',
SPOTIFY_CLIENT_SECRET: process.env.SPOTIFY_CLIENT_SECRET ?? '',
IP_COUNTRY_CODE: process.env.IP_COUNTRY_CODE?.toUpperCase(),
REGISTER_COMMANDS_ON_BOT: process.env.REGISTER_COMMANDS_ON_BOT === 'true',
DATA_DIR,
CACHE_DIR: path.join(DATA_DIR, 'cache'),
Expand All @@ -39,6 +40,7 @@ export default class Config {
readonly YOUTUBE_API_KEY!: string;
readonly SPOTIFY_CLIENT_ID!: string;
readonly SPOTIFY_CLIENT_SECRET!: string;
readonly IP_COUNTRY_CODE!: string;
readonly REGISTER_COMMANDS_ON_BOT!: boolean;
readonly DATA_DIR!: string;
readonly CACHE_DIR!: string;
Expand All @@ -60,6 +62,11 @@ export default class Config {
if (key === 'BOT_ACTIVITY_TYPE') {
this[key] = BOT_ACTIVITY_TYPE_MAP[(value as string).toUpperCase() as keyof typeof BOT_ACTIVITY_TYPE_MAP];
continue;
} else if (key === 'IP_COUNTRY_CODE') {
this[key] = (value as string).toUpperCase();
if ((value as string).length !== 2) {
throw new Error(`IP_COUNTRY_CODE must be a 2 character country code (like 'US') but is '${this[key]}'`);
}
}

if (typeof value === 'number') {
Expand Down
13 changes: 8 additions & 5 deletions src/services/get-songs.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {inject, injectable, optional} from 'inversify';
import * as spotifyURI from 'spotify-uri';
import {SongMetadata, QueuedPlaylist, MediaSource} from './player.js';
import {SongMetadata, UnplayableSong, QueuedPlaylist, MediaSource} from './player.js';
import {TYPES} from '../types.js';
import ffmpeg from 'fluent-ffmpeg';
import YoutubeAPI from './youtube-api.js';
Expand All @@ -17,9 +17,10 @@ export default class {
this.spotifyAPI = spotifyAPI;
}

async getSongs(query: string, playlistLimit: number, shouldSplitChapters: boolean): Promise<[SongMetadata[], string]> {
async getSongs(query: string, playlistLimit: number, shouldSplitChapters: boolean): Promise<[SongMetadata[], string, UnplayableSong[] | null]> {
const newSongs: SongMetadata[] = [];
let extraMsg = '';
const unplayableSongs: UnplayableSong[] = [];

// Test if it's a complete URL
try {
Expand All @@ -37,7 +38,9 @@ export default class {
// YouTube source
if (url.searchParams.get('list')) {
// YouTube playlist
newSongs.push(...await this.youtubePlaylist(url.searchParams.get('list')!, shouldSplitChapters));
const [songs, removed] = await this.youtubePlaylist(url.searchParams.get('list')!, shouldSplitChapters);
newSongs.push(...songs);
unplayableSongs.push(...removed);
} else {
const songs = await this.youtubeVideo(url.href, shouldSplitChapters);

Expand Down Expand Up @@ -95,7 +98,7 @@ export default class {
}
}

return [newSongs, extraMsg];
return [newSongs, extraMsg, (unplayableSongs.length > 0 ? unplayableSongs : null)];
}

private async youtubeVideoSearch(query: string, shouldSplitChapters: boolean): Promise<SongMetadata[]> {
Expand All @@ -106,7 +109,7 @@ export default class {
return this.youtubeAPI.getVideo(url, shouldSplitChapters);
}

private async youtubePlaylist(listId: string, shouldSplitChapters: boolean): Promise<SongMetadata[]> {
private async youtubePlaylist(listId: string, shouldSplitChapters: boolean): Promise<[SongMetadata[], UnplayableSong[]]> {
return this.youtubeAPI.getPlaylist(listId, shouldSplitChapters);
}

Expand Down
5 changes: 5 additions & 0 deletions src/services/player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@ export interface QueuedSong extends SongMetadata {
requestedBy: string;
}

export interface UnplayableSong {
playlistIndex: number;
status: string;
}

export enum STATUS {
PLAYING,
PAUSED,
Expand Down
88 changes: 81 additions & 7 deletions src/services/youtube-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {toSeconds, parse} from 'iso8601-duration';
import got, {Got} from 'got';
import ytsr, {Video} from '@distube/ytsr';
import PQueue from 'p-queue';
import {SongMetadata, QueuedPlaylist, MediaSource} from './player.js';
import {SongMetadata, UnplayableSong, QueuedPlaylist, MediaSource} from './player.js';
import {TYPES} from '../types.js';
import Config from './config.js';
import KeyValueCacheProvider from './key-value-cache.js';
Expand All @@ -16,6 +16,9 @@ interface VideoDetailsResponse {
contentDetails: {
videoId: string;
duration: string;
regionRestriction?: {
blocked?: string[];
};
};
snippet: {
title: string;
Expand Down Expand Up @@ -50,17 +53,22 @@ interface PlaylistItem {
contentDetails: {
videoId: string;
};
status: {
privacyStatus: string;
};
}

@injectable()
export default class {
private readonly youtubeKey: string;
private readonly ipCountryCode: string;
private readonly cache: KeyValueCacheProvider;
private readonly ytsrQueue: PQueue;
private readonly got: Got;

constructor(@inject(TYPES.Config) config: Config, @inject(TYPES.KeyValueCache) cache: KeyValueCacheProvider) {
this.youtubeKey = config.YOUTUBE_API_KEY;
this.ipCountryCode = config.IP_COUNTRY_CODE;
this.cache = cache;
this.ytsrQueue = new PQueue({concurrency: 4});

Expand Down Expand Up @@ -113,10 +121,19 @@ export default class {
throw new Error('Video could not be found.');
}

/*
Why is the region restriction not enforced when playing a single video?
if (video.contentDetails.regionRestriction?.blocked !== undefined) {
if (video.contentDetails.regionRestriction.blocked.includes(this.ipCountryCode)) {
throw new Error('Video is region blocked.');
}
}
*/

return this.getMetadataFromVideo({video, shouldSplitChapters});
}

async getPlaylist(listId: string, shouldSplitChapters: boolean): Promise<SongMetadata[]> {
async getPlaylist(listId: string, shouldSplitChapters: boolean): Promise<[SongMetadata[], UnplayableSong[]]> {
const playlistParams = {
searchParams: {
part: 'id, snippet, contentDetails',
Expand All @@ -140,13 +157,15 @@ export default class {
const playlistVideos: PlaylistItem[] = [];
const videoDetailsPromises: Array<Promise<void>> = [];
const videoDetails: VideoDetailsResponse[] = [];
const unplayableSongs: UnplayableSong[] = [];

let nextToken: string | undefined;
let iterationCount = 0;

while (playlistVideos.length < playlist.contentDetails.itemCount) {
while ((playlistVideos.length + unplayableSongs.length) < playlist.contentDetails.itemCount) {
const playlistItemsParams = {
searchParams: {
part: 'id, contentDetails',
part: 'id, contentDetails, status',
playlistId: listId,
maxResults: '50',
pageToken: nextToken,
Expand All @@ -162,7 +181,21 @@ export default class {
},
);

const toRemove = this.findUnplayableVideosInPlaylistItems(items);
for (let i = 0; i < toRemove.length; i++) {
const removed: UnplayableSong = {
playlistIndex: toRemove[i] + (iterationCount * 50),
status: items.splice(toRemove[i] - i, 1)[0].status.privacyStatus,
};
if (removed.status === 'privacyStatusUnspecified') {
removed.status = 'unspecified status (probably deleted)';
}

unplayableSongs.push(removed);
}

nextToken = nextPageToken;
iterationCount++;
playlistVideos.push(...items);

// Start fetching extra details about videos
Expand All @@ -179,10 +212,31 @@ export default class {

const songsToReturn: SongMetadata[] = [];

for (const video of playlistVideos) {
const blockedSongs: UnplayableSong[] = [];
const getCorrectIndex = (idx: number): number => {
for (let i = 0; i < unplayableSongs.length; i++) {
if (unplayableSongs[i].playlistIndex > idx) {
return idx + i;
}
}

return idx + unplayableSongs.length;
};

for (let i = 0; i < playlistVideos.length; i++) {
const video = playlistVideos[i];
const details = videoDetails.find((ind: {id: string}) => ind.id === video.contentDetails.videoId)!;

if (details.contentDetails.regionRestriction?.blocked !== undefined) {
if (details.contentDetails.regionRestriction.blocked.includes(this.ipCountryCode)) {
blockedSongs.push({playlistIndex: getCorrectIndex(i), status: `region restricted [**${details.snippet.title}**]`});
continue;
}
}

try {
songsToReturn.push(...this.getMetadataFromVideo({
video: videoDetails.find((i: {id: string}) => i.id === video.contentDetails.videoId)!,
video: details,
queuedPlaylist,
shouldSplitChapters,
}));
Expand All @@ -192,7 +246,12 @@ export default class {
}
}

return songsToReturn;
if (blockedSongs.length > 0) {
unplayableSongs.push(...blockedSongs);
unplayableSongs.sort((a, b) => a.playlistIndex - b.playlistIndex);
}

return [songsToReturn, unplayableSongs];
}

private getMetadataFromVideo({
Expand Down Expand Up @@ -299,4 +358,19 @@ export default class {
);
return videos;
}

private findUnplayableVideosInPlaylistItems(playlistItems: PlaylistItem[]): number[] {
const unplayableVideos: number[] = [];

for (let i = 0; i < playlistItems.length; i++) {
const status = playlistItems[i].status.privacyStatus;
if (status === 'private' || status === 'privacyStatusUnspecified') {
unplayableVideos.push(i);
} else if (status !== 'unlisted' && status !== 'public') {
console.log('got unknown privacyStatus value in playlist item at %d: %s', i, status);
}
}

return unplayableVideos;
}
}
21 changes: 20 additions & 1 deletion src/utils/build-embed.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import getYouTubeID from 'get-youtube-id';
import {EmbedBuilder} from 'discord.js';
import Player, {MediaSource, QueuedSong, STATUS} from '../services/player.js';
import Player, {MediaSource, QueuedSong, STATUS, UnplayableSong} from '../services/player.js';
import getProgressBar from './get-progress-bar.js';
import {prettyTime} from './time.js';
import {truncate} from './string.js';
Expand Down Expand Up @@ -75,6 +75,25 @@ export const buildPlayingMessageEmbed = (player: Player): EmbedBuilder => {
return message;
};

export const buildUnplayableSongsEmbed = (songs: UnplayableSong[]): EmbedBuilder => {
const songsList: string[] = [];
for (const song of songs) {
songsList.push(`- \`${song.playlistIndex + 1}\` - *${song.status}*`);
}

const message = new EmbedBuilder();
message
.setColor('DarkOrange')
.setTitle('Found Unplayable Songs')
.setDescription(`
**Found ${songs.length} unplayable songs in the playlist:**
${songsList.join('\n')}
`)
.setFooter({text: 'Indices of the songs in the playlist and their privacy status.'});

return message;
};

export const buildQueueEmbed = (player: Player, page: number, pageSize: number): EmbedBuilder => {
const currentlyPlaying = player.getCurrent();

Expand Down

0 comments on commit 303749c

Please sign in to comment.