More progress on the Innertube home parser

This commit is contained in:
Eclypsed
2024-03-09 01:44:22 -05:00
parent b03565f06f
commit d50497e7d5
8 changed files with 234 additions and 270 deletions

View File

@@ -6,6 +6,7 @@
img { img {
max-width: 100%; max-width: 100%;
max-height: 100%;
} }
/* Hide scrollbar for Chrome, Safari and Opera */ /* Hide scrollbar for Chrome, Safari and Opera */

View File

@@ -4,106 +4,58 @@
import IconButton from '$lib/components/util/iconButton.svelte' import IconButton from '$lib/components/util/iconButton.svelte'
import { goto } from '$app/navigation' import { goto } from '$app/navigation'
const iconClasses = { let image: HTMLImageElement, captionText: HTMLDivElement
song: 'fa-solid fa-music',
album: 'fa-solid fa-compact-disc',
artist: 'fa-solid fa-user',
playlist: 'fa-solid fa-forward-fast',
}
let card: HTMLDivElement, cardGlare: HTMLDivElement const checkSongOrAlbum = (item: MediaItem): item is Song | Album => {
return item.type === 'song' || item.type === 'album'
const rotateCard = (event: MouseEvent): void => {
const cardRect = card.getBoundingClientRect()
const x = (2 * (event.x - cardRect.left)) / cardRect.width - 1 // These are simplified calculations to find the x-y coords relative to the center of the card
const y = (2 * (cardRect.top - event.y)) / cardRect.height + 1
const angle = Math.atan(x / y) // You'd think it should be y / x but it's actually the inverse
const distanceFromCorner = Math.sqrt((x - 1) ** 2 + (y - 1) ** 2) // This is a cool little trick, the -1 on the x an y coordinate is effective the same as saying "make the origin of the glare [1, 1]"
cardGlare.style.backgroundImage = `linear-gradient(${angle}rad, transparent ${distanceFromCorner * 50 + 50}%, rgba(255, 255, 255, 0.1) ${distanceFromCorner * 50 + 60}%, transparent 100%)`
card.style.transform = `rotateX(${y * 10}deg) rotateY(${x * 10}deg)`
}
const checkSong = (item: MediaItem): item is Song => {
return (item as Song).type === 'song'
}
const checkAlbum = (item: MediaItem): item is Album => {
return (item as Album).type === 'album'
} }
</script> </script>
<div id="card-wrapper" class="w-52 flex-shrink-0"> <div id="card-wrapper" class="flex-shrink-0">
<div <button id="thumbnail" class="relative h-56 transition-all duration-200 ease-out" on:click={() => goto(`/details/${mediaItem.type}?id=${mediaItem.id}`)}>
bind:this={card}
id="card"
class="relative grid place-items-center transition-all duration-200 ease-out"
on:mousemove={(event) => rotateCard(event)}
on:mouseleave={() => (card.style.transform = '')}
role="menuitem"
tabindex="-1"
>
<button on:click={() => goto(`/details/${mediaItem.type}?id=${mediaItem.id}&connection=${mediaItem.connectionId}`)}>
{#if mediaItem.thumbnail} {#if mediaItem.thumbnail}
<img id="card-image" class="h-full rounded-lg transition-all" src={mediaItem.thumbnail} alt="{mediaItem.name} thumbnail" /> <img bind:this={image} id="card-image" on:load={() => (captionText.style.width = `${image.width}px`)} class="h-full rounded transition-all" src={mediaItem.thumbnail} alt="{mediaItem.name} thumbnail" />
{:else} {:else}
<div id="card-image" class="grid aspect-square h-full place-items-center rounded-lg bg-lazuli-primary transition-all"> <div id="card-image" class="grid aspect-square h-full place-items-center rounded-lg bg-lazuli-primary transition-all">
<i class="fa-solid fa-compact-disc text-7xl" /> <i class="fa-solid fa-compact-disc text-7xl" />
</div> </div>
{/if} {/if}
<div bind:this={cardGlare} id="card-glare" class="absolute top-0 h-full w-full rounded-lg opacity-0 transition-opacity duration-200 ease-out" /> <span id="play-button" class="absolute left-1/2 top-1/2 h-12 -translate-x-1/2 -translate-y-1/2 opacity-0 transition-opacity duration-200 ease-out">
</button>
<span id="play-button" class="absolute h-12 opacity-0 transition-opacity duration-200 ease-out">
<IconButton halo={true}> <IconButton halo={true}>
<i slot="icon" class="fa-solid fa-play text-xl" /> <i slot="icon" class="fa-solid fa-play text-xl" />
</IconButton> </IconButton>
</span> </span>
</div> </button>
<div class="p-2.5 text-sm"> <div bind:this={captionText} class="w-56 p-1">
<div class="overflow-hidden text-ellipsis" title={mediaItem.name}>{mediaItem.name}</div> <div class="mb-0.5 line-clamp-2 text-wrap text-sm" title={mediaItem.name}>{mediaItem.name}</div>
<div class="flex w-full items-center overflow-hidden text-neutral-400"> <div class="leading-2 line-clamp-2 text-neutral-400" style="font-size: 0;">
{#if checkSong(mediaItem) || checkAlbum(mediaItem)} {#if checkSongOrAlbum(mediaItem) && 'artists' in mediaItem && mediaItem.artists}
{#each mediaItem.artists as artist} {#each mediaItem.artists as artist}
{@const listIndex = mediaItem.artists.indexOf(artist)} {@const listIndex = mediaItem.artists.indexOf(artist)}
<a class="text-sm hover:underline focus:underline" href="/details/artist?id={artist.id}&connection={mediaItem.connectionId}">{artist.name}</a> <a class="text-sm hover:underline focus:underline" href="/details/artist?id={artist.id}&connection={mediaItem.connectionId}">{artist.name}</a>
{#if listIndex === mediaItem.artists.length - 2} {#if listIndex < mediaItem.artists.length - 1}
<span class="mx-0.5 text-sm">&</span>
{:else if listIndex < mediaItem.artists.length - 2}
<span class="mr-0.5 text-sm">,</span> <span class="mr-0.5 text-sm">,</span>
{/if} {/if}
{/each} {/each}
{/if} {/if}
<!-- {#if mediaItem.type}
<span>&bull;</span>
<i title="Stream from {Services[mediaItem.service.type].displayName}" class="{iconClasses[mediaItem.type]} text-xs" style="color: var({Services[mediaItem.service.type].primaryColor});" />
{/if} -->
</div> </div>
</div> </div>
</div> </div>
<style> <style>
#card-wrapper { #thumbnail:focus-within #card-image {
perspective: 1000px;
}
#card-wrapper:focus-within #card-image {
filter: brightness(50%); filter: brightness(50%);
} }
#card-wrapper:focus-within #card-glare { #thumbnail:focus-within #play-button {
opacity: 1; opacity: 1;
} }
#card-wrapper:focus-within #play-button { #thumbnail:hover {
opacity: 1;
}
#card:hover {
scale: 1.05; scale: 1.05;
} }
#card:hover #card-image { #thumbnail:hover #card-image {
filter: brightness(50%); filter: brightness(50%);
} }
#card:hover #card-glare { #thumbnail:hover #play-button {
opacity: 1;
}
#card:hover #play-button {
opacity: 1; opacity: 1;
} }
</style> </style>

