Redoing the standard format for songs, albums, artists, and playlists
This commit is contained in:
54
src/app.d.ts
vendored
54
src/app.d.ts
vendored
@@ -52,61 +52,79 @@ declare global {
|
||||
// Big data should be fetched as needed in the app, these exist to ensure that the info necessary to fetch that data is there.
|
||||
|
||||
type Song = {
|
||||
connection: string
|
||||
connection: {
|
||||
id: string
|
||||
type: 'jellyfin' | 'youtube-music'
|
||||
}
|
||||
id: string
|
||||
name: string
|
||||
type: 'song'
|
||||
duration?: number
|
||||
thumbnail?: string
|
||||
artists?: {
|
||||
duration: number // Seconds
|
||||
thumbnailUrl: string // Base/maxres url of song, any scaling for performance purposes will be handled by remoteImage endpoint
|
||||
artists: { // Should try to order
|
||||
id: string
|
||||
name: string
|
||||
profilePicture?: string
|
||||
}[]
|
||||
album?: {
|
||||
id: string
|
||||
name: string
|
||||
thumbnailUrl: string
|
||||
}
|
||||
createdBy?: {
|
||||
isVideo: boolean
|
||||
uploader?: {
|
||||
id: string
|
||||
name: string
|
||||
profilePicture?: string
|
||||
}
|
||||
releaseDate?: string
|
||||
releaseDate: string // YYYY-MM-DD || YYYY-MM || YYYY
|
||||
}
|
||||
|
||||
type Album = {
|
||||
connection: string
|
||||
connection: {
|
||||
id: string
|
||||
type: 'jellyfin' | 'youtube-music'
|
||||
}
|
||||
id: string
|
||||
name: string
|
||||
type: 'album'
|
||||
duration?: number
|
||||
thumbnail?: string
|
||||
artists?: {
|
||||
// Album Artists
|
||||
type: 'song'
|
||||
duration: number // Seconds
|
||||
thumbnailUrl: string
|
||||
artists: { // Should try to order
|
||||
id: string
|
||||
name: string
|
||||
}[]
|
||||
releaseDate?: string
|
||||
profilePicture?: string
|
||||
}[] | 'Various Artists'
|
||||
releaseDate: string // YYYY-MM-DD || YYYY-MM || YYYY
|
||||
length: number
|
||||
}
|
||||
|
||||
// Need to figure out how to do Artists, maybe just query MusicBrainz?
|
||||
type Artist = {
|
||||
connection: string
|
||||
connection: {
|
||||
id: string
|
||||
type: 'jellyfin' | 'youtube-music'
|
||||
}
|
||||
id: string
|
||||
name: string
|
||||
type: 'artist'
|
||||
thumbnail?: string
|
||||
profilePicture?: string
|
||||
}
|
||||
|
||||
type Playlist = {
|
||||
connection: string
|
||||
connection: {
|
||||
id: string
|
||||
type: 'jellyfin' | 'youtube-music'
|
||||
}
|
||||
id: string
|
||||
name: string
|
||||
type: 'playlist'
|
||||
thumbnailUrl: string
|
||||
createdBy?: {
|
||||
id: string
|
||||
name: string
|
||||
profilePicture?: string
|
||||
}
|
||||
thumbnail?: string
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
44
src/lib/components/util/loader.svelte
Normal file
44
src/lib/components/util/loader.svelte
Normal file
@@ -0,0 +1,44 @@
|
||||
<!-- Credit to https://cssloaders.github.io/ -->
|
||||
|
||||
<span id="loader" class="absolute left-1/2 top-1/2 h-20 w-20 -translate-x-1/2 -translate-y-1/2" />
|
||||
|
||||
<style>
|
||||
#loader:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: inherit;
|
||||
height: inherit;
|
||||
border-radius: 50%;
|
||||
animation: 1s spin linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0.2rem 0;
|
||||
}
|
||||
12% {
|
||||
box-shadow: 0.2rem 0.2rem;
|
||||
}
|
||||
25% {
|
||||
box-shadow: 0 0.2rem;
|
||||
}
|
||||
37% {
|
||||
box-shadow: -0.2rem 0.2rem;
|
||||
}
|
||||
50% {
|
||||
box-shadow: -0.2rem 0;
|
||||
}
|
||||
62% {
|
||||
box-shadow: -0.2rem -0.2rem;
|
||||
}
|
||||
75% {
|
||||
box-shadow: 0 -0.2rem;
|
||||
}
|
||||
87% {
|
||||
box-shadow: 0.2rem -0.2rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,4 +1,4 @@
|
||||
import { google } from 'googleapis'
|
||||
import { google, type youtube_v3 } from 'googleapis'
|
||||
import ytdl from 'ytdl-core'
|
||||
import { DB } from './db'
|
||||
import { PUBLIC_YOUTUBE_API_CLIENT_ID } from '$env/static/public'
|
||||
@@ -144,7 +144,7 @@ export class YouTubeMusic implements Connection {
|
||||
}
|
||||
|
||||
public async getRecommendations(): Promise<(Song | Album | Artist | Playlist)[]> {
|
||||
const browseResponse: InnerTube.BrowseResponse = await fetch(`https://music.youtube.com/youtubei/v1/browse`, {
|
||||
const homeResponse: InnerTube.HomeResponse = await fetch(`https://music.youtube.com/youtubei/v1/browse`, {
|
||||
headers: await this.innertubeRequestHeaders,
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
@@ -159,8 +159,12 @@ export class YouTubeMusic implements Connection {
|
||||
}),
|
||||
}).then((response) => response.json())
|
||||
|
||||
const contents = browseResponse.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents
|
||||
console.log(JSON.stringify(contents))
|
||||
const contents = homeResponse.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents
|
||||
|
||||
const playlist = await google.youtube('v3').playlistItems.list({ playlistId: 'OLAK5uy_luC2CX2NPU_qVCVvCh4r4M2igAltIJ0Bc', part: ['snippet'], maxResults: 50, access_token: await this.accessToken })
|
||||
console.log(JSON.stringify(playlist))
|
||||
|
||||
return []
|
||||
|
||||
const recommendations: (Song | Album | Artist | Playlist)[] = []
|
||||
const goodSections = ['Listen again', 'Forgotten favorites', 'Quick picks', 'From your library']
|
||||
@@ -174,6 +178,13 @@ export class YouTubeMusic implements Connection {
|
||||
recommendations.push(...parsedContent)
|
||||
}
|
||||
|
||||
const songs = recommendations.filter((recommendation) => recommendation.type === 'song') as Song[]
|
||||
const scrapedSong = songs.map((song) => {
|
||||
return { id: song.id, name: song.name, type: 'song', isVideo: false } satisfies ScrapedSong
|
||||
})
|
||||
|
||||
this.buildFullSongProfiles(scrapedSong)
|
||||
|
||||
return recommendations
|
||||
}
|
||||
|
||||
@@ -185,51 +196,153 @@ export class YouTubeMusic implements Connection {
|
||||
|
||||
return await fetch(format.url, { headers })
|
||||
}
|
||||
|
||||
public async getAlbum(id: string): Promise<ScrapedAlbum> {
|
||||
const albumResponse: InnerTube.AlbumResponse = await fetch(`https://music.youtube.com/youtubei/v1/browse`, {
|
||||
headers: await this.innertubeRequestHeaders,
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
browseId: id,
|
||||
context: {
|
||||
client: {
|
||||
clientName: 'WEB_REMIX',
|
||||
clientVersion: `1.${formatDate()}.01.00`,
|
||||
hl: 'en',
|
||||
},
|
||||
},
|
||||
}),
|
||||
}).then((response) => response.json())
|
||||
|
||||
const header = albumResponse.header.musicDetailHeaderRenderer
|
||||
const contents = albumResponse.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0].musicShelfRenderer.contents
|
||||
}
|
||||
|
||||
function parseTwoRowItemRenderer(connection: string, rowContent: InnerTube.musicTwoRowItemRenderer): Song | Album | Artist | Playlist {
|
||||
public async getArtist(id: string): Promise<ScrapedArtist> {}
|
||||
|
||||
public async getUser(id: string): Promise<ScrapedUser> {}
|
||||
|
||||
private async buildFullSongProfiles(scrapedSongs: ScrapedSong[]): Promise<Song[]> {
|
||||
const connection = { id: this.id, type: 'youtube-music' } satisfies Song['connection']
|
||||
|
||||
const songIds = new Set<string>(),
|
||||
albumIds = new Set<string>(),
|
||||
artistIds = new Set<string>(),
|
||||
userIds = new Set<string>()
|
||||
|
||||
scrapedSongs.forEach((song) => {
|
||||
songIds.add(song.id)
|
||||
if (song.album?.id) {
|
||||
albumIds.add(song.album.id)
|
||||
}
|
||||
song.artists.forEach((artist) => artistIds.add(artist.id))
|
||||
if (song.uploader) {
|
||||
userIds.add(song.uploader.id)
|
||||
}
|
||||
})
|
||||
|
||||
const getSongDetails = async () => google.youtube('v3').videos.list({ part: ['snippet', 'contentDetails'], id: Array.from(songIds), access_token: await this.accessToken })
|
||||
const getAlbumDetails = () => Promise.all(Array.from(albumIds).map((id) => this.getAlbum(id)))
|
||||
const getArtistDetails = () => Promise.all(Array.from(artistIds).map((id) => this.getArtist(id)))
|
||||
const getUserDetails = () => Promise.all(Array.from(userIds).map((id) => this.getUser(id)))
|
||||
|
||||
const [songDetails, albumDetails, artistDetails, userDetails] = await Promise.all([getSongDetails(), getAlbumDetails(), getArtistDetails(), getUserDetails()])
|
||||
const songDetailsMap = new Map<string, youtube_v3.Schema$Video>(),
|
||||
albumDetailsMap = new Map<string, ScrapedAlbum>(),
|
||||
artistDetailsMap = new Map<string, ScrapedArtist>(),
|
||||
userDetailsMap = new Map<string, ScrapedUser>()
|
||||
songDetails.data.items!.forEach((item) => songDetailsMap.set(item.id!, item))
|
||||
albumDetails.forEach((album) => albumDetailsMap.set(album.id, album))
|
||||
artistDetails.forEach((artist) => artistDetailsMap.set(artist.id, artist))
|
||||
userDetails.forEach((user) => userDetailsMap.set(user.id, user))
|
||||
|
||||
return scrapedSongs.map((song) => {
|
||||
const songDetails = songDetailsMap.get(song.id)!
|
||||
const duration = secondsFromISO8601(songDetails.contentDetails?.duration!)
|
||||
|
||||
const thumbnails = songDetails.snippet?.thumbnails!
|
||||
const thumbnailUrl = song.thumbnailUrl ?? thumbnails.maxres?.url ?? thumbnails.standard?.url ?? thumbnails.high?.url ?? thumbnails.medium?.url ?? thumbnails.default?.url!
|
||||
|
||||
let album: Song['album'],
|
||||
uploader: Song['uploader'],
|
||||
releaseDate = new Date(songDetails.snippet?.publishedAt!).toLocaleDateString()
|
||||
|
||||
const artists: Song['artists'] = song.artists.map((artist) => {
|
||||
const { id, name, profilePicture } = artistDetailsMap.get(artist.id)!
|
||||
return { id, name, profilePicture }
|
||||
})
|
||||
if (song.album) {
|
||||
const { id, name, thumbnailUrl, releaseYear } = albumDetailsMap.get(song.album.id)!
|
||||
album = { id, name, thumbnailUrl }
|
||||
releaseDate = releaseYear
|
||||
}
|
||||
if (song.uploader) {
|
||||
const { id, name, profilePicture } = userDetailsMap.get(song.uploader.id)!
|
||||
uploader = { id, name, profilePicture }
|
||||
}
|
||||
|
||||
return { connection, id: song.id, name: song.name, type: 'song', duration, thumbnailUrl, artists, album, isVideo: song.isVideo, uploader, releaseDate }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function parseTwoRowItemRenderer(rowContent: InnerTube.musicTwoRowItemRenderer): ScrapedSong | ScrapedAlbum | ScrapedArtist | ScrapedPlaylist {
|
||||
const name = rowContent.title.runs[0].text
|
||||
const thumbnail = refineThumbnailUrl(rowContent.thumbnailRenderer.musicThumbnailRenderer.thumbnail.thumbnails[0].url)
|
||||
|
||||
let artists: (Song | Album)['artists'], createdBy: (Song | Playlist)['createdBy']
|
||||
for (const run of rowContent.subtitle.runs) {
|
||||
if (!run.navigationEndpoint) continue
|
||||
|
||||
const pageType = run.navigationEndpoint.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType
|
||||
if (pageType === 'MUSIC_PAGE_TYPE_ARTIST') {
|
||||
const artist = { id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text }
|
||||
artists ? artists.push(artist) : (artists = [artist])
|
||||
} else if (pageType === 'MUSIC_PAGE_TYPE_USER_CHANNEL') {
|
||||
createdBy = { id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text }
|
||||
}
|
||||
}
|
||||
|
||||
let album: Song['album']
|
||||
if ('watchEndpoint' in rowContent.navigationEndpoint) {
|
||||
let album: ScrapedSong['album'],
|
||||
artists: ScrapedSong['artists'] = [],
|
||||
uploader: ScrapedSong['uploader']
|
||||
rowContent.menu.menuRenderer.items.forEach((menuOption) => {
|
||||
if (
|
||||
'menuNavigationItemRenderer' in menuOption &&
|
||||
'browseEndpoint' in menuOption.menuNavigationItemRenderer.navigationEndpoint &&
|
||||
menuOption.menuNavigationItemRenderer.navigationEndpoint.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType === 'MUSIC_PAGE_TYPE_ALBUM'
|
||||
) {
|
||||
album = { id: menuOption.menuNavigationItemRenderer.navigationEndpoint.browseEndpoint.browseId, name: 'NEED TO FIND A WAY TO GET ALBUM NAME FROM ID' }
|
||||
album = { id: menuOption.menuNavigationItemRenderer.navigationEndpoint.browseEndpoint.browseId }
|
||||
}
|
||||
})
|
||||
rowContent.subtitle.runs.forEach((run) => {
|
||||
if (!run.navigationEndpoint) return
|
||||
|
||||
const pageType = run.navigationEndpoint.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType
|
||||
const runData = { id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text }
|
||||
if (pageType === 'MUSIC_PAGE_TYPE_ARTIST') {
|
||||
artists.push(runData)
|
||||
} else if (pageType === 'MUSIC_PAGE_TYPE_USER_CHANNEL') {
|
||||
uploader = runData
|
||||
}
|
||||
})
|
||||
|
||||
if ('watchEndpoint' in rowContent.navigationEndpoint) {
|
||||
const isUserUploaded = rowContent.navigationEndpoint.watchEndpoint.watchEndpointMusicSupportedConfigs.watchEndpointMusicConfig.musicVideoType === 'MUSIC_VIDEO_TYPE_UGC'
|
||||
const thumbnailUrl: ScrapedSong['thumbnailUrl'] = isUserUploaded ? undefined : refineThumbnailUrl(rowContent.thumbnailRenderer.musicThumbnailRenderer.thumbnail.thumbnails[0].url)
|
||||
|
||||
const id = rowContent.navigationEndpoint.watchEndpoint.videoId
|
||||
return { connection, id, name, type: 'song', artists, createdBy, thumbnail } satisfies Song
|
||||
return { id, name, type: 'song', thumbnailUrl, album, artists, uploader, isVideo: isUserUploaded } satisfies ScrapedSong
|
||||
}
|
||||
|
||||
const pageType = rowContent.navigationEndpoint.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType
|
||||
rowContent.menu.menuRenderer.items.forEach((menuItem) => {
|
||||
if ('menuServiceItemRenderer' in menuItem) {
|
||||
const queueTarget = menuItem.menuServiceItemRenderer.serviceEndpoint.queueAddEndpoint.queueTarget
|
||||
if ('playlistId' in queueTarget) {
|
||||
const thumbnailUrl = refineThumbnailUrl(rowContent.thumbnailRenderer.musicThumbnailRenderer.thumbnail.thumbnails[0].url)
|
||||
if (pageType === 'MUSIC_PAGE_TYPE_ALBUM') {
|
||||
const album = { id: queueTarget.playlistId, name, type: 'album', thumbnailUrl } satisfies ScrapedAlbum
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const id = rowContent.navigationEndpoint.browseEndpoint.browseId
|
||||
const thumbnailUrl = refineThumbnailUrl(rowContent.thumbnailRenderer.musicThumbnailRenderer.thumbnail.thumbnails[0].url)
|
||||
switch (pageType) {
|
||||
case 'MUSIC_PAGE_TYPE_ALBUM':
|
||||
return { connection, id, name, type: 'album', artists, thumbnail } satisfies Album
|
||||
return { id, name, type: 'album', artists, thumbnailUrl } satisfies ScrapedAlbum
|
||||
case 'MUSIC_PAGE_TYPE_ARTIST':
|
||||
case 'MUSIC_PAGE_TYPE_USER_CHANNEL':
|
||||
return { connection, id, name, type: 'artist', thumbnail } satisfies Artist
|
||||
return { id, name, type: 'artist', profilePicture: thumbnailUrl } satisfies ScrapedArtist
|
||||
case 'MUSIC_PAGE_TYPE_PLAYLIST':
|
||||
return { connection, id, name, type: 'playlist', createdBy, thumbnail } satisfies Playlist
|
||||
return { id, name, type: 'playlist', createdBy, thumbnailUrl } satisfies ScrapedPlaylist
|
||||
}
|
||||
}
|
||||
|
||||
@@ -312,20 +425,35 @@ function parseMusicCardShelfRenderer(connection: string, cardContent: InnerTube.
|
||||
}
|
||||
}
|
||||
|
||||
/** YouTube should (I haven't confirmed) cap duration scaling at days, so any duration will be in the following ISO8601 Format: PnDTnHnMnS */
|
||||
function secondsFromISO8601(duration: string): number {
|
||||
const iso8601DurationRegex = /P(?:([.,\d]+)D)?T(?:([.,\d]+)H)?(?:([.,\d]+)M)?(?:([.,\d]+)S)?/ // Credit: https://stackoverflow.com/users/1195273/crush
|
||||
const result = iso8601DurationRegex.exec(duration)
|
||||
const days = result?.[1] ?? 0,
|
||||
hours = result?.[2] ?? 0,
|
||||
minutes = result?.[3] ?? 0,
|
||||
seconds = result?.[4] ?? 0
|
||||
return Number(seconds) + Number(minutes) * 60 + Number(hours) * 3600 + Number(days) * 86400
|
||||
}
|
||||
|
||||
/** Remove YouTubes fake query parameters from their thumbnail urls returning the base url for as needed modification.
|
||||
* Valid URL origins:
|
||||
* - https://lh3.googleusercontent.com
|
||||
* - https://yt3.googleusercontent.com
|
||||
* - https://yt3.ggpht.com
|
||||
* - https://music.youtube.com
|
||||
*/
|
||||
function refineThumbnailUrl(urlString: string): string {
|
||||
if (!URL.canParse(urlString)) throw new Error('Invalid thumbnail url')
|
||||
|
||||
const url = new URL(urlString)
|
||||
switch (url.origin) {
|
||||
case 'https://i.ytimg.com':
|
||||
return urlString.slice(0, urlString.indexOf('?')).replace('sddefault', 'mqdefault')
|
||||
switch (new URL(urlString).origin) {
|
||||
case 'https://lh3.googleusercontent.com':
|
||||
case 'https://yt3.googleusercontent.com':
|
||||
case 'https://yt3.ggpht.com':
|
||||
return urlString.slice(0, urlString.indexOf('='))
|
||||
case 'https://music.youtube.com':
|
||||
return urlString
|
||||
default:
|
||||
default: // 'https://i.ytimg.com' cannot be manimulated with query params and as such is invalid
|
||||
console.error(urlString)
|
||||
throw new Error('Invalid thumbnail url origin')
|
||||
}
|
||||
@@ -340,7 +468,141 @@ function formatDate(): string {
|
||||
return year + month + day
|
||||
}
|
||||
|
||||
// NOTE 1:
|
||||
// When scraping thumbnails from the YTMusic browse pages, there are two different types of images that can be returned,
|
||||
// standard video thumbnais and auto-generated square thumbnails for propper releases. The auto-generated thumbanils we want to
|
||||
// keep from the scrape because:
|
||||
// a) They can be easily scaled with ytmusic's weird fake query parameters (Ex: https://baseUrl=w1000&h1000)
|
||||
// b) When fetched from the youtube data api it returns the 16:9 filled thumbnails like you would see in the standard yt player, we want the squares
|
||||
//
|
||||
// However when the thumbnail is for a video, we want to ignore it because the highest quality thumbnail will rarely be used in the ytmusic player
|
||||
// and there is no easy way scale them due to the fixed sizes (default, medium, high, standard, maxres) without any way to determine if a higher quality exists.
|
||||
// Therefor, these thumbanils should be fetched from the youtube data api and the highest res should be chosen. In the remoteImage endpoint this his res can
|
||||
// be scaled to the desired resolution with image processing.
|
||||
type ScrapedSong = {
|
||||
id: string
|
||||
name: string
|
||||
type: 'song'
|
||||
thumbnailUrl: string
|
||||
artists: {
|
||||
id: string
|
||||
name?: string
|
||||
}[]
|
||||
album?: {
|
||||
id: string
|
||||
name?: string
|
||||
}
|
||||
uploader?: {
|
||||
id: string
|
||||
name?: string
|
||||
}
|
||||
isVideo: boolean
|
||||
}
|
||||
|
||||
type ScrapedAlbum = {
|
||||
id: string
|
||||
name: string
|
||||
type: 'album'
|
||||
thumbnailUrl: string
|
||||
artists:
|
||||
| {
|
||||
id: string
|
||||
name?: string
|
||||
}[]
|
||||
| 'Various Artists'
|
||||
releaseYear?: string
|
||||
length?: number
|
||||
}
|
||||
|
||||
type ScrapedArtist = {
|
||||
id: string
|
||||
name: string
|
||||
type: 'artist'
|
||||
profilePicture?: string
|
||||
}
|
||||
|
||||
type ScrapedPlaylist = {
|
||||
id: string
|
||||
name: string
|
||||
type: 'playlist'
|
||||
thumbnailUrl: string
|
||||
createdBy?: {
|
||||
id: string
|
||||
name?: string
|
||||
}
|
||||
}
|
||||
|
||||
type ScrapedUser = {
|
||||
id: string
|
||||
name: string
|
||||
type: 'user'
|
||||
profilePicture?: string
|
||||
}
|
||||
|
||||
declare namespace InnerTube {
|
||||
interface AlbumResponse {
|
||||
contents: {
|
||||
singleColumnBrowseResultsRenderer: {
|
||||
tabs: [
|
||||
{
|
||||
tabRenderer: {
|
||||
content: {
|
||||
sectionListRenderer: {
|
||||
contents: [
|
||||
{
|
||||
musicShelfRenderer: {
|
||||
contents: Array<{
|
||||
musicResponsiveListItemRenderer: {
|
||||
flexColumns: Array<{
|
||||
musicResponsiveListItemFlexColumnRenderer: {
|
||||
text: {
|
||||
runs?: [
|
||||
{
|
||||
text: string
|
||||
navigationEndpoint?: {
|
||||
watchEndpoint: watchEndpoint
|
||||
}
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
}>
|
||||
}
|
||||
}>
|
||||
}
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
header: {
|
||||
musicDetailHeaderRenderer: {
|
||||
title: {
|
||||
runs: [
|
||||
{
|
||||
text: string
|
||||
},
|
||||
]
|
||||
}
|
||||
subtitle: {
|
||||
runs: Array<{
|
||||
text: string
|
||||
navigationEndpoint?: {
|
||||
browseEndpoint: browseEndpoint
|
||||
}
|
||||
}>
|
||||
}
|
||||
thumbnail: {
|
||||
croppedSquareThumbnailRenderer: musicThumbnailRenderer
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface SearchResponse {
|
||||
contents: {
|
||||
tabbedSearchResultsRenderer: {
|
||||
@@ -416,7 +678,7 @@ declare namespace InnerTube {
|
||||
}>
|
||||
}
|
||||
|
||||
interface BrowseResponse {
|
||||
interface HomeResponse {
|
||||
contents: {
|
||||
singleColumnBrowseResultsRenderer: {
|
||||
tabs: [
|
||||
@@ -483,7 +745,7 @@ declare namespace InnerTube {
|
||||
| {
|
||||
browseEndpoint: browseEndpoint
|
||||
}
|
||||
menu: {
|
||||
menu?: {
|
||||
menuRenderer: {
|
||||
items: Array<
|
||||
| {
|
||||
@@ -511,7 +773,26 @@ declare namespace InnerTube {
|
||||
}
|
||||
}
|
||||
| {
|
||||
menuServiceItemRenderer: unknown
|
||||
menuServiceItemRenderer: {
|
||||
text: {
|
||||
runs: [
|
||||
{
|
||||
text: 'Play next' | 'Add to queue'
|
||||
},
|
||||
]
|
||||
}
|
||||
serviceEndpoint: {
|
||||
queueAddEndpoint: {
|
||||
queueTarget:
|
||||
| {
|
||||
playlistId: string
|
||||
}
|
||||
| {
|
||||
videoId: string
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
| {
|
||||
toggleMenuServiceItemRenderer: unknown
|
||||
@@ -626,7 +907,7 @@ declare namespace InnerTube {
|
||||
playlistId: string
|
||||
watchEndpointMusicSupportedConfigs: {
|
||||
watchEndpointMusicConfig: {
|
||||
musicVideoType: 'MUSIC_VIDEO_TYPE_UGC' | 'MUSIC_VIDEO_TYPE_ATV'
|
||||
musicVideoType: 'MUSIC_VIDEO_TYPE_UGC' | 'MUSIC_VIDEO_TYPE_ATV' // UGC Means it is a user-uploaded video, ATV means it is auto-generated
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
<script lang="ts">
|
||||
import ScrollableCardMenu from '$lib/components/media/scrollableCardMenu.svelte'
|
||||
import type { PageData } from './$types'
|
||||
import Loader from '$lib/components/util/loader.svelte'
|
||||
|
||||
export let data: PageData
|
||||
</script>
|
||||
|
||||
<div id="main">
|
||||
{#await data.recommendations then recommendations}
|
||||
{#await data.recommendations}
|
||||
<Loader />
|
||||
{:then recommendations}
|
||||
<ScrollableCardMenu header={'Listen Again'} cardDataList={recommendations} />
|
||||
{/await}
|
||||
<!-- <h1 class="mb-6 text-4xl"><strong>Listen Again</strong></h1>
|
||||
<div class="flex flex-wrap justify-between gap-6">
|
||||
{#each data.recommendations as recommendation}
|
||||
<MediaCard mediaItem={recommendation} />
|
||||
{/each}
|
||||
</div> -->
|
||||
</div>
|
||||
|
||||
@@ -29,13 +29,14 @@
|
||||
$newestAlert = ['caution', 'All fields must be filled out']
|
||||
return cancel()
|
||||
}
|
||||
try {
|
||||
formData.set('serverUrl', new URL(serverUrl.toString()).origin)
|
||||
} catch {
|
||||
|
||||
if (!URL.canParse(serverUrl.toString())) {
|
||||
$newestAlert = ['caution', 'Server URL is invalid']
|
||||
return cancel()
|
||||
}
|
||||
|
||||
formData.set('serverUrl', new URL(serverUrl.toString()).origin)
|
||||
|
||||
const deviceId = getDeviceUUID()
|
||||
formData.append('deviceId', deviceId)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user