Started on media player

This commit is contained in:
Eclypsed
2024-04-09 00:10:23 -04:00
parent c5408d76b6
commit 8e52bd71c4
19 changed files with 1095 additions and 319 deletions

958
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -32,8 +32,10 @@
"@types/jsonwebtoken": "^9.0.5", "@types/jsonwebtoken": "^9.0.5",
"bcrypt-ts": "^5.0.1", "bcrypt-ts": "^5.0.1",
"better-sqlite3": "^9.3.0", "better-sqlite3": "^9.3.0",
"fast-average-color": "^9.4.0",
"googleapis": "^133.0.0", "googleapis": "^133.0.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"node-vibrant": "^3.2.1-alpha.1",
"pocketbase": "^0.21.1", "pocketbase": "^0.21.1",
"type-fest": "^4.12.0", "type-fest": "^4.12.0",
"ytdl-core": "^4.11.5", "ytdl-core": "^4.11.5",

16
src/app.d.ts vendored
View File

@@ -30,9 +30,9 @@ declare global {
} & ({ } & ({
type: 'jellyfin' type: 'jellyfin'
serverUrl: string serverUrl: string
serverName: string serverName?: string
jellyfinUserId: string jellyfinUserId: string
username: string username?: string
} | { } | {
type: 'youtube-music' type: 'youtube-music'
youtubeUserId: string youtubeUserId: string
@@ -45,7 +45,7 @@ declare global {
getRecommendations: () => Promise<(Song | Album | Artist | Playlist)[]> getRecommendations: () => Promise<(Song | Album | Artist | Playlist)[]>
getConnectionInfo: () => Promise<ConnectionInfo> getConnectionInfo: () => Promise<ConnectionInfo>
search: (searchTerm: string, filter?: 'song' | 'album' | 'artist' | 'playlist') => Promise<(Song | Album | Artist | Playlist)[]> search: (searchTerm: string, filter?: 'song' | 'album' | 'artist' | 'playlist') => Promise<(Song | Album | Artist | Playlist)[]>
getSongAudio: (id: string) => Promise<ReadableStream<Uint8Array>> getAudioStream: (id: string) => Promise<Response>
} }
// These Schemas should only contain general info data that is necessary for data fetching purposes. // These Schemas should only contain general info data that is necessary for data fetching purposes.
// They are NOT meant to be stores for large amounts of data, i.e. Don't include the data for every single song the Playlist type. // They are NOT meant to be stores for large amounts of data, i.e. Don't include the data for every single song the Playlist type.
@@ -66,8 +66,10 @@ declare global {
id: string id: string
name: string name: string
} }
// audio: string <--- Because of youtube these will potentially expire. They are also not needed until a user requests that song, so instead fetch them as needed createdBy?: {
// video?: string id: string
name: string
}
releaseDate?: string releaseDate?: string
} }
@@ -100,6 +102,10 @@ declare global {
id: string id: string
name: string name: string
type: 'playlist' type: 'playlist'
createdBy?: {
id: string
name: string
}
thumbnail?: string thumbnail?: string
description?: string description?: string
} }

View File

