import { GxDialog } from "./dialog.js" import { requestJson } from "./startup.js" import { GxNotice } from "./notice.js" import { connectionError } from "./polling.js" import { uploadPackage } from "./starport.js" import { GalaxySettingRow, switchRow } from "./galaxy-setting-row.js" const ADDRESS = /^(?:[0-9A-F]{2}:){5}[0-9A-F]{2}$/ export const DOWNLOAD_PAGE = "https://developer.spotify.com/documentation/soloist/reference/downloads-and-updates" const STAGES = ["reading", "unpacking", "verifying", "testing", "installing", "done"] const CHECKS = [ { stage: "reading", label: "Received the download", codes: ["TOO_LARGE"] }, { stage: "unpacking", label: "Unpacked safely", codes: ["NOT_ARCHIVE", "CORRUPT", "UNSAFE", "NO_PLAYER", "WRONG_ARCH"] }, { stage: "verifying", label: "Matches Spotify’s official download", codes: ["NOT_OFFICIAL", "OFFLINE"] }, { stage: "testing", label: "Runs on this comma", codes: ["INCOMPATIBLE", "EXPIRED"] }, { stage: "installing", label: "Installed", codes: ["DRIVING"] }, ] function validSetup(value) { return value && typeof value.enabled === "boolean" && typeof value.parked === "boolean" && typeof value.keyPresent === "boolean" && value.release && typeof value.release.installed === "boolean" && value.preferences && Array.isArray(value.devices) && value.import && Number.isSafeInteger(value.maxUploadBytes) && value.maxUploadBytes > 0 && value.links } // Name and size checks that catch the usual wrong downloads before sending anything. export function archivePreflight(file) { const name = typeof file?.name === "string" ? file.name : "" if (/arm32|armv7|x86_64|amd64/i.test(name)) return "That’s the download for a different computer. Choose ARMv8/AArch64 Linux (soloist_release_arm64.tar.gz)." if (/\.tar$/i.test(name)) return "Your browser unpacked the download. Download it again and keep the .tar.gz file as it is." if (/^soloist$/i.test(name)) return "That’s the unpacked player. Upload the .tar.gz file exactly as it downloaded." if (name && !/\.(tar\.gz|tgz|gz)$/i.test(name)) return "Choose the soloist_release_arm64.tar.gz file you downloaded." return "" } export function importProblem(job) { if (job?.state !== "failed") return "" switch (job.code) { case "NOT_ARCHIVE": return "That isn’t the Soloist download. Choose soloist_release_arm64.tar.gz." case "CORRUPT": return "This file looks incomplete or damaged. Download it again and wait for it to finish." case "WRONG_ARCH": return "That’s the download for a different computer. On Spotify’s page, choose ARMv8/AArch64 Linux." case "NO_PLAYER": case "UNSAFE": return "This archive isn’t a Soloist release. Download it again from Spotify’s page." case "NOT_OFFICIAL": return "This file doesn’t match the build Spotify is offering right now. Spotify may have just released a new one; download it again and upload the new file." case "OFFLINE": return "Your comma needs an internet connection to check this download with Spotify. Connect it to Wi‑Fi or cellular and try again." case "EXPIRED": return "This Soloist build has expired. Download the newest one from Spotify." case "INCOMPATIBLE": return job.error || "This Soloist build doesn’t run on this comma yet." case "DRIVING": return "Put the car in Park or use offroad mode, then install again." case "TOO_LARGE": return "This file is too large to be the Soloist download." case "CANCELLED": return "Setup authorization changed. Sign in to Galaxy and try again." default: return job.error || "Something went wrong. Try again." } } export function installChecks(job) { const at = job?.state === "done" ? STAGES.length : STAGES.indexOf(job?.stage) const ends = CHECKS.map((_, index) => STAGES.indexOf(index + 1 < CHECKS.length ? CHECKS[index + 1].stage : "done")) let failed = -1 if (job?.state === "failed") { failed = CHECKS.findIndex((check) => check.codes.includes(job.code)) if (failed < 0) failed = Math.max(0, ends.findIndex((end) => at < end)) } return CHECKS.map((check, index) => ({ label: check.label, status: failed >= 0 ? (index < failed ? "done" : index === failed ? "failed" : "pending") : at >= ends[index] ? "done" : at >= STAGES.indexOf(check.stage) ? "active" : "pending", })) } export function releaseInfo(release, locale = undefined) { if (!release?.installed) return { level: "none", label: "Not installed" } const date = release.expires ? new Date(release.expires) : null const on = date && !Number.isNaN(date.getTime()) ? date.toLocaleDateString(locale, { month: "short", day: "numeric", year: "numeric" }) : "" const version = release.version ? `Version ${release.version}` : "Installed" if (release.expired) return { level: "expired", label: `${version} · expired${on ? ` on ${on}` : ""}` } if (release.level === "soon") { const left = release.daysLeft === 0 ? "expires within a day" : `expires in ${release.daysLeft} day${release.daysLeft === 1 ? "" : "s"}` return { level: "soon", label: `${version} · ${left}${on ? ` (${on})` : ""}` } } return { level: "ok", label: `${version} · ${on ? `expires on ${on}` : "expiry date unavailable"}` } } const STATE_TEXT = { missing_package: "Install Spotify Soloist to finish setup.", missing_key: "Add your API key to finish setup.", no_output: "Choose where music plays.", audio_unavailable: "This StarPilot build is missing Spotify’s audio component.", waiting_output: "Waiting for your speaker to connect.", starting: "Starting…", connecting: "Starting…", waiting_network: "Waiting for the comma’s network…", waiting: "Signing in to Spotify…", ready: "Ready. Pick this device in Spotify to play.", playing: "Playing", retrying: "Restarting…", failed: "Spotify couldn’t start.", expired: "This Soloist build expired. Install the newest one.", } export function serviceLabel(setup) { if (!setup?.enabled) return "Off" const service = setup.service if (!service) return "Starting…" const text = service.state === "ready" && service.player?.status === "paused" ? "Paused" : service.message && !["ready", "playing", "waiting"].includes(service.state) ? service.message : STATE_TEXT[service.state] || "Starting…" return service.outputWaiting ? `${text} Audio waits for ${service.outputWaiting}.` : text } // Where the track is now, from Soloist's anchor and the browser time elapsed since it. export function positionNow(position, durationMs, now = Date.now()) { if (!position) return 0 const value = position.ms + Math.max(0, now - position.at) * position.speed return Math.max(0, Math.min(durationMs ?? value, value)) } // Moves the play anchor onto the browser's clock, since the comma's and the browser's can differ. export function onBrowserClock(setup, now = Date.now()) { const position = setup?.service?.player?.position if (!position || !Number.isFinite(setup.clock)) return setup const player = { ...setup.service.player, position: { ...position, at: position.at + now - setup.clock } } return { ...setup, service: { ...setup.service, player } } } export function formatTime(ms) { const seconds = Math.floor((ms || 0) / 1000) return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}` } export function muteLabel(seconds) { const ms = Math.round((Number(seconds) || 0) * 1000) return ms === 0 ? "Off" : `${ms} ms` } export class SpotifyFeed { constructor({ publish, unauthorized = () => { }, fetcher = (...args) => fetch(...args), uploader = uploadPackage, later = (fn, ms) => setTimeout(fn, ms), cancelTimer = (id) => clearTimeout(id), overBluetooth = () => globalThis.StarPilotApp?.bluetooth?.() === true }) { Object.assign(this, { publish, unauthorized, fetcher, uploader, later, cancelTimer, overBluetooth }) this.active = false this.generation = 0 this.pollTimer = null this.setup = null this.busy = false this.uploadProgress = null this.error = "" } emit() { this.publish({ setup: this.setup, busy: this.busy, uploadProgress: this.uploadProgress, error: this.error }) } start() { this.stop(); this.active = true; return this.refresh() } stop() { this.active = false this.generation++ if (this.pollTimer !== null) this.cancelTimer(this.pollTimer) this.pollTimer = null this.busy = false this.uploadProgress = null this.emit() } schedule() { if (!this.active) return if (this.pollTimer !== null) this.cancelTimer(this.pollTimer) const fast = this.setup?.import?.state === "running" || this.setup?.service?.player?.connected // The phone Bluetooth link is slow, so poll it a lot less. const delay = this.overBluetooth() ? (fast ? 5000 : 15000) : (fast ? 1500 : 4000) this.pollTimer = this.later(() => { this.pollTimer = null; this.refresh() }, delay) } async request(path, options = {}, timeout = 8000, transport = this.fetcher) { const generation = this.generation try { const { response, data } = await requestJson(path, { fetcher: transport, timeout, later: this.later, cancel: this.cancelTimer, request: options, withResponse: true }) if (!this.active || this.generation !== generation) return null if (response.status === 401) { this.stop(); this.unauthorized(); return null } if (!response.ok) throw new Error(data?.error || "Spotify setup is unavailable") return data } catch (error) { if (this.active && this.generation === generation) { this.error = error.name === "TimeoutError" ? "Your comma took too long to respond. Retrying…" : connectionError(error) this.emit() } return null } } async refresh() { if (!this.active) return const setup = await this.request("./api/spotify/setup") if (setup && validSetup(setup)) { this.setup = onBrowserClock(setup); if (!this.busy) this.error = "" } else if (setup) this.error = "Spotify setup response changed" this.emit() this.schedule() } async post(path, body, timeout = 10000) { if (!this.active || this.busy) return null this.busy = true this.error = "" this.emit() const result = await this.request(path, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }, timeout) this.busy = false if (result && validSetup(result)) this.setup = onBrowserClock(result) this.emit() if (result) this.refresh() return result } // Playback controls stay outside the busy gate so a slow command never blocks the next tap. async control(action, args = undefined) { if (!this.active) return null const result = await this.request("./api/spotify/control", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(args === undefined ? { action } : { action, args }) }, 5000) if (result) this.later(() => this.refresh(), 400) return result } async upload(file) { if (!this.active || this.busy || !this.setup || this.setup.uploadAllowed === false || this.setup.import?.state === "running" || !file || !Number.isSafeInteger(file.size) || file.size <= 0 || file.size > this.setup.maxUploadBytes) return false this.busy = true this.uploadProgress = { loaded: 0, total: file.size } this.emit() const generation = this.generation const transport = (path, options) => this.uploader(path, options, (progress) => { if (this.active && generation === this.generation) { this.uploadProgress = progress; this.emit() } }) const result = await this.request("./api/spotify/upload", { method: "POST", headers: { "Content-Type": "application/octet-stream" }, body: file }, 180000, transport) this.busy = false this.uploadProgress = null if (result && validSetup(result)) this.setup = onBrowserClock(result) this.emit() this.schedule() return result !== null } async removeAll() { if (!this.active || this.busy) return false this.busy = true this.emit() const result = await this.request("./api/spotify", { method: "DELETE" }, 30000) this.busy = false if (result && validSetup(result)) this.setup = onBrowserClock(result) this.emit() return result !== null } } export const SpotifyPage = { components: { GxNotice, GxDialog, GalaxySettingRow }, props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true } }, data: () => ({ setup: null, busy: false, error: "", uploadProgress: null, now: Date.now(), installOpen: false, removeOpen: false, resetOpen: false, showSetup: false, downloadStarted: false, archiveFile: null, archiveMagicError: "", archiveChecking: false, archiveSelection: 0, installAttempted: false, installBaseline: null, keyValue: "", keyEditing: false, nameValue: "", pauseDraft: null, skipDraft: null, }), mounted() { this.feed = new SpotifyFeed({ publish: (value) => Object.assign(this, value, { now: Date.now() }), unauthorized: this.unauthorized }) this.visibility = () => { if (document.hidden) this.feed.stop(); else this.feed.start() } document.addEventListener("visibilitychange", this.visibility) this.clock = setInterval(() => { if (this.playing) this.now = Date.now() }, 1000) if (!document.hidden) this.feed.start() }, beforeUnmount() { document.removeEventListener("visibilitychange", this.visibility) clearInterval(this.clock) this.keyValue = "" this.feed?.stop() }, watch: { mode() { if (!document.hidden) this.feed.start(); else this.feed.stop() }, "setup.preferences.deviceName": { immediate: true, handler(value) { if (typeof value === "string") this.nameValue = value } }, }, computed: { release() { return releaseInfo(this.setup?.release) }, installed() { return !!this.setup?.release.installed && !this.setup.release.expired }, outputChosen() { const prefs = this.setup?.preferences return !!prefs && (["test", "starport"].includes(prefs.output) || ADDRESS.test(prefs.outputAddress)) }, phoneSource() { return this.setup?.preferences.source === "phone" }, pauseMuteLabel() { return muteLabel(this.pauseDraft ?? this.setup?.preferences.pauseMute) }, skipMuteLabel() { return muteLabel(this.skipDraft ?? this.setup?.preferences.skipMute) }, setupDone() { return this.phoneSource ? !!(ADDRESS.test(this.setup?.preferences.phoneAddress || "") && this.outputChosen) : !!(this.installed && this.setup?.keyPresent && this.outputChosen) }, player() { return this.setup?.service?.player || null }, playing() { return this.player?.status === "playing" }, track() { return this.player?.loggedIn ? this.player.track : null }, can() { return new Set(this.player?.actions || []) }, elapsed() { return positionNow(this.player?.position, this.track?.durationMs, this.now) }, statusText() { return serviceLabel(this.setup) }, outputName() { const prefs = this.setup?.preferences if (!prefs) return "" if (prefs.output === "starport") return "StarPort" return prefs.output === "test" ? "Silent test output" : prefs.outputName || prefs.outputAddress || "Not chosen" }, uploadPercent() { const { loaded, total } = this.uploadProgress || {} return total > 0 ? Math.min(100, Math.round(100 * loaded / total)) : 0 }, onPhone() { return typeof navigator !== "undefined" && (navigator.userAgentData?.mobile === true || /Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent || "") || navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1) }, downloadsHint() { if (typeof navigator === "undefined") return "" if (/iPhone|iPad|iPod/i.test(navigator.userAgent || "") || navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1) return "In Files, open Downloads." return /Android/i.test(navigator.userAgent || "") ? "Open Downloads." : "" }, archiveError() { if (!this.archiveFile) return "" const preflight = archivePreflight(this.archiveFile) if (preflight) return preflight if (!Number.isSafeInteger(this.archiveFile.size) || this.archiveFile.size <= 0) return "The selected file is empty or its size cannot be read." if (this.setup && this.archiveFile.size > this.setup.maxUploadBytes) return "This file is too large to be the Soloist download." return this.archiveMagicError }, installBlocked() { return this.setup?.uploadAllowed === false }, uploadReason() { if (this.installBlocked) return "Installing Spotify needs your local network or the Galaxy tunnel." if (!this.archiveFile) return "Choose the file you downloaded." if (this.archiveError) return this.archiveError if (this.archiveChecking) return "Checking the file…" if (!this.setup?.parked) return "Put the car in Park or use offroad mode to install." if (this.setup.import?.state === "running") return "Wait for the current install to finish." return "" }, installState() { const job = this.setup?.import const current = this.installAttempted && job && job.started !== this.installBaseline if (this.uploadProgress || this.installAttempted && this.busy && !current) return "uploading" if (job?.state === "running") return "checking" if (!current) return "choose" if (job.state === "done" && this.setup.release.installed) return "done" return job.state === "failed" ? "failed" : "choose" }, checks() { return installChecks(this.setup?.import) }, problem() { return importProblem(this.setup?.import) }, keyValid() { return /^[A-Za-z0-9._~+/=-]{16,256}$/.test(this.keyValue.trim()) }, nameValid() { return /^[^\x00-\x1f\x7f]{1,40}$/.test(this.nameValue.trim()) }, outputChoices() { const devices = this.setup?.devices || [] return devices.map((device) => ({ value: device.address, label: device.name + (device.connected ? "" : " (Not Connected)") })) }, }, methods: { switchRow, formatTime, setEnabled(enabled) { return this.feed.post("./api/spotify/enable", { enabled }) }, async saveKey() { if (!this.keyValid || this.busy) return const key = this.keyValue.trim() this.keyValue = "" const result = await this.feed.post("./api/spotify/key", { key }) if (result) this.keyEditing = false }, removeKey() { return this.feed.post("./api/spotify/maintenance", { action: "remove_key" }) }, chooseSource(event) { return this.feed.post("./api/spotify/preferences", { source: event.target.value }) }, choosePhone(event) { return this.feed.post("./api/spotify/preferences", { phoneAddress: event.target.value }) }, chooseOutput(event) { const value = event.target.value if (["test", "starport"].includes(value)) return this.feed.post("./api/spotify/preferences", { output: value, outputAddress: "" }) if (ADDRESS.test(value)) return this.feed.post("./api/spotify/preferences", { output: "bluetooth", outputAddress: value }) }, saveName() { if (!this.nameValid || this.nameValue.trim() === this.setup?.preferences.deviceName) return return this.feed.post("./api/spotify/preferences", { deviceName: this.nameValue.trim() }) }, saveCache(event) { return this.feed.post("./api/spotify/preferences", { cacheMb: Number(event.target.value) }) }, clearCache() { return this.feed.post("./api/spotify/maintenance", { action: "clear_cache" }, 20000) }, async resetSession() { this.resetOpen = false; return this.feed.post("./api/spotify/maintenance", { action: "reset_session" }, 20000) }, async removeAll() { this.removeOpen = false; return this.feed.removeAll() }, control(action, args) { return this.feed.control(action, args) }, togglePlay() { return this.control(this.playing ? "pause" : "play") }, commitMute(kind, event) { const value = Number(event.target.value) this[`${kind}Draft`] = null if (Number.isFinite(value)) return this.feed.post("./api/spotify/preferences", { [`${kind}Mute`]: value }) }, openInstall() { this.chooseAgain(); Object.assign(this, { installOpen: true, downloadStarted: false }) }, closeInstall() { this.installOpen = false }, markDownloadStarted() { if (!this.busy && ["choose", "failed"].includes(this.installState)) this.chooseAgain() this.downloadStarted = true }, chooseAgain() { this.archiveSelection++ Object.assign(this, { installAttempted: false, archiveFile: null, archiveMagicError: "", archiveChecking: false }) }, async chooseArchive(event) { const file = event.target.files?.[0] || null const selection = ++this.archiveSelection Object.assign(this, { archiveFile: file, archiveMagicError: "", archiveChecking: false }) if (!file || archivePreflight(file) || !Number.isSafeInteger(file.size) || file.size <= 0 || file.size > this.setup?.maxUploadBytes) return this.archiveChecking = true let error = "" try { const bytes = new Uint8Array(await file.slice(0, 2).arrayBuffer()) if (bytes[0] !== 0x1f || bytes[1] !== 0x8b) error = "That isn’t the Soloist download. Choose soloist_release_arm64.tar.gz." else if (file.size < 1024 * 1024) error = "This file is too small to be the Soloist download. Download it again." } catch { error = "The file couldn’t be read. Choose it again." } if (selection === this.archiveSelection) Object.assign(this, { archiveMagicError: error, archiveChecking: false }) }, async upload() { if (this.busy || this.uploadReason) return false Object.assign(this, { installAttempted: true, installBaseline: this.setup.import?.started ?? null }) return this.feed.upload(this.archiveFile) }, }, template: `

