Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 20 additions & 3 deletions packages/web/src/lib/base_controller.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,44 @@
import * as svelte from 'svelte'
import type {ApiSpec} from '$lib/api.ts'
import * as rpc from '@andykais/ts-rpc/client.ts'
import type { Config } from '$lib/server/config.ts'
import { resolve_connection, type Connection } from '$lib/connection.ts'
import {create_focuser, SettingsRune} from '$lib/runes/index.ts'
import {Keybinds} from '$lib/keybinds.ts'


abstract class BaseController {
client: ReturnType<typeof rpc.create<ApiSpec>>
keybinds: Keybinds
connection: Connection
#config: Config | undefined

abstract runes: {
focus: ReturnType<typeof create_focuser>
settings: SettingsRune
}

constructor(config: Config) {
constructor(config: Config, connection: Connection = resolve_connection()) {
this.#config = config
this.client = rpc.create<ApiSpec>(`${window.location.protocol}${window.location.host}/rpc/:signature`)
this.connection = connection
this.client = rpc.create<ApiSpec>(`${connection.base_url}/rpc/:signature`)
this.keybinds = new Keybinds(config)
}

/** URL for streaming a media file from the configured Forager server. */
media_url(media_reference_id: number): string {
return `${this.connection.base_url}/files/media_file/${media_reference_id}`
}

/**
* URL for a thumbnail. `index` is appended as a query-string when non-zero
* for parity with the existing route shape; the server's behaviour for
* `index` is unchanged from before phase 2.
*/
thumbnail_url(thumbnail_id: number | undefined, index: number = 0): string {
const base = `${this.connection.base_url}/files/thumbnail/${thumbnail_id}`
return index === 0 ? base : `${base}?index=${index}`
}

get config() {
if (this.#config) return this.#config
else throw new Error(`Controller::config not initialized`)
Expand Down
65 changes: 65 additions & 0 deletions packages/web/src/lib/connection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/**
* Connection abstraction for talking to a Forager server.
*
* The SPA used to assume same-origin everywhere. With the Tauri shell on
* the horizon (and a "point my browser at a remote forager" use-case for
* the web build), we now resolve a base URL at boot time from three
* sources, in priority order:
*
* 1. `window.__FORAGER_CONNECTION__` — injected by the Tauri shell
* before the SPA starts up (see the Tauri port plan §7).
* 2. `?api=https://example.com` query-string — handy for the
* browser-served build when you want to point a hosted SPA at a
* different backend.
* 3. Same origin — the current default.
*
* Auth is intentionally out of scope per the design doc; the Connection
* carries only a `base_url`.
*/

export interface Connection {
base_url: string
}

declare global {
interface Window {
__FORAGER_CONNECTION__?: { base_url: string }
}
}

function strip_trailing_slash(s: string): string {
return s.endsWith('/') ? s.slice(0, -1) : s
}

let cached: Connection | undefined

export function resolve_connection(): Connection {
if (cached) return cached

if (typeof window === 'undefined') {
// SSR / prerender / test contexts. We never actually render against a
// real backend here, so a placeholder is fine.
cached = { base_url: '' }
return cached
}

const injected = window.__FORAGER_CONNECTION__
if (injected?.base_url) {
cached = { base_url: strip_trailing_slash(injected.base_url) }
return cached
}

const url_param = new URL(window.location.href).searchParams.get('api')
if (url_param) {
cached = { base_url: strip_trailing_slash(url_param) }
return cached
}

cached = { base_url: window.location.origin }
return cached
}

/** Reset the cached connection. Intended for tests; not used at runtime. */
export function __reset_connection_for_tests() {
cached = undefined
}
6 changes: 4 additions & 2 deletions packages/web/src/lib/runes/media_view_rune.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,9 @@ export class MediaViewRune extends Rune {
}

get preview_thumbnail(): string | undefined {
return `/files/thumbnail/${this.media.thumbnails.results.at(0)?.id}`
const thumbnail = this.media.thumbnails.results.at(0)
if (thumbnail === undefined) return undefined
return this.thumbnail_url(thumbnail.id)
}

get thumbnails() {
Expand Down Expand Up @@ -185,7 +187,7 @@ export class MediaGroupRune extends MediaViewRune {
get preview_thumbnail(): string | undefined {
const media_entry = this.grouped_state.media_list.at(0)
if (media_entry) {
return `/files/thumbnail/${media_entry.thumbnails.results[0].id}`
return this.thumbnail_url(media_entry.thumbnails.results[0].id)
}
return undefined
}
Expand Down
22 changes: 20 additions & 2 deletions packages/web/src/lib/runes/rune.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,24 @@
import type { BaseController } from '$lib/base_controller.ts'
import { resolve_connection, type Connection } from '$lib/connection.ts'

export class Rune {
public constructor(protected client: BaseController['client']) {}
}
protected connection: Connection

public constructor(
protected client: BaseController['client'],
connection: Connection = resolve_connection(),
) {
this.connection = connection
}

/** URL for streaming a media file. Mirrors `BaseController.media_url`. */
protected media_url(media_reference_id: number): string {
return `${this.connection.base_url}/files/media_file/${media_reference_id}`
}

/** URL for a thumbnail. Mirrors `BaseController.thumbnail_url`. */
protected thumbnail_url(thumbnail_id: number | undefined, index: number = 0): string {
const base = `${this.connection.base_url}/files/thumbnail/${thumbnail_id}`
return index === 0 ? base : `${base}?index=${index}`
}
}
6 changes: 3 additions & 3 deletions packages/web/src/routes/browse/components/MediaView.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@
if (media_selections.current_selection.media_response?.media_type !== 'media_file') {
return
}
return `/files/media_file/${media_selections.current_selection.media_response.media_reference.id}`
return controller.media_url(media_selections.current_selection.media_response.media_reference.id)
})
$effect(() => {
if (media_url) {
Expand Down Expand Up @@ -111,14 +111,14 @@
<div class="w-full flex flex-row justify-center gap-1 overflow-x-scroll" style="height: {controller.runes.settings.ui.media_view.filmstrip.thumbnail_size}px;">
{#each media_selections.current_selection.thumbnails.results as thumbnail, index}
<div class="h-full">
<img class="h-full" src="/files/thumbnail/{media_selections.current_selection.media_response.media_reference.id}?index={index}" alt="Thumbnail {index}"></div>
<img class="h-full" src={controller.thumbnail_url(media_selections.current_selection.media_response.media_reference.id, index)} alt="Thumbnail {index}"></div>
{/each}
</div>
{/if}
{:else if media_selections.current_selection.media_response.media_file.media_type === 'AUDIO'}
<img
class="object-contain max-h-full"
src="/files/thumbnail/{media_selections.current_selection.media_response.media_reference.id}" alt="Audio thumbnail">
src={controller.thumbnail_url(media_selections.current_selection.media_response.media_reference.id)} alt="Audio thumbnail">
<audio
autoplay
loop
Expand Down
115 changes: 115 additions & 0 deletions packages/web/test/connection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { assertEquals } from 'jsr:@std/assert@^1.0.14'
import {
__reset_connection_for_tests,
resolve_connection,
} from '../src/lib/connection.ts'


/**
* `connection.ts` is a tiny browser module that picks an API base URL from
* three sources. These tests stub `globalThis.window` to exercise each
* branch without spinning up a browser.
*/

interface FakeWindow {
location: { href: string; origin: string }
__FORAGER_CONNECTION__?: { base_url: string }
}

function with_window<T>(fake: FakeWindow, fn: () => T): T {
__reset_connection_for_tests()
// deno-lint-ignore no-explicit-any
const g = globalThis as any
const had_window = 'window' in g
const previous = g.window
g.window = fake
try {
return fn()
} finally {
if (had_window) g.window = previous
else delete g.window
__reset_connection_for_tests()
}
}


Deno.test('resolve_connection: window.__FORAGER_CONNECTION__ wins over everything else', () => {
const c = with_window({
location: { href: 'http://127.0.0.1:8000/browse?api=http://ignored', origin: 'http://127.0.0.1:8000' },
__FORAGER_CONNECTION__: { base_url: 'http://my-nas.lan:9001' },
}, () => resolve_connection())
assertEquals(c.base_url, 'http://my-nas.lan:9001')
})

Deno.test('resolve_connection: ?api= query string wins over same-origin', () => {
const c = with_window({
location: { href: 'http://127.0.0.1:8000/browse?api=https://example.com', origin: 'http://127.0.0.1:8000' },
}, () => resolve_connection())
assertEquals(c.base_url, 'https://example.com')
})

Deno.test('resolve_connection: trailing slash is stripped from explicit base_url', () => {
const c = with_window({
location: { href: 'http://127.0.0.1:8000/browse', origin: 'http://127.0.0.1:8000' },
__FORAGER_CONNECTION__: { base_url: 'http://my-nas.lan:9001/' },
}, () => resolve_connection())
assertEquals(c.base_url, 'http://my-nas.lan:9001')
})

Deno.test('resolve_connection: trailing slash is stripped from ?api= query string', () => {
const c = with_window({
location: { href: 'http://127.0.0.1:8000/browse?api=https://example.com/', origin: 'http://127.0.0.1:8000' },
}, () => resolve_connection())
assertEquals(c.base_url, 'https://example.com')
})

Deno.test('resolve_connection: falls back to same origin when no overrides are set', () => {
const c = with_window({
location: { href: 'http://127.0.0.1:8000/browse', origin: 'http://127.0.0.1:8000' },
}, () => resolve_connection())
assertEquals(c.base_url, 'http://127.0.0.1:8000')
})

Deno.test('resolve_connection: empty ?api= falls back to same origin', () => {
const c = with_window({
location: { href: 'http://127.0.0.1:8000/browse?api=', origin: 'http://127.0.0.1:8000' },
}, () => resolve_connection())
assertEquals(c.base_url, 'http://127.0.0.1:8000')
})

Deno.test('resolve_connection: missing window (SSR/test) returns empty base_url placeholder', () => {
__reset_connection_for_tests()
// deno-lint-ignore no-explicit-any
const g = globalThis as any
const had_window = 'window' in g
const previous = g.window
delete g.window
try {
const c = resolve_connection()
assertEquals(c.base_url, '')
} finally {
if (had_window) g.window = previous
__reset_connection_for_tests()
}
})

Deno.test('resolve_connection: result is cached after first call', () => {
// deno-lint-ignore no-explicit-any
const g = globalThis as any
const had_window = 'window' in g
const previous = g.window
__reset_connection_for_tests()
g.window = {
location: { href: 'http://127.0.0.1:8000/browse', origin: 'http://127.0.0.1:8000' },
}
try {
const a = resolve_connection()
g.window.location = { href: 'http://other.example/', origin: 'http://other.example' }
const b = resolve_connection()
assertEquals(a, b, 'second call should return the same cached object')
} finally {
if (had_window) g.window = previous
else delete g.window
__reset_connection_for_tests()
}
})