@onpeek/nuvio 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +22 -0
- package/README.md +83 -0
- package/assets/nuvio-wordmark-dark.png +0 -0
- package/assets/nuvio-wordmark-light.png +0 -0
- package/dist/cli/confirmation.js +45 -0
- package/dist/cli/registry.js +14 -0
- package/dist/commands/account.js +66 -0
- package/dist/commands/addons.js +84 -0
- package/dist/commands/collections.js +147 -0
- package/dist/commands/helpers.js +186 -0
- package/dist/commands/index.js +26 -0
- package/dist/commands/library.js +105 -0
- package/dist/commands/plugins.js +58 -0
- package/dist/commands/profiles.js +126 -0
- package/dist/commands/providers.js +57 -0
- package/dist/commands/sessions.js +46 -0
- package/dist/commands/settings.js +43 -0
- package/dist/commands/trackers.js +84 -0
- package/dist/commands/undo.js +165 -0
- package/dist/config.js +60 -0
- package/dist/index.js +195 -0
- package/dist/mask.js +33 -0
- package/dist/nuvio/auth.js +123 -0
- package/dist/nuvio/call-context.js +20 -0
- package/dist/nuvio/client.js +149 -0
- package/dist/nuvio/errors.js +43 -0
- package/dist/nuvio/keys.js +23 -0
- package/dist/nuvio/ops/account.js +91 -0
- package/dist/nuvio/ops/addons.js +126 -0
- package/dist/nuvio/ops/collections.js +158 -0
- package/dist/nuvio/ops/library.js +139 -0
- package/dist/nuvio/ops/plan.js +728 -0
- package/dist/nuvio/ops/plugins.js +127 -0
- package/dist/nuvio/ops/profiles.js +383 -0
- package/dist/nuvio/ops/providers.js +103 -0
- package/dist/nuvio/ops/readers.js +57 -0
- package/dist/nuvio/ops/sessions.js +36 -0
- package/dist/nuvio/ops/settings.js +143 -0
- package/dist/nuvio/ops/trackers.js +80 -0
- package/dist/nuvio/ops/transitions.js +100 -0
- package/dist/nuvio/paths.js +74 -0
- package/dist/nuvio/safe-fetch.js +131 -0
- package/dist/nuvio/schemas.js +143 -0
- package/dist/nuvio/snapshots.js +664 -0
- package/dist/nuvio/types.js +1 -0
- package/dist/version.js +12 -0
- package/package.json +62 -0
- package/skills/nuvio/SKILL.md +19 -0
|
@@ -0,0 +1,664 @@
|
|
|
1
|
+
import { mkdirSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync, openSync, closeSync, fsyncSync, statSync, } from 'node:fs';
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
3
|
+
import { join, resolve, sep } from 'node:path';
|
|
4
|
+
import { NuvioError } from './errors.js';
|
|
5
|
+
import { readAllLibrary, readAllWatchHistory, readAllWatchProgress } from './ops/readers.js';
|
|
6
|
+
import { historyKeyOf, libraryKeyOf, storedProgressKey } from './keys.js';
|
|
7
|
+
/** Thrown when a mandatory pre-mutation snapshot cannot be persisted. */
|
|
8
|
+
export class SnapshotError extends Error {
|
|
9
|
+
constructor(message) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.name = 'SnapshotError';
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
/** Normalise a snapshot to its resource entries, regardless of single/composite shape. */
|
|
15
|
+
export function snapshotResources(s) {
|
|
16
|
+
if (s.resources && s.resources.length > 0)
|
|
17
|
+
return s.resources;
|
|
18
|
+
// Backward compatibility: legacy single-resource snapshots stored `scope` at
|
|
19
|
+
// the top level. Normalise it onto the resource entry.
|
|
20
|
+
if (s.resource)
|
|
21
|
+
return [{ resource: s.resource, before: s.before, scope: s.scope }];
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
const SNAPSHOT_ID_RE = /^\d{16}-[a-z0-9]{4,16}$/;
|
|
25
|
+
function snapshotId() {
|
|
26
|
+
const ms = String(Date.now()).padStart(16, '0');
|
|
27
|
+
const rand = randomBytes(4).toString('hex');
|
|
28
|
+
return `${ms}-${rand}`;
|
|
29
|
+
}
|
|
30
|
+
/** Resolve a snapshot file path, refusing ids that could escape the snapshot directory. */
|
|
31
|
+
function snapshotFile(cfg, id) {
|
|
32
|
+
if (!SNAPSHOT_ID_RE.test(id))
|
|
33
|
+
return null;
|
|
34
|
+
const dir = resolve(cfg.snapshotDir);
|
|
35
|
+
const target = resolve(join(cfg.snapshotDir, `${id}.json`));
|
|
36
|
+
if (!target.startsWith(dir + sep))
|
|
37
|
+
return null;
|
|
38
|
+
return target;
|
|
39
|
+
}
|
|
40
|
+
function sensitiveKind(kind) {
|
|
41
|
+
return kind === 'provider_credentials' || kind === 'tracker_tokens' || kind === 'profile_setup';
|
|
42
|
+
}
|
|
43
|
+
export function capture(cfg, client, input) {
|
|
44
|
+
const snapshot = {
|
|
45
|
+
id: snapshotId(),
|
|
46
|
+
ts: new Date().toISOString(),
|
|
47
|
+
tool: input.tool,
|
|
48
|
+
backend: cfg.backendUrl,
|
|
49
|
+
account: client.currentEmail ?? client.currentUserId,
|
|
50
|
+
resource: input.resource,
|
|
51
|
+
reversible: input.reversible ?? true,
|
|
52
|
+
sensitive: sensitiveKind(input.resource.kind),
|
|
53
|
+
note: input.note,
|
|
54
|
+
scope: input.scope,
|
|
55
|
+
before: input.before,
|
|
56
|
+
};
|
|
57
|
+
persist(cfg, snapshot);
|
|
58
|
+
return snapshot;
|
|
59
|
+
}
|
|
60
|
+
/** Capture one snapshot covering several resources (used by nuvio_apply_plan). */
|
|
61
|
+
export function captureComposite(cfg, client, input) {
|
|
62
|
+
const snapshot = {
|
|
63
|
+
id: snapshotId(),
|
|
64
|
+
ts: new Date().toISOString(),
|
|
65
|
+
tool: input.tool,
|
|
66
|
+
backend: cfg.backendUrl,
|
|
67
|
+
account: client.currentEmail ?? client.currentUserId,
|
|
68
|
+
reversible: input.reversible ?? true,
|
|
69
|
+
sensitive: input.entries.some((e) => sensitiveKind(e.resource.kind)),
|
|
70
|
+
note: input.note,
|
|
71
|
+
scope: input.scope,
|
|
72
|
+
resources: input.entries,
|
|
73
|
+
composite: true,
|
|
74
|
+
};
|
|
75
|
+
persist(cfg, snapshot);
|
|
76
|
+
return snapshot;
|
|
77
|
+
}
|
|
78
|
+
/** Atomically write a snapshot (temp file + fsync + rename, 0600). Throws on any failure. */
|
|
79
|
+
function persist(cfg, snapshot) {
|
|
80
|
+
const target = join(cfg.snapshotDir, `${snapshot.id}.json`);
|
|
81
|
+
const tmp = `${target}.tmp`;
|
|
82
|
+
try {
|
|
83
|
+
mkdirSync(cfg.snapshotDir, { recursive: true, mode: 0o700 });
|
|
84
|
+
writeFileSync(tmp, JSON.stringify(snapshot, null, 2), { mode: 0o600 });
|
|
85
|
+
const fd = openSync(tmp, 'r+');
|
|
86
|
+
try {
|
|
87
|
+
fsyncSync(fd);
|
|
88
|
+
}
|
|
89
|
+
finally {
|
|
90
|
+
closeSync(fd);
|
|
91
|
+
}
|
|
92
|
+
renameSync(tmp, target);
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
try {
|
|
96
|
+
unlinkSync(tmp);
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
/* temp file may not exist */
|
|
100
|
+
}
|
|
101
|
+
throw new SnapshotError(`Could not persist the pre-change snapshot (${error instanceof Error ? error.message : String(error)}). ` +
|
|
102
|
+
'The change was not applied.');
|
|
103
|
+
}
|
|
104
|
+
// Best-effort retention. keepLast=1 protects the snapshot just written; the
|
|
105
|
+
// age/count/size limits still apply to everything else.
|
|
106
|
+
try {
|
|
107
|
+
pruneSnapshots(cfg, { keepLast: 1 });
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
/* retention is best-effort */
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/** Delete old/large snapshots according to the retention limits. */
|
|
114
|
+
export function pruneSnapshots(cfg, options = {}) {
|
|
115
|
+
// Three independent limits (age, count, size). `keep_last` defaults to 0:
|
|
116
|
+
// it protects nothing unless a caller explicitly asks for it. Automatic GC
|
|
117
|
+
// passes keepLast=1 itself so it never deletes the snapshot it just wrote.
|
|
118
|
+
const olderThanDays = options.olderThanDays ?? cfg.snapshotMaxAgeDays;
|
|
119
|
+
const keepLast = options.keepLast ?? 0;
|
|
120
|
+
const maxCount = options.maxCount ?? cfg.snapshotMaxCount;
|
|
121
|
+
const maxTotalBytes = options.maxTotalBytes ?? cfg.snapshotMaxTotalBytes;
|
|
122
|
+
let files;
|
|
123
|
+
try {
|
|
124
|
+
files = readdirSync(cfg.snapshotDir).filter((f) => f.endsWith('.json'));
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
return { removed: [], kept: 0, freed_bytes: 0 };
|
|
128
|
+
}
|
|
129
|
+
const entries = files
|
|
130
|
+
.map((file) => {
|
|
131
|
+
const id = file.slice(0, -'.json'.length);
|
|
132
|
+
let size = 0;
|
|
133
|
+
try {
|
|
134
|
+
size = statSync(join(cfg.snapshotDir, file)).size;
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
/* unreadable, treat as empty */
|
|
138
|
+
}
|
|
139
|
+
return { id, size };
|
|
140
|
+
})
|
|
141
|
+
.sort((a, b) => (a.id < b.id ? 1 : -1)); // newest first
|
|
142
|
+
const cutoff = olderThanDays > 0 ? Date.now() - olderThanDays * 24 * 60 * 60 * 1000 : 0;
|
|
143
|
+
const removed = [];
|
|
144
|
+
let cumulative = 0;
|
|
145
|
+
entries.forEach((entry, index) => {
|
|
146
|
+
// The newest `keepLast` snapshots are always retained (that is the count limit).
|
|
147
|
+
if (index < keepLast) {
|
|
148
|
+
cumulative += entry.size;
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
const ts = Number(entry.id.slice(0, 16));
|
|
152
|
+
const tooOld = cutoff > 0 && Number.isFinite(ts) && ts < cutoff;
|
|
153
|
+
const tooBig = maxTotalBytes > 0 && cumulative + entry.size > maxTotalBytes;
|
|
154
|
+
const tooMany = maxCount > 0 && index >= maxCount;
|
|
155
|
+
if (tooOld || tooBig || tooMany) {
|
|
156
|
+
removed.push({ id: entry.id, bytes: entry.size, reason: tooOld ? 'age' : tooBig ? 'size' : 'count' });
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
cumulative += entry.size;
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
if (!options.dryRun) {
|
|
163
|
+
for (const entry of removed)
|
|
164
|
+
removeSnapshot(cfg, entry.id);
|
|
165
|
+
}
|
|
166
|
+
return {
|
|
167
|
+
removed,
|
|
168
|
+
kept: entries.length - removed.length,
|
|
169
|
+
freed_bytes: removed.reduce((sum, r) => sum + r.bytes, 0),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
export function removeSnapshot(cfg, id) {
|
|
173
|
+
const target = snapshotFile(cfg, id);
|
|
174
|
+
if (!target)
|
|
175
|
+
return;
|
|
176
|
+
try {
|
|
177
|
+
unlinkSync(target);
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
/* already gone */
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
export function listSnapshots(cfg, limit = 25) {
|
|
184
|
+
let files;
|
|
185
|
+
try {
|
|
186
|
+
files = readdirSync(cfg.snapshotDir).filter((f) => f.endsWith('.json'));
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
return [];
|
|
190
|
+
}
|
|
191
|
+
const snapshots = [];
|
|
192
|
+
for (const file of files) {
|
|
193
|
+
try {
|
|
194
|
+
snapshots.push(JSON.parse(readFileSync(join(cfg.snapshotDir, file), 'utf8')));
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
/* skip corrupt snapshot */
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
snapshots.sort((a, b) => (a.id < b.id ? 1 : -1));
|
|
201
|
+
return snapshots.slice(0, limit);
|
|
202
|
+
}
|
|
203
|
+
export function getSnapshot(cfg, id) {
|
|
204
|
+
const target = snapshotFile(cfg, id);
|
|
205
|
+
if (!target)
|
|
206
|
+
return null;
|
|
207
|
+
try {
|
|
208
|
+
return JSON.parse(readFileSync(target, 'utf8'));
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
export function findLastChange(cfg) {
|
|
215
|
+
return listSnapshots(cfg, 500).find((s) => s.reversible && s.tool !== 'nuvio_undo') ?? null;
|
|
216
|
+
}
|
|
217
|
+
export function findLastUndo(cfg) {
|
|
218
|
+
return listSnapshots(cfg, 500).find((s) => s.tool === 'nuvio_undo' && s.reversible) ?? null;
|
|
219
|
+
}
|
|
220
|
+
function strip(rows, keys) {
|
|
221
|
+
return rows.map((row) => {
|
|
222
|
+
const out = {};
|
|
223
|
+
for (const key of keys)
|
|
224
|
+
if (row[key] !== undefined)
|
|
225
|
+
out[key] = row[key];
|
|
226
|
+
return out;
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
const LIBRARY_FIELDS = [
|
|
230
|
+
'content_id',
|
|
231
|
+
'content_type',
|
|
232
|
+
'name',
|
|
233
|
+
'poster',
|
|
234
|
+
'poster_shape',
|
|
235
|
+
'background',
|
|
236
|
+
'description',
|
|
237
|
+
'release_info',
|
|
238
|
+
'imdb_rating',
|
|
239
|
+
'genres',
|
|
240
|
+
'addon_base_url',
|
|
241
|
+
'added_at',
|
|
242
|
+
];
|
|
243
|
+
const PROGRESS_FIELDS = [
|
|
244
|
+
'content_id',
|
|
245
|
+
'content_type',
|
|
246
|
+
'video_id',
|
|
247
|
+
'season',
|
|
248
|
+
'episode',
|
|
249
|
+
'position',
|
|
250
|
+
'duration',
|
|
251
|
+
'last_watched',
|
|
252
|
+
'progress_key',
|
|
253
|
+
];
|
|
254
|
+
const HISTORY_FIELDS = ['content_id', 'content_type', 'title', 'season', 'episode', 'watched_at'];
|
|
255
|
+
/**
|
|
256
|
+
* Read the current state of a resource in the shape `restore` expects. Uses the
|
|
257
|
+
* cached read RPCs so that, within one CLI invocation, the snapshot read and the ops
|
|
258
|
+
* read collapse into a single backend request.
|
|
259
|
+
*/
|
|
260
|
+
export async function readResource(client, ref) {
|
|
261
|
+
switch (ref.kind) {
|
|
262
|
+
case 'profiles': {
|
|
263
|
+
const rows = await client.readRpc('sync_pull_profiles', {});
|
|
264
|
+
return strip(rows, [
|
|
265
|
+
'profile_index',
|
|
266
|
+
'name',
|
|
267
|
+
'avatar_color_hex',
|
|
268
|
+
'uses_primary_addons',
|
|
269
|
+
'uses_primary_plugins',
|
|
270
|
+
'avatar_id',
|
|
271
|
+
'avatar_url',
|
|
272
|
+
]);
|
|
273
|
+
}
|
|
274
|
+
case 'addons': {
|
|
275
|
+
const rows = await client.select('addons', `select=id,user_id,profile_id,url,name,enabled,sort_order,created_at,updated_at` +
|
|
276
|
+
`&profile_id=eq.${ref.profile_id}&order=sort_order.asc,created_at.asc`);
|
|
277
|
+
return strip(rows, ['url', 'name', 'enabled', 'sort_order']);
|
|
278
|
+
}
|
|
279
|
+
case 'plugins': {
|
|
280
|
+
const rows = await client.select('plugins', `select=id,user_id,profile_id,url,name,enabled,sort_order,repo_type,created_at,updated_at` +
|
|
281
|
+
`&profile_id=eq.${ref.profile_id}&order=sort_order.asc`);
|
|
282
|
+
return strip(rows, ['url', 'name', 'enabled', 'sort_order', 'repo_type']);
|
|
283
|
+
}
|
|
284
|
+
case 'settings':
|
|
285
|
+
case 'home_catalog_settings': {
|
|
286
|
+
const fn = ref.kind === 'settings' ? 'sync_pull_profile_settings_blob' : 'sync_pull_home_catalog_settings';
|
|
287
|
+
const rows = await client.readRpc(fn, {
|
|
288
|
+
p_profile_id: ref.profile_id,
|
|
289
|
+
p_platform: ref.platform,
|
|
290
|
+
});
|
|
291
|
+
return rows[0]?.settings_json ?? {};
|
|
292
|
+
}
|
|
293
|
+
case 'collections': {
|
|
294
|
+
const rows = await client.readRpc('sync_pull_collections', {
|
|
295
|
+
p_profile_id: ref.profile_id,
|
|
296
|
+
});
|
|
297
|
+
return rows[0]?.collections_json ?? [];
|
|
298
|
+
}
|
|
299
|
+
case 'library':
|
|
300
|
+
return readAllLibrary(client, ref.profile_id);
|
|
301
|
+
case 'watch_progress':
|
|
302
|
+
// Progress has no pagination contract; refuse a snapshot we cannot prove complete.
|
|
303
|
+
return readAllWatchProgress(client, ref.profile_id, { requireComplete: true });
|
|
304
|
+
case 'watch_history':
|
|
305
|
+
return readAllWatchHistory(client, ref.profile_id);
|
|
306
|
+
case 'provider_credentials':
|
|
307
|
+
return client.readRpc('sync_pull_provider_credentials', { p_profile_id: ref.profile_id });
|
|
308
|
+
case 'tracker_tokens':
|
|
309
|
+
return client.readRpc('get_tracker_tokens', { p_profile_id: ref.profile_id });
|
|
310
|
+
case 'tracker_settings':
|
|
311
|
+
return client.readRpc('get_profile_tracker_settings', { p_profile_id: ref.profile_id });
|
|
312
|
+
case 'profile_setup': {
|
|
313
|
+
const settings = {};
|
|
314
|
+
for (const platform of ['tv', 'mobile', 'desktop']) {
|
|
315
|
+
const rows = await client.readRpc('sync_pull_profile_settings_blob', { p_profile_id: ref.profile_id, p_platform: platform });
|
|
316
|
+
settings[platform] = rows[0]?.settings_json ?? null;
|
|
317
|
+
}
|
|
318
|
+
const providerCredentials = await client.readRpc('sync_pull_provider_credentials', {
|
|
319
|
+
p_profile_id: ref.profile_id,
|
|
320
|
+
});
|
|
321
|
+
return { settings, provider_credentials: providerCredentials };
|
|
322
|
+
}
|
|
323
|
+
default:
|
|
324
|
+
throw new NuvioError('This resource cannot be read for snapshotting.');
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
function outcomeOf(entry, ok, message) {
|
|
328
|
+
const r = entry.resource;
|
|
329
|
+
return { resource: r.kind, profile_id: r.profile_id, platform: r.platform, ok, message };
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Restore the state captured by a snapshot. Works for single and composite
|
|
333
|
+
* snapshots and reports every resource individually — a partial rollback is
|
|
334
|
+
* never hidden.
|
|
335
|
+
*/
|
|
336
|
+
export async function restore(client, cfg, snapshot) {
|
|
337
|
+
if (!snapshot.reversible) {
|
|
338
|
+
throw new NuvioError(`Snapshot ${snapshot.id} (${snapshot.tool}) cannot be reverted automatically.`);
|
|
339
|
+
}
|
|
340
|
+
const entries = snapshotResources(snapshot);
|
|
341
|
+
if (entries.length === 0)
|
|
342
|
+
throw new NuvioError(`Snapshot ${snapshot.id} has no resources to restore.`);
|
|
343
|
+
const outcomes = [];
|
|
344
|
+
for (const entry of entries) {
|
|
345
|
+
try {
|
|
346
|
+
const message = await restoreResource(client, cfg, entry, snapshot);
|
|
347
|
+
outcomes.push(outcomeOf(entry, true, message));
|
|
348
|
+
}
|
|
349
|
+
catch (error) {
|
|
350
|
+
outcomes.push(outcomeOf(entry, false, error instanceof Error ? error.message : String(error)));
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
return { ok: outcomes.every((o) => o.ok), outcomes };
|
|
354
|
+
}
|
|
355
|
+
export async function restoreResource(client, cfg, entry, snapshot) {
|
|
356
|
+
if (!snapshot.reversible) {
|
|
357
|
+
throw new NuvioError(`Snapshot ${snapshot.id} (${snapshot.tool}) cannot be reverted automatically.`);
|
|
358
|
+
}
|
|
359
|
+
const origin = cfg.originClientId;
|
|
360
|
+
const r = entry.resource;
|
|
361
|
+
const before = entry.before;
|
|
362
|
+
switch (r.kind) {
|
|
363
|
+
case 'profiles': {
|
|
364
|
+
const wanted = new Set(before.map((p) => p.profile_index));
|
|
365
|
+
const current = await client.rpc('sync_pull_profiles', {});
|
|
366
|
+
for (const p of current) {
|
|
367
|
+
if (!wanted.has(p.profile_index)) {
|
|
368
|
+
await client.rpc('sync_delete_profile_data', {
|
|
369
|
+
p_profile_id: p.profile_index,
|
|
370
|
+
p_origin_client_id: origin,
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
await client.rpc('sync_push_profiles', {
|
|
375
|
+
p_client_max_profiles: 6,
|
|
376
|
+
p_origin_client_id: origin,
|
|
377
|
+
p_profiles: before,
|
|
378
|
+
});
|
|
379
|
+
return 'Restored the profile list.';
|
|
380
|
+
}
|
|
381
|
+
case 'addons':
|
|
382
|
+
await client.rpc('sync_push_addons', {
|
|
383
|
+
p_profile_id: r.profile_id,
|
|
384
|
+
p_addons: before,
|
|
385
|
+
p_origin_client_id: origin,
|
|
386
|
+
});
|
|
387
|
+
return `Restored addons for profile ${r.profile_id}.`;
|
|
388
|
+
case 'plugins':
|
|
389
|
+
await client.rpc('sync_push_plugins', {
|
|
390
|
+
p_profile_id: r.profile_id,
|
|
391
|
+
p_plugins: before,
|
|
392
|
+
p_origin_client_id: origin,
|
|
393
|
+
});
|
|
394
|
+
return `Restored plugins for profile ${r.profile_id}.`;
|
|
395
|
+
case 'settings':
|
|
396
|
+
await client.rpc('sync_push_profile_settings_blob', {
|
|
397
|
+
p_profile_id: r.profile_id,
|
|
398
|
+
p_platform: r.platform,
|
|
399
|
+
p_settings_json: before,
|
|
400
|
+
p_origin_client_id: origin,
|
|
401
|
+
});
|
|
402
|
+
return `Restored ${r.platform} settings for profile ${r.profile_id}.`;
|
|
403
|
+
case 'home_catalog_settings':
|
|
404
|
+
await client.rpc('sync_push_home_catalog_settings', {
|
|
405
|
+
p_profile_id: r.profile_id,
|
|
406
|
+
p_platform: r.platform,
|
|
407
|
+
p_settings_json: before,
|
|
408
|
+
p_origin_client_id: origin,
|
|
409
|
+
});
|
|
410
|
+
return `Restored ${r.platform} home catalog settings for profile ${r.profile_id}.`;
|
|
411
|
+
case 'collections':
|
|
412
|
+
await client.rpc('sync_push_collections', {
|
|
413
|
+
p_profile_id: r.profile_id,
|
|
414
|
+
p_collections_json: before,
|
|
415
|
+
p_origin_client_id: origin,
|
|
416
|
+
});
|
|
417
|
+
return `Restored collections for profile ${r.profile_id}.`;
|
|
418
|
+
case 'library': {
|
|
419
|
+
const beforeRows = before;
|
|
420
|
+
const scope = entry.scope;
|
|
421
|
+
const beforeKeys = new Set(beforeRows.map((i) => libraryKeyOf(i)));
|
|
422
|
+
if (scope === undefined) {
|
|
423
|
+
// Legacy snapshot without scope: restore the whole resource.
|
|
424
|
+
if (beforeRows.length > 0) {
|
|
425
|
+
await client.rpc('sync_push_library_items', {
|
|
426
|
+
p_profile_id: r.profile_id,
|
|
427
|
+
p_items: strip(beforeRows, LIBRARY_FIELDS),
|
|
428
|
+
p_origin_client_id: origin,
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
return `Restored the library for profile ${r.profile_id}.`;
|
|
432
|
+
}
|
|
433
|
+
const scopeKeys = new Set(scope.map((k) => libraryKeyOf(k)));
|
|
434
|
+
const scopedBefore = beforeRows.filter((i) => scopeKeys.has(libraryKeyOf(i)));
|
|
435
|
+
const remove = scope.filter((k) => !beforeKeys.has(libraryKeyOf(k)));
|
|
436
|
+
if (remove.length > 0) {
|
|
437
|
+
await client.rpc('sync_delete_library_items', {
|
|
438
|
+
p_profile_id: r.profile_id,
|
|
439
|
+
p_keys: remove,
|
|
440
|
+
p_origin_client_id: origin,
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
if (scopedBefore.length > 0) {
|
|
444
|
+
await client.rpc('sync_push_library_items', {
|
|
445
|
+
p_profile_id: r.profile_id,
|
|
446
|
+
p_items: strip(scopedBefore, LIBRARY_FIELDS),
|
|
447
|
+
p_origin_client_id: origin,
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
return `Restored the library for profile ${r.profile_id} (${scopedBefore.length} scoped item(s)).`;
|
|
451
|
+
}
|
|
452
|
+
case 'watch_progress': {
|
|
453
|
+
const beforeRows = before;
|
|
454
|
+
const scope = entry.scope;
|
|
455
|
+
const beforeKeys = new Set(beforeRows.map((p) => storedProgressKey(p)));
|
|
456
|
+
if (scope === undefined) {
|
|
457
|
+
if (beforeRows.length > 0) {
|
|
458
|
+
await client.rpc('sync_push_watch_progress', {
|
|
459
|
+
p_profile_id: r.profile_id,
|
|
460
|
+
p_entries: strip(beforeRows, PROGRESS_FIELDS),
|
|
461
|
+
p_origin_client_id: origin,
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
return `Restored watch progress for profile ${r.profile_id}.`;
|
|
465
|
+
}
|
|
466
|
+
const scopeKeys = new Set(scope);
|
|
467
|
+
const scopedBefore = beforeRows.filter((p) => scopeKeys.has(storedProgressKey(p)));
|
|
468
|
+
const remove = scope.filter((key) => !beforeKeys.has(key));
|
|
469
|
+
if (remove.length > 0) {
|
|
470
|
+
await client.rpc('sync_delete_watch_progress', {
|
|
471
|
+
p_profile_id: r.profile_id,
|
|
472
|
+
p_keys: remove,
|
|
473
|
+
p_origin_client_id: origin,
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
if (scopedBefore.length > 0) {
|
|
477
|
+
await client.rpc('sync_push_watch_progress', {
|
|
478
|
+
p_profile_id: r.profile_id,
|
|
479
|
+
p_entries: strip(scopedBefore, PROGRESS_FIELDS),
|
|
480
|
+
p_origin_client_id: origin,
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
return `Restored watch progress for profile ${r.profile_id} (${scopedBefore.length} scoped entry(ies)).`;
|
|
484
|
+
}
|
|
485
|
+
case 'watch_history': {
|
|
486
|
+
const beforeRows = before;
|
|
487
|
+
const scope = entry.scope;
|
|
488
|
+
const beforeKeys = new Set(beforeRows.map((i) => historyKeyOf(i)));
|
|
489
|
+
if (scope === undefined) {
|
|
490
|
+
if (beforeRows.length > 0) {
|
|
491
|
+
await client.rpc('sync_push_watched_items', {
|
|
492
|
+
p_profile_id: r.profile_id,
|
|
493
|
+
p_items: strip(beforeRows, HISTORY_FIELDS),
|
|
494
|
+
p_origin_client_id: origin,
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
return `Restored watch history for profile ${r.profile_id}.`;
|
|
498
|
+
}
|
|
499
|
+
const scopeKeys = new Set(scope.map((k) => historyKeyOf(k)));
|
|
500
|
+
const scopedBefore = beforeRows.filter((i) => scopeKeys.has(historyKeyOf(i)));
|
|
501
|
+
const remove = scope.filter((k) => !beforeKeys.has(historyKeyOf(k)));
|
|
502
|
+
if (remove.length > 0) {
|
|
503
|
+
await client.rpc('sync_delete_watched_items', {
|
|
504
|
+
p_profile_id: r.profile_id,
|
|
505
|
+
p_keys: remove.map((i) => ({
|
|
506
|
+
content_id: i.content_id,
|
|
507
|
+
season: i.season ?? null,
|
|
508
|
+
episode: i.episode ?? null,
|
|
509
|
+
})),
|
|
510
|
+
p_origin_client_id: origin,
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
if (scopedBefore.length > 0) {
|
|
514
|
+
await client.rpc('sync_push_watched_items', {
|
|
515
|
+
p_profile_id: r.profile_id,
|
|
516
|
+
p_items: strip(scopedBefore, HISTORY_FIELDS),
|
|
517
|
+
p_origin_client_id: origin,
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
return `Restored watch history for profile ${r.profile_id} (${scopedBefore.length} scoped item(s)).`;
|
|
521
|
+
}
|
|
522
|
+
case 'provider_credentials': {
|
|
523
|
+
const current = await client.rpc('sync_pull_provider_credentials', {
|
|
524
|
+
p_profile_id: r.profile_id,
|
|
525
|
+
});
|
|
526
|
+
const beforeRows = before;
|
|
527
|
+
const wanted = new Set(beforeRows.map((c) => c.provider));
|
|
528
|
+
for (const cred of current) {
|
|
529
|
+
if (!wanted.has(cred.provider)) {
|
|
530
|
+
await client.rpc('sync_delete_provider_credentials', {
|
|
531
|
+
p_profile_id: r.profile_id,
|
|
532
|
+
p_provider: cred.provider,
|
|
533
|
+
p_origin_client_id: origin,
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
if (beforeRows.length > 0) {
|
|
538
|
+
await client.rpc('sync_push_provider_credentials', {
|
|
539
|
+
p_profile_id: r.profile_id,
|
|
540
|
+
p_credentials: beforeRows.map((c) => ({
|
|
541
|
+
provider: c.provider,
|
|
542
|
+
credential_json: c.credential_json,
|
|
543
|
+
})),
|
|
544
|
+
p_origin_client_id: origin,
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
return `Restored provider credentials for profile ${r.profile_id}.`;
|
|
548
|
+
}
|
|
549
|
+
case 'tracker_tokens': {
|
|
550
|
+
const current = await client.rpc('get_tracker_tokens', {
|
|
551
|
+
p_profile_id: r.profile_id,
|
|
552
|
+
});
|
|
553
|
+
const beforeRows = before;
|
|
554
|
+
const wanted = new Set(beforeRows.map((t) => String(t.tracker)));
|
|
555
|
+
for (const token of current) {
|
|
556
|
+
if (!wanted.has(String(token.tracker))) {
|
|
557
|
+
await client.rpc('clear_tracker_tokens', { p_profile_id: r.profile_id, p_tracker: token.tracker });
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
for (const token of beforeRows) {
|
|
561
|
+
await client.rpc('upsert_tracker_tokens', {
|
|
562
|
+
p_profile_id: r.profile_id,
|
|
563
|
+
p_tracker: token.tracker,
|
|
564
|
+
p_access_token: token.access_token,
|
|
565
|
+
p_refresh_token: token.refresh_token ?? '',
|
|
566
|
+
p_expires_in_seconds: secondsUntil(token.expires_at),
|
|
567
|
+
p_tracker_user_id: token.tracker_user_id ?? '',
|
|
568
|
+
p_username: token.tracker_username ?? token.username ?? '',
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
return `Restored tracker links for profile ${r.profile_id}.`;
|
|
572
|
+
}
|
|
573
|
+
case 'tracker_settings': {
|
|
574
|
+
const beforeRows = before;
|
|
575
|
+
const wanted = new Set(beforeRows.map((row) => String(row.tracker)));
|
|
576
|
+
const current = await client.rpc('get_profile_tracker_settings', {
|
|
577
|
+
p_profile_id: r.profile_id,
|
|
578
|
+
});
|
|
579
|
+
for (const row of current) {
|
|
580
|
+
if (!wanted.has(String(row.tracker))) {
|
|
581
|
+
await client.rpc('upsert_profile_tracker_settings', {
|
|
582
|
+
p_profile_id: r.profile_id,
|
|
583
|
+
p_tracker: row.tracker,
|
|
584
|
+
p_enabled_statuses: [],
|
|
585
|
+
p_row_order: [],
|
|
586
|
+
p_send_progress: true,
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
for (const row of beforeRows) {
|
|
591
|
+
await client.rpc('upsert_profile_tracker_settings', {
|
|
592
|
+
p_profile_id: r.profile_id,
|
|
593
|
+
p_tracker: row.tracker,
|
|
594
|
+
p_enabled_statuses: row.enabled_statuses ?? [],
|
|
595
|
+
p_row_order: row.row_order ?? [],
|
|
596
|
+
p_send_progress: row.send_progress ?? true,
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
return `Restored tracker settings for profile ${r.profile_id}.`;
|
|
600
|
+
}
|
|
601
|
+
case 'profile_setup': {
|
|
602
|
+
const data = before;
|
|
603
|
+
for (const [platform, json] of Object.entries(data.settings ?? {})) {
|
|
604
|
+
if (json === null || json === undefined)
|
|
605
|
+
continue;
|
|
606
|
+
await client.rpc('sync_push_profile_settings_blob', {
|
|
607
|
+
p_profile_id: r.profile_id,
|
|
608
|
+
p_platform: platform,
|
|
609
|
+
p_settings_json: json,
|
|
610
|
+
p_origin_client_id: origin,
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
const wanted = new Set((data.provider_credentials ?? []).map((c) => c.provider));
|
|
614
|
+
const current = await client.rpc('sync_pull_provider_credentials', {
|
|
615
|
+
p_profile_id: r.profile_id,
|
|
616
|
+
});
|
|
617
|
+
for (const cred of current) {
|
|
618
|
+
if (!wanted.has(cred.provider)) {
|
|
619
|
+
await client.rpc('sync_delete_provider_credentials', {
|
|
620
|
+
p_profile_id: r.profile_id,
|
|
621
|
+
p_provider: cred.provider,
|
|
622
|
+
p_origin_client_id: origin,
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
if ((data.provider_credentials ?? []).length > 0) {
|
|
627
|
+
await client.rpc('sync_push_provider_credentials', {
|
|
628
|
+
p_profile_id: r.profile_id,
|
|
629
|
+
p_credentials: data.provider_credentials.map((c) => ({
|
|
630
|
+
provider: c.provider,
|
|
631
|
+
credential_json: c.credential_json,
|
|
632
|
+
})),
|
|
633
|
+
p_origin_client_id: origin,
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
return `Restored setup for profile ${r.profile_id}.`;
|
|
637
|
+
}
|
|
638
|
+
default:
|
|
639
|
+
throw new NuvioError(`No automatic revert available for resource "${r.kind}".`);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
function secondsUntil(expiresAt) {
|
|
643
|
+
const ts = typeof expiresAt === 'string' ? Date.parse(expiresAt) : NaN;
|
|
644
|
+
if (Number.isNaN(ts))
|
|
645
|
+
return 3600;
|
|
646
|
+
return Math.max(60, Math.floor((ts - Date.now()) / 1000));
|
|
647
|
+
}
|
|
648
|
+
export function describe(s) {
|
|
649
|
+
const entries = snapshotResources(s);
|
|
650
|
+
const where = s.composite
|
|
651
|
+
? ` ${entries.length} resources [${[...new Set(entries.map((e) => e.resource.kind))].join(', ')}]`
|
|
652
|
+
: (() => {
|
|
653
|
+
const r = entries[0]?.resource;
|
|
654
|
+
if (!r)
|
|
655
|
+
return '';
|
|
656
|
+
const target = r.profile_id !== undefined ? ` profile ${r.profile_id}` : '';
|
|
657
|
+
const platform = r.platform ? `/${r.platform}` : '';
|
|
658
|
+
return ` -> ${r.kind}${target}${platform}`;
|
|
659
|
+
})();
|
|
660
|
+
const flags = (s.reversible ? '' : ' [not reversible]') +
|
|
661
|
+
(s.sensitive ? ' [sensitive]' : '') +
|
|
662
|
+
(s.note ? ` — ${s.note}` : '');
|
|
663
|
+
return `${s.id} ${s.ts} ${s.tool}${where}${flags}`;
|
|
664
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/version.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
/** Single source of truth for the CLI version, read from package.json. */
|
|
3
|
+
export function resolveVersion() {
|
|
4
|
+
try {
|
|
5
|
+
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
6
|
+
return pkg.version ?? '0.0.0';
|
|
7
|
+
}
|
|
8
|
+
catch {
|
|
9
|
+
return '0.0.0';
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export const VERSION = resolveVersion();
|