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.
|
// 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 = {
|
type Song = {
|
||||||
connection: string
|
connection: {
|
||||||
|
id: string
|
||||||
|
type: 'jellyfin' | 'youtube-music'
|
||||||
|
}
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
type: 'song'
|
type: 'song'
|
||||||
duration?: number
|
duration: number // Seconds
|
||||||
thumbnail?: string
|
thumbnailUrl: string // Base/maxres url of song, any scaling for performance purposes will be handled by remoteImage endpoint
|
||||||
artists?: {
|
artists: { // Should try to order
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
|
profilePicture?: string
|
||||||
}[]
|
}[]
|
||||||
album?: {
|
album?: {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
|
thumbnailUrl: string
|
||||||
}
|
}
|
||||||
createdBy?: {
|
isVideo: boolean
|
||||||
|
uploader?: {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
|
profilePicture?: string
|
||||||
}
|
}
|
||||||
releaseDate?: string
|
releaseDate: string // YYYY-MM-DD || YYYY-MM || YYYY
|
||||||
}
|
}
|
||||||
|
|
||||||
type Album = {
|
type Album = {
|
||||||
connection: string
|
connection: {
|
||||||
|
id: string
|
||||||
|
type: 'jellyfin' | 'youtube-music'
|
||||||
|
}
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
type: 'album'
|
type: 'song'
|
||||||
duration?: number
|
duration: number // Seconds
|
||||||
thumbnail?: string
|
thumbnailUrl: string
|
||||||
artists?: {
|
artists: { // Should try to order
|
||||||
// Album Artists
|
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
}[]
|
profilePicture?: string
|
||||||
releaseDate?: 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?
|
// Need to figure out how to do Artists, maybe just query MusicBrainz?
|
||||||
type Artist = {
|
type Artist = {
|
||||||
connection: string
|
connection: {
|
||||||
|
id: string
|
||||||
|
type: 'jellyfin' | 'youtube-music'
|
||||||
|
}
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
type: 'artist'
|
type: 'artist'
|
||||||
thumbnail?: string
|
profilePicture?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type Playlist = {
|
type Playlist = {
|
||||||
connection: string
|
connection: {
|
||||||
|
id: string
|
||||||
|
type: 'jellyfin' | 'youtube-music'
|
||||||
|
}
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
type: 'playlist'
|
type: 'playlist'
|
||||||
|
thumbnailUrl: string
|
||||||
createdBy?: {
|
createdBy?: {
|
||||||
id: string
|
id: string
|
||||||
name: 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 ytdl from 'ytdl-core'
|
||||||
import { DB } from './db'
|
import { DB } from './db'
|
||||||
import { PUBLIC_YOUTUBE_API_CLIENT_ID } from '$env/static/public'
|
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)[]> {
|
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,
|
headers: await this.innertubeRequestHeaders,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -159,8 +159,12 @@ export class YouTubeMusic implements Connection {
|
|||||||
}),
|
}),
|
||||||
}).then((response) => response.json())
|
}).then((response) => response.json())
|
||||||
|
|
||||||
const contents = browseResponse.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents
|
const contents = homeResponse.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents
|
||||||
console.log(JSON.stringify(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 recommendations: (Song | Album | Artist | Playlist)[] = []
|
||||||
const goodSections = ['Listen again', 'Forgotten favorites', 'Quick picks', 'From your library']
|
const goodSections = ['Listen again', 'Forgotten favorites', 'Quick picks', 'From your library']
|
||||||
@@ -174,6 +178,13 @@ export class YouTubeMusic implements Connection {
|
|||||||
recommendations.push(...parsedContent)
|
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
|
return recommendations
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,51 +196,153 @@ export class YouTubeMusic implements Connection {
|
|||||||
|
|
||||||
return await fetch(format.url, { headers })
|
return await fetch(format.url, { headers })
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function parseTwoRowItemRenderer(connection: string, rowContent: InnerTube.musicTwoRowItemRenderer): Song | Album | Artist | Playlist {
|
public async getAlbum(id: string): Promise<ScrapedAlbum> {
|
||||||
const name = rowContent.title.runs[0].text
|
const albumResponse: InnerTube.AlbumResponse = await fetch(`https://music.youtube.com/youtubei/v1/browse`, {
|
||||||
const thumbnail = refineThumbnailUrl(rowContent.thumbnailRenderer.musicThumbnailRenderer.thumbnail.thumbnails[0].url)
|
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())
|
||||||
|
|
||||||
let artists: (Song | Album)['artists'], createdBy: (Song | Playlist)['createdBy']
|
const header = albumResponse.header.musicDetailHeaderRenderer
|
||||||
for (const run of rowContent.subtitle.runs) {
|
const contents = albumResponse.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0].musicShelfRenderer.contents
|
||||||
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']
|
public async getArtist(id: string): Promise<ScrapedArtist> {}
|
||||||
rowContent.menu.menuRenderer.items.forEach((menuOption) => {
|
|
||||||
if (
|
public async getUser(id: string): Promise<ScrapedUser> {}
|
||||||
'menuNavigationItemRenderer' in menuOption &&
|
|
||||||
'browseEndpoint' in menuOption.menuNavigationItemRenderer.navigationEndpoint &&
|
private async buildFullSongProfiles(scrapedSongs: ScrapedSong[]): Promise<Song[]> {
|
||||||
menuOption.menuNavigationItemRenderer.navigationEndpoint.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType === 'MUSIC_PAGE_TYPE_ALBUM'
|
const connection = { id: this.id, type: 'youtube-music' } satisfies Song['connection']
|
||||||
) {
|
|
||||||
album = { id: menuOption.menuNavigationItemRenderer.navigationEndpoint.browseEndpoint.browseId, name: 'NEED TO FIND A WAY TO GET ALBUM NAME FROM ID' }
|
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
|
||||||
|
|
||||||
if ('watchEndpoint' in rowContent.navigationEndpoint) {
|
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 }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
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
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
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
|
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
|
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 id = rowContent.navigationEndpoint.browseEndpoint.browseId
|
||||||
|
const thumbnailUrl = refineThumbnailUrl(rowContent.thumbnailRenderer.musicThumbnailRenderer.thumbnail.thumbnails[0].url)
|
||||||
switch (pageType) {
|
switch (pageType) {
|
||||||
case 'MUSIC_PAGE_TYPE_ALBUM':
|
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_ARTIST':
|
||||||
case 'MUSIC_PAGE_TYPE_USER_CHANNEL':
|
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':
|
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 {
|
function refineThumbnailUrl(urlString: string): string {
|
||||||
if (!URL.canParse(urlString)) throw new Error('Invalid thumbnail url')
|
if (!URL.canParse(urlString)) throw new Error('Invalid thumbnail url')
|
||||||
|
|
||||||
const url = new URL(urlString)
|
switch (new URL(urlString).origin) {
|
||||||
switch (url.origin) {
|
|
||||||
case 'https://i.ytimg.com':
|
|
||||||
return urlString.slice(0, urlString.indexOf('?')).replace('sddefault', 'mqdefault')
|
|
||||||
case 'https://lh3.googleusercontent.com':
|
case 'https://lh3.googleusercontent.com':
|
||||||
case 'https://yt3.googleusercontent.com':
|
case 'https://yt3.googleusercontent.com':
|
||||||
case 'https://yt3.ggpht.com':
|
case 'https://yt3.ggpht.com':
|
||||||
return urlString.slice(0, urlString.indexOf('='))
|
return urlString.slice(0, urlString.indexOf('='))
|
||||||
case 'https://music.youtube.com':
|
case 'https://music.youtube.com':
|
||||||
return urlString
|
return urlString
|
||||||
default:
|
default: // 'https://i.ytimg.com' cannot be manimulated with query params and as such is invalid
|
||||||
console.error(urlString)
|
console.error(urlString)
|
||||||
throw new Error('Invalid thumbnail url origin')
|
throw new Error('Invalid thumbnail url origin')
|
||||||
}
|
}
|
||||||
@@ -340,7 +468,141 @@ function formatDate(): string {
|
|||||||
return year + month + day
|
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 {
|
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 {
|
interface SearchResponse {
|
||||||
contents: {
|
contents: {
|
||||||
tabbedSearchResultsRenderer: {
|
tabbedSearchResultsRenderer: {
|
||||||
@@ -416,7 +678,7 @@ declare namespace InnerTube {
|
|||||||
}>
|
}>
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BrowseResponse {
|
interface HomeResponse {
|
||||||
contents: {
|
contents: {
|
||||||
singleColumnBrowseResultsRenderer: {
|
singleColumnBrowseResultsRenderer: {
|
||||||
tabs: [
|
tabs: [
|
||||||
@@ -483,7 +745,7 @@ declare namespace InnerTube {
|
|||||||
| {
|
| {
|
||||||
browseEndpoint: browseEndpoint
|
browseEndpoint: browseEndpoint
|
||||||
}
|
}
|
||||||
menu: {
|
menu?: {
|
||||||
menuRenderer: {
|
menuRenderer: {
|
||||||
items: Array<
|
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
|
toggleMenuServiceItemRenderer: unknown
|
||||||
@@ -626,7 +907,7 @@ declare namespace InnerTube {
|
|||||||
playlistId: string
|
playlistId: string
|
||||||
watchEndpointMusicSupportedConfigs: {
|
watchEndpointMusicSupportedConfigs: {
|
||||||
watchEndpointMusicConfig: {
|
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">
|
<script lang="ts">
|
||||||
import ScrollableCardMenu from '$lib/components/media/scrollableCardMenu.svelte'
|
import ScrollableCardMenu from '$lib/components/media/scrollableCardMenu.svelte'
|
||||||
import type { PageData } from './$types'
|
import type { PageData } from './$types'
|
||||||
|
import Loader from '$lib/components/util/loader.svelte'
|
||||||
|
|
||||||
export let data: PageData
|
export let data: PageData
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div id="main">
|
<div id="main">
|
||||||
{#await data.recommendations then recommendations}
|
{#await data.recommendations}
|
||||||
|
<Loader />
|
||||||
|
{:then recommendations}
|
||||||
<ScrollableCardMenu header={'Listen Again'} cardDataList={recommendations} />
|
<ScrollableCardMenu header={'Listen Again'} cardDataList={recommendations} />
|
||||||
{/await}
|
{/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>
|
</div>
|
||||||
|
|||||||
@@ -29,13 +29,14 @@
|
|||||||
$newestAlert = ['caution', 'All fields must be filled out']
|
$newestAlert = ['caution', 'All fields must be filled out']
|
||||||
return cancel()
|
return cancel()
|
||||||
}
|
}
|
||||||
try {
|
|
||||||
formData.set('serverUrl', new URL(serverUrl.toString()).origin)
|
if (!URL.canParse(serverUrl.toString())) {
|
||||||
} catch {
|
|
||||||
$newestAlert = ['caution', 'Server URL is invalid']
|
$newestAlert = ['caution', 'Server URL is invalid']
|
||||||
return cancel()
|
return cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
formData.set('serverUrl', new URL(serverUrl.toString()).origin)
|
||||||
|
|
||||||
const deviceId = getDeviceUUID()
|
const deviceId = getDeviceUUID()
|
||||||
formData.append('deviceId', deviceId)
|
formData.append('deviceId', deviceId)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user