feat(sync): add opds_catalog replica kind (plaintext fields) (#4087)

Wires OPDS catalogs through replica sync as a metadata-only kind.
Plaintext fields only in this PR — encrypted credentials (username,
password) ship in the follow-up alongside the SyncPassphrasePanel UI
and Tauri keychain backend.

- Migration 009 extends the kind allowlist with 'opds_catalog'.
- replicaSchemas adds opdsCatalogFieldsSchema (name, url, description,
  icon, customHeaders, autoDownload, disabled, addedAt) with a 50-row
  per-user cap.
- New opdsCatalogAdapter is metadata-only (no `binary` capability).
  Stable cross-device id from md5("opds:" + url.lower()) so two
  devices that import the same URL converge to one row instead of
  duplicating.
- New customOPDSStore (zustand) hydrates from SystemSettings,
  publishes upserts/deletes through the replica pipeline, preserves
  local-only username/password when overlaying remote updates, and
  strips tombstones at the persistence boundary so existing
  useSettingsStore readers (useOPDSSubscriptions, pseStream,
  app/opds/page.tsx) need no migration.
- replicaPullAndApply branches on adapter.binary so metadata-only
  kinds skip the bundleDir requirement and the manifest/binary path.
- CatalogManager rewires Add / Edit / Remove / Toggle / Add-popular
  through the new store.

Plan update bundled in: tenet 8 (scalar settings sync via a bundled
row; collections sync per-record), per-kind allowlist now includes a
`settings` singleton that will collapse PRs 5 + 6+ into one bundled
adapter, and PR 4 is split into 4a (already merged) / 4b (this) / 4c
(encrypted credentials + UX).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-05-08 11:33:18 +08:00
committed by GitHub
parent aea3fda086
commit 6bfeb295d2
16 changed files with 1069 additions and 76 deletions
@@ -0,0 +1,116 @@
import { md5 } from '@/utils/md5';
import type { OPDSCatalog } from '@/types/opds';
import type { ReplicaAdapter } from '@/services/sync/replicaRegistry';
import type { FieldsObject, ReplicaRow } from '@/types/replica';
import { defaultComputeId, unwrap } from './helpers';
export const OPDS_CATALOG_KIND = 'opds_catalog';
export const OPDS_CATALOG_SCHEMA_VERSION = 1;
interface UnwrappedOpdsFields {
name?: string;
url?: string;
description?: string;
icon?: string;
customHeaders?: Record<string, string>;
autoDownload?: boolean;
disabled?: boolean;
addedAt?: number;
}
const unwrapOpdsFields = (fields: FieldsObject): UnwrappedOpdsFields => {
const name = unwrap(fields['name']);
const url = unwrap(fields['url']);
const description = unwrap(fields['description']);
const icon = unwrap(fields['icon']);
const customHeaders = unwrap(fields['customHeaders']);
const autoDownload = unwrap(fields['autoDownload']);
const disabled = unwrap(fields['disabled']);
const addedAt = unwrap(fields['addedAt']);
return {
name: typeof name === 'string' ? name : undefined,
url: typeof url === 'string' ? url : undefined,
description: typeof description === 'string' ? description : undefined,
icon: typeof icon === 'string' ? icon : undefined,
customHeaders:
customHeaders && typeof customHeaders === 'object' && !Array.isArray(customHeaders)
? (customHeaders as Record<string, string>)
: undefined,
autoDownload: autoDownload === true ? true : undefined,
disabled: disabled === true ? true : undefined,
addedAt: typeof addedAt === 'number' ? addedAt : undefined,
};
};
/**
* Stable cross-device identity for an OPDS catalog. Two devices that import
* the same URL converge to a single replica row instead of duplicating.
* URL is normalized (trim + lower-case) so trailing-slash and case
* differences don't fragment identity. Username/password are intentionally
* excluded — encrypted-credential sync is in a follow-up PR; including
* username here would couple identity to a field that may not yet sync.
*/
export const computeOpdsCatalogContentId = (url: string): string =>
md5(`opds:${url.trim().toLowerCase()}`);
export const opdsCatalogAdapter: ReplicaAdapter<OPDSCatalog> = {
kind: OPDS_CATALOG_KIND,
schemaVersion: OPDS_CATALOG_SCHEMA_VERSION,
pack(catalog: OPDSCatalog): Record<string, unknown> {
const fields: Record<string, unknown> = {
name: catalog.name,
url: catalog.url,
addedAt: catalog.addedAt ?? Date.now(),
};
if (catalog.description !== undefined) fields['description'] = catalog.description;
if (catalog.icon !== undefined) fields['icon'] = catalog.icon;
if (catalog.customHeaders !== undefined) fields['customHeaders'] = catalog.customHeaders;
if (catalog.autoDownload !== undefined) fields['autoDownload'] = catalog.autoDownload;
if (catalog.disabled !== undefined) fields['disabled'] = catalog.disabled;
return fields;
},
unpack(fields: Record<string, unknown>): OPDSCatalog {
return {
id: '',
name: String(fields['name'] ?? ''),
url: String(fields['url'] ?? ''),
description: fields['description'] !== undefined ? String(fields['description']) : undefined,
icon: fields['icon'] !== undefined ? String(fields['icon']) : undefined,
customHeaders:
fields['customHeaders'] && typeof fields['customHeaders'] === 'object'
? (fields['customHeaders'] as Record<string, string>)
: undefined,
autoDownload: fields['autoDownload'] === true ? true : undefined,
disabled: fields['disabled'] === true ? true : undefined,
addedAt: fields['addedAt'] !== undefined ? Number(fields['addedAt']) : undefined,
};
},
computeId: defaultComputeId,
unpackRow(row: ReplicaRow): OPDSCatalog | null {
const fields = unwrapOpdsFields(row.fields_jsonb);
if (!fields.name || !fields.url) return null;
const catalog: OPDSCatalog = {
// OPDS catalogs use contentId as their local id — they have no
// "bundle dir" pointer to disambiguate, and the URL-derived
// contentId is already a stable cross-device identifier.
id: row.replica_id,
contentId: row.replica_id,
name: fields.name,
url: fields.url,
};
if (fields.description !== undefined) catalog.description = fields.description;
if (fields.icon !== undefined) catalog.icon = fields.icon;
if (fields.customHeaders !== undefined) catalog.customHeaders = fields.customHeaders;
if (fields.autoDownload !== undefined) catalog.autoDownload = fields.autoDownload;
if (fields.disabled !== undefined) catalog.disabled = fields.disabled;
if (fields.addedAt !== undefined) catalog.addedAt = fields.addedAt;
if (row.reincarnation) catalog.reincarnation = row.reincarnation;
return catalog;
},
// No `binary` capability — opds_catalog is metadata-only.
};
@@ -4,6 +4,7 @@ import { useCustomTextureStore } from '@/store/customTextureStore';
import { dictionaryAdapter, DICTIONARY_KIND } from './adapters/dictionary';
import { fontAdapter, FONT_KIND } from './adapters/font';
import { textureAdapter, TEXTURE_KIND } from './adapters/texture';
import { opdsCatalogAdapter } from './adapters/opdsCatalog';
import { getReplicaPersistEnv } from './replicaPersist';
import { getReplicaAdapter, registerReplicaAdapter } from './replicaRegistry';
import { registerReplicaDownloadHandler } from './replicaTransferIntegration';
@@ -13,6 +14,8 @@ const KNOWN_ADAPTERS: ReplicaAdapter<unknown>[] = [
dictionaryAdapter as unknown as ReplicaAdapter<unknown>,
fontAdapter as unknown as ReplicaAdapter<unknown>,
textureAdapter as unknown as ReplicaAdapter<unknown>,
// Metadata-only — no binary download handler needed.
opdsCatalogAdapter as unknown as ReplicaAdapter<unknown>,
];
let didBootstrap = false;
@@ -109,22 +109,29 @@ const applyRow = async <T extends ReplicaLocalRecord>(
// Decide bundleDir + display name. If a local entry already maps this
// contentId, reuse its bundleDir so we don't orphan the previously
// downloaded binaries; otherwise mint a fresh dir and apply the remote
// record to the local store. Legacy local records (pre-replica-sync)
// may carry no bundleDir — skip them; they aren't sync-eligible.
// record to the local store. Legacy binary-kind records (pre-replica-
// sync) may carry no bundleDir — skip them; they aren't sync-eligible.
// Metadata-only kinds (no `binary` capability, e.g. opds_catalog) have
// no on-disk anchor at all, so the bundleDir requirement is dropped.
const needsBundleDir = !!deps.adapter.binary;
let bundleDir: string;
let displayName: string;
if (local) {
if (!local.bundleDir) return;
bundleDir = local.bundleDir;
if (needsBundleDir && !local.bundleDir) return;
bundleDir = local.bundleDir ?? '';
displayName = deps.adapter.getDisplayName?.(local) ?? local.name;
} else {
bundleDir = await deps.createBundleDir();
bundleDir = needsBundleDir ? await deps.createBundleDir() : '';
const record = deps.adapter.unpackRow(row, bundleDir);
if (!record) return;
deps.applyRemote(record);
displayName = deps.adapter.getDisplayName?.(record) ?? record.name;
}
// Metadata-only kinds: nothing more to do. The orchestrator's manifest
// / binary-download path is a no-op for them.
if (!needsBundleDir) return;
if (!row.manifest_jsonb || row.manifest_jsonb.files.length === 0) {
// Server row has no manifest yet — typically the device that
// wrote the metadata never finished the binary upload (TM wasn't