diff --git a/README.md b/README.md index 908d822..4353a50 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ and any KOReader device. only the sync-server URL. Same accounts, same auth, same endpoints as `sync.koreader.rocks`. - **Better multi-device sync.** Progress is stored per device and the newest position wins, fixing the ping-pong you get with stock kosync servers. -- **Server-side service connectors.** Link services such as Hardcover, Micro.blog, Audiobookshelf, BookOrbit, Readwise Reader, and Seekquel once; readers continue speaking standard KOSync while the server updates external reading state. +- **Server-side service connectors.** Link services such as Hardcover, Micro.blog, Audiobookshelf, Jellyfin, BookOrbit, Readwise Reader, and Seekquel once; readers continue speaking standard KOSync while the server updates external reading state. - **BookOrbit on demand.** Reader progress requests fetch the matched book’s latest progress and highlights. On a book’s page, **Connected services → BookOrbit → Sync now** pulls before queueing outbound updates and refreshes that book’s reading history. BookOrbit has no periodic polling; reading-session uploads trigger delivery directly. - **Lossless CrossPoint sync.** An extended API carries the full CrossPoint position (spine, paragraph, anchor, page hints), not just a lossy xpath + percentage. diff --git a/app/src/Settings.jsx b/app/src/Settings.jsx index c66e5c9..ed0058d 100644 --- a/app/src/Settings.jsx +++ b/app/src/Settings.jsx @@ -44,6 +44,7 @@ const HINTS = { kosync: 'Mirrors your reading progress to another KOReader-compatible (KOSync) server. For Calibre-Web, use your library URL ending in /kosync.', bookfusion: 'Syncs reading progress with BookFusion. You approve the request on bookfusion.com.', audiobookshelf: 'Keeps your place between the ebook and the audiobook. Create an API key in Audiobookshelf under Settings, Users, API Keys.', + jellyfin: 'Syncs reading progress with your Jellyfin book library (Jellyfin 12.0 or newer). Sign in with your Jellyfin username and password.', bookorbit: 'Syncs progress both ways, adds clippings as highlights, and exchanges reading sessions for matched books. Imports BookOrbit history and exports sessions recorded by newer CrossInk firmware. Includes other BookOrbit reader history alongside your device stats. Repeated sessions are counted once.', seekquel: 'Logs your reading in your Seekquel library: where you are in each book, the books you finish, and your highlights. You approve the request in the Seekquel app.', spotify: 'Moves your progress forward when you listen ahead in the Spotify audiobook, and resumes it there. Audiobooks are in the US, UK, Canada, Ireland, Australia and New Zealand.', @@ -397,12 +398,12 @@ export function LinkForm({ session, conn, onLinked }) { {conn.id === 'hardcover' && tokenForm} ) - } else if (conn.credential_kind === 'kosync') { + } else if (conn.credential_kind === 'kosync' || conn.credential_kind === 'jellyfin') { body = (
- {submit('Connect', () => ({ + {submit(conn.id === 'jellyfin' ? 'Connect Jellyfin' : 'Connect', () => ({ server: (v.server ?? '').trim(), username: (v.username ?? '').trim(), password: v.password ?? '', diff --git a/assets/icons/jellyfin.png b/assets/icons/jellyfin.png new file mode 100644 index 0000000..2f6b9cf Binary files /dev/null and b/assets/icons/jellyfin.png differ diff --git a/docs/API.md b/docs/API.md index 4c40894..21f83a6 100644 --- a/docs/API.md +++ b/docs/API.md @@ -777,9 +777,10 @@ Body `{"external_id": "…"}`. `{"found": true, "book": {"externalId", "title", so connectors need "Send Metadata" on). **Fan-out** is automatic: a progress PUT enqueues a progress/finished event to write-connectors that carry it; a clippings PUT enqueues highlight events to highlight-connectors (Readwise, BookOrbit, and Hardcover when its `highlights` option is on). A background worker delivers them with retry/backoff. -**Fan-in** (read connectors: Audiobookshelf, BookOrbit, Readwise Reader, BookFusion) is pulled on a +**Fan-in** (read connectors: Audiobookshelf, Jellyfin, BookOrbit, Readwise Reader, BookFusion) is pulled on a background interval for library-wide providers, and on-demand — when a device asks for progress on a -matched book — for per-book providers. +matched book — for per-book providers. Jellyfin and Spotify also run an hourly per-match pull for users +with matched books. BookOrbit also pulls highlights on that refresh: each one is placed by its CFI in BookOrbit's copy of the EPUB and stored as a clipping (chapter codepoint offsets, firmware-style id), skipped when the same quote is already a clipping on the book, live or deleted. diff --git a/src/connectors/fanin.ts b/src/connectors/fanin.ts index eb1b9fb..c07fdd9 100644 --- a/src/connectors/fanin.ts +++ b/src/connectors/fanin.ts @@ -240,6 +240,26 @@ export async function spotifyFirstSync(db: DB, userId: number, http: HttpTranspo return pollConnector(db, userId, 'spotify', http); } +/** + * Jellyfin's hourly pull (same shape as pollSpotify): per-match UserData for every + * user with a matched book. Progress requests still pull on demand. + */ +export async function pollJellyfin(db: DB, http: HttpTransport = fetchTransport): Promise { + let total = 0; + for (const userId of usersWithMatches(db, 'jellyfin', false)) { + total += await pollConnector(db, userId, 'jellyfin', http); + } + return total; +} + +/** After Jellyfin is first linked: match books in progress, then pull their positions. */ +export async function jellyfinFirstSync(db: DB, userId: number, http: HttpTransport = fetchTransport): Promise { + for (const document of inProgressDocuments(db, userId)) { + await resolveMatch(db, 'jellyfin', userId, document, http).catch(() => null); + } + return pollConnector(db, userId, 'jellyfin', http); +} + /** Start a periodic fan-in poller (pollAll by default); returns a stop function. */ export function startFanInWorker(db: DB, intervalMs = 5 * 60_000, poll = pollAll): () => void { let running = false; diff --git a/src/connectors/jellyfin.ts b/src/connectors/jellyfin.ts new file mode 100644 index 0000000..af51fac --- /dev/null +++ b/src/connectors/jellyfin.ts @@ -0,0 +1,470 @@ +import { createHash } from 'node:crypto'; +import { baseUrl } from './audiobookshelf.js'; +import { decideMatch, extractTitleAuthor, type Candidate } from './matching.js'; +import { + ConnectorOperationError, + type Connector, + type Credential, + type DocumentMeta, + type ExternalBook, + type HttpTransport, + type InboundChange, + type Match, + type OutboundEvent, + type PushResult, + type ValidateResult, +} from './types.js'; + +interface JellyfinCred extends Credential { + server: string; + username: string; + password: string; +} + +interface Session { + accessToken: string; + userId: string; +} + +const CLIENT = 'CrossPoint Sync'; +const CLIENT_VERSION = '1.0'; + +function parseCred(cred: Credential): JellyfinCred | null { + const server = typeof cred.server === 'string' ? cred.server.trim() : ''; + const username = typeof cred.username === 'string' ? cred.username.trim() : ''; + const password = typeof cred.password === 'string' ? cred.password : ''; + if (!server || !username || !password) return null; + return { server, username, password }; +} + +function deviceId(c: JellyfinCred): string { + return createHash('sha256') + .update(`${baseUrl(c.server)}\n${c.username}`) + .digest('hex') + .slice(0, 32); +} + +function authHeaders(token: string): Record { + return { + authorization: `MediaBrowser Token="${token}"`, + 'content-type': 'application/json', + }; +} + +function clientAuthorization(c: JellyfinCred): string { + const id = deviceId(c); + return `MediaBrowser Client="${CLIENT}", Device="${CLIENT}", DeviceId="${id}", Version="${CLIENT_VERSION}"`; +} + +function loginHeaders(c: JellyfinCred): Record { + return { + accept: 'application/json', + 'content-type': 'application/json', + authorization: clientAuthorization(c), + }; +} + +const MAX_SESSIONS = 256; +const sessions = new Map(); +// Keyed by the password too, so a cached token is only reused with the credential that earned it. +const sessionKey = (c: JellyfinCred) => createHash('sha256').update(`${deviceId(c)}\n${c.password}`).digest('hex'); + +function versionAtLeast(version: string, major: number, minor: number, patch: number): boolean { + const core = (version.trim().split(/[-+~]/)[0] ?? '').split('.'); + const m = Number.parseInt(core[0] ?? '', 10); + const n = Number.parseInt(core[1] ?? '', 10); + const p = Number.parseInt(core[2] ?? '', 10); + const maj = Number.isFinite(m) ? m : 0; + const min = Number.isFinite(n) ? n : 0; + const pat = Number.isFinite(p) ? p : 0; + if (maj !== major) return maj > major; + if (min !== minor) return min > minor; + return pat >= patch; +} + +async function login(http: HttpTransport, c: JellyfinCred): Promise { + const key = sessionKey(c); + const cached = sessions.get(key); + if (cached) return cached; + + const res = await http(`${baseUrl(c.server)}/Users/AuthenticateByName`, { + method: 'POST', + headers: loginHeaders(c), + body: JSON.stringify({ Username: c.username, Pw: c.password }), + }); + let body: any = null; + try { + body = await res.json(); + } catch { + body = null; + } + if (res.status === 401 || res.status === 403) { + throw new ConnectorOperationError('invalid Jellyfin username or password', false, true); + } + if (res.status === 429) throw new ConnectorOperationError('Jellyfin rate limited login', true); + if (res.status === 400) { + throw new ConnectorOperationError( + 'Jellyfin rejected the login (check the server URL and use Jellyfin 12.0 or newer)', + false + ); + } + if (res.status < 200 || res.status >= 300) { + throw new ConnectorOperationError(`Jellyfin login failed (${res.status})`, res.status >= 500); + } + const accessToken = typeof body?.AccessToken === 'string' ? body.AccessToken : ''; + const userId = typeof body?.User?.Id === 'string' ? body.User.Id : ''; + if (!accessToken || !userId) throw new ConnectorOperationError('Jellyfin returned no session', false); + const session = { accessToken, userId }; + sessions.set(key, session); + while (sessions.size > MAX_SESSIONS) { + const oldest = sessions.keys().next().value; + if (oldest === undefined) break; + sessions.delete(oldest); + } + return session; +} + +async function call(http: HttpTransport, c: JellyfinCred, method: string, path: string, body?: unknown) { + for (let attempt = 0; ; attempt++) { + const key = sessionKey(c); + const fromCache = sessions.has(key); + const session = await login(http, c); + const res = await http(`${baseUrl(c.server)}${path}`, { + method, + headers: authHeaders(session.accessToken), + body: body === undefined ? undefined : JSON.stringify(body), + }); + const authErr = res.status === 401 || res.status === 403; + if (!authErr) return { res, session }; + if (res.status === 403 && !fromCache) return { res, session }; + if (attempt > 0) return { res, session }; + sessions.delete(key); + } +} + +async function getJson(http: HttpTransport, c: JellyfinCred, path: string): Promise { + const { res } = await call(http, c, 'GET', path); + if (res.status === 401) { + throw new ConnectorOperationError('Jellyfin session expired', false, true); + } + if (res.status === 403) return null; + if (res.status < 200 || res.status >= 300) return null; + try { + return await res.json(); + } catch { + return null; + } +} + +/** Map Jellyfin UserItemData to a fan-in change; null when there is no trustworthy timestamp. */ +export function userDataToInbound( + externalId: string, + userData: Record | null | undefined, + runTimeTicks: number | null | undefined +): InboundChange | null { + const last = userData?.LastPlayedDate; + if (typeof last !== 'string' || !last) return null; + const updatedAtMs = Date.parse(last); + if (!Number.isFinite(updatedAtMs)) return null; + + let pct = 0; + const playedPct = userData?.PlayedPercentage; + if (typeof playedPct === 'number' && Number.isFinite(playedPct)) { + pct = playedPct / 100; + } else if (typeof runTimeTicks === 'number' && runTimeTicks > 0) { + const ticks = userData?.PlaybackPositionTicks; + if (typeof ticks === 'number' && Number.isFinite(ticks)) pct = ticks / runTimeTicks; + } + pct = Math.max(0, Math.min(1, pct)); + const finished = userData?.Played === true || pct >= 0.999; + return { externalId, percentage: finished ? 1 : pct, finished, updatedAtMs }; +} + +function itemAuthor(item: Record): string | undefined { + const people = Array.isArray(item.People) ? item.People : []; + const names: string[] = []; + for (const p of people) { + if (!p || typeof p !== 'object') continue; + const person = p as Record; + const kinds = [person.Type, person.Role].map((v) => (typeof v === 'string' ? v.toLowerCase() : '')); + if (!kinds.some((k) => k.includes('writer') || k.includes('author'))) continue; + if (typeof person.Name === 'string' && person.Name) names.push(person.Name); + } + if (names.length) return names.join(', '); + const artists = item.Artists; + if (Array.isArray(artists) && typeof artists[0] === 'string') return artists[0]; + return undefined; +} + +function itemToCandidate(item: Record): Candidate | null { + const id = item.Id; + const title = item.Name; + if (id == null || typeof title !== 'string') return null; + return { externalId: String(id), title, author: itemAuthor(item) }; +} + +function itemsFromQuery(body: unknown): Record[] { + const items = (body as { Items?: unknown })?.Items; + return Array.isArray(items) ? (items.filter((x) => x && typeof x === 'object') as Record[]) : []; +} + +async function validate(cred: Credential, http: HttpTransport): Promise { + const c = parseCred(cred); + if (!c) + return { + ok: false, + error: 'server URL, username and password are required', + }; + try { + sessions.delete(sessionKey(c)); + await login(http, c); + const info = await getJson(http, c, '/System/Info'); + const version = typeof info?.Version === 'string' ? info.Version : ''; + if (!versionAtLeast(version, 12, 0, 0)) { + return { ok: false, error: 'Requires Jellyfin 12.0 or newer' }; + } + return { ok: true, accountLabel: `${c.username} @ ${c.server}` }; + } catch (err) { + if (err instanceof ConnectorOperationError && err.needsReauth) { + return { ok: false, error: err.message }; + } + return { + ok: false, + error: err instanceof Error ? err.message : String(err), + }; + } +} + +async function searchBooks( + http: HttpTransport, + c: JellyfinCred, + query: string +): Promise<(Candidate & { runTimeTicks?: number })[]> { + const { userId } = await login(http, c); + const q = encodeURIComponent(query); + const path = `/Users/${encodeURIComponent(userId)}/Items?Recursive=true&IncludeItemTypes=Book&SearchTerm=${q}&Fields=People,RunTimeTicks&Limit=25`; + const body = await getJson(http, c, path); + const out: (Candidate & { runTimeTicks?: number })[] = []; + for (const item of itemsFromQuery(body)) { + const cand = itemToCandidate(item); + if (!cand) continue; + const rt = item.RunTimeTicks; + out.push(typeof rt === 'number' && rt > 0 ? { ...cand, runTimeTicks: rt } : cand); + } + return out; +} + +async function match(cred: Credential, doc: DocumentMeta, http: HttpTransport): Promise { + const c = parseCred(cred); + if (!c) return null; + const ta = extractTitleAuthor(doc); + if (!ta) return null; + const candidates = await searchBooks(http, c, ta.title.trim()); + if (candidates.length === 0) return null; + const decision = decideMatch(ta.title, ta.author, candidates); + if (!decision.accepted || !decision.best) return null; + const chosen = candidates.find((x) => x.externalId === decision.best!.externalId); + return { + externalId: decision.best.externalId, + externalEdition: chosen?.runTimeTicks != null ? String(chosen.runTimeTicks) : null, + confidence: decision.best.score, + queryUsed: ta.title.trim(), + title: chosen?.title ?? null, + author: chosen?.author ?? null, + }; +} + +async function listCurrentlyReading(cred: Credential, http: HttpTransport): Promise { + const c = parseCred(cred); + if (!c) return []; + const session = await login(http, c); + const path = `/UserItems/Resume?UserId=${encodeURIComponent(session.userId)}&IncludeItemTypes=Book&Fields=People,RunTimeTicks&Limit=50`; + const body = await getJson(http, c, path); + const out: ExternalBook[] = []; + for (const item of itemsFromQuery(body)) { + const cand = itemToCandidate(item); + if (!cand) continue; + const rt = item.RunTimeTicks; + out.push({ + externalId: cand.externalId, + title: cand.title, + author: cand.author ?? null, + edition: typeof rt === 'number' && rt > 0 ? String(rt) : null, + }); + } + return out; +} + +async function search(cred: Credential, query: string, http: HttpTransport): Promise { + const c = parseCred(cred); + if (!c || !query.trim()) return []; + const candidates = await searchBooks(http, c, query.trim()); + return candidates.map((cand) => ({ + externalId: cand.externalId, + title: cand.title, + author: cand.author ?? null, + edition: cand.runTimeTicks != null ? String(cand.runTimeTicks) : null, + })); +} + +async function fetchRunTimeTicks( + http: HttpTransport, + c: JellyfinCred, + session: Session, + externalId: string +): Promise { + const body = await getJson( + http, + c, + `/Users/${encodeURIComponent(session.userId)}/Items/${encodeURIComponent(externalId)}?Fields=RunTimeTicks` + ); + const rt = body?.RunTimeTicks; + return typeof rt === 'number' && rt > 0 ? rt : null; +} + +async function resolveRunTimeTicks( + http: HttpTransport, + c: JellyfinCred, + session: Session, + m: Match +): Promise { + const cached = m.externalEdition ? Number(m.externalEdition) : NaN; + if (Number.isFinite(cached) && cached > 0) return cached; + return fetchRunTimeTicks(http, c, session, m.externalId); +} + +async function resolveEdition(cred: Credential, externalId: string, http: HttpTransport): Promise { + const c = parseCred(cred); + if (!c) return null; + const session = await login(http, c); + const rt = await fetchRunTimeTicks(http, c, session, externalId); + return rt != null ? String(rt) : null; +} + +async function pullProgress( + cred: Credential, + m: Match, + http: HttpTransport, + sinceMs: number +): Promise { + const c = parseCred(cred); + if (!c) return null; + const session = await login(http, c); + const ud = await getJson( + http, + c, + `/Users/${encodeURIComponent(session.userId)}/Items/${encodeURIComponent(m.externalId)}/UserData` + ); + if (!ud) return null; + const playedPct = ud.PlayedPercentage; + const hasPct = typeof playedPct === 'number' && Number.isFinite(playedPct); + const runTimeTicks = hasPct ? null : await resolveRunTimeTicks(http, c, session, m); + const change = userDataToInbound(m.externalId, ud, runTimeTicks); + if (!change || change.updatedAtMs <= sinceMs) return null; + return change; +} + +async function push(cred: Credential, m: Match, ev: OutboundEvent, http: HttpTransport): Promise { + const c = parseCred(cred); + if (!c) + return { + ok: false, + retryable: false, + needsReauth: true, + error: 'bad credential', + }; + const session = await login(http, c); + const itemPath = `/Users/${encodeURIComponent(session.userId)}/Items/${encodeURIComponent(m.externalId)}`; + + const getRes = await call(http, c, 'GET', `${itemPath}/UserData`); + if (getRes.res.status === 401) { + return { + ok: false, + retryable: false, + needsReauth: true, + error: 'unauthorized', + }; + } + if (getRes.res.status === 403) { + return { + ok: false, + retryable: false, + error: 'forbidden', + }; + } + let remote: Record | null = null; + if (getRes.res.status >= 200 && getRes.res.status < 300) { + try { + remote = (await getRes.res.json()) as Record; + } catch { + remote = null; + } + } + const remoteLast = remote?.LastPlayedDate; + if (typeof remoteLast === 'string' && remoteLast) { + const remoteMs = Date.parse(remoteLast); + if (Number.isFinite(remoteMs) && remoteMs > ev.timestamp * 1000) { + return { ok: true }; + } + } + + const pct = Math.max(0, Math.min(1, ev.percentage ?? 0)); + const finished = ev.kind === 'finished' || pct >= 0.999; + const runTimeTicks = await resolveRunTimeTicks(http, c, session, m); + const playedPct = finished ? 100 : pct * 100; + const payload: Record = { + PlayedPercentage: playedPct, + Played: finished, + LastPlayedDate: new Date(ev.timestamp * 1000).toISOString(), + }; + if (runTimeTicks) { + payload.PlaybackPositionTicks = Math.round((finished ? 1 : pct) * runTimeTicks); + } + + const postRes = await call(http, c, 'POST', `${itemPath}/UserData`, payload); + if (postRes.res.status === 401) { + return { + ok: false, + retryable: false, + needsReauth: true, + error: 'unauthorized', + }; + } + if (postRes.res.status === 403) { + return { + ok: false, + retryable: false, + error: 'forbidden', + }; + } + if (postRes.res.status === 429) return { ok: false, retryable: true, error: 'rate limited' }; + if (postRes.res.status >= 500) + return { + ok: false, + retryable: true, + error: `server ${postRes.res.status}`, + }; + if (postRes.res.status >= 200 && postRes.res.status < 300) return { ok: true }; + return { + ok: false, + retryable: false, + error: `unexpected status ${postRes.res.status}`, + }; +} + +export const jellyfinConnector: Connector = { + id: 'jellyfin', + displayName: 'Jellyfin', + tier: 1, + capabilities: { read: true, write: true }, + carries: ['progress', 'finished'], + credentialKind: 'jellyfin', + beta: true, + validate, + match, + push, + listCurrentlyReading, + search, + resolveEdition, + pullProgress, +}; diff --git a/src/connectors/refresh.ts b/src/connectors/refresh.ts index 1d7f70e..b4ee13a 100644 --- a/src/connectors/refresh.ts +++ b/src/connectors/refresh.ts @@ -10,7 +10,7 @@ export type ProgressRefresh = (userId: number, document: string) => Promise r.user_id); diff --git a/src/connectors/types.ts b/src/connectors/types.ts index 87e933e..bb195c4 100644 --- a/src/connectors/types.ts +++ b/src/connectors/types.ts @@ -24,7 +24,7 @@ export interface ConnectorOption { gates?: DataKind; } -export type CredentialKind = 'token' | 'oauth' | 'cookies' | 'kosync' | 'device_code' | 'abs'; +export type CredentialKind = 'token' | 'oauth' | 'cookies' | 'kosync' | 'device_code' | 'abs' | 'jellyfin'; /** Interactive OAuth device-code link handshake (BookFusion). */ export interface DeviceLinkStart { diff --git a/src/index.ts b/src/index.ts index 84b215b..8f156e2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,7 +5,7 @@ import { migrate, openDatabase } from './db/db.js'; import { secretsEnabled } from './crypto/secrets.js'; import { loadSessionSecret } from './auth/session.js'; import { startQueueWorker } from './connectors/runner.js'; -import { pollSpotify, startFanInWorker } from './connectors/fanin.js'; +import { pollJellyfin, pollSpotify, startFanInWorker } from './connectors/fanin.js'; import { autoPause } from './models/pause.js'; const DATABASE_PATH = process.env.DATABASE_PATH ?? '/data/crosspoint.db'; @@ -26,6 +26,8 @@ if (connectorsEnabled) { // Fan-in: pull position changes back from bidirectional connectors (e.g. // Audiobookshelf audiobook -> ebook). startFanInWorker(db, Number(process.env.FANIN_INTERVAL_MS ?? 5 * 60_000)); + // Jellyfin and Spotify per-match pulls (hourly). + startFanInWorker(db, 60 * 60_000, pollJellyfin); startFanInWorker(db, 60 * 60_000, pollSpotify); } // Daily: pause books with no progress for 30 days (reads also check lazily). diff --git a/src/routes/landing.ts b/src/routes/landing.ts index 01dec5f..89fa7bd 100644 --- a/src/routes/landing.ts +++ b/src/routes/landing.ts @@ -44,6 +44,7 @@ const SERVICES: [string, string, string, 'ready' | 'beta'][] = [ ['spotify', 'Spotify', 'Listen ahead in the audiobook and your progress moves forward. Resume it in one tap.', 'beta'], ['microblog', 'Micro.blog', 'Keep your Currently reading and Finished shelves current.', 'ready'], ['audiobookshelf', 'Audiobookshelf', 'Switch between the ebook and the audiobook without losing your place.', 'ready'], + ['jellyfin', 'Jellyfin', 'Keep your place in sync with books on your Jellyfin server.', 'beta'], ['bookorbit', 'BookOrbit', 'Two-way progress with your own library, clippings as highlights.', 'beta'], ['seekquel', 'Seekquel', 'Log your progress, finished books and highlights in your Seekquel library.', 'beta'], ]; diff --git a/src/routes/v1/connectors.ts b/src/routes/v1/connectors.ts index 01d26b1..7737988 100644 --- a/src/routes/v1/connectors.ts +++ b/src/routes/v1/connectors.ts @@ -6,7 +6,7 @@ import { secretsEnabled } from '../../crypto/secrets.js'; import { fetchTransport, getConnector, listConnectors } from '../../connectors/registry.js'; import { purgeConnector, queueDepth, queueErrors } from '../../connectors/queue.js'; import { backfillConnector } from '../../connectors/fanout.js'; -import { pollConnector, spotifyFirstSync } from '../../connectors/fanin.js'; +import { pollConnector, jellyfinFirstSync, spotifyFirstSync } from '../../connectors/fanin.js'; import { resolveMatch } from '../../connectors/runner.js'; import { CLIENT_ID_RE, CLIENT_ID_REJECTED, playWhenDeviceAppears, spotifyPlan, spotifyResume, spotifyTracks } from '../../connectors/spotify.js'; import { @@ -245,8 +245,14 @@ export function connectorRoutes( return c.json({ code: 2003, message: result.error ?? 'Credential rejected' }, 400); } const user = c.get('user'); + const first = !getAccount(db, user.id, conn.id); upsertAccount(db, user.id, conn.id, cred as Record, result.accountLabel ?? null); if (conn.id === 'bookorbit') void importBookorbitStats(db, user.id, transport); + if (first && conn.id === 'jellyfin') { + jellyfinFirstSync(db, user.id, transport).catch((err) => + console.error(JSON.stringify({ msg: 'first sync failed', connector: conn.id, user_id: user.id, + error: err instanceof Error ? err.message : String(err) }))); + } return c.json({ id: conn.id, linked: true, account: result.accountLabel ?? null }); }); diff --git a/src/routes/web.ts b/src/routes/web.ts index aabe0fd..afe3a53 100644 --- a/src/routes/web.ts +++ b/src/routes/web.ts @@ -25,7 +25,7 @@ const OG_IMAGE = fs.readFileSync(path.join(ASSETS_DIR, 'og.jpg')); // Service app icons, served at /icons/:id.png. Loaded once at boot; a missing // file just means no icon for that service (the UI falls back gracefully). const SERVICE_ICONS = new Map(); -for (const id of ['kosync', 'hardcover', 'audiobookshelf', 'bookfusion', 'readwise', 'microblog', 'bookorbit', 'seekquel', 'spotify', 'kindle']) { +for (const id of ['kosync', 'hardcover', 'audiobookshelf', 'jellyfin', 'bookfusion', 'readwise', 'microblog', 'bookorbit', 'seekquel', 'spotify', 'kindle']) { try { SERVICE_ICONS.set(id, fs.readFileSync(path.join(ASSETS_DIR, 'icons', `${id}.png`))); } catch { @@ -284,6 +284,9 @@ const LANDING = (basePath: string) => shell(basePath,
Audiobookshelf
Keep your place between the ebook and the audiobook, both ways. Read some, then pick up listening right where you left off.
ready
+
Jellyfin
+
Sync reading progress with books on your Jellyfin server (12.0+).
+ beta
BookOrbit
Keep your place in sync, both ways, with your own BookOrbit library. Import reading time and session history for matched books. Clippings show up as highlights, and finished books are marked Read.
beta
@@ -616,6 +619,7 @@ const HINTS = { kosync: 'Mirror your reading progress to another KOReader-compatible (KOSync) server, so your other devices see it too.', bookfusion: 'Connect your BookFusion account to sync reading progress. You will approve the request on bookfusion.com.', audiobookshelf: 'Sync your reading position to the matching audiobook on your Audiobookshelf server. Create an API key in Audiobookshelf under Settings, Users, API Keys.', + jellyfin: 'Sync reading progress with books in your Jellyfin library. Requires Jellyfin 12.0 or newer. Sign in with your Jellyfin username and password.', bookorbit: 'Sync reading progress both ways with your BookOrbit server. Import reading time and session history for matched books, including Kindle and other BookOrbit reader history alongside your device stats. Repeated sessions are counted once. Finishing a book marks it Read, and clippings appear as highlights. Sign in with your BookOrbit account.', seekquel: 'Log your reading in your Seekquel library: where you are in each book, the books you finish, and your highlights. You will approve the request in the Seekquel app.' }; @@ -675,11 +679,12 @@ function render(conn) { const r = await linkCredential({ token: $('tok').value.trim() }); if (r.ok) done(); else $('e').textContent = r.data.message || 'Could not link'; }; - } else if (conn.credential_kind === 'kosync') { - f.innerHTML = '' + } else if (conn.credential_kind === 'kosync' || conn.credential_kind === 'jellyfin') { + const srvPh = { bookorbit: 'https://books.example.com', jellyfin: 'https://jellyfin.example.com' }[ID] || 'https://sync.koreader.rocks:443'; + f.innerHTML = '' + '
' + '
' - + '
'; + + '
'; $('go').onclick = async () => { $('e').textContent = ''; const r = await linkCredential({ server: $('srv').value.trim(), username: $('u').value.trim(), password: $('p').value }); diff --git a/test/connectors.test.ts b/test/connectors.test.ts index 1ee0f8f..0715cfd 100644 --- a/test/connectors.test.ts +++ b/test/connectors.test.ts @@ -76,7 +76,7 @@ describe('connector management API', () => { const ids = body.connectors.map((c: { id: string }) => c.id).sort(); // The classic (highlights-only) readwise connector is hidden; still // registered but not listed. Kindle is stealth: hidden until revealed. - expect(ids).toEqual(['audiobookshelf', 'bookfusion', 'bookorbit', 'hardcover', 'kosync', 'microblog', 'readwise-reader', 'seekquel', 'spotify']); + expect(ids).toEqual(['audiobookshelf', 'bookfusion', 'bookorbit', 'hardcover', 'jellyfin', 'kosync', 'microblog', 'readwise-reader', 'seekquel', 'spotify']); expect(body.connectors.every((c: { linked: boolean }) => !c.linked)).toBe(true); }); diff --git a/test/jellyfin.test.ts b/test/jellyfin.test.ts new file mode 100644 index 0000000..0b7230a --- /dev/null +++ b/test/jellyfin.test.ts @@ -0,0 +1,371 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { pollConnector } from '../src/connectors/fanin.js'; +import { jellyfinConnector, userDataToInbound } from '../src/connectors/jellyfin.js'; +import { saveMatch } from '../src/connectors/store.js'; +import type { HttpTransport } from '../src/connectors/types.js'; +import { resetEncryptionKeyCache } from '../src/crypto/secrets.js'; +import { DOC, makeTestApp, registerUser } from './helpers.js'; + +function fakeTransport() { + const calls: { + url: string; + method: string; + body?: string; + headers?: Record; + }[] = []; + const handlers: { match: string; status: number; body: unknown }[] = []; + const t: HttpTransport = async (url, init) => { + const headers = init.headers as Record | undefined; + calls.push({ url, method: init.method ?? 'GET', body: init.body, headers }); + const h = [...handlers].reverse().find((x) => url.includes(x.match) || (init.body ?? '').includes(x.match)); + const status = h?.status ?? 200; + const body = h?.body ?? {}; + return { + status, + text: async () => (typeof body === 'string' ? body : JSON.stringify(body)), + json: async () => body, + }; + }; + return { + transport: t, + calls, + on: (m: string, s: number, b: unknown) => handlers.push({ match: m, status: s, body: b }), + }; +} + +const CRED = { server: 'jf.test', username: 'reader', password: 'secret' }; +const AUTH = { AccessToken: 'access-tok', User: { Id: 'user-1' } }; +const INFO_12 = { Version: '12.0.3' }; +const INFO_11 = { Version: '11.0.0' }; + +function wireAuth(fake: ReturnType) { + fake.on('AuthenticateByName', 200, AUTH); + fake.on('/System/Info', 200, INFO_12); +} + +const KEY = { TOKEN_ENC_KEY: 'a'.repeat(64) }; +beforeEach(() => { + Object.assign(process.env, KEY); + resetEncryptionKeyCache(); +}); +afterEach(() => { + delete process.env.TOKEN_ENC_KEY; + resetEncryptionKeyCache(); +}); + +describe('jellyfin userDataToInbound', () => { + it('uses PlayedPercentage and LastPlayedDate', () => { + const ch = userDataToInbound( + 'book-1', + { + PlayedPercentage: 42, + LastPlayedDate: '2026-01-15T12:00:00.000Z', + }, + 1_000_000 + ); + expect(ch).toEqual({ + externalId: 'book-1', + percentage: 0.42, + finished: false, + updatedAtMs: Date.parse('2026-01-15T12:00:00.000Z'), + }); + }); + + it('falls back to ticks over RunTimeTicks', () => { + const ch = userDataToInbound( + 'book-1', + { + PlaybackPositionTicks: 250_000, + LastPlayedDate: '2026-01-15T12:00:00.000Z', + }, + 1_000_000 + ); + expect(ch?.percentage).toBe(0.25); + }); + + it('returns null without LastPlayedDate', () => { + expect(userDataToInbound('x', { PlayedPercentage: 10 }, 100)).toBeNull(); + }); +}); + +describe('jellyfin connector', () => { + it('validate accepts 12.x and rejects 11.x', async () => { + const fake = fakeTransport(); + wireAuth(fake); + fake.on('/System/Info', 200, INFO_11); + const bad = await jellyfinConnector.validate(CRED, fake.transport); + expect(bad.ok).toBe(false); + expect(bad.error).toMatch(/12\.0/); + + fake.on('/System/Info', 200, INFO_12); + let ok = await jellyfinConnector.validate(CRED, fake.transport); + expect(ok.ok).toBe(true); + expect(ok.accountLabel).toContain('reader'); + + fake.on('/System/Info', 200, { Version: '12.10.1' }); + ok = await jellyfinConnector.validate({ ...CRED, server: 'jf2.test' }, fake.transport); + expect(ok.ok).toBe(true); + }); + + it('login uses X-Emby-Authorization and API calls use MediaBrowser Token', async () => { + const fake = fakeTransport(); + wireAuth(fake); + fake.on('/Users/user-1/Items', 200, { Items: [] }); + const cred = { + server: 'jf-auth.test', + username: 'auth-user', + password: 'secret', + }; + await jellyfinConnector.match( + cred, + { document: 'd', title: 'Dune', author: 'Frank Herbert', filename: null }, + fake.transport + ); + const authCall = fake.calls.find((c) => c.url.includes('AuthenticateByName')); + const hdrs = authCall?.headers as Record | undefined; + const authzClient = hdrs?.authorization ?? hdrs?.Authorization; + expect(authzClient).toMatch(/MediaBrowser Client/); + const apiCall = fake.calls.find((c) => c.url.includes('/Users/user-1/Items') && c.method === 'GET'); + const authz = (apiCall?.headers as Record | undefined)?.authorization; + expect(authz).toMatch(/MediaBrowser Token="access-tok"/); + }); + + it('match picks the book by author', async () => { + const fake = fakeTransport(); + wireAuth(fake); + fake.on('/Users/user-1/Items', 200, { + Items: [ + { + Id: 'a', + Name: 'Dune', + People: [{ Name: 'Someone Else', Role: 'Writer' }], + }, + { + Id: 'b', + Name: 'Dune', + People: [{ Name: 'Frank Herbert', Role: 'Writer' }], + RunTimeTicks: 500_000, + }, + ], + }); + const m = await jellyfinConnector.match( + CRED, + { document: DOC, title: 'Dune', author: 'Frank Herbert', filename: null }, + fake.transport + ); + expect(m?.externalId).toBe('b'); + expect(m?.externalEdition).toBe('500000'); + }); + + it('push posts UserData and skips when Jellyfin is newer', async () => { + const fake = fakeTransport(); + wireAuth(fake); + fake.on('/UserData', 200, { + LastPlayedDate: '2030-01-01T00:00:00.000Z', + PlayedPercentage: 90, + }); + const skip = await jellyfinConnector.push( + CRED, + { externalId: 'book-1', confidence: 1, externalEdition: '1000000' }, + { + kind: 'progress', + document: DOC, + percentage: 0.5, + progress: 'p', + position: null, + timestamp: 1_700_000_000, + }, + fake.transport + ); + expect(skip.ok).toBe(true); + expect(fake.calls.filter((c) => c.method === 'POST' && c.url.includes('UserData'))).toHaveLength(0); + + fake.on('/UserData', 200, { LastPlayedDate: '2020-01-01T00:00:00.000Z' }); + const r = await jellyfinConnector.push( + CRED, + { externalId: 'book-1', confidence: 1, externalEdition: '1000000' }, + { + kind: 'progress', + document: DOC, + percentage: 0.5, + progress: 'p', + position: null, + timestamp: 1_700_000_000, + }, + fake.transport + ); + expect(r.ok).toBe(true); + const post = fake.calls.find((c) => c.method === 'POST' && c.url.includes('/Items/book-1/UserData')); + expect(post).toBeTruthy(); + const body = JSON.parse(post!.body!); + expect(body).toMatchObject({ + PlayedPercentage: 50, + PlaybackPositionTicks: 500_000, + Played: false, + }); + }); + + it('treats 403 on UserData as per-item failure, not reauth', async () => { + const fake = fakeTransport(); + wireAuth(fake); + fake.on('/Items/book-1/UserData', 403, {}); + const ch = await jellyfinConnector.pullProgress!( + CRED, + { externalId: 'book-1', confidence: 1, externalEdition: '1000000' }, + fake.transport, + 0 + ); + expect(ch).toBeNull(); + + fake.on('/UserData', 403, {}); + const push = await jellyfinConnector.push( + CRED, + { externalId: 'book-1', confidence: 1, externalEdition: '1000000' }, + { + kind: 'progress', + document: DOC, + percentage: 0.5, + progress: 'p', + position: null, + timestamp: 1_700_000_000, + }, + fake.transport + ); + expect(push.ok).toBe(false); + if (push.ok) throw new Error('expected push failure'); + expect(push.needsReauth).toBeFalsy(); + expect(push.error).toBe('forbidden'); + }); + + it('pullProgress respects sinceMs', async () => { + const fake = fakeTransport(); + wireAuth(fake); + fake.on('/Items/book-1/UserData', 200, { + PlayedPercentage: 60, + LastPlayedDate: '2026-06-01T00:00:00.000Z', + }); + const since = Date.parse('2026-05-01T00:00:00.000Z'); + const ch = await jellyfinConnector.pullProgress!( + CRED, + { externalId: 'book-1', confidence: 1, externalEdition: '1000000' }, + fake.transport, + since + ); + expect(ch?.percentage).toBe(0.6); + const stale = await jellyfinConnector.pullProgress!( + CRED, + { externalId: 'book-1', confidence: 1, externalEdition: '1000000' }, + fake.transport, + Date.parse('2026-07-01T00:00:00.000Z') + ); + expect(stale).toBeNull(); + }); +}); + +describe('jellyfin fan-in', () => { + it('poller updates canonical progress and fans out to others (not Jellyfin)', async () => { + const fake = fakeTransport(); + wireAuth(fake); + const { app, db } = makeTestApp({}, { connectorTransport: fake.transport }); + const { headers } = await registerUser(app); + const userId = 1; + await app.request('/api/v1/connectors/jellyfin', { + method: 'PUT', + headers, + body: JSON.stringify({ credential: CRED }), + }); + fake.on('/users/auth', 200, {}); + await app.request('/api/v1/connectors/kosync', { + method: 'PUT', + headers, + body: JSON.stringify({ + credential: { server: 'mirror.test', username: 'u', password: 'p' }, + }), + }); + saveMatch(db, userId, 'jellyfin', DOC, { externalId: 'book-1', confidence: 1 }, 'manual'); + await app.request('/syncs/progress', { + method: 'PUT', + headers, + body: JSON.stringify({ + document: DOC, + progress: 'p', + percentage: 0.2, + device_id: 'reader', + }), + }); + fake.on('/Items/book-1/UserData', 200, { + PlayedPercentage: 60, + LastPlayedDate: '2030-08-01T12:00:00.000Z', + }); + db.prepare('DELETE FROM connector_queue').run(); + + const applied = await pollConnector(db, userId, 'jellyfin', fake.transport); + expect(applied).toBe(1); + + const got = await (await app.request(`/syncs/progress/${DOC}`, { headers })).json(); + expect(got.percentage).toBe(0.6); + expect(got.device_id).toBe('jellyfin'); + + const queued = db.prepare('SELECT connector_id FROM connector_queue WHERE user_id = ?').all(userId) as { + connector_id: string; + }[]; + const targets = queued.map((q) => q.connector_id); + expect(targets).toContain('kosync'); + expect(targets).not.toContain('jellyfin'); + }); + + it('maps percentage-only fan-in to a nearby device position, not a far one', async () => { + const fake = fakeTransport(); + wireAuth(fake); + const { app, db } = makeTestApp({}, { connectorTransport: fake.transport }); + const { headers } = await registerUser(app); + const userId = 1; + await app.request('/api/v1/connectors/jellyfin', { + method: 'PUT', + headers, + body: JSON.stringify({ credential: CRED }), + }); + saveMatch(db, userId, 'jellyfin', DOC, { externalId: 'book-1', confidence: 1 }, 'manual'); + const XPOINTER = '/body/DocFragment[11]/body/div[1]/p[3]'; + await app.request('/syncs/progress', { + method: 'PUT', + headers, + body: JSON.stringify({ + document: DOC, + progress: XPOINTER, + percentage: 0.38, + device_id: 'kindle', + }), + }); + fake.on('/Items/book-1/UserData', 200, { + PlayedPercentage: 62, + LastPlayedDate: '2030-09-01T00:00:00.000Z', + }); + db.prepare('DELETE FROM connector_queue').run(); + expect(await pollConnector(db, userId, 'jellyfin', fake.transport)).toBe(1); + + let got = await (await app.request(`/syncs/progress/${DOC}`, { headers })).json(); + expect(got.percentage).toBe(0.62); + expect(got.progress).toBe('jellyfin:620000'); + + const NEAR = '/body/DocFragment[20]/body/p[7]'; + await app.request('/syncs/progress', { + method: 'PUT', + headers, + body: JSON.stringify({ + document: DOC, + progress: NEAR, + percentage: 0.652, + device_id: 'kindle', + }), + }); + fake.on('/Items/book-1/UserData', 200, { + PlayedPercentage: 65.5, + LastPlayedDate: '2030-10-01T00:00:00.000Z', + }); + expect(await pollConnector(db, userId, 'jellyfin', fake.transport)).toBe(1); + got = await (await app.request(`/syncs/progress/${DOC}`, { headers })).json(); + expect(got.percentage).toBe(0.655); + expect(got.progress).toBe(NEAR); + }); +});