@@ -6,9 +6,13 @@ export const handle: Handle = async ({ event, resolve }) => {
const nonJwtProtectedRoutes = ['/login', '/api'] const nonJwtProtectedRoutes = ['/login', '/api']
const urlpath = event.url.pathname const urlpath = event.url.pathname
if (urlpath.startsWith('/api') && event.request.headers.get('apikey') !== SECRET_INTERNAL_API_KEY && event.url.searchParams.get('apikey') !== SECRET_INTERNAL_API_KEY) { if (urlpath.startsWith('/api')) {
const unprotectedAPIRoutes = ['/api/audio', '/api/remoteImage']
const apikey = event.request.headers.get('apikey') || event.url.searchParams.get('apikey')
if (!unprotectedAPIRoutes.includes(urlpath) && apikey !== SECRET_INTERNAL_API_KEY) {
return new Response('Unauthorized', { status: 401 }) return new Response('Unauthorized', { status: 401 })
} }
}
if (!nonJwtProtectedRoutes.some((route) => urlpath.startsWith(route))) { if (!nonJwtProtectedRoutes.some((route) => urlpath.startsWith(route))) {
const authToken = event.cookies.get('lazuli-auth') const authToken = event.cookies.get('lazuli-auth')

View File

@@ -40,6 +40,8 @@
<span class="mr-0.5 text-sm">,</span> <span class="mr-0.5 text-sm">,</span>
{/if} {/if}
{/each} {/each}
{:else if 'createdBy' in mediaItem && mediaItem.createdBy}
<span class="text-sm">{mediaItem.createdBy.name}</span>
{/if} {/if}
</div> </div>
</div> </div>

View File

@@ -1,19 +1,112 @@
<script lang="ts"> <script lang="ts">
export let currentlyPlaying: Song import { onMount } from 'svelte'
import { fade } from 'svelte/transition'
import { currentlyPlaying } from '$lib/stores'
import { FastAverageColor } from 'fast-average-color'
import Slider from '$lib/components/util/slider.svelte'
export let song: Song
let playing = false,
shuffle = false,
repeat = false
let bgColor = 'black',
primaryColor = 'var(--lazuli-primary)'
const fac = new FastAverageColor()
$: fac.getColorAsync(`/api/remoteImage?url=${song.thumbnail}`, { algorithm: 'dominant' }).then((color) => (bgColor = color.rgb))
$: fac.getColorAsync(`/api/remoteImage?url=${song.thumbnail}`, { algorithm: 'simple' }).then((color) => (primaryColor = color.rgb))
// let audioSource: HTMLAudioElement, progressBar: HTMLInputElement
// onMount(() => (audioSource.volume = 0.4))
</script> </script>
<main class="h-full w-full"> <main class="relative m-4 flex h-24 flex-grow-0 gap-4 overflow-clip rounded-xl text-white transition-colors duration-1000" style="background-color: {bgColor};">
<div class="bg-red-300"> <img src={song.thumbnail} alt="" class="aspect-square max-h-full object-cover" />
<audio controls> <section class="flex w-96 flex-col justify-center gap-1">
<source src="/api/audio?id=KfmrhlGCfWk&connectionId=cae88864-e116-4ce8-b3cc-5383eae0b781&apikey=2ff052272eeb44628c97314e09f384c10ae7fb31d8a40630442d3cb417512574" type="audio/webm" /> <div class="line-clamp-2">{song.name}</div>
</audio> <div class="text-sm text-neutral-400">{song.artists?.map((artist) => artist.name) || song.createdBy?.name}</div>
</section>
<section class="flex w-96 flex-col items-center justify-center gap-4">
<div class="flex items-center gap-3 text-xl">
<button on:click={() => (shuffle = !shuffle)} class="aspect-square h-8">
<i class="fa-solid fa-shuffle" style="color: {shuffle ? primaryColor : 'rgb(163, 163, 163)'};" />
</button>
<button class="aspect-square h-8">
<i class="fa-solid fa-backward-step" />
</button>
<button on:click={() => (playing = !playing)} class="grid aspect-square h-10 place-items-center rounded-full bg-white">
<i class="fa-solid {playing ? 'fa-pause' : 'fa-play'} text-black" />
</button>
<button class="aspect-square h-8">
<i class="fa-solid fa-forward-step" />
</button>
<button on:click={() => (repeat = !repeat)} class="aspect-square h-8">
<i class="fa-solid fa-repeat" style="color: {repeat ? primaryColor : 'rgb(163, 163, 163)'};" />
</button>
</div> </div>
<div class="bg-green-300">{currentlyPlaying.type}</div> <Slider sliderColor={primaryColor} />
</section>
</main> </main>
<style> <!-- <main class="h-screen w-full">
<div class="relative h-full overflow-hidden">
<div class="absolute z-0 flex h-full w-full items-center justify-items-center bg-neutral-900">
<div class="absolute z-10 h-full w-full backdrop-blur-3xl"></div>
{#key song}
<img in:fade src={song.thumbnail} alt="" class="absolute h-full w-full object-cover brightness-50" />
{/key}
</div>
<div class="absolute grid h-full w-full grid-rows-[auto_8rem_3rem_6rem] justify-items-center p-8">
{#key song}
<img in:fade src={song.thumbnail} alt="" class="h-full min-h-[8rem] overflow-hidden rounded-xl object-contain p-2" />
{/key}
<div in:fade class="flex flex-col items-center justify-center gap-1 px-8 text-center font-notoSans">
{#key song}
<span class="text-3xl text-neutral-300">{song.name}</span>
{#if song.album?.name}
<span class="text-xl text-neutral-300">{song.album.name}</span>
{/if}
<span class="text-xl text-neutral-300">{song.artists?.map((artist) => artist.name).join(' / ') || song.createdBy?.name}</span>
{/key}
</div>
<input
id="progress-bar"
on:mouseup={() => (audioSource.currentTime = audioSource.duration * Number(progressBar.value))}
type="range"
value="0"
min="0"
max="1"
step="any"
class="w-[90%] cursor-pointer rounded-lg bg-gray-400"
/>
<div class="flex h-full w-11/12 justify-around overflow-hidden text-3xl text-white">
<button class="relative z-0 aspect-square max-h-full max-w-full overflow-hidden rounded-full">
<i class="fa-solid fa-backward-step" />
</button>
<button class="relative z-0 aspect-square max-h-full max-w-full overflow-hidden rounded-full">
<i class={playing ? 'fa-solid fa-pause' : 'fa-solid fa-play'} />
</button>
<button on:click={() => ($currentlyPlaying = null)} class="relative z-0 aspect-square max-h-full max-w-full overflow-hidden rounded-full">
<i class="fa-solid fa-stop" />
</button>
<button class="relative z-0 aspect-square max-h-full max-w-full overflow-hidden rounded-full">
<i class="fa-solid fa-forward-step" />
</button>
</div>
</div>
</div>
<div class="no-scrollbar flex w-full flex-col items-center divide-y-[1px] divide-[#353535] overflow-y-scroll bg-neutral-900 p-4">
<div>This is where playlist items go</div>
</div>
{#key song}
<audio bind:this={audioSource} id="audio" class="hidden" src="/api/audio?id={song.id}&connection={song.connection}" />
{/key}
</main> -->
<!-- <style>
main { main {
display: grid; display: grid;
grid-template-columns: 2fr 1fr; grid-template-columns: 2fr 1fr;
} }
</style> </style> -->

View File

@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
export let value = 0 export let value = 0
export let sliderColor: string | undefined = undefined
let sliderThumb: HTMLSpanElement, sliderTrail: HTMLSpanElement let sliderThumb: HTMLSpanElement, sliderTrail: HTMLSpanElement
@@ -18,7 +19,8 @@
<div <div
id="slider-track" id="slider-track"
class="relative isolate h-1 w-full rounded-full bg-neutral-600" class="relative isolate h-1.5 w-full rounded-full bg-neutral-600"
style="--slider-color: {sliderColor || 'var(--lazuli-primary)'}"
role="slider" role="slider"
tabindex="0" tabindex="0"
aria-valuenow={value} aria-valuenow={value}
@@ -26,9 +28,9 @@
aria-valuemax="100" aria-valuemax="100"
on:keydown={(event) => handleKeyPress(event.key)} on:keydown={(event) => handleKeyPress(event.key)}
> >
<input type="range" class="absolute z-10 h-1 w-full" step="any" min="0" max="100" bind:value tabindex="-1" aria-hidden="true" aria-disabled="true" /> <input type="range" class="absolute z-10 h-1.5 w-full" step="any" min="0" max="100" bind:value tabindex="-1" aria-hidden="true" aria-disabled="true" />
<span bind:this={sliderTrail} id="slider-trail" class="absolute left-0 h-1 rounded-full bg-white transition-colors" /> <span bind:this={sliderTrail} id="slider-trail" class="absolute left-0 h-1.5 rounded-full bg-white transition-colors" />
<span bind:this={sliderThumb} id="slider-thumb" class="absolute top-1/2 aspect-square h-3 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white opacity-0 transition-opacity duration-300" /> <span bind:this={sliderThumb} id="slider-thumb" class="absolute top-1/2 aspect-square h-3.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white opacity-0 transition-opacity duration-300" />
</div> </div>
<style> <style>
@@ -38,10 +40,10 @@
opacity: 0; opacity: 0;
} }
#slider-track:hover > #slider-trail { #slider-track:hover > #slider-trail {
background-color: var(--lazuli-primary); background-color: var(--slider-color);
} }
#slider-track:focus > #slider-trail { #slider-track:focus > #slider-trail {
background-color: var(--lazuli-primary); background-color: var(--slider-color);
} }
#slider-track:hover > #slider-thumb { #slider-track:hover > #slider-thumb {
opacity: 1; opacity: 1;

View File

@@ -1,11 +1,11 @@
export class Jellyfin implements Connection { export class Jellyfin implements Connection {
public id: string public readonly id: string
private userId: string private readonly userId: string
private jfUserId: string private readonly jfUserId: string
private serverUrl: string private readonly serverUrl: string
private accessToken: string private readonly accessToken: string
private readonly BASEHEADERS: Headers private readonly authHeader: Headers
constructor(id: string, userId: string, jellyfinUserId: string, serverUrl: string, accessToken: string) { constructor(id: string, userId: string, jellyfinUserId: string, serverUrl: string, accessToken: string) {
this.id = id this.id = id
@@ -14,33 +14,34 @@ export class Jellyfin implements Connection {
this.serverUrl = serverUrl this.serverUrl = serverUrl
this.accessToken = accessToken this.accessToken = accessToken
this.BASEHEADERS = new Headers({ Authorization: `MediaBrowser Token="${this.accessToken}"` }) this.authHeader = new Headers({ Authorization: `MediaBrowser Token="${this.accessToken}"` })
} }
// const audoSearchParams = new URLSearchParams({
// MaxStreamingBitrate: '999999999',
// Container: 'opus,webm|opus,mp3,aac,m4a|aac,m4b|aac,flac,webma,webm|webma,wav,ogg',
// TranscodingContainer: 'ts',
// TranscodingProtocol: 'hls',
// AudioCodec: 'aac',
// userId: this.jfUserId,
// })
public getConnectionInfo = async (): Promise<Extract<ConnectionInfo, { type: 'jellyfin' }>> => { public getConnectionInfo = async (): Promise<Extract<ConnectionInfo, { type: 'jellyfin' }>> => {
const userUrl = new URL(`Users/${this.jfUserId}`, this.serverUrl).toString() const userUrl = new URL(`Users/${this.jfUserId}`, this.serverUrl)
const systemUrl = new URL('System/Info', this.serverUrl).toString() const systemUrl = new URL('System/Info', this.serverUrl)
const userData: JellyfinAPI.User = await fetch(userUrl, { headers: this.BASEHEADERS }).then((response) => response.json()) const userData: JellyfinAPI.User | undefined = await fetch(userUrl, { headers: this.authHeader })
const systemData: JellyfinAPI.System = await fetch(systemUrl, { headers: this.BASEHEADERS }).then((response) => response.json()) .then((response) => response.json())
.catch(() => {
console.error(`Fetch to ${userUrl.toString()} failed`)
return undefined
})
const systemData: JellyfinAPI.System | undefined = await fetch(systemUrl, { headers: this.authHeader })
.then((response) => response.json())
.catch(() => {
console.error(`Fetch to ${systemUrl.toString()} failed`)
return undefined
})
return { return {
id: this.id, id: this.id,
userId: this.userId, userId: this.userId,
type: 'jellyfin', type: 'jellyfin',
serverUrl: this.serverUrl, serverUrl: this.serverUrl,
serverName: systemData.ServerName, serverName: systemData?.ServerName,
jellyfinUserId: this.jfUserId, jellyfinUserId: this.jfUserId,
username: userData.Name, username: userData?.Name,
} }
} }
@@ -53,16 +54,11 @@ export class Jellyfin implements Connection {
limit: '10', limit: '10',
}) })
const mostPlayedSongsURL = new URL(`/Users/${this.jfUserId}/Items?${searchParams.toString()}`, this.serverUrl).toString() const mostPlayedSongsURL = new URL(`/Users/${this.jfUserId}/Items?${searchParams.toString()}`, this.serverUrl)
const mostPlayedResponse = await fetch(mostPlayedSongsURL, { headers: this.BASEHEADERS }) const mostPlayed: { Items: JellyfinAPI.Song[] } = await fetch(mostPlayedSongsURL, { headers: this.authHeader }).then((response) => response.json())
const mostPlayedData = await mostPlayedResponse.json()
return Array.from(mostPlayedData.Items as JellyfinAPI.Song[], (song) => this.parseSong(song)) return Array.from(mostPlayed.Items, (song) => this.parseSong(song))
}
public getSongAudio = (id: string): string => {
return 'need to implement'
} }
public search = async (searchTerm: string, filter?: 'song' | 'album' | 'artist' | 'playlist'): Promise<(Song | Album | Playlist)[]> => { public search = async (searchTerm: string, filter?: 'song' | 'album' | 'artist' | 'playlist'): Promise<(Song | Album | Playlist)[]> => {
@@ -72,9 +68,9 @@ export class Jellyfin implements Connection {
recursive: 'true', recursive: 'true',
}) })
const searchURL = new URL(`Users/${this.jfUserId}/Items?${searchParams.toString()}`, this.serverUrl).toString() const searchURL = new URL(`Users/${this.jfUserId}/Items?${searchParams.toString()}`, this.serverUrl)
const searchResponse = await fetch(searchURL, { headers: this.BASEHEADERS }) const searchResponse = await fetch(searchURL, { headers: this.authHeader })
if (!searchResponse.ok) throw new JellyfinFetchError('Failed to search Jellyfin', searchResponse.status, searchURL) if (!searchResponse.ok) throw new JellyfinFetchError('Failed to search Jellyfin', searchResponse.status, searchURL.toString())
const searchResults = (await searchResponse.json()).Items as (JellyfinAPI.Song | JellyfinAPI.Album | JellyfinAPI.Playlist)[] // JellyfinAPI.Artist const searchResults = (await searchResponse.json()).Items as (JellyfinAPI.Song | JellyfinAPI.Album | JellyfinAPI.Playlist)[] // JellyfinAPI.Artist
const parsedResults: (Song | Album | Playlist)[] = Array.from(searchResults, (result) => { const parsedResults: (Song | Album | Playlist)[] = Array.from(searchResults, (result) => {
@@ -90,6 +86,21 @@ export class Jellyfin implements Connection {
return parsedResults return parsedResults
} }
public getAudioStream = async (id: string): Promise<Response> => {
const audoSearchParams = new URLSearchParams({
MaxStreamingBitrate: '2000000',
Container: 'opus,webm|opus,mp3,aac,m4a|aac,m4b|aac,flac,webma,webm|webma,wav,ogg',
TranscodingContainer: 'ts',
TranscodingProtocol: 'hls',
AudioCodec: 'aac',
userId: this.jfUserId,
})
const audioUrl = new URL(`Audio/${id}/universal?${audoSearchParams.toString()}`, this.serverUrl)
return await fetch(audioUrl, { headers: this.authHeader })
}
private parseSong = (song: JellyfinAPI.Song): Song => { private parseSong = (song: JellyfinAPI.Song): Song => {
const thumbnail = song.ImageTags?.Primary const thumbnail = song.ImageTags?.Primary
? new URL(`Items/${song.Id}/Images/Primary`, this.serverUrl).href ? new URL(`Items/${song.Id}/Images/Primary`, this.serverUrl).href

View File

@@ -156,29 +156,34 @@ export class YouTubeMusic implements Connection {
return recommendations return recommendations
} }
public getSongAudio = async (id: string): Promise<ReadableStream<Uint8Array>> => { public getAudioStream = async (id: string): Promise<Response> => {
const videoInfo = await ytdl.getInfo(`http://www.youtube.com/watch?v=${id}`) const videoInfo = await ytdl.getInfo(`http://www.youtube.com/watch?v=${id}`)
const format = ytdl.chooseFormat(videoInfo.formats, { quality: 'highestaudio', filter: 'audioonly' }) const format = ytdl.chooseFormat(videoInfo.formats, { quality: 'highestaudio', filter: 'audioonly' })
const audioResponse = await fetch(format.url)
return audioResponse.body! return await fetch(format.url)
} }
} }
const parseTwoRowItemRenderer = (connection: string, rowContent: InnerTube.musicTwoRowItemRenderer): Song | Album | Artist | Playlist => { const parseTwoRowItemRenderer = (connection: string, rowContent: InnerTube.musicTwoRowItemRenderer): Song | Album | Artist | Playlist => {
const name = rowContent.title.runs[0].text const name = rowContent.title.runs[0].text
let artists: (Song | Album)['artists'] let artists: (Song | Album)['artists'], createdBy: (Song | Playlist)['createdBy']
for (const run of rowContent.subtitle.runs) { for (const run of rowContent.subtitle.runs) {
if (!run.navigationEndpoint) continue 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 } const artist = { id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text }
artists ? artists.push(artist) : (artists = [artist]) artists ? artists.push(artist) : (artists = [artist])
} else if (pageType === 'MUSIC_PAGE_TYPE_USER_CHANNEL') {
createdBy = { id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text }
}
} }
const thumbnail = refineThumbnailUrl(rowContent.thumbnailRenderer.musicThumbnailRenderer.thumbnail.thumbnails[0].url) const thumbnail = refineThumbnailUrl(rowContent.thumbnailRenderer.musicThumbnailRenderer.thumbnail.thumbnails[0].url)
if ('watchEndpoint' in rowContent.navigationEndpoint) { if ('watchEndpoint' in rowContent.navigationEndpoint) {
const id = rowContent.navigationEndpoint.watchEndpoint.videoId const id = rowContent.navigationEndpoint.watchEndpoint.videoId
return { connection, id, name, type: 'song', artists, thumbnail } satisfies Song return { connection, id, name, type: 'song', artists, createdBy, thumbnail } satisfies Song
} }
const pageType = rowContent.navigationEndpoint.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType const pageType = rowContent.navigationEndpoint.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType
@@ -187,9 +192,10 @@ const parseTwoRowItemRenderer = (connection: string, rowContent: InnerTube.music
case 'MUSIC_PAGE_TYPE_ALBUM': case 'MUSIC_PAGE_TYPE_ALBUM':
return { connection, id, name, type: 'album', artists, thumbnail } satisfies Album return { connection, id, name, type: 'album', artists, thumbnail } satisfies Album
case 'MUSIC_PAGE_TYPE_ARTIST': case 'MUSIC_PAGE_TYPE_ARTIST':
case 'MUSIC_PAGE_TYPE_USER_CHANNEL':
return { connection, id, name, type: 'artist', thumbnail } satisfies Artist return { connection, id, name, type: 'artist', thumbnail } satisfies Artist
case 'MUSIC_PAGE_TYPE_PLAYLIST': case 'MUSIC_PAGE_TYPE_PLAYLIST':
return { connection, id, name, type: 'playlist', thumbnail } satisfies Playlist return { connection, id, name, type: 'playlist', createdBy, thumbnail } satisfies Playlist
} }
} }
@@ -197,11 +203,16 @@ const parseResponsiveListItemRenderer = (connection: string, listContent: InnerT
const name = listContent.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text const name = listContent.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text
const thumbnail = refineThumbnailUrl(listContent.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails[0].url) const thumbnail = refineThumbnailUrl(listContent.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails[0].url)
let artists: (Song | Album)['artists'] let artists: (Song | Album)['artists'], createdBy: (Song | Playlist)['createdBy']
for (const run of listContent.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text.runs) { for (const run of listContent.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text.runs) {
if (!run.navigationEndpoint) continue 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 } const artist = { id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text }
artists ? artists.push(artist) : (artists = [artist]) artists ? artists.push(artist) : (artists = [artist])
} else if (pageType === 'MUSIC_PAGE_TYPE_USER_CHANNEL') {
createdBy = { id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text }
}
} }
if (!('navigationEndpoint' in listContent)) { if (!('navigationEndpoint' in listContent)) {
@@ -212,7 +223,7 @@ const parseResponsiveListItemRenderer = (connection: string, listContent: InnerT
album = { id: column2run.navigationEndpoint.browseEndpoint.browseId, name: column2run.text } album = { id: column2run.navigationEndpoint.browseEndpoint.browseId, name: column2run.text }
} }
return { connection, id, name, type: 'song', artists, album, thumbnail } satisfies Song return { connection, id, name, type: 'song', artists, album, createdBy, thumbnail } satisfies Song
} }
const id = listContent.navigationEndpoint.browseEndpoint.browseId const id = listContent.navigationEndpoint.browseEndpoint.browseId
@@ -221,9 +232,10 @@ const parseResponsiveListItemRenderer = (connection: string, listContent: InnerT
case 'MUSIC_PAGE_TYPE_ALBUM': case 'MUSIC_PAGE_TYPE_ALBUM':
return { connection, id, name, type: 'album', artists, thumbnail } satisfies Album return { connection, id, name, type: 'album', artists, thumbnail } satisfies Album
case 'MUSIC_PAGE_TYPE_ARTIST': case 'MUSIC_PAGE_TYPE_ARTIST':
case 'MUSIC_PAGE_TYPE_USER_CHANNEL':
return { connection, id, name, type: 'artist', thumbnail } satisfies Artist return { connection, id, name, type: 'artist', thumbnail } satisfies Artist
case 'MUSIC_PAGE_TYPE_PLAYLIST': case 'MUSIC_PAGE_TYPE_PLAYLIST':
return { connection, id, name, type: 'playlist', thumbnail } satisfies Playlist return { connection, id, name, type: 'playlist', createdBy, thumbnail } satisfies Playlist
} }
} }
@@ -231,23 +243,25 @@ const parseMusicCardShelfRenderer = (connection: string, cardContent: InnerTube.
const name = cardContent.title.runs[0].text const name = cardContent.title.runs[0].text
const thumbnail = refineThumbnailUrl(cardContent.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails[0].url) const thumbnail = refineThumbnailUrl(cardContent.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails[0].url)
let album: Song['album'], artists: (Song | Album)['artists'] let album: Song['album'], artists: (Song | Album)['artists'], createdBy: (Song | Playlist)['createdBy']
for (const run of cardContent.subtitle.runs) { for (const run of cardContent.subtitle.runs) {
if (!run.navigationEndpoint) continue if (!run.navigationEndpoint) continue
const pageType = run.navigationEndpoint.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType const pageType = run.navigationEndpoint.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType
if (pageType === 'MUSIC_PAGE_TYPE_ARTIST') { if (pageType === 'MUSIC_PAGE_TYPE_ALBUM') {
album = { id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text }
} else if (pageType === 'MUSIC_PAGE_TYPE_ARTIST') {
const artist = { id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text } const artist = { id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text }
artists ? artists.push(artist) : (artists = [artist]) artists ? artists.push(artist) : (artists = [artist])
} else if (pageType === 'MUSIC_PAGE_TYPE_ALBUM') { } else if (pageType === 'MUSIC_PAGE_TYPE_USER_CHANNEL') {
album = { id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text } createdBy = { id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text }
} }
} }
const navigationEndpoint = cardContent.title.runs[0].navigationEndpoint const navigationEndpoint = cardContent.title.runs[0].navigationEndpoint
if ('watchEndpoint' in navigationEndpoint) { if ('watchEndpoint' in navigationEndpoint) {
const id = navigationEndpoint.watchEndpoint.videoId const id = navigationEndpoint.watchEndpoint.videoId
return { connection, id, name, type: 'song', artists, album, thumbnail } satisfies Song return { connection, id, name, type: 'song', artists, album, createdBy, thumbnail } satisfies Song
} }
const pageType = navigationEndpoint.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType const pageType = navigationEndpoint.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType
@@ -256,9 +270,10 @@ const parseMusicCardShelfRenderer = (connection: string, cardContent: InnerTube.
case 'MUSIC_PAGE_TYPE_ALBUM': case 'MUSIC_PAGE_TYPE_ALBUM':
return { connection, id, name, type: 'album', artists, thumbnail } satisfies Album return { connection, id, name, type: 'album', artists, thumbnail } satisfies Album
case 'MUSIC_PAGE_TYPE_ARTIST': case 'MUSIC_PAGE_TYPE_ARTIST':
case 'MUSIC_PAGE_TYPE_USER_CHANNEL':
return { connection, id, name, type: 'artist', thumbnail } satisfies Artist return { connection, id, name, type: 'artist', thumbnail } satisfies Artist
case 'MUSIC_PAGE_TYPE_PLAYLIST': case 'MUSIC_PAGE_TYPE_PLAYLIST':
return { connection, id, name, type: 'playlist', thumbnail } satisfies Playlist return { connection, id, name, type: 'playlist', createdBy, thumbnail } satisfies Playlist
} }
} }
@@ -266,11 +281,16 @@ const 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) const url = new URL(urlString)
if (url.origin === 'https://i.ytimg.com') { switch (url.origin) {
case 'https://i.ytimg.com':
return urlString.slice(0, urlString.indexOf('?')).replace('sddefault', 'mqdefault') 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') { case 'https://lh3.googleusercontent.com':
case 'https://yt3.googleusercontent.com':
case 'https://yt3.ggpht.com':
return urlString.slice(0, urlString.indexOf('=')) return urlString.slice(0, urlString.indexOf('='))
} else { case 'https://music.youtube.com':
return urlString
default:
console.error(urlString) console.error(urlString)
throw new Error('Invalid thumbnail url origin') throw new Error('Invalid thumbnail url origin')
} }
@@ -520,7 +540,7 @@ declare namespace InnerTube {
browseId: string browseId: string
browseEndpointContextSupportedConfigs: { browseEndpointContextSupportedConfigs: {
browseEndpointContextMusicConfig: { browseEndpointContextMusicConfig: {
pageType: 'MUSIC_PAGE_TYPE_ALBUM' | 'MUSIC_PAGE_TYPE_ARTIST' | 'MUSIC_PAGE_TYPE_PLAYLIST' pageType: 'MUSIC_PAGE_TYPE_ALBUM' | 'MUSIC_PAGE_TYPE_ARTIST' | 'MUSIC_PAGE_TYPE_PLAYLIST' | 'MUSIC_PAGE_TYPE_USER_CHANNEL'
} }
} }
} }

