Redoing the standard format for songs, albums, artists, and playlists

This commit is contained in:
Eclypsed
2024-04-24 20:36:56 -04:00
parent 28e4569507
commit 8432184a87
5 changed files with 411 additions and 70 deletions

54
src/app.d.ts vendored
View File

@@ -52,61 +52,79 @@ declare global {
// Big data should be fetched as needed in the app, these exist to ensure that the info necessary to fetch that data is there. // Big data should be fetched as needed in the app, these exist to ensure that the info necessary to fetch that data is there.
type Song = { type Song = {
connection: string connection: {
id: string
type: 'jellyfin' | 'youtube-music'
}
id: string id: string
name: string name: string
type: 'song' type: 'song'
duration?: number duration: number // Seconds
thumbnail?: string thumbnailUrl: string // Base/maxres url of song, any scaling for performance purposes will be handled by remoteImage endpoint
artists?: { artists: { // Should try to order
id: string id: string
name: string name: string
profilePicture?: string
}[] }[]
album?: { album?: {
id: string id: string
name: string name: string
thumbnailUrl: string
} }
createdBy?: { isVideo: boolean
uploader?: {
id: string id: string
name: string name: string
profilePicture?: string
} }
releaseDate?: string releaseDate: string // YYYY-MM-DD || YYYY-MM || YYYY
} }
type Album = { type Album = {
connection: string connection: {
id: string
type: 'jellyfin' | 'youtube-music'
}
id: string id: string
name: string name: string
type: 'album' type: 'song'
duration?: number duration: number // Seconds
thumbnail?: string thumbnailUrl: string
artists?: { artists: { // Should try to order
// Album Artists
id: string id: string
name: string name: string
}[] profilePicture?: string
releaseDate?: string }[] | 'Various Artists'
releaseDate: string // YYYY-MM-DD || YYYY-MM || YYYY
length: number
} }
// Need to figure out how to do Artists, maybe just query MusicBrainz? // Need to figure out how to do Artists, maybe just query MusicBrainz?
type Artist = { type Artist = {
connection: string connection: {
id: string
type: 'jellyfin' | 'youtube-music'
}
id: string id: string
name: string name: string
type: 'artist' type: 'artist'
thumbnail?: string profilePicture?: string
} }
type Playlist = { type Playlist = {
connection: string connection: {
id: string
type: 'jellyfin' | 'youtube-music'
}
id: string id: string
name: string name: string
type: 'playlist' type: 'playlist'
thumbnailUrl: string
createdBy?: { createdBy?: {
id: string id: string
name: string name: string
profilePicture?: string
} }
thumbnail?: string
} }
} }

View File

@@ -0,0 +1,44 @@
<!-- Credit to https://cssloaders.github.io/ -->
<span id="loader" class="absolute left-1/2 top-1/2 h-20 w-20 -translate-x-1/2 -translate-y-1/2" />
<style>
#loader:after {
content: '';
position: absolute;
top: 0;
left: 0;
width: inherit;
height: inherit;
border-radius: 50%;
animation: 1s spin linear infinite;
}
@keyframes spin {
0%,
100% {
box-shadow: 0.2rem 0;
}
12% {
box-shadow: 0.2rem 0.2rem;
}
25% {
box-shadow: 0 0.2rem;
}
37% {
box-shadow: -0.2rem 0.2rem;
}
50% {
box-shadow: -0.2rem 0;
}
62% {
box-shadow: -0.2rem -0.2rem;
}
75% {
box-shadow: 0 -0.2rem;
}
87% {
box-shadow: 0.2rem -0.2rem;
}
}
</style>

View File

