ConnectionInfo and the db ConnectionRow types are now completely seperate things. Started on audio fetching yay!
This commit is contained in:
17
src/app.d.ts
vendored
17
src/app.d.ts
vendored
@@ -24,11 +24,28 @@ declare global {
|
|||||||
passwordHash: string
|
passwordHash: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ConnectionInfo = {
|
||||||
|
id: string
|
||||||
|
userId: string
|
||||||
|
} & ({
|
||||||
|
type: 'jellyfin'
|
||||||
|
serverUrl: string
|
||||||
|
serverName: string
|
||||||
|
jellyfinUserId: string
|
||||||
|
username: string
|
||||||
|
} | {
|
||||||
|
type: 'youtube-music'
|
||||||
|
youtubeUserId: string
|
||||||
|
username: string
|
||||||
|
profilePicture: string
|
||||||
|
})
|
||||||
|
|
||||||
interface Connection {
|
interface Connection {
|
||||||
public id: string
|
public id: string
|
||||||
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>>
|
||||||
}
|
}
|
||||||
// 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.
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
export let mediaItem: Song | Album | Playlist
|
export let mediaItem: Song | Album | Artist | Playlist
|
||||||
|
|
||||||
import IconButton from '$lib/components/util/iconButton.svelte'
|
import IconButton from '$lib/components/util/iconButton.svelte'
|
||||||
import { goto } from '$app/navigation'
|
import { goto } from '$app/navigation'
|
||||||
|
import { currentlyPlaying } from '$lib/stores'
|
||||||
|
|
||||||
let image: HTMLImageElement, captionText: HTMLDivElement
|
let image: HTMLImageElement, captionText: HTMLDivElement
|
||||||
|
|
||||||
|
const setCurrentlyPlaying = () => {
|
||||||
|
if (mediaItem.type === 'song') {
|
||||||
|
$currentlyPlaying = mediaItem
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div id="card-wrapper" class="flex-shrink-0">
|
<div id="card-wrapper" class="flex-shrink-0">
|
||||||
@@ -17,7 +24,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<span id="play-button" class="absolute left-1/2 top-1/2 h-12 -translate-x-1/2 -translate-y-1/2 opacity-0 transition-opacity duration-200 ease-out">
|
<span id="play-button" class="absolute left-1/2 top-1/2 h-12 -translate-x-1/2 -translate-y-1/2 opacity-0 transition-opacity duration-200 ease-out">
|
||||||
<IconButton halo={true}>
|
<IconButton halo={true} on:click={setCurrentlyPlaying}>
|
||||||
<i slot="icon" class="fa-solid fa-play text-xl" />
|
<i slot="icon" class="fa-solid fa-play text-xl" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</span>
|
</span>
|
||||||
@@ -39,12 +46,6 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
#thumbnail:focus-within #card-image {
|
|
||||||
filter: brightness(50%);
|
|
||||||
}
|
|
||||||
#thumbnail:focus-within #play-button {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
#thumbnail:hover {
|
#thumbnail:hover {
|
||||||
scale: 1.05;
|
scale: 1.05;
|
||||||
}
|
}
|
||||||
|
|||||||
19
src/lib/components/media/mediaPlayer.svelte
Normal file
19
src/lib/components/media/mediaPlayer.svelte
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
export let currentlyPlaying: Song
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<main class="h-full w-full">
|
||||||
|
<div class="bg-red-300">
|
||||||
|
<audio controls>
|
||||||
|
<source src="/api/audio?id=KfmrhlGCfWk&connectionId=cae88864-e116-4ce8-b3cc-5383eae0b781&apikey=2ff052272eeb44628c97314e09f384c10ae7fb31d8a40630442d3cb417512574" type="audio/webm" />
|
||||||
|
</audio>
|
||||||
|
</div>
|
||||||
|
<div class="bg-green-300">{currentlyPlaying.type}</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
main {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 2fr 1fr;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
export let header: string
|
export let header: string
|
||||||
export let cardDataList: (Song | Album | Playlist)[]
|
export let cardDataList: (Song | Album | Artist | Playlist)[]
|
||||||
|
|
||||||
import MediaCard from '$lib/components/media/mediaCard.svelte'
|
import MediaCard from '$lib/components/media/mediaCard.svelte'
|
||||||
import IconButton from '$lib/components/util/iconButton.svelte'
|
import IconButton from '$lib/components/util/iconButton.svelte'
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
import { DB, type DBConnectionInfo } from './db'
|
import { DB, type ConnectionRow } from './db'
|
||||||
import { Jellyfin, type JellyfinConnectionInfo } from './jellyfin'
|
import { Jellyfin } from './jellyfin'
|
||||||
import { YouTubeMusic, type YouTubeMusicConnectionInfo } from './youtube-music'
|
import { YouTubeMusic } from './youtube-music'
|
||||||
|
|
||||||
export type ConnectionInfo = JellyfinConnectionInfo | YouTubeMusicConnectionInfo
|
const constructConnection = (connectionInfo: ConnectionRow): Connection => {
|
||||||
|
|
||||||
const constructConnection = (connectionInfo: DBConnectionInfo): Connection => {
|
|
||||||
const { id, userId, type, service, tokens } = connectionInfo
|
const { id, userId, type, service, tokens } = connectionInfo
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'jellyfin':
|
case 'jellyfin':
|
||||||
return new Jellyfin(id, userId, service.userId, service.urlOrigin, tokens.accessToken)
|
return new Jellyfin(id, userId, service.userId, service.serverUrl, tokens.accessToken)
|
||||||
case 'youtube-music':
|
case 'youtube-music':
|
||||||
return new YouTubeMusic(id, userId, service.userId, tokens)
|
return new YouTubeMusic(id, userId, service.userId, tokens.accessToken, tokens.refreshToken, tokens.expiry)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,22 +10,21 @@ interface DBConnectionsTableSchema {
|
|||||||
tokens?: string
|
tokens?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type JellyfinDBConnection = {
|
export type ConnectionRow = {
|
||||||
id: string
|
id: string
|
||||||
userId: string
|
userId: string
|
||||||
|
} & (
|
||||||
|
| {
|
||||||
type: 'jellyfin'
|
type: 'jellyfin'
|
||||||
service: {
|
service: {
|
||||||
userId: string
|
userId: string
|
||||||
urlOrigin: string
|
serverUrl: string
|
||||||
}
|
}
|
||||||
tokens: {
|
tokens: {
|
||||||
accessToken: string
|
accessToken: string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
type YouTubeMusicDBConnection = {
|
|
||||||
id: string
|
|
||||||
userId: string
|
|
||||||
type: 'youtube-music'
|
type: 'youtube-music'
|
||||||
service: {
|
service: {
|
||||||
userId: string
|
userId: string
|
||||||
@@ -36,8 +35,7 @@ type YouTubeMusicDBConnection = {
|
|||||||
expiry: number
|
expiry: number
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
)
|
||||||
export type DBConnectionInfo = JellyfinDBConnection | YouTubeMusicDBConnection
|
|
||||||
|
|
||||||
class Storage {
|
class Storage {
|
||||||
private readonly database: Sqlite3DB
|
private readonly database: Sqlite3DB
|
||||||
@@ -89,8 +87,8 @@ class Storage {
|
|||||||
if (commandInfo.changes === 0) throw new Error(`User with id ${id} does not exist`)
|
if (commandInfo.changes === 0) throw new Error(`User with id ${id} does not exist`)
|
||||||
}
|
}
|
||||||
|
|
||||||
public getConnectionInfo = (ids: string[]): DBConnectionInfo[] => {
|
public getConnectionInfo = (ids: string[]): ConnectionRow[] => {
|
||||||
const connectionInfo: DBConnectionInfo[] = []
|
const connectionInfo: ConnectionRow[] = []
|
||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
const result = this.database.prepare(`SELECT * FROM Connections WHERE id = ?`).get(id) as DBConnectionsTableSchema | undefined
|
const result = this.database.prepare(`SELECT * FROM Connections WHERE id = ?`).get(id) as DBConnectionsTableSchema | undefined
|
||||||
if (!result) continue
|
if (!result) continue
|
||||||
@@ -98,23 +96,23 @@ class Storage {
|
|||||||
const { userId, type, service, tokens } = result
|
const { userId, type, service, tokens } = result
|
||||||
const parsedService = service ? JSON.parse(service) : undefined
|
const parsedService = service ? JSON.parse(service) : undefined
|
||||||
const parsedTokens = tokens ? JSON.parse(tokens) : undefined
|
const parsedTokens = tokens ? JSON.parse(tokens) : undefined
|
||||||
connectionInfo.push({ id, userId, type: type as DBConnectionInfo['type'], service: parsedService, tokens: parsedTokens })
|
connectionInfo.push({ id, userId, type: type as ConnectionRow['type'], service: parsedService, tokens: parsedTokens })
|
||||||
}
|
}
|
||||||
return connectionInfo
|
return connectionInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
public getUserConnectionInfo = (userId: string): DBConnectionInfo[] => {
|
public getUserConnectionInfo = (userId: string): ConnectionRow[] => {
|
||||||
const connectionRows = this.database.prepare(`SELECT * FROM Connections WHERE userId = ?`).all(userId) as DBConnectionsTableSchema[]
|
const connectionRows = this.database.prepare(`SELECT * FROM Connections WHERE userId = ?`).all(userId) as DBConnectionsTableSchema[]
|
||||||
const connections: DBConnectionInfo[] = []
|
const connections: ConnectionRow[] = []
|
||||||
for (const { id, type, service, tokens } of connectionRows) {
|
for (const { id, type, service, tokens } of connectionRows) {
|
||||||
const parsedService = service ? JSON.parse(service) : undefined
|
const parsedService = service ? JSON.parse(service) : undefined
|
||||||
const parsedTokens = tokens ? JSON.parse(tokens) : undefined
|
const parsedTokens = tokens ? JSON.parse(tokens) : undefined
|
||||||
connections.push({ id, userId, type: type as DBConnectionInfo['type'], service: parsedService, tokens: parsedTokens })
|
connections.push({ id, userId, type: type as ConnectionRow['type'], service: parsedService, tokens: parsedTokens })
|
||||||
}
|
}
|
||||||
return connections
|
return connections
|
||||||
}
|
}
|
||||||
|
|
||||||
public addConnectionInfo = (connectionInfo: Omit<DBConnectionInfo, 'id'>): string => {
|
public addConnectionInfo = (connectionInfo: Omit<ConnectionRow, 'id'>): string => {
|
||||||
const { userId, type, service, tokens } = connectionInfo
|
const { userId, type, service, tokens } = connectionInfo
|
||||||
const connectionId = generateUUID()
|
const connectionId = generateUUID()
|
||||||
this.database.prepare(`INSERT INTO Connections(id, userId, type, service, tokens) VALUES(?, ?, ?, ?, ?)`).run(connectionId, userId, type, JSON.stringify(service), JSON.stringify(tokens))
|
this.database.prepare(`INSERT INTO Connections(id, userId, type, service, tokens) VALUES(?, ?, ?, ?, ?)`).run(connectionId, userId, type, JSON.stringify(service), JSON.stringify(tokens))
|
||||||
@@ -126,7 +124,7 @@ class Storage {
|
|||||||
if (commandInfo.changes === 0) throw new Error(`Connection with id: ${id} does not exist`)
|
if (commandInfo.changes === 0) throw new Error(`Connection with id: ${id} does not exist`)
|
||||||
}
|
}
|
||||||
|
|
||||||
public updateTokens = (id: string, tokens: DBConnectionInfo['tokens']): void => {
|
public updateTokens = (id: string, tokens: ConnectionRow['tokens']): void => {
|
||||||
const commandInfo = this.database.prepare(`UPDATE Connections SET tokens = ? WHERE id = ?`).run(JSON.stringify(tokens), id)
|
const commandInfo = this.database.prepare(`UPDATE Connections SET tokens = ? WHERE id = ?`).run(JSON.stringify(tokens), id)
|
||||||
if (commandInfo.changes === 0) throw new Error('Failed to update tokens')
|
if (commandInfo.changes === 0) throw new Error('Failed to update tokens')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,3 @@
|
|||||||
export type JellyfinConnectionInfo = {
|
|
||||||
id: string
|
|
||||||
userId: string
|
|
||||||
type: 'jellyfin'
|
|
||||||
service: {
|
|
||||||
userId: string
|
|
||||||
serverUrl: string
|
|
||||||
username: string
|
|
||||||
serverName: string
|
|
||||||
}
|
|
||||||
tokens: {
|
|
||||||
accessToken: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Jellyfin implements Connection {
|
export class Jellyfin implements Connection {
|
||||||
public id: string
|
public id: string
|
||||||
private userId: string
|
private userId: string
|
||||||
@@ -41,29 +26,21 @@ export class Jellyfin implements Connection {
|
|||||||
// userId: this.jfUserId,
|
// userId: this.jfUserId,
|
||||||
// })
|
// })
|
||||||
|
|
||||||
public getConnectionInfo = async (): Promise<JellyfinConnectionInfo> => {
|
public getConnectionInfo = async (): Promise<Extract<ConnectionInfo, { type: 'jellyfin' }>> => {
|
||||||
const userUrl = new URL(`Users/${this.jfUserId}`, this.serverUrl).href
|
const userUrl = new URL(`Users/${this.jfUserId}`, this.serverUrl).toString()
|
||||||
const systemUrl = new URL('System/Info', this.serverUrl).href
|
const systemUrl = new URL('System/Info', this.serverUrl).toString()
|
||||||
|
|
||||||
const userResponse = await fetch(userUrl, { headers: this.BASEHEADERS })
|
const userData: JellyfinAPI.User = await fetch(userUrl, { headers: this.BASEHEADERS }).then((response) => response.json())
|
||||||
const systemResponse = await fetch(systemUrl, { headers: this.BASEHEADERS })
|
const systemData: JellyfinAPI.System = await fetch(systemUrl, { headers: this.BASEHEADERS }).then((response) => response.json())
|
||||||
|
|
||||||
const userData: JellyfinAPI.User = await userResponse.json()
|
|
||||||
const systemData: JellyfinAPI.System = await systemResponse.json()
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: this.id,
|
id: this.id,
|
||||||
userId: this.userId,
|
userId: this.userId,
|
||||||
type: 'jellyfin',
|
type: 'jellyfin',
|
||||||
service: {
|
|
||||||
userId: this.jfUserId,
|
|
||||||
serverUrl: this.serverUrl,
|
serverUrl: this.serverUrl,
|
||||||
username: userData.Name,
|
|
||||||
serverName: systemData.ServerName,
|
serverName: systemData.ServerName,
|
||||||
},
|
jellyfinUserId: this.jfUserId,
|
||||||
tokens: {
|
username: userData.Name,
|
||||||
accessToken: this.accessToken,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,6 +61,10 @@ export class Jellyfin implements Connection {
|
|||||||
return Array.from(mostPlayedData.Items as JellyfinAPI.Song[], (song) => this.parseSong(song))
|
return Array.from(mostPlayedData.Items as JellyfinAPI.Song[], (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)[]> => {
|
||||||
const searchParams = new URLSearchParams({
|
const searchParams = new URLSearchParams({
|
||||||
searchTerm,
|
searchTerm,
|
||||||
|
|||||||
@@ -1,35 +1,24 @@
|
|||||||
import { google } from 'googleapis'
|
import { google } from 'googleapis'
|
||||||
|
import ytdl from 'ytdl-core'
|
||||||
import { DB } from './db'
|
import { DB } from './db'
|
||||||
import { PUBLIC_YOUTUBE_API_CLIENT_ID } from '$env/static/public'
|
import { PUBLIC_YOUTUBE_API_CLIENT_ID } from '$env/static/public'
|
||||||
import { YOUTUBE_API_CLIENT_SECRET } from '$env/static/private'
|
import { YOUTUBE_API_CLIENT_SECRET } from '$env/static/private'
|
||||||
|
|
||||||
export type YouTubeMusicConnectionInfo = {
|
|
||||||
id: string
|
|
||||||
userId: string
|
|
||||||
type: 'youtube-music'
|
|
||||||
service: {
|
|
||||||
userId: string
|
|
||||||
username: string
|
|
||||||
profilePicture: string
|
|
||||||
}
|
|
||||||
tokens: {
|
|
||||||
accessToken: string
|
|
||||||
refreshToken: string
|
|
||||||
expiry: number
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class YouTubeMusic implements Connection {
|
export class YouTubeMusic implements Connection {
|
||||||
public id: string
|
public readonly id: string
|
||||||
private userId: string
|
private readonly userId: string
|
||||||
private ytUserId: string
|
private readonly ytUserId: string
|
||||||
private tokens: YouTubeMusicConnectionInfo['tokens']
|
private accessToken: string
|
||||||
|
private readonly refreshToken: string
|
||||||
|
private expiry: number
|
||||||
|
|
||||||
constructor(id: string, userId: string, youtubeUserId: string, tokens: YouTubeMusicConnectionInfo['tokens']) {
|
constructor(id: string, userId: string, youtubeUserId: string, accessToken: string, refreshToken: string, expiry: number) {
|
||||||
this.id = id
|
this.id = id
|
||||||
this.userId = userId
|
this.userId = userId
|
||||||
this.ytUserId = youtubeUserId
|
this.ytUserId = youtubeUserId
|
||||||
this.tokens = tokens
|
this.accessToken = accessToken
|
||||||
|
this.refreshToken = refreshToken
|
||||||
|
this.expiry = expiry
|
||||||
}
|
}
|
||||||
|
|
||||||
private headers = async () => {
|
private headers = async () => {
|
||||||
@@ -41,21 +30,19 @@ export class YouTubeMusic implements Connection {
|
|||||||
'content-encoding': 'gzip',
|
'content-encoding': 'gzip',
|
||||||
origin: 'https://music.youtube.com',
|
origin: 'https://music.youtube.com',
|
||||||
Cookie: 'SOCS=CAI;',
|
Cookie: 'SOCS=CAI;',
|
||||||
authorization: `Bearer ${(await this.getTokens()).accessToken}`,
|
authorization: `Bearer ${await this.getAccessToken()}`,
|
||||||
'X-Goog-Request-Time': `${Date.now()}`,
|
'X-Goog-Request-Time': `${Date.now()}`,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private getTokens = async (): Promise<YouTubeMusicConnectionInfo['tokens']> => {
|
private getAccessToken = async (): Promise<string> => {
|
||||||
if (this.tokens.expiry < Date.now()) {
|
if (this.expiry < Date.now()) {
|
||||||
const refreshToken = this.tokens.refreshToken
|
|
||||||
|
|
||||||
const response = await fetch('https://oauth2.googleapis.com/token', {
|
const response = await fetch('https://oauth2.googleapis.com/token', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
client_id: PUBLIC_YOUTUBE_API_CLIENT_ID,
|
client_id: PUBLIC_YOUTUBE_API_CLIENT_ID,
|
||||||
client_secret: YOUTUBE_API_CLIENT_SECRET,
|
client_secret: YOUTUBE_API_CLIENT_SECRET,
|
||||||
refresh_token: refreshToken,
|
refresh_token: this.refreshToken,
|
||||||
grant_type: 'refresh_token',
|
grant_type: 'refresh_token',
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
@@ -63,29 +50,26 @@ export class YouTubeMusic implements Connection {
|
|||||||
const { access_token, expires_in } = await response.json()
|
const { access_token, expires_in } = await response.json()
|
||||||
const newExpiry = Date.now() + expires_in * 1000
|
const newExpiry = Date.now() + expires_in * 1000
|
||||||
|
|
||||||
const newTokens: YouTubeMusicConnectionInfo['tokens'] = { accessToken: access_token, refreshToken, expiry: newExpiry }
|
DB.updateTokens(this.id, { accessToken: access_token, refreshToken: this.refreshToken, expiry: newExpiry })
|
||||||
DB.updateTokens(this.id, newTokens)
|
this.accessToken = access_token
|
||||||
this.tokens = newTokens
|
this.expiry = newExpiry
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.tokens
|
return this.accessToken
|
||||||
}
|
}
|
||||||
|
|
||||||
public getConnectionInfo = async (): Promise<YouTubeMusicConnectionInfo> => {
|
public getConnectionInfo = async (): Promise<Extract<ConnectionInfo, { type: 'youtube-music' }>> => {
|
||||||
const youtube = google.youtube('v3')
|
const youtube = google.youtube('v3')
|
||||||
const userChannelResponse = await youtube.channels.list({ mine: true, part: ['snippet'], access_token: (await this.getTokens()).accessToken })
|
const userChannelResponse = await youtube.channels.list({ mine: true, part: ['snippet'], access_token: await this.getAccessToken() })
|
||||||
const userChannel = userChannelResponse.data.items![0]
|
const userChannel = userChannelResponse.data.items![0]
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: this.id,
|
id: this.id,
|
||||||
userId: this.userId,
|
userId: this.userId,
|
||||||
type: 'youtube-music',
|
type: 'youtube-music',
|
||||||
service: {
|
youtubeUserId: this.ytUserId,
|
||||||
userId: this.ytUserId,
|
username: userChannel.snippet?.title!,
|
||||||
username: userChannel.snippet?.title as string,
|
profilePicture: userChannel.snippet?.thumbnails?.default?.url!,
|
||||||
profilePicture: userChannel.snippet?.thumbnails?.default?.url as string,
|
|
||||||
},
|
|
||||||
tokens: await this.getTokens(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,7 +99,6 @@ export class YouTubeMusic implements Connection {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
}).then((response) => response.json())
|
}).then((response) => response.json())
|
||||||
console.log(JSON.stringify(searchResulsts))
|
|
||||||
|
|
||||||
const contents = searchResulsts.contents.tabbedSearchResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents
|
const contents = searchResulsts.contents.tabbedSearchResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents
|
||||||
|
|
||||||
@@ -172,6 +155,13 @@ export class YouTubeMusic implements Connection {
|
|||||||
|
|
||||||
return recommendations
|
return recommendations
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getSongAudio = async (id: string): Promise<ReadableStream<Uint8Array>> => {
|
||||||
|
const videoInfo = await ytdl.getInfo(`http://www.youtube.com/watch?v=${id}`)
|
||||||
|
const format = ytdl.chooseFormat(videoInfo.formats, { quality: 'highestaudio', filter: 'audioonly' })
|
||||||
|
const audioResponse = await fetch(format.url)
|
||||||
|
return audioResponse.body!
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const parseTwoRowItemRenderer = (connection: string, rowContent: InnerTube.musicTwoRowItemRenderer): Song | Album | Artist | Playlist => {
|
const parseTwoRowItemRenderer = (connection: string, rowContent: InnerTube.musicTwoRowItemRenderer): Song | Album | Artist | Playlist => {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ export const pageWidth: Writable<number> = writable()
|
|||||||
|
|
||||||
export const newestAlert: Writable<[AlertType, string]> = writable()
|
export const newestAlert: Writable<[AlertType, string]> = writable()
|
||||||
|
|
||||||
export const currentlyPlaying = writable()
|
export const currentlyPlaying: Writable<Song | null> = writable()
|
||||||
|
|
||||||
const youtubeMusicBackground: string = 'https://www.gstatic.com/youtube/media/ytm/images/sbg/wsbg@4000x2250.png' // Default Youtube music background
|
const youtubeMusicBackground: string = 'https://www.gstatic.com/youtube/media/ytm/images/sbg/wsbg@4000x2250.png' // Default Youtube music background
|
||||||
export const backgroundImage: Writable<string> = writable(youtubeMusicBackground)
|
export const backgroundImage: Writable<string> = writable(youtubeMusicBackground)
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { pageWidth } from '$lib/stores'
|
|
||||||
import SearchBar from '$lib/components/util/searchBar.svelte'
|
import SearchBar from '$lib/components/util/searchBar.svelte'
|
||||||
import type { LayoutData } from './$types'
|
import type { LayoutData } from './$types'
|
||||||
|
import { currentlyPlaying } from '$lib/stores'
|
||||||
import NavTab from '$lib/components/navbar/navTab.svelte'
|
import NavTab from '$lib/components/navbar/navTab.svelte'
|
||||||
import PlaylistTab from '$lib/components/navbar/playlistTab.svelte'
|
import PlaylistTab from '$lib/components/navbar/playlistTab.svelte'
|
||||||
|
import MediaPlayer from '$lib/components/media/mediaPlayer.svelte'
|
||||||
|
|
||||||
export let data: LayoutData
|
export let data: LayoutData
|
||||||
|
|
||||||
@@ -21,7 +22,6 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if $pageWidth >= 768}
|
|
||||||
<div class="h-full overflow-hidden">
|
<div class="h-full overflow-hidden">
|
||||||
<div class="no-scrollbar fixed left-0 top-0 z-10 grid h-full w-20 grid-cols-1 grid-rows-[min-content_auto] gap-5 px-3 py-12">
|
<div class="no-scrollbar fixed left-0 top-0 z-10 grid h-full w-20 grid-cols-1 grid-rows-[min-content_auto] gap-5 px-3 py-12">
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
@@ -45,26 +45,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<slot />
|
<slot />
|
||||||
</section>
|
</section>
|
||||||
<footer class="fixed bottom-0 flex w-full flex-col items-center justify-center">
|
<section class="absolute bottom-0 z-40 max-h-full w-full">
|
||||||
<!-- <MiniPlayer displayMode={'horizontal'} /> -->
|
{#if $currentlyPlaying}
|
||||||
</footer>
|
<MediaPlayer currentlyPlaying={$currentlyPlaying} />
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="h-full overflow-hidden">
|
|
||||||
<section class="no-scrollbar h-full overflow-y-scroll px-[5vw] pt-16">
|
|
||||||
<slot />
|
|
||||||
</section>
|
|
||||||
<footer class="fixed bottom-0 flex w-full flex-col items-center justify-center">
|
|
||||||
<!-- <MiniPlayer displayMode={'vertical'} /> -->
|
|
||||||
<!-- <NavbarFoot
|
|
||||||
{currentPathname}
|
|
||||||
transitionTime={pageTransitionTime}
|
|
||||||
on:navigate={(event) => {
|
|
||||||
event.detail.direction === 'right' ? (directionMultiplier = 1) : (directionMultiplier = -1)
|
|
||||||
currentPathname = event.detail.pathname
|
|
||||||
goto(currentPathname)
|
|
||||||
}}
|
|
||||||
/> -->
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ 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`, { headers: { apikey: SECRET_INTERNAL_API_KEY } })
|
const recommendationResponse = await fetch(`/api/users/${locals.user.id}/recommendations`, {
|
||||||
const recommendationData = await recommendationResponse.json()
|
headers: { apikey: SECRET_INTERNAL_API_KEY },
|
||||||
const { recommendations } = recommendationData
|
}).then((response) => response.json())
|
||||||
|
|
||||||
|
const recommendations: (Song | Album | Artist | Playlist)[] = recommendationResponse.recommendations
|
||||||
|
|
||||||
return { recommendations }
|
return { recommendations }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import ScrollableCardMenu from '$lib/components/media/scrollableCardMenu.svelte'
|
import ScrollableCardMenu from '$lib/components/media/scrollableCardMenu.svelte'
|
||||||
import MediaCard from '$lib/components/media/mediaCard.svelte'
|
|
||||||
import type { PageData } from './$types'
|
import type { PageData } from './$types'
|
||||||
|
|
||||||
export let data: PageData
|
export let data: PageData
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { PUBLIC_YOUTUBE_API_CLIENT_ID } from '$env/static/public'
|
|||||||
import type { PageServerLoad, Actions } from './$types'
|
import type { PageServerLoad, Actions } from './$types'
|
||||||
import { DB } from '$lib/server/db'
|
import { DB } from '$lib/server/db'
|
||||||
import { Jellyfin, JellyfinFetchError } from '$lib/server/jellyfin'
|
import { Jellyfin, JellyfinFetchError } from '$lib/server/jellyfin'
|
||||||
import type { ConnectionInfo } from '$lib/server/connections'
|
|
||||||
import { google } from 'googleapis'
|
import { google } from 'googleapis'
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ fetch, locals }) => {
|
export const load: PageServerLoad = async ({ fetch, locals }) => {
|
||||||
@@ -29,7 +28,7 @@ export const actions: Actions = {
|
|||||||
|
|
||||||
if (authData instanceof JellyfinFetchError) return fail(authData.httpCode, { message: authData.message })
|
if (authData instanceof JellyfinFetchError) return fail(authData.httpCode, { message: authData.message })
|
||||||
|
|
||||||
const newConnectionId = DB.addConnectionInfo({ userId: locals.user.id, type: 'jellyfin', service: { userId: authData.User.Id, urlOrigin: serverUrl.toString() }, tokens: { accessToken: authData.AccessToken } })
|
const newConnectionId = DB.addConnectionInfo({ userId: locals.user.id, type: 'jellyfin', service: { userId: authData.User.Id, serverUrl: serverUrl.toString() }, tokens: { accessToken: authData.AccessToken } })
|
||||||
|
|
||||||
const response = await fetch(`/api/connections?ids=${newConnectionId}`, {
|
const response = await fetch(`/api/connections?ids=${newConnectionId}`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
|
|||||||
@@ -134,15 +134,8 @@
|
|||||||
</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 connection}
|
{#each connections as connectionInfo}
|
||||||
<ConnectionProfile
|
<ConnectionProfile {connectionInfo} submitFunction={profileActions} />
|
||||||
id={connection.id}
|
|
||||||
type={connection.type}
|
|
||||||
username={connection.service.username}
|
|
||||||
profilePicture={'profilePicture' in connection.service ? connection.service.profilePicture : undefined}
|
|
||||||
serverName={'serverName' in connection.service ? connection.service.serverName : undefined}
|
|
||||||
submitFunction={profileActions}
|
|
||||||
/>
|
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{#if newConnectionModal !== null}
|
{#if newConnectionModal !== null}
|
||||||
|
|||||||
@@ -6,26 +6,32 @@
|
|||||||
import { fly } from 'svelte/transition'
|
import { fly } from 'svelte/transition'
|
||||||
import { enhance } from '$app/forms'
|
import { enhance } from '$app/forms'
|
||||||
|
|
||||||
export let id: string, type: 'jellyfin' | 'youtube-music', username: string | undefined, profilePicture: string | undefined, serverName: string | undefined
|
export let connectionInfo: ConnectionInfo
|
||||||
export let submitFunction: SubmitFunction
|
export let submitFunction: SubmitFunction
|
||||||
|
|
||||||
$: serviceData = Services[type]
|
$: serviceData = Services[connectionInfo.type]
|
||||||
|
|
||||||
let showModal = false
|
let showModal = false
|
||||||
|
|
||||||
const subHeaderItems = [username, serverName]
|
const subHeaderItems: string[] = []
|
||||||
|
if ('username' in connectionInfo) {
|
||||||
|
subHeaderItems.push(connectionInfo.username)
|
||||||
|
}
|
||||||
|
if ('serverName' in connectionInfo) {
|
||||||
|
subHeaderItems.push(connectionInfo.serverName)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="rounded-lg" style="background-color: rgba(82, 82, 82, 0.25);" transition:fly={{ x: 50 }}>
|
<section class="rounded-lg" style="background-color: rgba(82, 82, 82, 0.25);" transition:fly={{ x: 50 }}>
|
||||||
<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" />
|
||||||
{#if profilePicture}
|
{#if 'profilePicture' in connectionInfo}
|
||||||
<img src={profilePicture} alt="" class="absolute bottom-0 right-0 aspect-square h-5 rounded-full" />
|
<img src={connectionInfo.profilePicture} alt="" class="absolute bottom-0 right-0 aspect-square h-5 rounded-full" />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div>{serviceData.displayName}</div>
|
<div>{serviceData.displayName} - {connectionInfo.id}</div>
|
||||||
<div class="text-sm text-neutral-500">
|
<div class="text-sm text-neutral-500">
|
||||||
{subHeaderItems.join(' - ')}
|
{subHeaderItems.join(' - ')}
|
||||||
</div>
|
</div>
|
||||||
@@ -40,7 +46,7 @@
|
|||||||
<i class="fa-solid fa-link-slash mr-1" />
|
<i class="fa-solid fa-link-slash mr-1" />
|
||||||
Delete Connection
|
Delete Connection
|
||||||
</button>
|
</button>
|
||||||
<input type="hidden" value={id} name="connectionId" />
|
<input type="hidden" value={connectionInfo.id} name="connectionId" />
|
||||||
</form>
|
</form>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
39
src/routes/api/audio/+server.ts
Normal file
39
src/routes/api/audio/+server.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import type { RequestHandler } from '@sveltejs/kit'
|
||||||
|
import { Connections } from '$lib/server/connections'
|
||||||
|
import ytdl from 'ytdl-core'
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ url, request }) => {
|
||||||
|
const connectionId = url.searchParams.get('connectionId')
|
||||||
|
const id = url.searchParams.get('id')
|
||||||
|
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 videoInfo = await ytdl.getInfo(videourl)
|
||||||
|
const format = ytdl.chooseFormat(videoInfo.formats, { quality: 'highestaudio', filter: 'audioonly' })
|
||||||
|
|
||||||
|
const audioSize = format.contentLength
|
||||||
|
const CHUNK_SIZE = 5 * 10 ** 6
|
||||||
|
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({
|
||||||
|
'Content-Range': `bytes ${start}-${end}/${audioSize}`,
|
||||||
|
'Accept-Ranges': 'bytes',
|
||||||
|
'Content-Length': contentLength.toString(),
|
||||||
|
'Content-Type': 'audio/webm',
|
||||||
|
})
|
||||||
|
|
||||||
|
const partialStream = ytdl(videourl, { format, range: { start, end } })
|
||||||
|
|
||||||
|
// @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 })
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { RequestHandler } from '@sveltejs/kit'
|
import type { RequestHandler } from '@sveltejs/kit'
|
||||||
import { Connections, type ConnectionInfo } from '$lib/server/connections'
|
import { Connections } from '$lib/server/connections'
|
||||||
|
|
||||||
export const GET: RequestHandler = async ({ url }) => {
|
export const GET: RequestHandler = async ({ url }) => {
|
||||||
const ids = url.searchParams.get('ids')?.replace(/\s/g, '').split(',')
|
const ids = url.searchParams.get('ids')?.replace(/\s/g, '').split(',')
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { RequestHandler } from '@sveltejs/kit'
|
import type { RequestHandler } from '@sveltejs/kit'
|
||||||
import { Connections, type ConnectionInfo } from '$lib/server/connections'
|
import { Connections } from '$lib/server/connections'
|
||||||
|
|
||||||
export const GET: RequestHandler = async ({ params }) => {
|
export const GET: RequestHandler = async ({ params }) => {
|
||||||
const userId = params.userId!
|
const userId = params.userId!
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import IconButton from '$lib/components/util/iconButton.svelte'
|
|
||||||
import { goto } from '$app/navigation';
|
|
||||||
import type { LayoutServerData } from '../$types'
|
|
||||||
|
|
||||||
export let data: LayoutServerData
|
|
||||||
|
|
||||||
interface SettingRoute {
|
|
||||||
pathname: string
|
|
||||||
displayName: string
|
|
||||||
icon: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const accountRoutes: SettingRoute[] = [
|
|
||||||
{
|
|
||||||
pathname: '/settings/connections',
|
|
||||||
displayName: 'Connections',
|
|
||||||
icon: 'fa-solid fa-circle-nodes',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
pathname: '/settings/devices',
|
|
||||||
displayName: 'Devices',
|
|
||||||
icon: 'fa-solid fa-mobile-screen',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<main class="grid h-full grid-rows-[min-content_auto] pb-12">
|
|
||||||
<h1 class="sticky top-0 grid grid-cols-[1fr_auto_1fr] grid-rows-1 items-center p-6 text-2xl">
|
|
||||||
<span class="h-12">
|
|
||||||
<IconButton on:click={() => goto('/user')}>
|
|
||||||
<i slot="icon" class="fa-solid fa-arrow-left" />
|
|
||||||
</IconButton>
|
|
||||||
</span>
|
|
||||||
<span>Settings</span>
|
|
||||||
</h1>
|
|
||||||
<section class="grid grid-cols-[min-content_auto] grid-rows-1 gap-8 px-[5vw]">
|
|
||||||
<nav class="h-full">
|
|
||||||
<a class="whitespace-nowrap text-lg {data.url.pathname === '/settings' ? 'text-lazuli-primary' : 'text-neutral-400 hover:text-lazuli-primary'}" href="/settings">
|
|
||||||
<i class="fa-solid fa-user mr-1 w-4 text-center" />
|
|
||||||
Account
|
|
||||||
</a>
|
|
||||||
<ol class="ml-2 mt-4 flex flex-col gap-3 border-2 border-transparent border-l-neutral-500 px-2">
|
|
||||||
{#each accountRoutes as route}
|
|
||||||
{@const isActive = route.pathname === data.url.pathname}
|
|
||||||
<li class="w-60 px-3 py-1">
|
|
||||||
<a class="whitespace-nowrap {isActive ? 'text-lazuli-primary' : 'text-neutral-400 hover:text-lazuli-primary'}" href={route.pathname}>
|
|
||||||
<i class="{route.icon} mr-1 w-4 text-center" />
|
|
||||||
{route.displayName}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ol>
|
|
||||||
</nav>
|
|
||||||
<slot />
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
<h1>Main Settings Page</h1>
|
|
||||||
Reference in New Issue
Block a user