@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,728 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { NuvioError } from '../errors.js';
|
|
3
|
+
import { libraryKeyOf, progressKeyOf, storedProgressKey } from '../keys.js';
|
|
4
|
+
import { readResource } from '../snapshots.js';
|
|
5
|
+
import { addonAddShape, addonRemoveShape, addonReorderShape, addonUpdateShape, assertTarget, historyAddShape, historyDeleteShape, libraryAddShape, libraryRemoveShape, progressDeleteShape, progressSetShape, providerDeleteShape, providerSetShape, updateSettingsShape, } from '../schemas.js';
|
|
6
|
+
import { applySettingsEdit, assertEditProvided, diffTree, getHomeCatalogSettings, getSettings, isConcurrencyConflict, writeSettings, } from './settings.js';
|
|
7
|
+
import { PROVIDER_CREDENTIAL_FIELD } from './providers.js';
|
|
8
|
+
import { planHistoryAdd, planHistoryDelete, planLibraryAdd, planLibraryRemove, planProgressDelete, planProgressSet, } from './transitions.js';
|
|
9
|
+
/** A writer failure plus the side-effect information the plan needs to classify it. */
|
|
10
|
+
export class WriteFailure extends Error {
|
|
11
|
+
opts;
|
|
12
|
+
constructor(message, opts) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.opts = opts;
|
|
15
|
+
this.name = 'WriteFailure';
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
function copy(value) {
|
|
19
|
+
return structuredClone(value);
|
|
20
|
+
}
|
|
21
|
+
/** Order-independent comparison for detecting concurrent changes. */
|
|
22
|
+
function canonical(value) {
|
|
23
|
+
if (Array.isArray(value))
|
|
24
|
+
return value.map(canonical);
|
|
25
|
+
if (value && typeof value === 'object') {
|
|
26
|
+
const out = {};
|
|
27
|
+
for (const key of Object.keys(value).sort()) {
|
|
28
|
+
out[key] = canonical(value[key]);
|
|
29
|
+
}
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
return value;
|
|
33
|
+
}
|
|
34
|
+
export function resourceKey(ref) {
|
|
35
|
+
const r = ref;
|
|
36
|
+
return `${r.kind}${r.profile_id !== undefined ? `:${r.profile_id}` : ''}${r.platform ? `/${r.platform}` : ''}`;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Apply list upserts/removes. Any write RPC that has *started* may have been
|
|
40
|
+
* applied on the backend even if it later throws (timeout, reset response, 5xx
|
|
41
|
+
* after commit), so failures are conservatively marked `mayHaveApplied: true`.
|
|
42
|
+
*/
|
|
43
|
+
async function pushList(client, ref, before, after, keyOf, upsertRpc, upsertField, deleteRpc, deleteField, mapDeleteKey, originId) {
|
|
44
|
+
const profileId = ref.profile_id;
|
|
45
|
+
const b = new Map(before.map((i) => [keyOf(i), i]));
|
|
46
|
+
const a = new Map(after.map((i) => [keyOf(i), i]));
|
|
47
|
+
const upserts = after.filter((i) => {
|
|
48
|
+
const prev = b.get(keyOf(i));
|
|
49
|
+
return !prev || JSON.stringify(prev) !== JSON.stringify(i);
|
|
50
|
+
});
|
|
51
|
+
const removals = before.filter((i) => !a.has(keyOf(i)));
|
|
52
|
+
let writeAttempted = false;
|
|
53
|
+
try {
|
|
54
|
+
if (removals.length > 0) {
|
|
55
|
+
writeAttempted = true;
|
|
56
|
+
await client.rpc(deleteRpc, {
|
|
57
|
+
p_profile_id: profileId,
|
|
58
|
+
[deleteField]: removals.map(mapDeleteKey),
|
|
59
|
+
p_origin_client_id: originId,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
if (upserts.length > 0) {
|
|
63
|
+
writeAttempted = true;
|
|
64
|
+
await client.rpc(upsertRpc, {
|
|
65
|
+
p_profile_id: profileId,
|
|
66
|
+
[upsertField]: upserts,
|
|
67
|
+
p_origin_client_id: originId,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
throw new WriteFailure(error instanceof Error ? error.message : String(error), {
|
|
73
|
+
mayHaveApplied: writeAttempted,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// Planners (canonical plan-supported tools only)
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
function settingsPlan(state, args, _now) {
|
|
81
|
+
const edit = {
|
|
82
|
+
patch: args.patch,
|
|
83
|
+
set: args.set,
|
|
84
|
+
unset: args.unset,
|
|
85
|
+
};
|
|
86
|
+
const result = applySettingsEdit(state ?? {}, edit);
|
|
87
|
+
return { state: result.after, diff: result.diff };
|
|
88
|
+
}
|
|
89
|
+
const settingsDescriptor = {
|
|
90
|
+
schema: updateSettingsShape,
|
|
91
|
+
validate: (args) => assertEditProvided({
|
|
92
|
+
patch: args.patch,
|
|
93
|
+
set: args.set,
|
|
94
|
+
unset: args.unset,
|
|
95
|
+
}),
|
|
96
|
+
ref: (args) => ({
|
|
97
|
+
kind: 'settings',
|
|
98
|
+
profile_id: args.profile_id,
|
|
99
|
+
platform: args.platform,
|
|
100
|
+
}),
|
|
101
|
+
plan: settingsPlan,
|
|
102
|
+
readMeta: async (client, ref) => {
|
|
103
|
+
const r = ref;
|
|
104
|
+
const blob = await getSettings(client, r.profile_id, r.platform);
|
|
105
|
+
return { updated_at: blob?.updated_at ?? null };
|
|
106
|
+
},
|
|
107
|
+
write: async (client, ref, state, meta, originId) => {
|
|
108
|
+
const r = ref;
|
|
109
|
+
try {
|
|
110
|
+
const result = await writeSettings(client, r.profile_id, r.platform, state, meta.updated_at ?? null, originId);
|
|
111
|
+
return { writtenRevision: result.revision, writtenGuarded: result.guarded };
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
if (isConcurrencyConflict(error)) {
|
|
115
|
+
throw new WriteFailure('Settings changed on another device since the plan started (guarded write was rejected).', { mayHaveApplied: false, conflict: true });
|
|
116
|
+
}
|
|
117
|
+
throw new WriteFailure(error instanceof Error ? error.message : String(error), {
|
|
118
|
+
mayHaveApplied: true,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
guardedRollback: (meta) => Boolean(meta.writtenGuarded) && Boolean(meta.writtenRevision),
|
|
123
|
+
rollback: async (client, ref, before, meta, originId) => {
|
|
124
|
+
const r = ref;
|
|
125
|
+
try {
|
|
126
|
+
// Guarded rollback: only succeeds if nobody changed the settings after us.
|
|
127
|
+
await writeSettings(client, r.profile_id, r.platform, before, meta.writtenRevision, originId);
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
throw new WriteFailure(error instanceof Error ? error.message : String(error), {
|
|
131
|
+
mayHaveApplied: true,
|
|
132
|
+
conflict: isConcurrencyConflict(error),
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
const homeDescriptor = {
|
|
138
|
+
schema: updateSettingsShape,
|
|
139
|
+
validate: (args) => assertEditProvided({
|
|
140
|
+
patch: args.patch,
|
|
141
|
+
set: args.set,
|
|
142
|
+
unset: args.unset,
|
|
143
|
+
}),
|
|
144
|
+
ref: (args) => ({
|
|
145
|
+
kind: 'home_catalog_settings',
|
|
146
|
+
profile_id: args.profile_id,
|
|
147
|
+
platform: args.platform,
|
|
148
|
+
}),
|
|
149
|
+
plan: settingsPlan,
|
|
150
|
+
readMeta: async (client, ref) => {
|
|
151
|
+
const r = ref;
|
|
152
|
+
const blob = await getHomeCatalogSettings(client, r.profile_id, r.platform);
|
|
153
|
+
return { updated_at: blob?.updated_at ?? null };
|
|
154
|
+
},
|
|
155
|
+
write: async (client, ref, state, _meta, originId) => {
|
|
156
|
+
const r = ref;
|
|
157
|
+
try {
|
|
158
|
+
await client.rpc('sync_push_home_catalog_settings', {
|
|
159
|
+
p_profile_id: r.profile_id,
|
|
160
|
+
p_platform: r.platform,
|
|
161
|
+
p_settings_json: state,
|
|
162
|
+
p_origin_client_id: originId,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
catch (error) {
|
|
166
|
+
throw new WriteFailure(error instanceof Error ? error.message : String(error), {
|
|
167
|
+
mayHaveApplied: true,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
function findAddon(list, args, meta) {
|
|
173
|
+
if (typeof args.url === 'string') {
|
|
174
|
+
const found = list.find((a) => a.url === args.url);
|
|
175
|
+
if (found)
|
|
176
|
+
return found;
|
|
177
|
+
}
|
|
178
|
+
if (typeof args.id === 'string') {
|
|
179
|
+
// The list state is the push shape (no id); ids are supplied via readMeta.
|
|
180
|
+
const ids = (meta.ids ?? {});
|
|
181
|
+
const url = Object.keys(ids).find((u) => ids[u] === args.id);
|
|
182
|
+
const found = url ? list.find((a) => a.url === url) : undefined;
|
|
183
|
+
if (found)
|
|
184
|
+
return found;
|
|
185
|
+
}
|
|
186
|
+
throw new NuvioError('Addon not found');
|
|
187
|
+
}
|
|
188
|
+
const addonsDescriptor = {
|
|
189
|
+
schema: addonUpdateShape,
|
|
190
|
+
validate: (args) => assertTarget('nuvio_update_addon', args),
|
|
191
|
+
ref: (args) => ({ kind: 'addons', profile_id: args.profile_id }),
|
|
192
|
+
readMeta: async (client, ref) => {
|
|
193
|
+
const rows = await client.select('addons', `select=id,user_id,profile_id,url,name,enabled,sort_order,created_at,updated_at` +
|
|
194
|
+
`&profile_id=eq.${ref.profile_id}&order=sort_order.asc,created_at.asc`);
|
|
195
|
+
const ids = {};
|
|
196
|
+
for (const row of rows)
|
|
197
|
+
if (typeof row.url === 'string' && typeof row.id === 'string')
|
|
198
|
+
ids[row.url] = row.id;
|
|
199
|
+
return { ids };
|
|
200
|
+
},
|
|
201
|
+
plan: (state, args, _now, meta) => {
|
|
202
|
+
const list = copy(state);
|
|
203
|
+
const tool = String(args.__tool);
|
|
204
|
+
const diff = [];
|
|
205
|
+
if (tool === 'nuvio_add_addon') {
|
|
206
|
+
const url = String(args.url);
|
|
207
|
+
if (list.some((a) => a.url === url))
|
|
208
|
+
throw new NuvioError(`Addon already installed: ${url}`);
|
|
209
|
+
list.push({
|
|
210
|
+
url,
|
|
211
|
+
name: args.name ?? null,
|
|
212
|
+
enabled: args.enabled ?? true,
|
|
213
|
+
sort_order: args.sort_order ??
|
|
214
|
+
list.reduce((m, a) => Math.max(m, Number(a.sort_order) + 1), 0),
|
|
215
|
+
});
|
|
216
|
+
diff.push(`+ addon ${url}`);
|
|
217
|
+
}
|
|
218
|
+
else if (tool === 'nuvio_update_addon') {
|
|
219
|
+
const target = findAddon(list, args, meta);
|
|
220
|
+
if (args.name !== undefined)
|
|
221
|
+
target.name = args.name;
|
|
222
|
+
if (args.enabled !== undefined)
|
|
223
|
+
target.enabled = args.enabled;
|
|
224
|
+
if (args.sort_order !== undefined)
|
|
225
|
+
target.sort_order = args.sort_order;
|
|
226
|
+
diff.push(`~ addon ${String(target.url)}`);
|
|
227
|
+
}
|
|
228
|
+
else if (tool === 'nuvio_reorder_addons') {
|
|
229
|
+
const ordered = args.ordered_urls;
|
|
230
|
+
const current = list.map((a) => a.url).sort();
|
|
231
|
+
if (ordered.length !== current.length || [...ordered].sort().some((u, i) => u !== current[i])) {
|
|
232
|
+
throw new NuvioError('Reorder must list every installed addon URL exactly once.');
|
|
233
|
+
}
|
|
234
|
+
return {
|
|
235
|
+
state: ordered.map((url, index) => ({ ...list.find((a) => a.url === url), sort_order: index })),
|
|
236
|
+
diff: ['~ reorder addons'],
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
else if (tool === 'nuvio_remove_addon') {
|
|
240
|
+
const target = findAddon(list, args, meta);
|
|
241
|
+
diff.push(`- addon ${String(target.url)}`);
|
|
242
|
+
return { state: list.filter((a) => a.url !== target.url), diff };
|
|
243
|
+
}
|
|
244
|
+
else {
|
|
245
|
+
throw new NuvioError(`Unsupported addon operation: ${tool}`);
|
|
246
|
+
}
|
|
247
|
+
return { state: list, diff };
|
|
248
|
+
},
|
|
249
|
+
write: async (client, ref, state, _meta, originId) => {
|
|
250
|
+
try {
|
|
251
|
+
await client.rpc('sync_push_addons', {
|
|
252
|
+
p_profile_id: ref.profile_id,
|
|
253
|
+
p_addons: state,
|
|
254
|
+
p_origin_client_id: originId,
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
catch (error) {
|
|
258
|
+
throw new WriteFailure(error instanceof Error ? error.message : String(error), {
|
|
259
|
+
mayHaveApplied: true,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
},
|
|
263
|
+
};
|
|
264
|
+
const providersDescriptor = {
|
|
265
|
+
schema: providerSetShape,
|
|
266
|
+
validate: (args) => {
|
|
267
|
+
const key = String(args.provider ?? '')
|
|
268
|
+
.trim()
|
|
269
|
+
.toLowerCase();
|
|
270
|
+
if (!PROVIDER_CREDENTIAL_FIELD[key]) {
|
|
271
|
+
throw new NuvioError(`Unsupported provider "${String(args.provider)}". Supported: ${Object.keys(PROVIDER_CREDENTIAL_FIELD).join(', ')}`);
|
|
272
|
+
}
|
|
273
|
+
},
|
|
274
|
+
ref: (args) => ({ kind: 'provider_credentials', profile_id: args.profile_id }),
|
|
275
|
+
plan: (state, args) => {
|
|
276
|
+
const list = copy(state);
|
|
277
|
+
const tool = String(args.__tool);
|
|
278
|
+
const provider = String(args.provider ?? '')
|
|
279
|
+
.trim()
|
|
280
|
+
.toLowerCase();
|
|
281
|
+
if (!provider)
|
|
282
|
+
throw new NuvioError('apply_plan: provider is required');
|
|
283
|
+
const diff = [];
|
|
284
|
+
if (tool === 'nuvio_delete_provider_credential') {
|
|
285
|
+
const index = list.findIndex((c) => c.provider === provider);
|
|
286
|
+
if (index < 0)
|
|
287
|
+
throw new NuvioError(`No credential stored for ${provider}`);
|
|
288
|
+
list.splice(index, 1);
|
|
289
|
+
diff.push(`- remove credential for ${provider}`);
|
|
290
|
+
}
|
|
291
|
+
else {
|
|
292
|
+
const field = provider === 'animeskip' ? 'client_id' : 'api_key';
|
|
293
|
+
const value = String(args.api_key ?? '');
|
|
294
|
+
if (!value.trim())
|
|
295
|
+
throw new NuvioError('Credential value cannot be empty');
|
|
296
|
+
const entry = { provider, credential_json: { [field]: value.trim() } };
|
|
297
|
+
const index = list.findIndex((c) => c.provider === provider);
|
|
298
|
+
if (index >= 0)
|
|
299
|
+
list[index] = entry;
|
|
300
|
+
else
|
|
301
|
+
list.push(entry);
|
|
302
|
+
diff.push(`~ set credential for ${provider}`);
|
|
303
|
+
}
|
|
304
|
+
return { state: list, diff };
|
|
305
|
+
},
|
|
306
|
+
write: async (client, ref, state, _meta, originId) => {
|
|
307
|
+
const profileId = ref.profile_id;
|
|
308
|
+
const before = (await readResource(client, ref));
|
|
309
|
+
const wanted = new Set(state.map((c) => c.provider));
|
|
310
|
+
const rows = state;
|
|
311
|
+
let writeAttempted = false;
|
|
312
|
+
try {
|
|
313
|
+
for (const cred of before) {
|
|
314
|
+
if (!wanted.has(cred.provider)) {
|
|
315
|
+
writeAttempted = true;
|
|
316
|
+
await client.rpc('sync_delete_provider_credentials', {
|
|
317
|
+
p_profile_id: profileId,
|
|
318
|
+
p_provider: cred.provider,
|
|
319
|
+
p_origin_client_id: originId,
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
if (rows.length > 0) {
|
|
324
|
+
writeAttempted = true;
|
|
325
|
+
await client.rpc('sync_push_provider_credentials', {
|
|
326
|
+
p_profile_id: profileId,
|
|
327
|
+
p_credentials: rows,
|
|
328
|
+
p_origin_client_id: originId,
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
catch (error) {
|
|
333
|
+
throw new WriteFailure(error instanceof Error ? error.message : String(error), {
|
|
334
|
+
mayHaveApplied: writeAttempted,
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
},
|
|
338
|
+
};
|
|
339
|
+
const libraryDescriptor = {
|
|
340
|
+
schema: libraryAddShape,
|
|
341
|
+
ref: (args) => ({ kind: 'library', profile_id: args.profile_id }),
|
|
342
|
+
scope: (args) => {
|
|
343
|
+
const tool = String(args.__tool);
|
|
344
|
+
const items = (tool === 'nuvio_add_to_library' ? args.items : args.keys);
|
|
345
|
+
return items.map((i) => ({ content_id: i.content_id, content_type: i.content_type }));
|
|
346
|
+
},
|
|
347
|
+
plan: (state, args, now) => {
|
|
348
|
+
const tool = String(args.__tool);
|
|
349
|
+
const list = state;
|
|
350
|
+
const t = tool === 'nuvio_add_to_library'
|
|
351
|
+
? planLibraryAdd(list, args.items, now)
|
|
352
|
+
: tool === 'nuvio_remove_from_library'
|
|
353
|
+
? planLibraryRemove(list, args.keys)
|
|
354
|
+
: null;
|
|
355
|
+
if (!t)
|
|
356
|
+
throw new NuvioError(`Unsupported library operation: ${tool}`);
|
|
357
|
+
return { state: t.after, diff: t.diff };
|
|
358
|
+
throw new NuvioError(`Unsupported library operation: ${tool}`);
|
|
359
|
+
},
|
|
360
|
+
write: async (client, ref, state, _meta, originId) => {
|
|
361
|
+
await pushList(client, ref, (await readResource(client, ref)), state, (i) => libraryKeyOf(i), 'sync_push_library_items', 'p_items', 'sync_delete_library_items', 'p_keys', (i) => ({ content_id: i.content_id, content_type: i.content_type }), originId);
|
|
362
|
+
},
|
|
363
|
+
};
|
|
364
|
+
const progressDescriptor = {
|
|
365
|
+
schema: progressSetShape,
|
|
366
|
+
ref: (args) => ({ kind: 'watch_progress', profile_id: args.profile_id }),
|
|
367
|
+
scope: (args) => {
|
|
368
|
+
const tool = String(args.__tool);
|
|
369
|
+
const items = (tool === 'nuvio_set_watch_progress' ? args.entries : args.keys);
|
|
370
|
+
return items.map((i) => progressKeyOf(i));
|
|
371
|
+
},
|
|
372
|
+
plan: (state, args, now) => {
|
|
373
|
+
const tool = String(args.__tool);
|
|
374
|
+
const list = state;
|
|
375
|
+
const t = tool === 'nuvio_set_watch_progress'
|
|
376
|
+
? planProgressSet(list, args.entries, now)
|
|
377
|
+
: tool === 'nuvio_delete_watch_progress'
|
|
378
|
+
? planProgressDelete(list, args.keys)
|
|
379
|
+
: null;
|
|
380
|
+
if (!t)
|
|
381
|
+
throw new NuvioError(`Unsupported watch progress operation: ${tool}`);
|
|
382
|
+
return { state: t.after, diff: t.diff };
|
|
383
|
+
throw new NuvioError(`Unsupported watch progress operation: ${tool}`);
|
|
384
|
+
},
|
|
385
|
+
write: async (client, ref, state, _meta, originId) => {
|
|
386
|
+
await pushList(client, ref, (await readResource(client, ref)), state, (i) => storedProgressKey(i), 'sync_push_watch_progress', 'p_entries', 'sync_delete_watch_progress', 'p_keys', (i) => storedProgressKey(i), originId);
|
|
387
|
+
},
|
|
388
|
+
};
|
|
389
|
+
const historyDescriptor = {
|
|
390
|
+
schema: historyAddShape,
|
|
391
|
+
ref: (args) => ({ kind: 'watch_history', profile_id: args.profile_id }),
|
|
392
|
+
scope: (args) => {
|
|
393
|
+
const tool = String(args.__tool);
|
|
394
|
+
const items = (tool === 'nuvio_add_to_watch_history' ? args.items : args.keys);
|
|
395
|
+
return items.map((i) => ({
|
|
396
|
+
content_id: i.content_id,
|
|
397
|
+
season: i.season ?? null,
|
|
398
|
+
episode: i.episode ?? null,
|
|
399
|
+
}));
|
|
400
|
+
},
|
|
401
|
+
plan: (state, args, now) => {
|
|
402
|
+
const tool = String(args.__tool);
|
|
403
|
+
const list = state;
|
|
404
|
+
const t = tool === 'nuvio_add_to_watch_history'
|
|
405
|
+
? planHistoryAdd(list, args.items, now)
|
|
406
|
+
: tool === 'nuvio_delete_watch_history'
|
|
407
|
+
? planHistoryDelete(list, args.keys)
|
|
408
|
+
: null;
|
|
409
|
+
if (!t)
|
|
410
|
+
throw new NuvioError(`Unsupported watch history operation: ${tool}`);
|
|
411
|
+
return { state: t.after, diff: t.diff };
|
|
412
|
+
throw new NuvioError(`Unsupported watch history operation: ${tool}`);
|
|
413
|
+
},
|
|
414
|
+
write: async (client, ref, state, _meta, originId) => {
|
|
415
|
+
await pushList(client, ref, (await readResource(client, ref)), state, (i) => `${i.content_id}|${i.season ?? -1}|${i.episode ?? -1}`, 'sync_push_watched_items', 'p_items', 'sync_delete_watched_items', 'p_keys', (i) => ({ content_id: i.content_id, season: i.season ?? null, episode: i.episode ?? null }), originId);
|
|
416
|
+
},
|
|
417
|
+
};
|
|
418
|
+
const DESCRIPTORS = {
|
|
419
|
+
nuvio_update_settings: settingsDescriptor,
|
|
420
|
+
nuvio_update_home_catalog_settings: homeDescriptor,
|
|
421
|
+
nuvio_add_addon: addonsDescriptor,
|
|
422
|
+
nuvio_update_addon: addonsDescriptor,
|
|
423
|
+
nuvio_reorder_addons: addonsDescriptor,
|
|
424
|
+
nuvio_remove_addon: addonsDescriptor,
|
|
425
|
+
nuvio_set_provider_credential: providersDescriptor,
|
|
426
|
+
nuvio_delete_provider_credential: providersDescriptor,
|
|
427
|
+
nuvio_add_to_library: libraryDescriptor,
|
|
428
|
+
nuvio_remove_from_library: libraryDescriptor,
|
|
429
|
+
nuvio_set_watch_progress: progressDescriptor,
|
|
430
|
+
nuvio_delete_watch_progress: progressDescriptor,
|
|
431
|
+
nuvio_add_to_watch_history: historyDescriptor,
|
|
432
|
+
nuvio_delete_watch_history: historyDescriptor,
|
|
433
|
+
};
|
|
434
|
+
const SCHEMA_OVERRIDES = {
|
|
435
|
+
nuvio_add_addon: addonAddShape,
|
|
436
|
+
nuvio_update_addon: addonUpdateShape,
|
|
437
|
+
nuvio_reorder_addons: addonReorderShape,
|
|
438
|
+
nuvio_remove_addon: addonRemoveShape,
|
|
439
|
+
nuvio_delete_provider_credential: providerDeleteShape,
|
|
440
|
+
nuvio_remove_from_library: libraryRemoveShape,
|
|
441
|
+
nuvio_delete_watch_progress: progressDeleteShape,
|
|
442
|
+
nuvio_delete_watch_history: historyDeleteShape,
|
|
443
|
+
};
|
|
444
|
+
/** Per-tool validation override (e.g. reorder has no url/id target to assert). */
|
|
445
|
+
const VALIDATE_OVERRIDES = new Map([
|
|
446
|
+
['nuvio_reorder_addons', null],
|
|
447
|
+
]);
|
|
448
|
+
function validateFor(tool, descriptor, parsed) {
|
|
449
|
+
if (VALIDATE_OVERRIDES.has(tool)) {
|
|
450
|
+
VALIDATE_OVERRIDES.get(tool)?.(parsed);
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
descriptor.validate?.(parsed);
|
|
454
|
+
}
|
|
455
|
+
function parseArgs(tool, args) {
|
|
456
|
+
return z.object(planSchemaFor(tool)).parse(args);
|
|
457
|
+
}
|
|
458
|
+
export function planSchemaFor(tool) {
|
|
459
|
+
return SCHEMA_OVERRIDES[tool] ?? DESCRIPTORS[tool]?.schema;
|
|
460
|
+
}
|
|
461
|
+
const IRREVERSIBLE = new Set([
|
|
462
|
+
'nuvio_delete_profile',
|
|
463
|
+
'nuvio_restore_backup',
|
|
464
|
+
'nuvio_revoke_session',
|
|
465
|
+
'nuvio_set_profile_pin',
|
|
466
|
+
'nuvio_clear_profile_pin',
|
|
467
|
+
'nuvio_register_device',
|
|
468
|
+
]);
|
|
469
|
+
export function isPlanSupported(tool) {
|
|
470
|
+
return tool in DESCRIPTORS;
|
|
471
|
+
}
|
|
472
|
+
export function isPlanIrreversible(tool) {
|
|
473
|
+
return IRREVERSIBLE.has(tool);
|
|
474
|
+
}
|
|
475
|
+
export async function applyPlan(operations, dryRun, deps) {
|
|
476
|
+
const { client } = deps;
|
|
477
|
+
const planNow = Math.floor(Date.now() / 1000);
|
|
478
|
+
const perOp = [];
|
|
479
|
+
const states = new Map();
|
|
480
|
+
const order = [];
|
|
481
|
+
let current;
|
|
482
|
+
try {
|
|
483
|
+
if (operations.length === 0)
|
|
484
|
+
throw new NuvioError('apply_plan: at least one operation is required');
|
|
485
|
+
operations.forEach((op, index) => {
|
|
486
|
+
current = { index, tool: op.tool };
|
|
487
|
+
if (isPlanIrreversible(op.tool)) {
|
|
488
|
+
throw new NuvioError(`apply_plan: "${op.tool}" is irreversible and cannot be part of a plan.`);
|
|
489
|
+
}
|
|
490
|
+
const descriptor = DESCRIPTORS[op.tool];
|
|
491
|
+
if (!descriptor) {
|
|
492
|
+
throw new NuvioError(`apply_plan: "${op.tool}" is not supported by a plan (canonical plan-supported tools only).`);
|
|
493
|
+
}
|
|
494
|
+
const parsed = parseArgs(op.tool, op.args);
|
|
495
|
+
validateFor(op.tool, descriptor, parsed);
|
|
496
|
+
const key = resourceKey(descriptor.ref(parsed));
|
|
497
|
+
if (!states.has(key)) {
|
|
498
|
+
states.set(key, {
|
|
499
|
+
ref: descriptor.ref(parsed),
|
|
500
|
+
key,
|
|
501
|
+
before: undefined,
|
|
502
|
+
after: undefined,
|
|
503
|
+
scope: undefined,
|
|
504
|
+
meta: {},
|
|
505
|
+
});
|
|
506
|
+
order.push(key);
|
|
507
|
+
}
|
|
508
|
+
});
|
|
509
|
+
for (const key of order) {
|
|
510
|
+
const entry = states.get(key);
|
|
511
|
+
entry.before = await readResource(client, entry.ref);
|
|
512
|
+
entry.after = entry.before;
|
|
513
|
+
const descriptor = descriptorFor(entry.ref, operations);
|
|
514
|
+
if (descriptor.readMeta)
|
|
515
|
+
entry.meta = await descriptor.readMeta(client, entry.ref);
|
|
516
|
+
}
|
|
517
|
+
operations.forEach((op, index) => {
|
|
518
|
+
current = { index, tool: op.tool };
|
|
519
|
+
const descriptor = DESCRIPTORS[op.tool];
|
|
520
|
+
const parsed = parseArgs(op.tool, op.args);
|
|
521
|
+
const key = resourceKey(descriptor.ref(parsed));
|
|
522
|
+
const entry = states.get(key);
|
|
523
|
+
const result = descriptor.plan(entry.after, { ...parsed, __tool: op.tool }, planNow, entry.meta);
|
|
524
|
+
entry.after = result.state;
|
|
525
|
+
if (descriptor.scope)
|
|
526
|
+
entry.scope = unionScope(entry.scope, descriptor.scope({ ...parsed, __tool: op.tool }));
|
|
527
|
+
perOp.push({ index, tool: op.tool, resource: key, diff: result.diff });
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
catch (error) {
|
|
531
|
+
return {
|
|
532
|
+
status: 'failed_before_apply',
|
|
533
|
+
dry_run: dryRun,
|
|
534
|
+
operations: perOp,
|
|
535
|
+
resources: [],
|
|
536
|
+
applied_operations: [],
|
|
537
|
+
attempted_resources: [],
|
|
538
|
+
completed_resources: [],
|
|
539
|
+
failed_operation: current
|
|
540
|
+
? {
|
|
541
|
+
index: current.index,
|
|
542
|
+
tool: current.tool,
|
|
543
|
+
error: error instanceof Error ? error.message : String(error),
|
|
544
|
+
}
|
|
545
|
+
: { index: 0, tool: 'unknown', error: error instanceof Error ? error.message : String(error) },
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
const resources = order.map((key) => {
|
|
549
|
+
const entry = states.get(key);
|
|
550
|
+
return { resource: key, diff: diffTree(entry.before, entry.after) };
|
|
551
|
+
});
|
|
552
|
+
const changed = resources.some((r) => r.diff.length > 0);
|
|
553
|
+
if (dryRun || !changed) {
|
|
554
|
+
return {
|
|
555
|
+
status: 'preview',
|
|
556
|
+
dry_run: true,
|
|
557
|
+
operations: perOp,
|
|
558
|
+
resources,
|
|
559
|
+
applied_operations: [],
|
|
560
|
+
attempted_resources: [],
|
|
561
|
+
completed_resources: [],
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
const entries = order.map((key) => {
|
|
565
|
+
const e = states.get(key);
|
|
566
|
+
return { resource: e.ref, before: e.before, scope: e.scope };
|
|
567
|
+
});
|
|
568
|
+
const snapshotId = deps.captureComposite(entries);
|
|
569
|
+
const applied = [];
|
|
570
|
+
const attemptedResources = [];
|
|
571
|
+
const completedResources = [];
|
|
572
|
+
let writePhaseStarted = false;
|
|
573
|
+
let failedOperation;
|
|
574
|
+
let failedMayHaveApplied = false;
|
|
575
|
+
let failedConflict = false;
|
|
576
|
+
for (const key of order) {
|
|
577
|
+
const entry = states.get(key);
|
|
578
|
+
const descriptor = descriptorFor(entry.ref, operations);
|
|
579
|
+
const representative = perOp.find((op) => op.resource === key);
|
|
580
|
+
writePhaseStarted = true;
|
|
581
|
+
attemptedResources.push(key);
|
|
582
|
+
try {
|
|
583
|
+
const metaPatch = await descriptor.write(client, entry.ref, entry.after, entry.meta, deps.originId);
|
|
584
|
+
if (metaPatch)
|
|
585
|
+
Object.assign(entry.meta, metaPatch);
|
|
586
|
+
completedResources.push(key);
|
|
587
|
+
for (const op of perOp)
|
|
588
|
+
if (op.resource === key)
|
|
589
|
+
applied.push(op.index);
|
|
590
|
+
}
|
|
591
|
+
catch (error) {
|
|
592
|
+
failedOperation = {
|
|
593
|
+
index: representative.index,
|
|
594
|
+
tool: representative.tool,
|
|
595
|
+
error: error instanceof Error ? error.message : String(error),
|
|
596
|
+
};
|
|
597
|
+
failedMayHaveApplied = error instanceof WriteFailure ? error.opts.mayHaveApplied : true;
|
|
598
|
+
failedConflict = error instanceof WriteFailure ? Boolean(error.opts.conflict) : false;
|
|
599
|
+
break;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
if (!failedOperation) {
|
|
603
|
+
return {
|
|
604
|
+
status: 'applied',
|
|
605
|
+
dry_run: false,
|
|
606
|
+
operations: perOp,
|
|
607
|
+
resources,
|
|
608
|
+
applied_operations: applied,
|
|
609
|
+
attempted_resources: attemptedResources,
|
|
610
|
+
completed_resources: completedResources,
|
|
611
|
+
snapshot_id: snapshotId,
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
const failedKey = attemptedResources[attemptedResources.length - 1];
|
|
615
|
+
const rollbackKeys = order.filter((key) => {
|
|
616
|
+
if (completedResources.includes(key))
|
|
617
|
+
return true;
|
|
618
|
+
if (key === failedKey)
|
|
619
|
+
return failedMayHaveApplied && !failedConflict;
|
|
620
|
+
return false;
|
|
621
|
+
});
|
|
622
|
+
let rollback;
|
|
623
|
+
if (deps.snapshotsDisabled) {
|
|
624
|
+
rollback = {
|
|
625
|
+
attempted: false,
|
|
626
|
+
successful: false,
|
|
627
|
+
detail: 'Snapshots are disabled; no rollback was attempted and the final state is unknown.',
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
else if (failedConflict && rollbackKeys.length === 0) {
|
|
631
|
+
rollback = {
|
|
632
|
+
attempted: false,
|
|
633
|
+
successful: false,
|
|
634
|
+
detail: 'Concurrent change detected; rollback skipped to avoid overwriting the newer state.',
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
else if (!writePhaseStarted) {
|
|
638
|
+
rollback = { attempted: false, successful: false, detail: 'No write was attempted.' };
|
|
639
|
+
}
|
|
640
|
+
else {
|
|
641
|
+
const outcomes = [];
|
|
642
|
+
for (const key of rollbackKeys) {
|
|
643
|
+
const entry = states.get(key);
|
|
644
|
+
const descriptor = descriptorFor(entry.ref, operations);
|
|
645
|
+
const snapshotEntry = {
|
|
646
|
+
resource: entry.ref,
|
|
647
|
+
before: entry.before,
|
|
648
|
+
scope: entry.scope,
|
|
649
|
+
};
|
|
650
|
+
try {
|
|
651
|
+
if (descriptor.rollback && descriptor.guardedRollback?.(entry.meta)) {
|
|
652
|
+
await descriptor.rollback(client, entry.ref, entry.before, entry.meta, deps.originId);
|
|
653
|
+
}
|
|
654
|
+
else if (completedResources.includes(key)) {
|
|
655
|
+
// No guarded RPC for this resource: only restore if nobody changed it
|
|
656
|
+
// since we wrote it, otherwise a blind rollback would clobber the
|
|
657
|
+
// concurrent change.
|
|
658
|
+
const current = await readResource(client, entry.ref);
|
|
659
|
+
if (JSON.stringify(canonical(current)) !== JSON.stringify(canonical(entry.after))) {
|
|
660
|
+
outcomes.push({
|
|
661
|
+
ok: false,
|
|
662
|
+
detail: `fail ${key}: changed concurrently; restore skipped to avoid overwriting`,
|
|
663
|
+
});
|
|
664
|
+
continue;
|
|
665
|
+
}
|
|
666
|
+
await deps.restoreEntry(snapshotEntry);
|
|
667
|
+
}
|
|
668
|
+
else {
|
|
669
|
+
await deps.restoreEntry(snapshotEntry);
|
|
670
|
+
}
|
|
671
|
+
outcomes.push({ ok: true, detail: `ok ${key}` });
|
|
672
|
+
}
|
|
673
|
+
catch (error) {
|
|
674
|
+
outcomes.push({
|
|
675
|
+
ok: false,
|
|
676
|
+
detail: `fail ${key}: ${error instanceof Error ? error.message : String(error)}`,
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
const ok = outcomes.every((o) => o.ok);
|
|
681
|
+
rollback = { attempted: true, successful: ok, detail: outcomes.map((o) => o.detail).join(', ') };
|
|
682
|
+
}
|
|
683
|
+
let status;
|
|
684
|
+
if (!writePhaseStarted)
|
|
685
|
+
status = 'failed_before_apply';
|
|
686
|
+
else if (deps.snapshotsDisabled)
|
|
687
|
+
status = 'partially_applied';
|
|
688
|
+
else if (failedConflict && rollbackKeys.length === 0)
|
|
689
|
+
status = 'partially_applied';
|
|
690
|
+
else if (rollback.successful)
|
|
691
|
+
status = 'rolled_back';
|
|
692
|
+
else
|
|
693
|
+
status = 'partially_applied';
|
|
694
|
+
return {
|
|
695
|
+
status,
|
|
696
|
+
dry_run: false,
|
|
697
|
+
operations: perOp,
|
|
698
|
+
resources,
|
|
699
|
+
applied_operations: applied,
|
|
700
|
+
attempted_resources: attemptedResources,
|
|
701
|
+
completed_resources: completedResources,
|
|
702
|
+
failed_operation: failedOperation,
|
|
703
|
+
rollback,
|
|
704
|
+
snapshot_id: snapshotId,
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
function unionScope(existing, next) {
|
|
708
|
+
const base = Array.isArray(existing) ? existing : [];
|
|
709
|
+
const seen = new Set(base.map((k) => JSON.stringify(k)));
|
|
710
|
+
const out = [...base];
|
|
711
|
+
for (const item of next) {
|
|
712
|
+
const key = JSON.stringify(item);
|
|
713
|
+
if (!seen.has(key)) {
|
|
714
|
+
seen.add(key);
|
|
715
|
+
out.push(item);
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
return out;
|
|
719
|
+
}
|
|
720
|
+
function descriptorFor(ref, operations) {
|
|
721
|
+
const key = resourceKey(ref);
|
|
722
|
+
for (const op of operations) {
|
|
723
|
+
const d = DESCRIPTORS[op.tool];
|
|
724
|
+
if (d && resourceKey(d.ref(parseArgs(op.tool, op.args))) === key)
|
|
725
|
+
return d;
|
|
726
|
+
}
|
|
727
|
+
throw new NuvioError(`apply_plan: no descriptor for resource ${key}`);
|
|
728
|
+
}
|