diff --git a/src/interfaces/album.ts b/src/interfaces/album.ts index 2f4d6e3..dc98b9d 100644 --- a/src/interfaces/album.ts +++ b/src/interfaces/album.ts @@ -1,6 +1,6 @@ import { ApiArtist } from './artist'; import { ApiPartialTrack } from './track'; -import { ApiImage } from './user'; +import { ApiImage } from '.'; export interface ApiPartialAlbum { album_type: 'album' | 'single' | 'compilation'; diff --git a/src/interfaces/artist.ts b/src/interfaces/artist.ts index 419a7d0..1f46bdc 100644 --- a/src/interfaces/artist.ts +++ b/src/interfaces/artist.ts @@ -1,4 +1,4 @@ -import { ApiImage } from './user'; +import { ApiImage } from './'; export interface ApiPartialArtist { external_urls: Record; diff --git a/src/interfaces/episode.ts b/src/interfaces/episode.ts new file mode 100644 index 0000000..4492a69 --- /dev/null +++ b/src/interfaces/episode.ts @@ -0,0 +1,54 @@ +import { ApiImage } from '.'; + +export interface ApiEpisode { + audio_preview_url: string | null; + description: string; + html_description: string; + duration_ms: number; + explicit: boolean; + external_urls: Record; + href: string; + id: string; + images: ApiImage[]; + is_externally_hosted: boolean; + is_playable: boolean; + /** + * @deprecated {@link https://developer.spotify.com/documentation/web-api/reference/get-information-about-the-users-current-playback} + */ + language?: string; + languages: string[]; + name: string; + release_date: string; + release_date_precision: 'year' | 'month' | 'day'; + resume_point: { + fully_played: boolean; + resume_position_ms: number; + }; + type: 'episode'; + uri: string; + restrictions: { + reason: 'market' | 'product' | 'explicit' + }; + show: { + available_markets: string[]; + copyrights: { + text: string; + type: 'C' | 'P' + } + description: string; + html_description: string; + explicit: boolean; + external_urls: Record; + href: string; + id: string; + images: ApiImage[]; + is_externally_hosted: boolean | null; + languages: string[]; + media_type: string; + name: string; + publisher: string; + type: 'show'; + uri: string; + total_episodes: number; + }; +} \ No newline at end of file diff --git a/src/interfaces/index.ts b/src/interfaces/index.ts new file mode 100644 index 0000000..e29266c --- /dev/null +++ b/src/interfaces/index.ts @@ -0,0 +1,5 @@ +export interface ApiImage { + url: string; + height: number; + width: number; +} \ No newline at end of file diff --git a/src/interfaces/player.ts b/src/interfaces/player.ts index 59e234a..65f75f7 100644 --- a/src/interfaces/player.ts +++ b/src/interfaces/player.ts @@ -1,5 +1,19 @@ import { ApiTrack } from './track'; -import { ApiImage } from './user'; +import { ApiEpisode } from './episode'; + +export enum PlayerContextType { + Artist = 'artist', + Playlist = 'playlist', + Album = 'album', + Show = 'show', +} + +export enum CurrentlyPlayingType { + Track = 'track', + Episode = 'episode', + // when not even spotify knows what you're playing, you music taste might suck + Unknown = 'unknown' +} export interface ApiDevice { id?: string; @@ -17,7 +31,7 @@ export interface ApiPlaybackState { repeat_state: 'off' | 'track' | 'context'; shuffle_state: boolean; context?: { - type: 'artist' | 'playlist' | 'album' | 'show'; + type: PlayerContextType; href: string; external_urls: Record; uri: string; @@ -26,7 +40,7 @@ export interface ApiPlaybackState { progress_ms: number; is_playing: boolean; item?: ApiTrack | ApiEpisode; - currently_playing_type: 'track' | 'episode' | 'unknown'; + currently_playing_type: CurrentlyPlayingType; actions: { interrupting_playback: boolean pausing: boolean @@ -39,57 +53,4 @@ export interface ApiPlaybackState { toggling_repeat_track: boolean transferring_playback: boolean }; -} - -export interface ApiEpisode { - audio_preview_url: string | null; - description: string; - html_description: string; - duration_ms: number; - explicit: boolean; - external_urls: Record; - href: string; - id: string; - images: ApiImage[]; - is_externally_hosted: boolean; - is_playable: boolean; - /** - * @deprecated {@link https://developer.spotify.com/documentation/web-api/reference/get-information-about-the-users-current-playback} - */ - language?: string; - languages: string[]; - name: string; - release_date: string; - release_date_precision: 'year' | 'month' | 'day'; - resume_point: { - fully_played: boolean; - resume_position_ms: number; - }; - type: 'episode'; - uri: string; - restrictions: { - reason: 'market' | 'product' | 'explicit' - }; - show: { - available_markets: string[]; - copyrights: { - text: string; - type: 'C' | 'P' - } - description: string; - html_description: string; - explicit: boolean; - external_urls: Record; - href: string; - id: string; - images: ApiImage[]; - is_externally_hosted: boolean | null; - languages: string[]; - media_type: string; - name: string; - publisher: string; - type: 'show'; - uri: string; - total_episodes: number; - }; } \ No newline at end of file diff --git a/src/interfaces/playlist.ts b/src/interfaces/playlist.ts new file mode 100644 index 0000000..ecc8975 --- /dev/null +++ b/src/interfaces/playlist.ts @@ -0,0 +1,61 @@ +import { ApiImage } from '.'; +import { ApiEpisode } from './episode'; +import { ApiTrack } from './track'; + +export interface ApiUserPlaylists { + href: string; + limit: number; + next: string | null; + offset: number; + previous: string | null; + total: number; + items: ApiPartialPlaylist[]; +} + +export interface ApiPlaylistOwner { + external_urls: Record; + followers: { + href: string; + total: number; + }; + href: string; + id: string; + type: 'user'; + uri: string; + display_name: string | null; +} + +export interface ApiPartialPlaylist { + collaborative: boolean; + description: string | null; + external_urls: Record; + href: string; + id: string; + images: ApiImage[]; + name: string; + owner: ApiPlaylistOwner; + public: boolean; + snapshot_id: string; + tracks: { + href: string; + total: number; + } | null; + type: string; + uri: string; +} + +// no ApiPlaylist for now since only ApiPartialPlaylist is used to get users' playlists. +// export interface ApiPlaylist extends ApiPartialPlaylist {} + +export interface ApiPlaylistTrack { + /* + * Will only be `null` in very old playlists. + */ + added_at: string | null; + /* + * Will only be `null` in very old playlists. + */ + added_by: Omit | null; + is_local: boolean; + track: T; +} \ No newline at end of file diff --git a/src/interfaces/user.ts b/src/interfaces/user.ts index 3cb51a5..297ec57 100644 --- a/src/interfaces/user.ts +++ b/src/interfaces/user.ts @@ -1,3 +1,5 @@ +import { ApiImage } from '.'; + export interface ApiUser { country?: string; display_name: string | null; @@ -15,10 +17,4 @@ export interface ApiUser { id: string; images: ApiImage[]; product?: 'premium' | 'free' | 'open'; -} - -export interface ApiImage { - url: string; - height: number; - width: number; } \ No newline at end of file diff --git a/src/lib/structures/player/DeviceManager.ts b/src/lib/managers/devices/index.ts similarity index 63% rename from src/lib/structures/player/DeviceManager.ts rename to src/lib/managers/devices/index.ts index e11911b..8f5609b 100644 --- a/src/lib/structures/player/DeviceManager.ts +++ b/src/lib/managers/devices/index.ts @@ -1,13 +1,11 @@ -import { Lunify } from '../..'; -import { Player } from '.'; +import { Lunify, Player, PlayerDevice } from '../..'; import { ApiDevice } from '../../../interfaces/player'; -import { PlayerDevice } from './Device'; export class PlayerDeviceManager { constructor( public client: Lunify, - private player: Player, + public player: Player, ) { } /** @@ -24,11 +22,9 @@ export class PlayerDeviceManager { } }); - const devices: PlayerDevice[] = []; - - for (const apiDevice of res.devices) devices.push(new PlayerDevice(this.client, this.player.user, apiDevice)); - - return devices; + return res.devices.map((device) => + new PlayerDevice(this.client, this.player, device) + ); } /** @@ -40,23 +36,16 @@ export class PlayerDeviceManager { * player.devices.transferPlaybackTo(deviceId); * ``` */ - async transferPlaybackTo(device: string | string[]) { - - const finalDevices: string[] = []; - - if (typeof device !== 'string') { - for (const d of device) finalDevices.push(d); - } - else { - finalDevices.push(device); - } + async transferPlaybackTo(deviceId: string | string[]) { await this.client.rest.put('/me/player', { headers: { Authorization: await this.player.user.oauth.getAuthorization() }, body: { - device_ids: finalDevices + device_ids: typeof deviceId === 'string' + ? [deviceId] + : deviceId } }); diff --git a/src/lib/managers/index.ts b/src/lib/managers/index.ts index 654b749..5f36baa 100644 --- a/src/lib/managers/index.ts +++ b/src/lib/managers/index.ts @@ -1,5 +1,6 @@ export * from './cache'; export * from './credentials'; +export * from './devices'; export * from './oauth'; export * from './rest'; export * from './tracks'; diff --git a/src/lib/managers/oauth/index.ts b/src/lib/managers/oauth/index.ts index dab1e17..3f40d1d 100644 --- a/src/lib/managers/oauth/index.ts +++ b/src/lib/managers/oauth/index.ts @@ -1,13 +1,10 @@ -import { EventEmitter } from 'stream'; import { ApiTokenResponse } from '../../../interfaces/oauth'; import { Lunify, LunifyErrors, RequestDomain, Scopes } from '../..'; import { UserOauth } from '../../structures/user'; -export class OauthManager extends EventEmitter { +export class OauthManager { - constructor(public client: Lunify) { - super(); - } + constructor(public client: Lunify) {} /** * Create a oAuth url for users to authorize @@ -95,4 +92,4 @@ export class OauthManager extends EventEmitter { return new UserOauth(this.client, res); } -} +} \ No newline at end of file diff --git a/src/lib/managers/playlists/Tracks.ts b/src/lib/managers/playlists/Tracks.ts new file mode 100644 index 0000000..83ea2c8 --- /dev/null +++ b/src/lib/managers/playlists/Tracks.ts @@ -0,0 +1,59 @@ +import { Lunify, PartialPlaylist, PlaylistTrack, UserOauth } from '../..'; +import { ApiEpisode } from '../../../interfaces/episode'; +import { ApiPlaylistTrack } from '../../../interfaces/playlist'; +import { ApiTrack } from '../../../interfaces/track'; +import { CacheManager } from '../cache'; + +const FETCH_TRACK_CHUNK_SIZE = 100; + +export class PlaylistTracksManager { + public cache: CacheManager; + + constructor( + public client: Lunify, + public playlist: PartialPlaylist, // | Playlist + private oauth: UserOauth + ) { + this.cache = new CacheManager(); + } + + async fetchSinglePage(page: number) { + + const params = new URLSearchParams(); + params.append('limit', FETCH_TRACK_CHUNK_SIZE.toString()); + params.append('offset', (page * FETCH_TRACK_CHUNK_SIZE).toString() || '0'); + + const res = await this.client.rest.get<{ items: ApiPlaylistTrack[] }>('/playlists/' + this.playlist.id + '/tracks?' + params.toString(), { + headers: { + Authorization: await this.oauth.getAuthorization() + } + }); + + const tracks = res.items.map((track) => + track.track.type === 'episode' + ? track.track + : new PlaylistTrack(this.client, track as ApiPlaylistTrack) + ); + + for (const track of tracks) this.cache.set(track.id, track); + + return tracks; + } + + private async fetchMany(page: number): Promise<(PlaylistTrack | ApiEpisode)[]> { + const res = await this.fetchSinglePage(page); + + if (res.length >= FETCH_TRACK_CHUNK_SIZE) { + return [ + ...res, + ...(await this.fetchMany(page + 1)) + ]; + } + + return res; + } + + fetch() { + return this.fetchMany(0); + } +} \ No newline at end of file diff --git a/src/lib/managers/rest/index.ts b/src/lib/managers/rest/index.ts index 421f94b..dde492a 100644 --- a/src/lib/managers/rest/index.ts +++ b/src/lib/managers/rest/index.ts @@ -162,4 +162,4 @@ export class RestManager { return this.request({ ...options, route, method: RequestMethod.Patch }); } -} +} \ No newline at end of file diff --git a/src/lib/managers/users/Playlists.ts b/src/lib/managers/users/Playlists.ts new file mode 100644 index 0000000..b91acb4 --- /dev/null +++ b/src/lib/managers/users/Playlists.ts @@ -0,0 +1,36 @@ +import { Lunify, PartialUser, User } from '../..'; +import { ApiUserPlaylists } from '../../../interfaces/playlist'; +import { PartialPlaylist } from '../../structures/playlist'; +import { CacheManager } from '../cache'; + +export class UserPlaylistsManager { + public cache: CacheManager; + + constructor( + public client: Lunify, + public user: PartialUser | User, + ) { + this.cache = new CacheManager(); + } + + async fetch(page?: number) { + + const params = new URLSearchParams(); + params.append('limit', '50'); + params.append('offset', ((page || 0) * 50).toString() || '0'); + + const res = await this.client.rest.get(`/me/playlists?${params.toString()}`, { + headers: { + Authorization: await this.user.oauth.getAuthorization() + } + }); + + const playlists = res.items.map((playlist) => + new PartialPlaylist(this.client, playlist, this.user.oauth) + ); + + for (const playlist of playlists) this.cache.set(playlist.id, playlist); + + return playlists; + } +} \ No newline at end of file diff --git a/src/lib/managers/users/index.ts b/src/lib/managers/users/index.ts index 541ca15..ffceecc 100644 --- a/src/lib/managers/users/index.ts +++ b/src/lib/managers/users/index.ts @@ -2,6 +2,8 @@ import { Lunify, UserOauth, User } from '../..'; import { ApiUser } from '../../../interfaces/user'; import { CacheManager } from '../cache'; +export * from './Playlists'; + export class UsersManager { public cache: CacheManager; diff --git a/src/lib/structures/album/index.ts b/src/lib/structures/album/index.ts index 9bed4b5..303e019 100644 --- a/src/lib/structures/album/index.ts +++ b/src/lib/structures/album/index.ts @@ -1,6 +1,6 @@ import { Lunify, PartialTrack } from '../..'; import { ApiAlbum, ApiPartialAlbum } from '../../../interfaces/album'; -import { ApiImage } from '../../../interfaces/user'; +import { ApiImage } from '../../../interfaces'; import { Artist } from '../artist'; export class PartialAlbum { diff --git a/src/lib/structures/index.ts b/src/lib/structures/index.ts index 47b33bf..8e8bdad 100644 --- a/src/lib/structures/index.ts +++ b/src/lib/structures/index.ts @@ -1,5 +1,6 @@ export * from './album'; export * from './artist'; export * from './player'; +export * from './playlist'; export * from './track'; export * from './user'; \ No newline at end of file diff --git a/src/lib/structures/player/CurrentPlayback.ts b/src/lib/structures/player/CurrentPlayback.ts index 0168589..bf7466f 100644 --- a/src/lib/structures/player/CurrentPlayback.ts +++ b/src/lib/structures/player/CurrentPlayback.ts @@ -1,29 +1,37 @@ import { Lunify, Track } from '../..'; -import { ApiEpisode, ApiPlaybackState } from '../../../interfaces/player'; +import { ApiPlaybackState, CurrentlyPlayingType, PlayerContextType } from '../../../interfaces/player'; import { ApiTrack } from '../../../interfaces/track'; -import { PartialUser } from '../user'; import { PlayerDevice } from './Device'; +import { Player } from '.'; +import { ApiEpisode } from '../../../interfaces/episode'; export class CurrentPlayback { public device: PlayerDevice; public repeat: 'track' | 'context' | false; public shuffle: boolean; - public context: Omit, 'href'> & { externalUrls: Record, url: string; }; + public context?: { + type: PlayerContextType; + url: string; + externalUrls: Record; + uri: string; + }; public timestamp: number; public progress: number; public playing: boolean; public item: Track | ApiEpisode; - public playingType: ApiPlaybackState['currently_playing_type']; + public playingType: CurrentlyPlayingType; constructor( public client: Lunify, - public user: PartialUser, + public player: Player, data: ApiPlaybackState ) { - this.device = new PlayerDevice(this.client, user, data.device); + this.device = new PlayerDevice(this.client, player, data.device); this.repeat = data.repeat_state !== 'off' ? data.repeat_state : false; this.shuffle = data.shuffle_state; - this.context = { type: data.context?.type || null, url: data.context?.href || null, externalUrls: data.context?.external_urls || null, uri: data.context?.uri || null }; + this.context = data.context + ? { type: data.context.type, url: data.context.href, externalUrls: data.context.external_urls, uri: data.context.uri } + : null; this.timestamp = data.timestamp; this.progress = data.progress_ms; this.playing = data.is_playing; diff --git a/src/lib/structures/player/Device.ts b/src/lib/structures/player/Device.ts index 2e7c8d9..6918b80 100644 --- a/src/lib/structures/player/Device.ts +++ b/src/lib/structures/player/Device.ts @@ -1,6 +1,6 @@ import { Lunify } from '../..'; import { ApiDevice } from '../../../interfaces/player'; -import { PartialUser, User } from '../user'; +import { Player } from '.'; export class PlayerDevice { public id?: string; @@ -14,7 +14,7 @@ export class PlayerDevice { constructor( public client: Lunify, - public user: User | PartialUser, + public player: Player, data: ApiDevice ) { this.id = data.id; @@ -34,7 +34,7 @@ export class PlayerDevice { async transferPlaybackTo() { if (this.active) return false; - await this.user.player.devices.transferPlaybackTo(this.id); + await this.player.devices.transferPlaybackTo(this.id); return true; } diff --git a/src/lib/structures/player/index.ts b/src/lib/structures/player/index.ts index b66591b..ed7a9a5 100644 --- a/src/lib/structures/player/index.ts +++ b/src/lib/structures/player/index.ts @@ -1,11 +1,11 @@ import { Lunify } from '../..'; import { ApiPlaybackState } from '../../../interfaces/player'; +import { PlayerDeviceManager } from '../../managers/devices'; import { PartialUser, User } from '../user'; import { CurrentPlayback } from './CurrentPlayback'; -import { PlayerDeviceManager } from './DeviceManager'; export * from './Device'; -export * from './DeviceManager'; +export * from './CurrentPlayback'; export class Player { public devices: PlayerDeviceManager; @@ -33,7 +33,7 @@ export class Player { }); if (!res) return null; - return new CurrentPlayback(this.client, this.user, res); + return new CurrentPlayback(this.client, this, res); } /** diff --git a/src/lib/structures/playlist/index.ts b/src/lib/structures/playlist/index.ts new file mode 100644 index 0000000..fae558b --- /dev/null +++ b/src/lib/structures/playlist/index.ts @@ -0,0 +1,38 @@ +import { Lunify, UserOauth } from '../..'; +import { ApiImage } from '../../../interfaces'; +import { ApiPartialPlaylist, ApiPlaylistOwner } from '../../../interfaces/playlist'; +import { PlaylistTracksManager } from '../../managers/playlists/Tracks'; + +export class PartialPlaylist { + public tracks: PlaylistTracksManager; + + public collaborative: boolean; + public description: string | null; + public externalUrls: Record; + public url: string; + public id: string; + public images: ApiImage[]; + public name: string; + public owner: ApiPlaylistOwner; + public public: boolean; + public uri: string; + + constructor( + public client: Lunify, + data: ApiPartialPlaylist, + oauth: UserOauth + ) { + this.tracks = new PlaylistTracksManager(client, this, oauth); + + this.collaborative = data.collaborative; + this.description = data.description; + this.externalUrls = data.external_urls; + this.url = data.href; + this.id = data.id; + this.images = data.images; + this.name = data.name; + this.owner = data.owner; + this.public = data.public; + this.uri = data.uri; + } +} \ No newline at end of file diff --git a/src/lib/structures/track/index.ts b/src/lib/structures/track/index.ts index 041b97f..c2f95aa 100644 --- a/src/lib/structures/track/index.ts +++ b/src/lib/structures/track/index.ts @@ -1,4 +1,5 @@ import { Lunify } from '../..'; +import { ApiPlaylistOwner, ApiPlaylistTrack } from '../../../interfaces/playlist'; import { ApiPartialTrack, ApiTrack } from '../../../interfaces/track'; import { PartialAlbum } from '../album'; import { PartialArtist } from '../artist'; @@ -25,7 +26,7 @@ export class PartialTrack { constructor( public client: Lunify, - data?: Omit & { album?: ApiPartialTrack['album'] } + data: Omit & { album?: ApiPartialTrack['album'] } ) { if (data.album) this.album = new PartialAlbum(client, data.album); @@ -58,7 +59,7 @@ export class Track extends PartialTrack { constructor( public client: Lunify, - data?: ApiTrack + data: ApiTrack ) { super(client, data); @@ -66,4 +67,25 @@ export class Track extends PartialTrack { this.popularity = data.popularity; } +} + +export class PlaylistTrack extends Track { + /* + * Will only be `null` in very old playlists. + */ + public addedTimestamp: number | null; + /* + * Will only be `null` in very old playlists. + */ + public addedBy: Omit; + + constructor( + public client: Lunify, + data: ApiPlaylistTrack + ) { + super(client, data.track); + + this.addedTimestamp = new Date(data.added_at).getTime() || null; + this.addedBy = data.added_by; + } } \ No newline at end of file diff --git a/src/lib/structures/user/Oauth.ts b/src/lib/structures/user/Oauth.ts index 4ba757e..d627ac3 100644 --- a/src/lib/structures/user/Oauth.ts +++ b/src/lib/structures/user/Oauth.ts @@ -13,6 +13,7 @@ export class UserOauth { constructor( public client: Lunify, + // omiting "public user" due to ".refreshToken(...)" data: ApiTokenResponse | ApiRefreshTokenResponse ) { this.refreshToken = null; diff --git a/src/lib/structures/user/index.ts b/src/lib/structures/user/index.ts index 22244fd..a4633d2 100644 --- a/src/lib/structures/user/index.ts +++ b/src/lib/structures/user/index.ts @@ -1,7 +1,8 @@ -import { Lunify } from '../..'; -import { ApiImage, ApiUser } from '../../../interfaces/user'; +import { Lunify, UserPlaylistsManager } from '../..'; +import { ApiUser } from '../../../interfaces/user'; import { UserOauth } from './Oauth'; import { Player } from '../player'; +import { ApiImage } from '../../../interfaces'; export * from './Oauth'; @@ -10,24 +11,25 @@ export class PartialUser { * Control user playback */ public player: Player; + public playlists: UserPlaylistsManager; constructor( public client: Lunify, public oauth: UserOauth, ) { this.player = new Player(client, this); + this.playlists = new UserPlaylistsManager(client, this); } } export class User extends PartialUser { - public country?: string; public displayName: string | null; public email?: string; - public explicitContent?: { - enabled: boolean; - locked: boolean; + public explicitContent: { + enabled: boolean | null; + locked: boolean | null; }; public externalUrls: Record; public followers: { @@ -51,7 +53,7 @@ export class User extends PartialUser { this.email = data.email; this.explicitContent = { enabled: data.explicit_content?.filter_enabled || null, locked: data.explicit_content?.filter_locked || null }; this.externalUrls = data.external_urls; - this.followers = { url: data.followers?.href, total: data.followers?.total }; + this.followers = { url: data.followers.href, total: data.followers.total }; this.url = data.href; this.id = data.id; this.images = data.images;