@@ -1,4 +1,4 @@
import { google } from 'googleapis' import { google, type youtube_v3 } from 'googleapis'
import ytdl from 'ytdl-core' import ytdl from 'ytdl-core'
import { DB } from './db' import { DB } from './db'
import { PUBLIC_YOUTUBE_API_CLIENT_ID } from '$env/static/public' import { PUBLIC_YOUTUBE_API_CLIENT_ID } from '$env/static/public'
@@ -144,7 +144,7 @@ export class YouTubeMusic implements Connection {
} }
public async getRecommendations(): Promise<(Song | Album | Artist | Playlist)[]> { public async getRecommendations(): Promise<(Song | Album | Artist | Playlist)[]> {
const browseResponse: InnerTube.BrowseResponse = await fetch(`https://music.youtube.com/youtubei/v1/browse`, { const homeResponse: InnerTube.HomeResponse = await fetch(`https://music.youtube.com/youtubei/v1/browse`, {
headers: await this.innertubeRequestHeaders, headers: await this.innertubeRequestHeaders,
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
@@ -159,8 +159,12 @@ export class YouTubeMusic implements Connection {
}), }),
}).then((response) => response.json()) }).then((response) => response.json())
const contents = browseResponse.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents const contents = homeResponse.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents
console.log(JSON.stringify(contents))
const playlist = await google.youtube('v3').playlistItems.list({ playlistId: 'OLAK5uy_luC2CX2NPU_qVCVvCh4r4M2igAltIJ0Bc', part: ['snippet'], maxResults: 50, access_token: await this.accessToken })
console.log(JSON.stringify(playlist))
return []
const recommendations: (Song | Album | Artist | Playlist)[] = [] const recommendations: (Song | Album | Artist | Playlist)[] = []
const goodSections = ['Listen again', 'Forgotten favorites', 'Quick picks', 'From your library'] const goodSections = ['Listen again', 'Forgotten favorites', 'Quick picks', 'From your library']
@@ -174,6 +178,13 @@ export class YouTubeMusic implements Connection {
recommendations.push(...parsedContent) recommendations.push(...parsedContent)
} }
const songs = recommendations.filter((recommendation) => recommendation.type === 'song') as Song[]
const scrapedSong = songs.map((song) => {
return { id: song.id, name: song.name, type: 'song', isVideo: false } satisfies ScrapedSong
})
this.buildFullSongProfiles(scrapedSong)
return recommendations return recommendations
} }
@@ -185,51 +196,153 @@ export class YouTubeMusic implements Connection {
return await fetch(format.url, { headers }) return await fetch(format.url, { headers })
} }
public async getAlbum(id: string): Promise<ScrapedAlbum> {
const albumResponse: InnerTube.AlbumResponse = await fetch(`https://music.youtube.com/youtubei/v1/browse`, {
headers: await this.innertubeRequestHeaders,
method: 'POST',
body: JSON.stringify({
browseId: id,
context: {
client: {
clientName: 'WEB_REMIX',
clientVersion: `1.${formatDate()}.01.00`,
hl: 'en',
},
},
}),
}).then((response) => response.json())
const header = albumResponse.header.musicDetailHeaderRenderer
const contents = albumResponse.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0].musicShelfRenderer.contents
} }
function parseTwoRowItemRenderer(connection: string, rowContent: InnerTube.musicTwoRowItemRenderer): Song | Album | Artist | Playlist { public async getArtist(id: string): Promise<ScrapedArtist> {}
public async getUser(id: string): Promise<ScrapedUser> {}
private async buildFullSongProfiles(scrapedSongs: ScrapedSong[]): Promise<Song[]> {
const connection = { id: this.id, type: 'youtube-music' } satisfies Song['connection']
const songIds = new Set<string>(),
albumIds = new Set<string>(),
artistIds = new Set<string>(),
userIds = new Set<string>()
scrapedSongs.forEach((song) => {
songIds.add(song.id)
if (song.album?.id) {
albumIds.add(song.album.id)
}
song.artists.forEach((artist) => artistIds.add(artist.id))
if (song.uploader) {
userIds.add(song.uploader.id)
}
})
const getSongDetails = async () => google.youtube('v3').videos.list({ part: ['snippet', 'contentDetails'], id: Array.from(songIds), access_token: await this.accessToken })
const getAlbumDetails = () => Promise.all(Array.from(albumIds).map((id) => this.getAlbum(id)))
const getArtistDetails = () => Promise.all(Array.from(artistIds).map((id) => this.getArtist(id)))
const getUserDetails = () => Promise.all(Array.from(userIds).map((id) => this.getUser(id)))
const [songDetails, albumDetails, artistDetails, userDetails] = await Promise.all([getSongDetails(), getAlbumDetails(), getArtistDetails(), getUserDetails()])
const songDetailsMap = new Map<string, youtube_v3.Schema$Video>(),
albumDetailsMap = new Map<string, ScrapedAlbum>(),
artistDetailsMap = new Map<string, ScrapedArtist>(),
userDetailsMap = new Map<string, ScrapedUser>()
songDetails.data.items!.forEach((item) => songDetailsMap.set(item.id!, item))
albumDetails.forEach((album) => albumDetailsMap.set(album.id, album))
artistDetails.forEach((artist) => artistDetailsMap.set(artist.id, artist))
userDetails.forEach((user) => userDetailsMap.set(user.id, user))
return scrapedSongs.map((song) => {
const songDetails = songDetailsMap.get(song.id)!
const duration = secondsFromISO8601(songDetails.contentDetails?.duration!)
const thumbnails = songDetails.snippet?.thumbnails!
const thumbnailUrl = song.thumbnailUrl ?? thumbnails.maxres?.url ?? thumbnails.standard?.url ?? thumbnails.high?.url ?? thumbnails.medium?.url ?? thumbnails.default?.url!
let album: Song['album'],
uploader: Song['uploader'],
releaseDate = new Date(songDetails.snippet?.publishedAt!).toLocaleDateString()
const artists: Song['artists'] = song.artists.map((artist) => {
const { id, name, profilePicture } = artistDetailsMap.get(artist.id)!
return { id, name, profilePicture }
})
if (song.album) {
const { id, name, thumbnailUrl, releaseYear } = albumDetailsMap.get(song.album.id)!
album = { id, name, thumbnailUrl }
releaseDate = releaseYear
}
if (song.uploader) {
const { id, name, profilePicture } = userDetailsMap.get(song.uploader.id)!
uploader = { id, name, profilePicture }
}
return { connection, id: song.id, name: song.name, type: 'song', duration, thumbnailUrl, artists, album, isVideo: song.isVideo, uploader, releaseDate }
})
}
}
function parseTwoRowItemRenderer(rowContent: InnerTube.musicTwoRowItemRenderer): ScrapedSong | ScrapedAlbum | ScrapedArtist | ScrapedPlaylist {
const name = rowContent.title.runs[0].text const name = rowContent.title.runs[0].text
const thumbnail = refineThumbnailUrl(rowContent.thumbnailRenderer.musicThumbnailRenderer.thumbnail.thumbnails[0].url)
let artists: (Song | Album)['artists'], createdBy: (Song | Playlist)['createdBy'] if ('watchEndpoint' in rowContent.navigationEndpoint) {
for (const run of rowContent.subtitle.runs) { let album: ScrapedSong['album'],
if (!run.navigationEndpoint) continue artists: ScrapedSong['artists'] = [],
uploader: ScrapedSong['uploader']
const pageType = run.navigationEndpoint.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType
if (pageType === 'MUSIC_PAGE_TYPE_ARTIST') {
const artist = { id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text }
artists ? artists.push(artist) : (artists = [artist])
} else if (pageType === 'MUSIC_PAGE_TYPE_USER_CHANNEL') {
createdBy = { id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text }
}
}
let album: Song['album']
rowContent.menu.menuRenderer.items.forEach((menuOption) => { rowContent.menu.menuRenderer.items.forEach((menuOption) => {
if ( if (
'menuNavigationItemRenderer' in menuOption && 'menuNavigationItemRenderer' in menuOption &&
'browseEndpoint' in menuOption.menuNavigationItemRenderer.navigationEndpoint && 'browseEndpoint' in menuOption.menuNavigationItemRenderer.navigationEndpoint &&
menuOption.menuNavigationItemRenderer.navigationEndpoint.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType === 'MUSIC_PAGE_TYPE_ALBUM' menuOption.menuNavigationItemRenderer.navigationEndpoint.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType === 'MUSIC_PAGE_TYPE_ALBUM'
) { ) {
album = { id: menuOption.menuNavigationItemRenderer.navigationEndpoint.browseEndpoint.browseId, name: 'NEED TO FIND A WAY TO GET ALBUM NAME FROM ID' } album = { id: menuOption.menuNavigationItemRenderer.navigationEndpoint.browseEndpoint.browseId }
}
})
rowContent.subtitle.runs.forEach((run) => {
if (!run.navigationEndpoint) return
const pageType = run.navigationEndpoint.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType
const runData = { id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text }
if (pageType === 'MUSIC_PAGE_TYPE_ARTIST') {
artists.push(runData)
} else if (pageType === 'MUSIC_PAGE_TYPE_USER_CHANNEL') {
uploader = runData
} }
}) })
if ('watchEndpoint' in rowContent.navigationEndpoint) { const isUserUploaded = rowContent.navigationEndpoint.watchEndpoint.watchEndpointMusicSupportedConfigs.watchEndpointMusicConfig.musicVideoType === 'MUSIC_VIDEO_TYPE_UGC'
const thumbnailUrl: ScrapedSong['thumbnailUrl'] = isUserUploaded ? undefined : refineThumbnailUrl(rowContent.thumbnailRenderer.musicThumbnailRenderer.thumbnail.thumbnails[0].url)
const id = rowContent.navigationEndpoint.watchEndpoint.videoId const id = rowContent.navigationEndpoint.watchEndpoint.videoId
return { connection, id, name, type: 'song', artists, createdBy, thumbnail } satisfies Song return { id, name, type: 'song', thumbnailUrl, album, artists, uploader, isVideo: isUserUploaded } satisfies ScrapedSong
} }
const pageType = rowContent.navigationEndpoint.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType const pageType = rowContent.navigationEndpoint.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType
rowContent.menu.menuRenderer.items.forEach((menuItem) => {
if ('menuServiceItemRenderer' in menuItem) {
const queueTarget = menuItem.menuServiceItemRenderer.serviceEndpoint.queueAddEndpoint.queueTarget
if ('playlistId' in queueTarget) {
const thumbnailUrl = refineThumbnailUrl(rowContent.thumbnailRenderer.musicThumbnailRenderer.thumbnail.thumbnails[0].url)
if (pageType === 'MUSIC_PAGE_TYPE_ALBUM') {
const album = { id: queueTarget.playlistId, name, type: 'album', thumbnailUrl } satisfies ScrapedAlbum
}
}
}
})
const id = rowContent.navigationEndpoint.browseEndpoint.browseId const id = rowContent.navigationEndpoint.browseEndpoint.browseId
const thumbnailUrl = refineThumbnailUrl(rowContent.thumbnailRenderer.musicThumbnailRenderer.thumbnail.thumbnails[0].url)
switch (pageType) { switch (pageType) {
case 'MUSIC_PAGE_TYPE_ALBUM': case 'MUSIC_PAGE_TYPE_ALBUM':
return { connection, id, name, type: 'album', artists, thumbnail } satisfies Album return { id, name, type: 'album', artists, thumbnailUrl } satisfies ScrapedAlbum
case 'MUSIC_PAGE_TYPE_ARTIST': case 'MUSIC_PAGE_TYPE_ARTIST':
case 'MUSIC_PAGE_TYPE_USER_CHANNEL': case 'MUSIC_PAGE_TYPE_USER_CHANNEL':
return { connection, id, name, type: 'artist', thumbnail } satisfies Artist return { id, name, type: 'artist', profilePicture: thumbnailUrl } satisfies ScrapedArtist
case 'MUSIC_PAGE_TYPE_PLAYLIST': case 'MUSIC_PAGE_TYPE_PLAYLIST':
return { connection, id, name, type: 'playlist', createdBy, thumbnail } satisfies Playlist return { id, name, type: 'playlist', createdBy, thumbnailUrl } satisfies ScrapedPlaylist
} }
} }
@@ -312,20 +425,35 @@ function parseMusicCardShelfRenderer(connection: string, cardContent: InnerTube.
} }
} }
/** YouTube should (I haven't confirmed) cap duration scaling at days, so any duration will be in the following ISO8601 Format: PnDTnHnMnS */
function secondsFromISO8601(duration: string): number {
const iso8601DurationRegex = /P(?:([.,\d]+)D)?T(?:([.,\d]+)H)?(?:([.,\d]+)M)?(?:([.,\d]+)S)?/ // Credit: https://stackoverflow.com/users/1195273/crush
const result = iso8601DurationRegex.exec(duration)
const days = result?.[1] ?? 0,
hours = result?.[2] ?? 0,
minutes = result?.[3] ?? 0,
seconds = result?.[4] ?? 0
return Number(seconds) + Number(minutes) * 60 + Number(hours) * 3600 + Number(days) * 86400
}
/** Remove YouTubes fake query parameters from their thumbnail urls returning the base url for as needed modification.
* Valid URL origins:
* - https://lh3.googleusercontent.com
* - https://yt3.googleusercontent.com
* - https://yt3.ggpht.com
* - https://music.youtube.com
*/
function refineThumbnailUrl(urlString: string): string { function refineThumbnailUrl(urlString: string): string {
if (!URL.canParse(urlString)) throw new Error('Invalid thumbnail url') if (!URL.canParse(urlString)) throw new Error('Invalid thumbnail url')
const url = new URL(urlString) switch (new URL(urlString).origin) {
switch (url.origin) {
case 'https://i.ytimg.com':
return urlString.slice(0, urlString.indexOf('?')).replace('sddefault', 'mqdefault')
case 'https://lh3.googleusercontent.com': case 'https://lh3.googleusercontent.com':
case 'https://yt3.googleusercontent.com': case 'https://yt3.googleusercontent.com':
case 'https://yt3.ggpht.com': case 'https://yt3.ggpht.com':
return urlString.slice(0, urlString.indexOf('=')) return urlString.slice(0, urlString.indexOf('='))
case 'https://music.youtube.com': case 'https://music.youtube.com':
return urlString return urlString
default: default: // 'https://i.ytimg.com' cannot be manimulated with query params and as such is invalid
console.error(urlString) console.error(urlString)
throw new Error('Invalid thumbnail url origin') throw new Error('Invalid thumbnail url origin')
} }
@@ -340,7 +468,141 @@ function formatDate(): string {
return year + month + day return year + month + day
} }
// NOTE 1:
// When scraping thumbnails from the YTMusic browse pages, there are two different types of images that can be returned,
// standard video thumbnais and auto-generated square thumbnails for propper releases. The auto-generated thumbanils we want to
// keep from the scrape because:
// a) They can be easily scaled with ytmusic's weird fake query parameters (Ex: https://baseUrl=w1000&h1000)
// b) When fetched from the youtube data api it returns the 16:9 filled thumbnails like you would see in the standard yt player, we want the squares
//
// However when the thumbnail is for a video, we want to ignore it because the highest quality thumbnail will rarely be used in the ytmusic player
// and there is no easy way scale them due to the fixed sizes (default, medium, high, standard, maxres) without any way to determine if a higher quality exists.
// Therefor, these thumbanils should be fetched from the youtube data api and the highest res should be chosen. In the remoteImage endpoint this his res can
// be scaled to the desired resolution with image processing.
type ScrapedSong = {
id: string
name: string
type: 'song'
thumbnailUrl: string
artists: {
id: string
name?: string
}[]
album?: {
id: string
name?: string
}
uploader?: {
id: string
name?: string
}
isVideo: boolean
}
type ScrapedAlbum = {
id: string
name: string
type: 'album'
thumbnailUrl: string
artists:
| {
id: string
name?: string
}[]
| 'Various Artists'
releaseYear?: string
length?: number
}
type ScrapedArtist = {
id: string
name: string
type: 'artist'
profilePicture?: string
}
type ScrapedPlaylist = {
id: string
name: string
type: 'playlist'
thumbnailUrl: string
createdBy?: {
id: string
name?: string
}
}
type ScrapedUser = {
id: string
name: string
type: 'user'
profilePicture?: string
}
declare namespace InnerTube { declare namespace InnerTube {
interface AlbumResponse {
contents: {
singleColumnBrowseResultsRenderer: {
tabs: [
{
tabRenderer: {
content: {
sectionListRenderer: {
contents: [
{
musicShelfRenderer: {
contents: Array<{
musicResponsiveListItemRenderer: {
flexColumns: Array<{
musicResponsiveListItemFlexColumnRenderer: {
text: {
runs?: [
{
text: string
navigationEndpoint?: {
watchEndpoint: watchEndpoint
}
},
]
}
}
}>
}
}>
}
},
]
}
}
}
},
]
}
}
header: {
musicDetailHeaderRenderer: {
title: {
runs: [
{
text: string
},
]
}
subtitle: {
runs: Array<{
text: string
navigationEndpoint?: {
browseEndpoint: browseEndpoint
}
}>
}
thumbnail: {
croppedSquareThumbnailRenderer: musicThumbnailRenderer
}
}
}
}
interface SearchResponse { interface SearchResponse {
contents: { contents: {
tabbedSearchResultsRenderer: { tabbedSearchResultsRenderer: {
@@ -416,7 +678,7 @@ declare namespace InnerTube {
}> }>
} }
interface BrowseResponse { interface HomeResponse {
contents: { contents: {
singleColumnBrowseResultsRenderer: { singleColumnBrowseResultsRenderer: {
tabs: [ tabs: [
@@ -483,7 +745,7 @@ declare namespace InnerTube {
| { | {
browseEndpoint: browseEndpoint browseEndpoint: browseEndpoint
} }
menu: { menu?: {
menuRenderer: { menuRenderer: {
items: Array< items: Array<
| { | {
@@ -511,7 +773,26 @@ declare namespace InnerTube {
} }
} }
| { | {
menuServiceItemRenderer: unknown menuServiceItemRenderer: {
text: {
runs: [
{
text: 'Play next' | 'Add to queue'
},
]
}
serviceEndpoint: {
queueAddEndpoint: {
queueTarget:
| {
playlistId: string
}
| {
videoId: string
}
}
}
}
} }
| { | {
toggleMenuServiceItemRenderer: unknown toggleMenuServiceItemRenderer: unknown
@@ -626,7 +907,7 @@ declare namespace InnerTube {
playlistId: string playlistId: string
watchEndpointMusicSupportedConfigs: { watchEndpointMusicSupportedConfigs: {
watchEndpointMusicConfig: { watchEndpointMusicConfig: {
musicVideoType: 'MUSIC_VIDEO_TYPE_UGC' | 'MUSIC_VIDEO_TYPE_ATV' musicVideoType: 'MUSIC_VIDEO_TYPE_UGC' | 'MUSIC_VIDEO_TYPE_ATV' // UGC Means it is a user-uploaded video, ATV means it is auto-generated
} }
} }
} }

View File

@@ -1,18 +1,15 @@
<script lang="ts"> <script lang="ts">
import ScrollableCardMenu from '$lib/components/media/scrollableCardMenu.svelte' import ScrollableCardMenu from '$lib/components/media/scrollableCardMenu.svelte'
import type { PageData } from './$types' import type { PageData } from './$types'
import Loader from '$lib/components/util/loader.svelte'
export let data: PageData export let data: PageData
</script> </script>
<div id="main"> <div id="main">
{#await data.recommendations then recommendations} {#await data.recommendations}
<Loader />
{:then recommendations}
<ScrollableCardMenu header={'Listen Again'} cardDataList={recommendations} /> <ScrollableCardMenu header={'Listen Again'} cardDataList={recommendations} />
{/await} {/await}
<!-- <h1 class="mb-6 text-4xl"><strong>Listen Again</strong></h1>
<div class="flex flex-wrap justify-between gap-6">
{#each data.recommendations as recommendation}
<MediaCard mediaItem={recommendation} />
{/each}
</div> -->
</div> </div>

View File

@@ -29,13 +29,14 @@
$newestAlert = ['caution', 'All fields must be filled out'] $newestAlert = ['caution', 'All fields must be filled out']
return cancel() return cancel()
} }
try {
formData.set('serverUrl', new URL(serverUrl.toString()).origin) if (!URL.canParse(serverUrl.toString())) {
} catch {
$newestAlert = ['caution', 'Server URL is invalid'] $newestAlert = ['caution', 'Server URL is invalid']
return cancel() return cancel()
} }
formData.set('serverUrl', new URL(serverUrl.toString()).origin)
const deviceId = getDeviceUUID() const deviceId = getDeviceUUID()
formData.append('deviceId', deviceId) formData.append('deviceId', deviceId)