diff options
Diffstat (limited to 'src/services/get-songs.ts')
| -rw-r--r-- | src/services/get-songs.ts | 356 |
1 files changed, 46 insertions, 310 deletions
diff --git a/src/services/get-songs.ts b/src/services/get-songs.ts index 55a4215..e7f9b45 100644 --- a/src/services/get-songs.ts +++ b/src/services/get-songs.ts @@ -1,247 +1,90 @@ -import {URL} from 'url'; import {inject, injectable} from 'inversify'; -import {toSeconds, parse} from 'iso8601-duration'; -import got from 'got'; -import ytsr, {Video} from 'ytsr'; import spotifyURI from 'spotify-uri'; -import Spotify from 'spotify-web-api-node'; -import YouTube, {YoutubePlaylistItem, YoutubeVideo} from 'youtube.ts'; -import PQueue from 'p-queue'; -import shuffle from 'array-shuffle'; -import {Except} from 'type-fest'; -import {QueuedSong, QueuedPlaylist} from '../services/player.js'; +import {SongMetadata, QueuedPlaylist, MediaSource} from '../services/player.js'; import {TYPES} from '../types.js'; -import {cleanUrl} from '../utils/url.js'; -import ThirdParty from './third-party.js'; -import Config from './config.js'; -import KeyValueCacheProvider from './key-value-cache.js'; -import {ONE_HOUR_IN_SECONDS, ONE_MINUTE_IN_SECONDS} from '../utils/constants.js'; -import {parseTime} from '../utils/time.js'; - -type SongMetadata = Except<QueuedSong, 'addedInChannelId' | 'requestedBy'>; - -interface VideoDetailsResponse { - id: string; - contentDetails: { - videoId: string; - duration: string; - }; -} +import ffmpeg from 'fluent-ffmpeg'; +import YoutubeAPI from './youtube-api.js'; +import SpotifyAPI, {SpotifyTrack} from './spotify-api.js'; @injectable() export default class { - private readonly youtube: YouTube; - private readonly youtubeKey: string; - private readonly spotify: Spotify; - private readonly cache: KeyValueCacheProvider; - - private readonly ytsrQueue: PQueue; + private readonly youtubeAPI: YoutubeAPI; + private readonly spotifyAPI: SpotifyAPI; constructor( - @inject(TYPES.ThirdParty) thirdParty: ThirdParty, - @inject(TYPES.Config) config: Config, - @inject(TYPES.KeyValueCache) cache: KeyValueCacheProvider) { - this.youtube = thirdParty.youtube; - this.youtubeKey = config.YOUTUBE_API_KEY; - this.spotify = thirdParty.spotify; - this.cache = cache; - - this.ytsrQueue = new PQueue({concurrency: 4}); + @inject(TYPES.Services.YoutubeAPI) youtubeAPI: YoutubeAPI, + @inject(TYPES.Services.SpotifyAPI) spotifyAPI: SpotifyAPI) { + this.youtubeAPI = youtubeAPI; + this.spotifyAPI = spotifyAPI; } async youtubeVideoSearch(query: string, shouldSplitChapters: boolean): Promise<SongMetadata[]> { - const {items} = await this.ytsrQueue.add(async () => this.cache.wrap( - ytsr, - query, - { - limit: 10, - }, - { - expiresIn: ONE_HOUR_IN_SECONDS, - }, - )); - - let firstVideo: Video | undefined; - - for (const item of items) { - if (item.type === 'video') { - firstVideo = item; - break; - } - } - - if (!firstVideo) { - throw new Error('No video found.'); - } - - return this.youtubeVideo(firstVideo.id, shouldSplitChapters); + return this.youtubeAPI.search(query, shouldSplitChapters); } async youtubeVideo(url: string, shouldSplitChapters: boolean): Promise<SongMetadata[]> { - const video = await this.cache.wrap( - this.youtube.videos.get, - cleanUrl(url), - { - expiresIn: ONE_HOUR_IN_SECONDS, - }, - ); - - return this.getMetadataFromVideo({video, shouldSplitChapters}); + return this.youtubeAPI.getVideo(url, shouldSplitChapters); } async youtubePlaylist(listId: string, shouldSplitChapters: boolean): Promise<SongMetadata[]> { - // YouTube playlist - const playlist = await this.cache.wrap( - this.youtube.playlists.get, - listId, - { - expiresIn: ONE_MINUTE_IN_SECONDS, - }, - ); - - const playlistVideos: YoutubePlaylistItem[] = []; - const videoDetailsPromises: Array<Promise<void>> = []; - const videoDetails: VideoDetailsResponse[] = []; - - let nextToken: string | undefined; - - while (playlistVideos.length !== playlist.contentDetails.itemCount) { - // eslint-disable-next-line no-await-in-loop - const {items, nextPageToken} = await this.cache.wrap( - this.youtube.playlists.items, - listId, - {maxResults: '50', pageToken: nextToken}, - { - expiresIn: ONE_MINUTE_IN_SECONDS, - }, - ); - - nextToken = nextPageToken; - - playlistVideos.push(...items); - - // Start fetching extra details about videos - videoDetailsPromises.push((async () => { - // Unfortunately, package doesn't provide a method for this - const p = { - searchParams: { - part: 'contentDetails', - id: items.map(item => item.contentDetails.videoId).join(','), - key: this.youtubeKey, - responseType: 'json', - }, - }; - const {items: videoDetailItems} = await this.cache.wrap( - async () => got( - 'https://www.googleapis.com/youtube/v3/videos', - p, - ).json() as Promise<{items: VideoDetailsResponse[]}>, - p, - { - expiresIn: ONE_MINUTE_IN_SECONDS, - }, - ); - - videoDetails.push(...videoDetailItems); - })()); - } - - await Promise.all(videoDetailsPromises); - - const queuedPlaylist = {title: playlist.snippet.title, source: playlist.id}; - - const songsToReturn: SongMetadata[] = []; - - for (const video of playlistVideos) { - try { - songsToReturn.push(...this.getMetadataFromVideo({ - video, - queuedPlaylist, - videoDetails: videoDetails.find((i: {id: string}) => i.id === video.contentDetails.videoId), - shouldSplitChapters, - })); - } catch (_: unknown) { - // Private and deleted videos are sometimes in playlists, duration of these is not returned and they should not be added to the queue. - } - } - - return songsToReturn; + return this.youtubeAPI.getPlaylist(listId, shouldSplitChapters); } async spotifySource(url: string, playlistLimit: number, shouldSplitChapters: boolean): Promise<[SongMetadata[], number, number]> { const parsed = spotifyURI.parse(url); - let tracks: SpotifyApi.TrackObjectSimplified[] = []; - - let playlist: QueuedPlaylist | null = null; - switch (parsed.type) { case 'album': { - const uri = parsed as spotifyURI.Album; - - const [{body: album}, {body: {items}}] = await Promise.all([this.spotify.getAlbum(uri.id), this.spotify.getAlbumTracks(uri.id, {limit: 50})]); - - tracks.push(...items); - - playlist = {title: album.name, source: album.href}; - break; + const [tracks, playlist] = await this.spotifyAPI.getAlbum(url, playlistLimit); + return this.spotifyToYouTube(tracks, shouldSplitChapters, playlist); } case 'playlist': { - const uri = parsed as spotifyURI.Playlist; - - let [{body: playlistResponse}, {body: tracksResponse}] = await Promise.all([this.spotify.getPlaylist(uri.id), this.spotify.getPlaylistTracks(uri.id, {limit: 50})]); - - playlist = {title: playlistResponse.name, source: playlistResponse.href}; - - tracks.push(...tracksResponse.items.map(playlistItem => playlistItem.track)); - - while (tracksResponse.next) { - // eslint-disable-next-line no-await-in-loop - ({body: tracksResponse} = await this.spotify.getPlaylistTracks(uri.id, { - limit: parseInt(new URL(tracksResponse.next).searchParams.get('limit') ?? '50', 10), - offset: parseInt(new URL(tracksResponse.next).searchParams.get('offset') ?? '0', 10), - })); - - tracks.push(...tracksResponse.items.map(playlistItem => playlistItem.track)); - } - - break; + const [tracks, playlist] = await this.spotifyAPI.getPlaylist(url, playlistLimit); + return this.spotifyToYouTube(tracks, shouldSplitChapters, playlist); } case 'track': { - const uri = parsed as spotifyURI.Track; - - const {body} = await this.spotify.getTrack(uri.id); - - tracks.push(body); - break; + const tracks = [await this.spotifyAPI.getTrack(url)]; + return this.spotifyToYouTube(tracks, shouldSplitChapters); } case 'artist': { - const uri = parsed as spotifyURI.Artist; - - const {body} = await this.spotify.getArtistTopTracks(uri.id, 'US'); - - tracks.push(...body.tracks); - break; + const tracks = await this.spotifyAPI.getArtist(url, playlistLimit); + return this.spotifyToYouTube(tracks, shouldSplitChapters); } default: { return [[], 0, 0]; } } + } - // Get random songs if the playlist is larger than limit - const originalNSongs = tracks.length; - - if (tracks.length > playlistLimit) { - const shuffled = shuffle(tracks); + async httpLiveStream(url: string): Promise<SongMetadata> { + return new Promise((resolve, reject) => { + ffmpeg(url).ffprobe((err, _) => { + if (err) { + reject(); + } - tracks = shuffled.slice(0, playlistLimit); - } + resolve({ + url, + source: MediaSource.HLS, + isLive: true, + title: url, + artist: url, + length: 0, + offset: 0, + playlist: null, + thumbnailUrl: null, + }); + }); + }); + } - const searchResults = await Promise.allSettled(tracks.map(async track => this.spotifyToYouTube(track, shouldSplitChapters))); + private async spotifyToYouTube(tracks: SpotifyTrack[], shouldSplitChapters: boolean, playlist?: QueuedPlaylist | undefined): Promise<[SongMetadata[], number, number]> { + const promisedResults = tracks.map(async track => this.youtubeAPI.search(`"${track.name}" "${track.artist}"`, shouldSplitChapters)); + const searchResults = await Promise.allSettled(promisedResults); let nSongsNotFound = 0; @@ -261,113 +104,6 @@ export default class { return accum; }, []); - return [songs, nSongsNotFound, originalNSongs]; - } - - private async spotifyToYouTube(track: SpotifyApi.TrackObjectSimplified, shouldSplitChapters: boolean): Promise<SongMetadata[]> { - return this.youtubeVideoSearch(`"${track.name}" "${track.artists[0].name}"`, shouldSplitChapters); - } - - // TODO: we should convert YouTube videos (from both single videos and playlists) to an intermediate representation so we don't have to check if it's from a playlist - private getMetadataFromVideo({ - video, - queuedPlaylist, - videoDetails, - shouldSplitChapters, - }: { - video: YoutubeVideo | YoutubePlaylistItem; - queuedPlaylist?: QueuedPlaylist; - videoDetails?: VideoDetailsResponse; - shouldSplitChapters?: boolean; - }): SongMetadata[] { - let url: string; - let videoDurationSeconds: number; - // Dirty hack - if (queuedPlaylist) { - // Is playlist item - video = video as YoutubePlaylistItem; - url = video.contentDetails.videoId; - videoDurationSeconds = toSeconds(parse(videoDetails!.contentDetails.duration)); - } else { - video = video as YoutubeVideo; - videoDurationSeconds = toSeconds(parse(video.contentDetails.duration)); - url = video.id; - } - - const base: SongMetadata = { - title: video.snippet.title, - artist: video.snippet.channelTitle, - length: videoDurationSeconds, - offset: 0, - url, - playlist: queuedPlaylist ?? null, - isLive: false, - thumbnailUrl: video.snippet.thumbnails.medium.url, - }; - - if (!shouldSplitChapters) { - return [base]; - } - - const chapters = this.parseChaptersFromDescription(video.snippet.description, videoDurationSeconds); - - if (!chapters) { - return [base]; - } - - const tracks: SongMetadata[] = []; - - for (const [label, {offset, length}] of chapters) { - tracks.push({ - ...base, - offset, - length, - title: `${label} (${base.title})`, - }); - } - - return tracks; - } - - private parseChaptersFromDescription(description: string, videoDurationSeconds: number) { - const map = new Map<string, {offset: number; length: number}>(); - let foundFirstTimestamp = false; - - const foundTimestamps: Array<{name: string; offset: number}> = []; - for (const line of description.split('\n')) { - const timestamps = Array.from(line.matchAll(/(?:\d+:)+\d+/g)); - if (timestamps?.length !== 1) { - continue; - } - - if (!foundFirstTimestamp) { - if (/0{1,2}:00/.test(timestamps[0][0])) { - foundFirstTimestamp = true; - } else { - continue; - } - } - - const timestamp = timestamps[0][0]; - const seconds = parseTime(timestamp); - const chapterName = line.split(timestamp)[1].trim(); - - foundTimestamps.push({name: chapterName, offset: seconds}); - } - - for (const [i, {name, offset}] of foundTimestamps.entries()) { - map.set(name, { - offset, - length: i === foundTimestamps.length - 1 - ? videoDurationSeconds - offset - : foundTimestamps[i + 1].offset - offset, - }); - } - - if (!map.size) { - return null; - } - - return map; + return [songs, nSongsNotFound, tracks.length]; } } |