View File

@@ -7,7 +7,13 @@
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
</script> </script>
<button class:disabled class:halo class="relative grid aspect-square h-full place-items-center transition-transform duration-75 active:scale-90" on:click|preventDefault={() => dispatch('click')} {disabled}> <button
class:disabled
class:halo
class="relative grid aspect-square h-full place-items-center transition-transform duration-75 active:scale-90"
on:click|preventDefault|stopPropagation={() => dispatch('click')}
{disabled}
>
<slot name="icon" /> <slot name="icon" />
</button> </button>

Binary file not shown.

View File

@@ -0,0 +1,192 @@
export namespace InnerTube {
interface BrowseResponse {
responseContext: {
visitorData: string
serviceTrackingParams: object[]
maxAgeSeconds: number
}
contents: {
singleColumnBrowseResultsRenderer: {
tabs: [
{
tabRenderer: {
endpoint: object
title: 'Home'
selected: boolean
content: {
sectionListRenderer: {
contents: {
musicCarouselShelfRenderer: musicCarouselShelfRenderer
}[]
continuations: [object]
trackingParams: string
header: {
chipCloudRenderer: object
}
}
}
icon: object
tabIdentifier: 'FEmusic_home'
trackingParams: string
}
},
]
}
}
trackingParams: string
maxAgeStoreSeconds: number
background: {
musicThumbnailRenderer: {
thumbnail: object
thumbnailCrop: string
thumbnailScale: string
trackingParams: string
}
}
}
type musicCarouselShelfRenderer = {
header: {
musicCarouselShelfBasicHeaderRenderer: {
title: {
runs: [runs]
}
strapline: [runs]
accessibilityData: accessibilityData
headerStyle: string
moreContentButton?: {
buttonRenderer: {
style: string
text: {
runs: [runs]
}
navigationEndpoint: navigationEndpoint
trackingParams: string
accessibilityData: accessibilityData
}
}
thumbnail?: musicThumbnailRenderer
trackingParams: string
}
}
contents:
| {
musicTwoRowItemRenderer: musicTwoRowItemRenderer
}[]
| {
musicResponsiveListItemRenderer: musicResponsiveListItemRenderer
}[]
trackingParams: string
itemSize: string
}
type musicDescriptionShelfRenderer = {
header: {
runs: [runs]
}
description: {
runs: [runs]
}
}
type musicTwoRowItemRenderer = {
thumbnailRenderer: {
musicThumbnailRenderer: musicThumbnailRenderer
}
aspectRatio: string
title: {
runs: [runs]
}
subtitle: {
runs: runs[]
}
navigationEndpoint: navigationEndpoint
trackingParams: string
menu: unknown
thumbnailOverlay: unknown
}
type musicResponsiveListItemRenderer = {
thumbnail: {
musicThumbnailRenderer: musicThumbnailRenderer
}
overlay: unknown
flexColumns: {
musicResponsiveListItemFlexColumnRenderer: {
text: { runs: [runs] }
}
}[]
menu: unknown
playlistItemData: {
videoId: string
}
}
type musicThumbnailRenderer = {
thumbnail: {
thumbnails: {
url: string
width: number
height: number
}[]
}
thumbnailCrop: string
thumbnailScale: string
trackingParams: string
accessibilityData?: accessibilityData
onTap?: navigationEndpoint
targetId?: string
}
type runs = {
text: string
navigationEndpoint?: navigationEndpoint
}
type navigationEndpoint = {
clickTrackingParams: string
} & (
| {
browseEndpoint: browseEndpoint
}
| {
watchEndpoint: watchEndpoint
}
| {
watchPlaylistEndpoint: watchPlaylistEndpoint
}
)
type browseEndpoint = {
browseId: string
params?: string
browseEndpointContextSupportedConfigs: {
browseEndpointContextMusicConfig: {
pageType: 'MUSIC_PAGE_TYPE_ALBUM' | 'MUSIC_PAGE_TYPE_ARTIST' | 'MUSIC_PAGE_TYPE_PLAYLIST'
}
}
}
type watchEndpoint = {
videoId: string
playlistId: string
params?: string
loggingContext: {
vssLoggingContext: object
}
watchEndpointMusicSupportedConfigs: {
watchEndpointMusicConfig: object
}
}
type watchPlaylistEndpoint = {
playlistId: string
params?: string
}
type accessibilityData = {
accessibilityData: {
label: string
}
}
}

