Skip to content
Merged
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 4 additions & 3 deletions app/src/Settings.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand Down Expand Up @@ -397,20 +398,20 @@ export function LinkForm({ session, conn, onLinked }) {
{conn.id === 'hardcover' && tokenForm}
</div>
)
} else if (conn.credential_kind === 'kosync') {
} else if (conn.credential_kind === 'kosync' || conn.credential_kind === 'jellyfin') {
body = (
<form className="space-y-2">
<input
className={`${field} font-mono`}
placeholder={conn.id === 'bookorbit' ? 'https://books.example.com' : 'https://sync.koreader.rocks'}
placeholder={{ bookorbit: 'https://books.example.com', jellyfin: 'https://jellyfin.example.com' }[conn.id] ?? 'https://sync.koreader.rocks'}
value={v.server ?? ''}
onChange={set('server')}
inputMode="url"
autoCapitalize="none"
/>
<input className={field} placeholder="Username" value={v.username ?? ''} onChange={set('username')} autoCapitalize="none" autoComplete="off" />
<input className={field} type="password" placeholder="Password" value={v.password ?? ''} onChange={set('password')} autoComplete="off" />
{submit('Connect', () => ({
{submit(conn.id === 'jellyfin' ? 'Connect Jellyfin' : 'Connect', () => ({
server: (v.server ?? '').trim(),
username: (v.username ?? '').trim(),
password: v.password ?? '',
Expand Down
Binary file added assets/icons/jellyfin.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 3 additions & 2 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
20 changes: 20 additions & 0 deletions src/connectors/fanin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number> {
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<number> {
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;
Expand Down
Loading
Loading