Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Check YouTube playlists for unplayable videos while queuing them #1230

Open
wants to merge 6 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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