View File

@@ -1,197 +1,7 @@
import { google } from 'googleapis' import { google } from 'googleapis'
import type { InnerTube } from './youtube-music-types.d.ts'
declare namespace InnerTube { // TODO: Change hook based token refresh to YouTubeMusic class middleware
interface BrowseResponse {
responseContext: {
visitorData: string
serviceTrackingParams: object[]
maxAgeSeconds: number
}
contents: {
singleColumnBrowseResultsRenderer: {
tabs: [
{
tabRenderer: {
endpoint: object
title: 'Home'
selected: boolean
content: {
sectionListRenderer: {
contents: {
musicCarouselShelfRenderer: musicCarouselShelfRenderer
}[]
continuations: [object]
trackingParams: string
header: {
chipCloudRenderer: object
}
}
}
icon: object
tabIdentifier: 'FEmusic_home'
trackingParams: string
}
},
]
}
}
trackingParams: string
maxAgeStoreSeconds: number
background: {
musicThumbnailRenderer: {
thumbnail: object
thumbnailCrop: string
thumbnailScale: string
trackingParams: string
}
}
}
type musicCarouselShelfRenderer = {
header: {
musicCarouselShelfBasicHeaderRenderer: {
title: {
runs: [runs]
}
strapline: [runs]
accessibilityData: accessibilityData
headerStyle: string
moreContentButton?: {
buttonRenderer: {
style: string
text: {
runs: [runs]
}
navigationEndpoint: navigationEndpoint
trackingParams: string
accessibilityData: accessibilityData
}
}
thumbnail?: musicThumbnailRenderer
trackingParams: string
}
}
contents:
| {
musicTwoRowItemRenderer: musicTwoRowItemRenderer
}[]
| {
musicResponsiveListItemRenderer: musicResponsiveListItemRenderer
}[]
trackingParams: string
itemSize: string
}
type musicDescriptionShelfRenderer = {
header: {
runs: [runs]
}
description: {
runs: [runs]
}
}
type musicTwoRowItemRenderer = {
thumbnailRenderer: {
musicThumbnailRenderer: musicThumbnailRenderer
}
aspectRatio: string
title: {
runs: [runs]
}
subtitle: {
runs: runs[]
}
navigationEndpoint: navigationEndpoint
trackingParams: string
menu: unknown
thumbnailOverlay: unknown
}
type musicResponsiveListItemRenderer = {
thumbnail: {
musicThumbnailRenderer: musicThumbnailRenderer
}
overlay: unknown
flexColumns: {
musicResponsiveListItemFlexColumnRenderer: {
text: { runs: [runs] }
}
}[]
menu: unknown
playlistItemData: {
videoId: string
}
}
type musicThumbnailRenderer = {
thumbnail: {
thumbnails: {
url: string
width: number
height: number
}[]
}
thumbnailCrop: string
thumbnailScale: string
trackingParams: string
accessibilityData?: accessibilityData
onTap?: navigationEndpoint
targetId?: string
}
type runs = {
text: string
navigationEndpoint?: navigationEndpoint
}
type navigationEndpoint = {
clickTrackingParams: string
} & (
| {
browseEndpoint: browseEndpoint
}
| {
watchEndpoint: watchEndpoint
}
| {
watchPlaylistEndpoint: watchPlaylistEndpoint
}
)
type browseEndpoint = {
browseId: string
params?: string
browseEndpointContextSupportedConfigs: {
browseEndpointContextMusicConfig: {
pageType: 'MUSIC_PAGE_TYPE_ALBUM' | 'MUSIC_PAGE_TYPE_ARTIST' | 'MUSIC_PAGE_TYPE_PLAYLIST'
}
}
}
type watchEndpoint = {
videoId: string
playlistId: string
params?: string
loggingContext: {
vssLoggingContext: object
}
watchEndpointMusicSupportedConfigs: {
watchEndpointMusicConfig: object
}
}
type watchPlaylistEndpoint = {
playlistId: string
params?: string
}
type accessibilityData = {
accessibilityData: {
label: string
}
}
}
export class YouTubeMusic { export class YouTubeMusic {
connectionId: string connectionId: string
@@ -253,7 +63,6 @@ export class YouTubeMusic {
}), }),
}) })
console.log(response.status)
const data: InnerTube.BrowseResponse = await response.json() const data: InnerTube.BrowseResponse = await response.json()
const contents = data.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents const contents = data.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents
@@ -281,7 +90,6 @@ export class YouTubeMusic {
} }
} }
console.log(JSON.stringify(homeItems))
return homeItems return homeItems
} }
@@ -367,7 +175,7 @@ export class YouTubeMusic {
private refineThumbnailUrl = (urlString: string): string => { private refineThumbnailUrl = (urlString: string): string => {
const url = new URL(urlString) const url = new URL(urlString)
if (url.origin === 'https://i.ytimg.com') { if (url.origin === 'https://i.ytimg.com') {
return urlString.slice(0, urlString.indexOf('?')) return urlString.slice(0, urlString.indexOf('?')).replace('sddefault', 'mqdefault')
} else if (url.origin === 'https://lh3.googleusercontent.com' || url.origin === 'https://yt3.googleusercontent.com' || url.origin === 'https://yt3.ggpht.com') { } else if (url.origin === 'https://lh3.googleusercontent.com' || url.origin === 'https://yt3.googleusercontent.com' || url.origin === 'https://yt3.ggpht.com') {
return urlString.slice(0, urlString.indexOf('=')) return urlString.slice(0, urlString.indexOf('='))
} else { } else {

View File

@@ -6,5 +6,5 @@
</script> </script>
<div id="main"> <div id="main">
<!-- <ScrollableCardMenu header={'Listen Again'} cardDataList={data.recommendations} /> --> <ScrollableCardMenu header={'Listen Again'} cardDataList={data.recommendations} />
</div> </div>

View File

@@ -36,7 +36,12 @@ export const GET: RequestHandler = async ({ params, fetch }) => {
break break
case 'youtube-music': case 'youtube-music':
const youtubeMusic = new YouTubeMusic(connection) const youtubeMusic = new YouTubeMusic(connection)
youtubeMusic.getHome() await youtubeMusic
.getHome()
.then(({ listenAgain, quickPicks, newReleases }) => {
for (const mediaItem of listenAgain) recommendations.push(mediaItem)
})
.catch()
break break
} }
} }