Spotify and Phone Audio

Play Spotify on your comma, or send music from your phone through the comma to your car.

{{ error }} {{ error || "Checking Spotify setup… Reconnecting automatically…" }}

Soloist expires 90 days after Spotify builds it, regardless of when you install it.

Music Source

Put the car in Park or use offroad mode to change the phone.

{{ track?.title || setup.preferences.deviceName }}

{{ track.artists }}

{{ statusText }}

{{ formatTime(elapsed) }} / {{ formatTime(track.durationMs) }}
Volume {{ player.volume }}% - change in Spotify app if needed, otherwise use car volume
Takes over from the phone or speaker Spotify is playing on now. Music plays through {{ outputName }}. You might need to confirm the switch in the Spotify app.
  1. Spotify Soloist

    Spotify’s player for devices like your comma. You download it from Spotify, then upload it here.

    {{ release.label }}

    Installing Spotify needs your local network or the Galaxy tunnel. Phone audio works over Bluetooth.

    Installing…

    Each Soloist build works for 90 days from its build date, not its install date. Before it runs out, this step shows Update; download again the same way.

  2. Your API Key

    Spotify gives each person their own key. Creating one needs Spotify Premium.

  3. Where Music Plays

    Through Android Auto, or your car or a speaker over Bluetooth. Alerts keep playing from the comma itself.

    Put the car in Park or use offroad mode to change this.

    Music plays through the car while StarPort is connected, and the car shows it as Android Auto audio. If you switch the car to the radio, playback is paused when the source supports it.

    No paired devices yet. Pair your car or speaker on the Bluetooth page first.

    Don’t see it? Pair it on the Bluetooth page. In your car, choose Bluetooth Audio as the source.

  4. Connect Spotify

    Turn it on, then pick {{ setup.preferences.deviceName }} in the Spotify app’s device list.

    {{ statusText }}

    Pair once on the same Wi‑Fi or hotspot. After pairing, keep Spotify enabled and both devices online; your comma can use cellular or a phone hotspot. Use the same Spotify account on your phone.

