Started on media player
This commit is contained in:
958
package-lock.json
generated
958
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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
16
src/app.d.ts
vendored
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,12 @@ 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')) {
|
||||||
return new Response('Unauthorized', { status: 401 })
|
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 })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!nonJwtProtectedRoutes.some((route) => urlpath.startsWith(route))) {
|
if (!nonJwtProtectedRoutes.some((route) => urlpath.startsWith(route))) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
</div>
|
</section>
|
||||||
<div class="bg-green-300">{currentlyPlaying.type}</div>
|
<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>
|
||||||
|
<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> -->
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 artist = { id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text }
|
const pageType = run.navigationEndpoint.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType
|
||||||
artists ? artists.push(artist) : (artists = [artist])
|
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 }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 artist = { id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text }
|
const pageType = run.navigationEndpoint.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType
|
||||||
artists ? artists.push(artist) : (artists = [artist])
|
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 }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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,13 +281,18 @@ 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) {
|
||||||
return urlString.slice(0, urlString.indexOf('?')).replace('sddefault', 'mqdefault')
|
case 'https://i.ytimg.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('?')).replace('sddefault', 'mqdefault')
|
||||||
return urlString.slice(0, urlString.indexOf('='))
|
case 'https://lh3.googleusercontent.com':
|
||||||
} else {
|
case 'https://yt3.googleusercontent.com':
|
||||||
console.error(urlString)
|
case 'https://yt3.ggpht.com':
|
||||||
throw new Error('Invalid thumbnail url origin')
|
return urlString.slice(0, urlString.indexOf('='))
|
||||||
|
case 'https://music.youtube.com':
|
||||||
|
return urlString
|
||||||
|
default:
|
||||||
|
console.error(urlString)
|
||||||
|
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'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 recommendationResponse = await fetch(`/api/users/${locals.user.id}/recommendations`, {
|
const getRecommendations = async (): Promise<(Song | Album | Artist | Playlist)[]> => {
|
||||||
headers: { apikey: SECRET_INTERNAL_API_KEY },
|
const recommendationResponse = await fetch(`/api/users/${locals.user.id}/recommendations`, {
|
||||||
}).then((response) => response.json())
|
headers: { apikey: SECRET_INTERNAL_API_KEY },
|
||||||
|
}).then((response) => response.json())
|
||||||
|
return recommendationResponse.recommendations
|
||||||
|
}
|
||||||
|
|
||||||
const recommendations: (Song | Album | Artist | Playlist)[] = recommendationResponse.recommendations
|
return { recommendations: getRecommendations() }
|
||||||
|
|
||||||
return { recommendations }
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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)[]> => {
|
||||||
method: 'GET',
|
const searchResults = await fetch(`/api/search?query=${query}&userId=${locals.user.id}`, {
|
||||||
headers: { apikey: SECRET_INTERNAL_API_KEY },
|
method: 'GET',
|
||||||
}).then((response) => response.json())
|
headers: { apikey: SECRET_INTERNAL_API_KEY },
|
||||||
|
}).then((response) => response.json())
|
||||||
|
return searchResults.searchResults
|
||||||
|
}
|
||||||
|
|
||||||
return searchResults
|
return { searchResults: getSearchResults() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,9 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if data.searchResults}
|
{#if data.searchResults}
|
||||||
{#each data.searchResults as searchResult}
|
{#await data.searchResults then searchResults}
|
||||||
<div>{searchResult.name} - {searchResult.type}</div>
|
{#each searchResults as searchResult}
|
||||||
{/each}
|
<div>{searchResult.name} - {searchResult.type}</div>
|
||||||
|
{/each}
|
||||||
|
{/await}
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -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 connectionInfoResponse = await fetch(`/api/users/${locals.user.id}/connections`, {
|
const getConnectionInfo = async (): Promise<ConnectionInfo[]> => {
|
||||||
method: 'GET',
|
const connectionInfoResponse = await fetch(`/api/users/${locals.user.id}/connections`, {
|
||||||
headers: { apikey: SECRET_INTERNAL_API_KEY },
|
method: 'GET',
|
||||||
}).then((response) => response.json())
|
headers: { apikey: SECRET_INTERNAL_API_KEY },
|
||||||
|
}).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 = {
|
||||||
|
|||||||
@@ -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">
|
||||||
{#each connections as connectionInfo}
|
{#if connections}
|
||||||
<ConnectionProfile {connectionInfo} submitFunction={profileActions} />
|
{#each connections as connectionInfo}
|
||||||
{/each}
|
<ConnectionProfile {connectionInfo} submitFunction={profileActions} />
|
||||||
|
{/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>
|
||||||
|
|||||||
@@ -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>
|
||||||
<button formaction="?/deleteConnection" class="whitespace-nowrap rounded-md px-3 py-2 hover:bg-neutral-800">
|
</form>
|
||||||
<i class="fa-solid fa-link-slash mr-1" />
|
|
||||||
Delete Connection
|
|
||||||
</button>
|
|
||||||
<input type="hidden" value={connectionInfo.id} name="connectionId" />
|
|
||||||
</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" />
|
||||||
|
|||||||
@@ -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 })
|
|
||||||
}
|
}
|
||||||
|
|||||||
13
src/routes/api/remoteImage/+server.ts
Normal file
13
src/routes/api/remoteImage/+server.ts
Normal 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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user