Cloud sync guide

Nyora Cloud Sync lets a signed-in user keep their library — favourites, reading history, and bookmarks — in sync across devices. It is exposed through a single client, nyora.sync.NyoraSync, which is independent of the browsing nyora.Nyora client.

Note

Sync is optional. Browsing, searching, and reading work anonymously through nyora.Nyora. You only need NyoraSync to store or retrieve a user’s library.

How it works

NyoraSync talks to the Nyora sync server at https://stream.hasanraza.tech using an OAuth2 password grant for sign-in and a JWT bearer token for every request thereafter. On top of that it offers a generic, last-write-wins upsert/select over a small set of per-user tables. When an access token expires, the client transparently refreshes it with the stored refresh token and retries the request once.

Access and refresh tokens are held in memory and, unless disabled, persisted to disk so a process (or the terminal reader) stays signed in across runs.

Import and construct

from nyora import NyoraSync          # or: from nyora.sync import NyoraSync

sync = NyoraSync()

NyoraSync(base_url=None, *, timeout=30.0, token_path=None):

  • base_url — the sync server URL. Defaults to https://stream.hasanraza.tech, or the NYORA_SYNC_URL environment variable when set.

  • timeout — per-request HTTP timeout in seconds.

  • token_path — where to persist tokens. None (the default) uses the standard user config path (see Token persistence). Pass an explicit path to override it, or an empty string to disable persistence entirely (tokens then live only in memory).

NyoraSync is a context manager; use with (or call close()) to release the underlying HTTP connection:

with NyoraSync() as sync:
    ...

Accounts

Sign in

sync = NyoraSync()
sync.sign_in("me@example.com", "hunter2")
print(sync.is_signed_in)   # True
print(sync.email)          # "me@example.com"

sign_in(email, password) performs the OAuth2 password grant and stores (and persists) the returned tokens.

Register

sync.register("new@example.com", "hunter2")

register(email, password) creates a new account and signs it in. The server may have registration disabled, in which case the request raises.

Sign out

sync.sign_out()

sign_out() forgets the in-memory tokens and deletes the persisted token file, so the next process starts signed out.

State

  • sync.is_signed_in -> bool — whether an access token is currently held.

  • sync.email -> str | None — the signed-in account’s email, or None.

Reading and writing library data

Two generic methods move rows to and from the server. Both require an active session and raise nyora.sync.NotSignedInError otherwise.

upsert(table, rows) -> int

Last-write-wins upsert of rows (a list of dicts) into table. Returns the number of rows written. An empty rows list is a no-op that returns 0.

written = sync.upsert(
    "nyora_favourite",
    [{"manga_id": "abc", "sort_key": 0, "updated_at": "2026-07-05T12:00:00Z"}],
)

Deletes are modeled as tombstones — upsert a row carrying a deleted_at timestamp rather than removing it, so the delete propagates by last-write-wins.

select(table, since=None) -> list[dict]

Fetch rows from table. Pass an ISO-8601 since timestamp to receive only rows changed after that instant (an incremental pull); omit it for a full fetch.

all_favs = sync.select("nyora_favourite")
recent = sync.select("nyora_history", since="2026-07-01T00:00:00Z")

Tables

Sync operates over these per-user tables:

Table

Holds

nyora_manga

Manga metadata (title, cover, authors, source reference, …).

nyora_favourite

Favourited manga, referencing nyora_manga by id.

nyora_history

Reading-history records (progress per chapter).

nyora_bookmark

Page bookmarks within chapters.

A typical “favourite this manga” operation writes one row to nyora_manga (the metadata) and one to nyora_favourite (the favourite link):

from datetime import datetime, timezone

now = datetime.now(timezone.utc).isoformat()

sync.upsert("nyora_manga", [{
    "id": "abc",
    "title": "Berserk",
    "url": "abc",
    "source_ref": '{"source": "mangadex"}',
    "updated_at": now,
}])
sync.upsert("nyora_favourite", [{
    "manga_id": "abc",
    "sort_key": 0,
    "added_at": now,
    "updated_at": now,
}])

To pull the library back, select the favourites and join them against the manga metadata:

favourites = [f for f in sync.select("nyora_favourite") if not f.get("deleted_at")]
manga = {m["id"]: m for m in sync.select("nyora_manga")}
for fav in favourites:
    meta = manga.get(fav["manga_id"], {})
    print(meta.get("title"), meta.get("source_ref"))

(The terminal reader does exactly this for its lib view — see the TUI guide.)

Token persistence

Unless disabled, tokens are written to a JSON file so sign-in survives across runs. The default location is:

$XDG_CONFIG_HOME/nyora/sync.json      # when XDG_CONFIG_HOME is set
~/.config/nyora/sync.json             # otherwise

The file stores the access_token, refresh_token, and email. It is created on sign-in and deleted on sign-out. To keep tokens in memory only, construct the client with an empty token_path:

sync = NyoraSync(token_path="")   # no file is read or written

Errors

  • nyora.sync.NotSignedInError — a RuntimeError raised when upsert, select, or a token refresh is attempted without a valid session. Call sign_in() first.

  • Network and HTTP failures surface as httpx errors (httpx.HTTPStatusError for non-2xx responses); a failed sign-in raises rather than silently leaving you signed out.

from nyora import NyoraSync
from nyora.sync import NotSignedInError

sync = NyoraSync()
try:
    rows = sync.select("nyora_favourite")
except NotSignedInError:
    sync.sign_in("me@example.com", "hunter2")
    rows = sync.select("nyora_favourite")

See also

  • TUI guide — the reader’s built-in sync account menu, lib library view, and “Favourite to library?” prompt.

  • API reference — full autodoc of nyora.sync.