Storage and Account

Help

Why do I download Soloist myself?

Spotify asks that each person download Soloist from Spotify and use their own API key, so StarPilot can’t include it. Your comma checks that the file you upload is exactly what Spotify is offering before installing it.

Spotify doesn’t show my comma
  1. Keep Spotify enabled in step 4. A disconnected Bluetooth speaker can wait while you pair; music will be silent until it connects.
  2. Put your phone on the same Wi‑Fi or hotspot as your comma. Some hotspots and guest networks block devices from seeing each other.
  3. For cellular or different networks after pairing, turn off Local device visibility in Spotify → Settings → Apps and devices. Both devices need internet access.
  4. On iPhone, allow Spotify’s Local Network permission in iPhone Settings. If your phone’s hotspot blocks initial pairing, pair once on another shared Wi‑Fi network.
  5. If it still does not appear, turn Spotify off and back on here. This reconnects using your saved account; you do not need to clear the account or cache.
  6. In Spotify, tap the device icon and look for {{ setup?.preferences.deviceName || 'StarPilot' }}.
Can I use the comma hotspot or wireless Android Auto?

The comma hotspot can provide the local connection for pairing, but the comma also needs internet access to stream. A wireless Android Auto connection alone does not guarantee Spotify discovery or internet access. After pairing, Spotify can use the saved account over the comma’s cellular connection.

