diff --git a/.env.example b/.env.example index cb4fc193..ec9f6d5a 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,7 @@ DISCORD_TOKEN= DATA_DIR=./data YOUTUBE_API_KEY= +IP_COUNTRY_CODE= SPOTIFY_CLIENT_ID= SPOTIFY_CLIENT_SECRET= diff --git a/CHANGELOG.md b/CHANGELOG.md index 52263f11..29807cf4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index d22400ec..8b6d4cfb 100644 --- a/README.md +++ b/README.md @@ -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. @@ -76,6 +78,7 @@ services: - YOUTUBE_API_KEY= - SPOTIFY_CLIENT_ID= - SPOTIFY_CLIENT_SECRET= + - IP_COUNTRY_CODE= ``` ### Node.js diff --git a/src/services/add-query-to-queue.ts b/src/services/add-query-to-queue.ts index 95e16fff..526c8bef 100644 --- a/src/services/add-query-to-queue.ts +++ b/src/services/add-query-to-queue.ts @@ -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'; @@ -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'); @@ -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(); @@ -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 { diff --git a/src/services/config.ts b/src/services/config.ts index 3941a3c7..d5b53633 100644 --- a/src/services/config.ts +++ b/src/services/config.ts @@ -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'), @@ -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; @@ -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') { diff --git a/src/services/get-songs.ts b/src/services/get-songs.ts index c48d87dc..fb8e71c6 100644 --- a/src/services/get-songs.ts +++ b/src/services/get-songs.ts @@ -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'; @@ -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 { @@ -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); @@ -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 { @@ -106,7 +109,7 @@ export default class { return this.youtubeAPI.getVideo(url, shouldSplitChapters); } - private async youtubePlaylist(listId: string, shouldSplitChapters: boolean): Promise { + private async youtubePlaylist(listId: string, shouldSplitChapters: boolean): Promise<[SongMetadata[], UnplayableSong[]]> { return this.youtubeAPI.getPlaylist(listId, shouldSplitChapters); } diff --git a/src/services/player.ts b/src/services/player.ts index d2c2f920..e9b2c176 100644 --- a/src/services/player.ts +++ b/src/services/player.ts @@ -48,6 +48,11 @@ export interface QueuedSong extends SongMetadata { requestedBy: string; } +export interface UnplayableSong { + playlistIndex: number; + status: string; +} + export enum STATUS { PLAYING, PAUSED, diff --git a/src/services/youtube-api.ts b/src/services/youtube-api.ts index 47b91d8e..668f7636 100644 --- a/src/services/youtube-api.ts +++ b/src/services/youtube-api.ts @@ -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'; @@ -16,6 +16,9 @@ interface VideoDetailsResponse { contentDetails: { videoId: string; duration: string; + regionRestriction?: { + blocked?: string[]; + }; }; snippet: { title: string; @@ -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}); @@ -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 { + async getPlaylist(listId: string, shouldSplitChapters: boolean): Promise<[SongMetadata[], UnplayableSong[]]> { const playlistParams = { searchParams: { part: 'id, snippet, contentDetails', @@ -140,13 +157,15 @@ export default class { const playlistVideos: PlaylistItem[] = []; const videoDetailsPromises: Array> = []; 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, @@ -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 @@ -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, })); @@ -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({ @@ -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; + } } diff --git a/src/utils/build-embed.ts b/src/utils/build-embed.ts index 23db0b9c..b56fe474 100644 --- a/src/utils/build-embed.ts +++ b/src/utils/build-embed.ts @@ -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'; @@ -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();