View File

@@ -45,9 +45,9 @@
</div> </div>
<slot /> <slot />
</section> </section>
<section class="absolute bottom-0 z-40 max-h-full w-full"> <section class="absolute bottom-0 z-40 grid max-h-full w-full place-items-center">
{#if $currentlyPlaying} {#if $currentlyPlaying}
<MediaPlayer currentlyPlaying={$currentlyPlaying} /> <MediaPlayer song={$currentlyPlaying} />
{/if} {/if}
</section> </section>
</div> </div>

View File

@@ -2,11 +2,12 @@ import { SECRET_INTERNAL_API_KEY } from '$env/static/private'
import type { PageServerLoad } from './$types' import type { PageServerLoad } from './$types'
export const load: PageServerLoad = async ({ locals, fetch }) => { export const load: PageServerLoad = async ({ locals, fetch }) => {
const getRecommendations = async (): Promise<(Song | Album | Artist | Playlist)[]> => {
const recommendationResponse = await fetch(`/api/users/${locals.user.id}/recommendations`, { const recommendationResponse = await fetch(`/api/users/${locals.user.id}/recommendations`, {
headers: { apikey: SECRET_INTERNAL_API_KEY }, headers: { apikey: SECRET_INTERNAL_API_KEY },
}).then((response) => response.json()) }).then((response) => response.json())
return recommendationResponse.recommendations
const recommendations: (Song | Album | Artist | Playlist)[] = recommendationResponse.recommendations }
return { recommendations } return { recommendations: getRecommendations() }
} }

View File

@@ -6,7 +6,9 @@
</script> </script>
<div id="main"> <div id="main">
<ScrollableCardMenu header={'Listen Again'} cardDataList={data.recommendations} /> {#await data.recommendations then recommendations}
<ScrollableCardMenu header={'Listen Again'} cardDataList={recommendations} />
{/await}
<!-- <h1 class="mb-6 text-4xl"><strong>Listen Again</strong></h1> <!-- <h1 class="mb-6 text-4xl"><strong>Listen Again</strong></h1>
<div class="flex flex-wrap justify-between gap-6"> <div class="flex flex-wrap justify-between gap-6">
{#each data.recommendations as recommendation} {#each data.recommendations as recommendation}

View File

@@ -4,11 +4,14 @@ import { SECRET_INTERNAL_API_KEY } from '$env/static/private'
export const load: PageServerLoad = async ({ fetch, url, locals }) => { export const load: PageServerLoad = async ({ fetch, url, locals }) => {
const query = url.searchParams.get('query') const query = url.searchParams.get('query')
if (query) { if (query) {
const searchResults: { searchResults: (Song | Album | Artist | Playlist)[] } = await fetch(`/api/search?query=${query}&userId=${locals.user.id}`, { const getSearchResults = async (): Promise<(Song | Album | Artist | Playlist)[]> => {
const searchResults = await fetch(`/api/search?query=${query}&userId=${locals.user.id}`, {
method: 'GET', method: 'GET',
headers: { apikey: SECRET_INTERNAL_API_KEY }, headers: { apikey: SECRET_INTERNAL_API_KEY },
}).then((response) => response.json()) }).then((response) => response.json())
return searchResults.searchResults
}
return searchResults return { searchResults: getSearchResults() }
} }
} }

View File

@@ -5,7 +5,9 @@
</script> </script>
{#if data.searchResults} {#if data.searchResults}
{#each data.searchResults as searchResult} {#await data.searchResults then searchResults}
{#each searchResults as searchResult}
<div>{searchResult.name} - {searchResult.type}</div> <div>{searchResult.name} - {searchResult.type}</div>
{/each} {/each}
{/await}
{/if} {/if}

View File

@@ -7,14 +7,15 @@ import { Jellyfin, JellyfinFetchError } from '$lib/server/jellyfin'
import { google } from 'googleapis' import { google } from 'googleapis'
export const load: PageServerLoad = async ({ fetch, locals }) => { export const load: PageServerLoad = async ({ fetch, locals }) => {
const getConnectionInfo = async (): Promise<ConnectionInfo[]> => {
const connectionInfoResponse = await fetch(`/api/users/${locals.user.id}/connections`, { const connectionInfoResponse = await fetch(`/api/users/${locals.user.id}/connections`, {
method: 'GET', method: 'GET',
headers: { apikey: SECRET_INTERNAL_API_KEY }, headers: { apikey: SECRET_INTERNAL_API_KEY },
}).then((response) => response.json()) }).then((response) => response.json())
return connectionInfoResponse.connections
}
const connections: ConnectionInfo[] = connectionInfoResponse.connections return { connections: getConnectionInfo() }
return { connections }
} }
export const actions: Actions = { export const actions: Actions = {

View File

@@ -14,9 +14,13 @@
import ConnectionProfile from './connectionProfile.svelte' import ConnectionProfile from './connectionProfile.svelte'
import { enhance } from '$app/forms' import { enhance } from '$app/forms'
import { PUBLIC_YOUTUBE_API_CLIENT_ID } from '$env/static/public' import { PUBLIC_YOUTUBE_API_CLIENT_ID } from '$env/static/public'
import { onMount } from 'svelte'
export let data: PageServerData & LayoutData export let data: PageServerData & LayoutData
let connections = data.connections let connections: ConnectionInfo[]
onMount(async () => {
connections = await data.connections
})
const authenticateJellyfin: SubmitFunction = ({ formData, cancel }) => { const authenticateJellyfin: SubmitFunction = ({ formData, cancel }) => {
const { serverUrl, username, password } = Object.fromEntries(formData) const { serverUrl, username, password } = Object.fromEntries(formData)
@@ -79,7 +83,7 @@
} }
} }
const profileActions: SubmitFunction = ({ action, cancel }) => { const profileActions: SubmitFunction = () => {
return ({ result }) => { return ({ result }) => {
if (result.type === 'failure') { if (result.type === 'failure') {
return ($newestAlert = ['warning', result.data?.message]) return ($newestAlert = ['warning', result.data?.message])
@@ -134,9 +138,11 @@
</div> </div>
</section> </section>
<div id="connection-profile-grid" class="grid gap-8"> <div id="connection-profile-grid" class="grid gap-8">
{#if connections}
{#each connections as connectionInfo} {#each connections as connectionInfo}
<ConnectionProfile {connectionInfo} submitFunction={profileActions} /> <ConnectionProfile {connectionInfo} submitFunction={profileActions} />
{/each} {/each}
{/if}
</div> </div>
{#if newConnectionModal !== null} {#if newConnectionModal !== null}
<svelte:component this={newConnectionModal} submitFunction={authenticateJellyfin} on:close={() => (newConnectionModal = null)} /> <svelte:component this={newConnectionModal} submitFunction={authenticateJellyfin} on:close={() => (newConnectionModal = null)} />
@@ -149,6 +155,6 @@
background-image: linear-gradient(to bottom, rgb(30, 30, 30), rgb(10, 10, 10)); background-image: linear-gradient(to bottom, rgb(30, 30, 30), rgb(10, 10, 10));
} }
#connection-profile-grid { #connection-profile-grid {
grid-template-columns: repeat(auto-fit, minmax(24rem, 1fr)); grid-template-columns: repeat(auto-fit, minmax(28rem, 1fr));
} }
</style> </style>

View File

@@ -1,9 +1,8 @@
<script lang="ts"> <script lang="ts">
import Services from '$lib/services.json' import Services from '$lib/services.json'
import IconButton from '$lib/components/util/iconButton.svelte'
import Toggle from '$lib/components/util/toggle.svelte' import Toggle from '$lib/components/util/toggle.svelte'
import type { SubmitFunction } from '@sveltejs/kit'
import { fly } from 'svelte/transition' import { fly } from 'svelte/transition'
import type { SubmitFunction } from '@sveltejs/kit'
import { enhance } from '$app/forms' import { enhance } from '$app/forms'
export let connectionInfo: ConnectionInfo export let connectionInfo: ConnectionInfo
@@ -11,8 +10,6 @@
$: serviceData = Services[connectionInfo.type] $: serviceData = Services[connectionInfo.type]
let showModal = false
const subHeaderItems: string[] = [] const subHeaderItems: string[] = []
if ('username' in connectionInfo) { if ('username' in connectionInfo) {
subHeaderItems.push(connectionInfo.username) subHeaderItems.push(connectionInfo.username)
@@ -22,7 +19,8 @@
} }
</script> </script>
<section class="rounded-lg" style="background-color: rgba(82, 82, 82, 0.25);" transition:fly={{ x: 50 }}> <section class="relative overflow-clip rounded-lg" transition:fly={{ x: 50 }}>
<div class="absolute -z-10 h-full w-full bg-black bg-cover bg-right bg-no-repeat brightness-[25%]" style="background-image: url({serviceData.icon}); mask-image: linear-gradient(to left, black, rgba(0, 0, 0, 0));" />
<header class="flex h-20 items-center gap-4 p-4"> <header class="flex h-20 items-center gap-4 p-4">
<div class="relative aspect-square h-full p-1"> <div class="relative aspect-square h-full p-1">
<img src={serviceData.icon} alt="{serviceData.displayName} icon" /> <img src={serviceData.icon} alt="{serviceData.displayName} icon" />
@@ -31,24 +29,18 @@
{/if} {/if}
</div> </div>
<div> <div>
<div>{serviceData.displayName} - {connectionInfo.id}</div> <div>{serviceData.displayName}</div>
<div class="text-sm text-neutral-500"> <div class="text-sm text-neutral-500">
{subHeaderItems.join(' - ')} {subHeaderItems.join(' - ')}
</div> </div>
</div> </div>
<div class="relative ml-auto flex h-8 flex-row-reverse gap-2"> <div class="relative ml-auto flex flex-row-reverse gap-2">
<IconButton halo={true} on:click={() => (showModal = !showModal)}> <form action="?/deleteConnection" method="post" use:enhance={submitFunction}>
<i slot="icon" class="fa-solid fa-ellipsis-vertical text-xl text-neutral-500" /> <input type="hidden" name="connectionId" value={connectionInfo.id} />
</IconButton> <button class="aspect-square h-8 text-2xl text-neutral-500 hover:text-lazuli-primary">
{#if showModal} <i class="fa-solid fa-xmark" />
<form use:enhance={submitFunction} method="post" class="absolute right-0 top-full flex flex-col items-center justify-center gap-1 rounded-md bg-neutral-900 p-2 text-xs">
<button formaction="?/deleteConnection" class="whitespace-nowrap rounded-md px-3 py-2 hover:bg-neutral-800">
<i class="fa-solid fa-link-slash mr-1" />
Delete Connection
</button> </button>
<input type="hidden" value={connectionInfo.id} name="connectionId" />
</form> </form>
{/if}
</div> </div>
</header> </header>
<hr class="mx-2 border-t-2 border-neutral-600" /> <hr class="mx-2 border-t-2 border-neutral-600" />

View File

@@ -1,39 +1,25 @@
import type { RequestHandler } from '@sveltejs/kit' import type { RequestHandler } from '@sveltejs/kit'
import { Connections } from '$lib/server/connections' import { Connections } from '$lib/server/connections'
import ytdl from 'ytdl-core'
export const GET: RequestHandler = async ({ url, request }) => { export const GET: RequestHandler = async ({ url }) => {
const connectionId = url.searchParams.get('connectionId') const connectionId = url.searchParams.get('connection')
const id = url.searchParams.get('id') const id = url.searchParams.get('id')
if (!(connectionId && id)) return new Response('Missing query parameter', { status: 400 }) if (!(connectionId && id)) return new Response('Missing query parameter', { status: 400 })
const range = request.headers.get('range')
if (!range) return new Response('Missing Range Header')
const videourl = `http://www.youtube.com/watch?v=${id}` const connection = Connections.getConnections([connectionId])[0]
const stream = await connection.getAudioStream(id)
const videoInfo = await ytdl.getInfo(videourl) if (!stream.body) throw new Error(`Audio fetch did not return valid ReadableStream (Connection: ${connection.id})`)
const format = ytdl.chooseFormat(videoInfo.formats, { quality: 'highestaudio', filter: 'audioonly' })
const audioSize = format.contentLength const contentLength = stream.headers.get('Content-Length')
const CHUNK_SIZE = 5 * 10 ** 6 if (!contentLength || isNaN(Number(contentLength))) throw new Error(`Audio fetch did not return valid Content-Length header (Connection: ${connection.id})`)
const start = Number(range.replace(/\D/g, ''))
const end = Math.min(start + CHUNK_SIZE, Number(audioSize) - 1)
const contentLength = end - start + 1
const headers = new Headers({ const headers = new Headers({
'Content-Range': `bytes ${start}-${end}/${audioSize}`, 'Content-Range': `bytes 0-${Number(contentLength) - 1}/${contentLength}`,
'Accept-Ranges': 'bytes', 'Accept-Ranges': 'bytes',
'Content-Length': contentLength.toString(), 'Content-Length': contentLength.toString(),
'Content-Type': 'audio/webm', 'Content-Type': 'audio/webm',
}) })
const partialStream = ytdl(videourl, { format, range: { start, end } }) return new Response(stream.body, { status: 206, headers })
// @ts-ignore IDK enough about streaming to understand what the problem is here
// but it appears that ytdl has a custom version of a readable stream type they use internally
// and is what gets returned by ytdl(). Svelte will only allow you to send back the type ReadableStream
// so it ts gets mad if you try to send back their internal type.
// IDK to me a custom readable type seems incredibly stupid but what do I know?
// Currently haven't found a way to convert their readable to ReadableStream type, casting doesn't seem to work either.
return new Response(partialStream, { status: 206, headers })
} }

View File

@@ -0,0 +1,13 @@
import type { RequestHandler } from '@sveltejs/kit'
export const GET: RequestHandler = async ({ url }) => {
// const connectionId = url.searchParams.get('connection')
// const id = url.searchParams.get('id')
// if (!(connectionId && id)) return new Response('Missing query parameter', { status: 400 })
const imageUrl = url.searchParams.get('url')
if (!imageUrl) return new Response('Missing url', { status: 400 })
const image = await fetch(imageUrl).then((response) => response.arrayBuffer())
return new Response(image)
}