Does it use my data plan?

Yes, when your comma streams over cellular. Music usually uses about 40–150 MB an hour depending on quality. Songs you’ve played recently come from the cache instead.

Will music cover up StarPilot’s alerts?

Music plays through your car or speaker; alerts keep playing from the comma’s own speaker. Keep the music at a level where you can still hear the comma.

Can I use it with Android Auto?

Yes. In step 3 choose Android Auto, and music plays through the car as Android Auto audio alongside the StarPilot screen. It needs Android Auto to be connected, so if it isn’t, music has nowhere to play.

More help from Spotify

Spotify’s Soloist getting-started guide

Use a different Spotify account?

This signs your comma out of Spotify and clears its cache. Then pick it in the Spotify app on the account you want.

Remove Spotify?

This stops music and deletes Soloist, your API key, your Spotify sign-in and the music cache from this comma. Your Bluetooth devices and StarPort setup stay as they are.

Soloist is installed

{{ release.label }}

  • {{ check.label }}

{{ setup.release.installed ? 'Install a Newer Soloist' : 'Install Spotify Soloist' }}

{{ setup.release.installed ? release.label + '. Your settings and Spotify sign-in stay as they are.' : 'Download it from Spotify, then send it to your comma.' }}

  1. 1
    Download Soloist From Spotify’s Downloads page, to this {{ onPhone ? 'phone' : 'computer' }}
    Open Spotify Downloads
    1. Download the ARMv8/AArch64 Linux build. The file is named {{ setup.archiveName }}.
    2. Leave it as it is. Don’t unzip or open it.
  2. 2
    Choose the downloaded file {{ downloadStarted && downloadsHint ? downloadsHint : 'The file you just downloaded' }}
    Checking the file…
    {{ archiveFile.name }}{{ Math.ceil(archiveFile.size / 1048576) }} MB
    {{ archiveError }}
  3. 3
    InstallSend it to your comma and check it with Spotify
    {{ uploadReason }}
    Sending to your comma… {{ uploadPercent }}%
    • {{ check.label }}

    {{ problem }}

`, }