@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,127 @@
|
|
|
1
|
+
import { NuvioError } from '../errors.js';
|
|
2
|
+
function assertHttpUrl(url) {
|
|
3
|
+
let parsed;
|
|
4
|
+
try {
|
|
5
|
+
parsed = new URL(url);
|
|
6
|
+
}
|
|
7
|
+
catch {
|
|
8
|
+
throw new NuvioError(`Invalid plugin URL: ${url}`);
|
|
9
|
+
}
|
|
10
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
11
|
+
throw new NuvioError(`Plugin URL must be http(s): ${url}`);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export async function listPlugins(client, profileId) {
|
|
15
|
+
return client.select('plugins', `select=id,user_id,profile_id,url,name,enabled,sort_order,repo_type,created_at,updated_at` +
|
|
16
|
+
`&profile_id=eq.${profileId}&order=sort_order.asc`);
|
|
17
|
+
}
|
|
18
|
+
function toPushShape(plugins) {
|
|
19
|
+
return plugins.map((p) => ({
|
|
20
|
+
url: p.url,
|
|
21
|
+
name: p.name,
|
|
22
|
+
enabled: p.enabled,
|
|
23
|
+
sort_order: p.sort_order,
|
|
24
|
+
repo_type: p.repo_type,
|
|
25
|
+
}));
|
|
26
|
+
}
|
|
27
|
+
function diffPlugins(before, after) {
|
|
28
|
+
const b = new Map(before.map((p) => [p.url, p]));
|
|
29
|
+
const a = new Map(after.map((p) => [p.url, p]));
|
|
30
|
+
const diff = [];
|
|
31
|
+
for (const [url, p] of a) {
|
|
32
|
+
const prev = b.get(url);
|
|
33
|
+
if (!prev)
|
|
34
|
+
diff.push(`+ plugin ${url} (enabled=${p.enabled})`);
|
|
35
|
+
else {
|
|
36
|
+
if (prev.enabled !== p.enabled)
|
|
37
|
+
diff.push(`~ plugin ${url} enabled ${prev.enabled} -> ${p.enabled}`);
|
|
38
|
+
if (prev.name !== p.name)
|
|
39
|
+
diff.push(`~ plugin ${url} name ${prev.name ?? 'null'} -> ${p.name ?? 'null'}`);
|
|
40
|
+
if (prev.sort_order !== p.sort_order)
|
|
41
|
+
diff.push(`~ plugin ${url} order ${prev.sort_order} -> ${p.sort_order}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
for (const url of b.keys())
|
|
45
|
+
if (!a.has(url))
|
|
46
|
+
diff.push(`- plugin ${url}`);
|
|
47
|
+
return diff.sort();
|
|
48
|
+
}
|
|
49
|
+
async function commit(client, profileId, originId, before, after, apply) {
|
|
50
|
+
const diff = diffPlugins(before, after);
|
|
51
|
+
if (apply && diff.length > 0) {
|
|
52
|
+
await client.rpc('sync_push_plugins', {
|
|
53
|
+
p_profile_id: profileId,
|
|
54
|
+
p_plugins: after,
|
|
55
|
+
p_origin_client_id: originId,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
return { applied: apply && diff.length > 0, changed: diff.length > 0, before, after, diff };
|
|
59
|
+
}
|
|
60
|
+
export async function addPlugin(client, profileId, input, originId, apply) {
|
|
61
|
+
assertHttpUrl(input.url);
|
|
62
|
+
const before = toPushShape(await listPlugins(client, profileId));
|
|
63
|
+
if (before.some((p) => p.url === input.url))
|
|
64
|
+
throw new NuvioError(`Plugin already installed: ${input.url}`);
|
|
65
|
+
const after = [
|
|
66
|
+
...before,
|
|
67
|
+
{
|
|
68
|
+
url: input.url,
|
|
69
|
+
name: input.name ?? null,
|
|
70
|
+
enabled: input.enabled ?? true,
|
|
71
|
+
sort_order: input.sort_order ?? before.reduce((m, p) => Math.max(m, p.sort_order + 1), 0),
|
|
72
|
+
repo_type: input.repo_type ?? null,
|
|
73
|
+
},
|
|
74
|
+
];
|
|
75
|
+
if (after.length !== before.length + 1)
|
|
76
|
+
throw new NuvioError('Internal invariant failed (plugin add)');
|
|
77
|
+
return commit(client, profileId, originId, before, after, apply);
|
|
78
|
+
}
|
|
79
|
+
export async function removePlugin(client, profileId, match, originId, apply) {
|
|
80
|
+
const rows = await listPlugins(client, profileId);
|
|
81
|
+
const target = rows.find((p) => (match.url ? p.url === match.url : p.id === match.id));
|
|
82
|
+
if (!target)
|
|
83
|
+
throw new NuvioError('Plugin not found on profile');
|
|
84
|
+
const before = toPushShape(rows);
|
|
85
|
+
const after = before.filter((p) => p.url !== target.url);
|
|
86
|
+
if (after.length !== before.length - 1)
|
|
87
|
+
throw new NuvioError('Internal invariant failed (plugin remove)');
|
|
88
|
+
return commit(client, profileId, originId, before, after, apply);
|
|
89
|
+
}
|
|
90
|
+
/** Change a plugin's name, enabled flag, repo type or sort order. Identify it by url or table id. */
|
|
91
|
+
export async function updatePlugin(client, profileId, match, changes, originId, apply) {
|
|
92
|
+
const rows = await listPlugins(client, profileId);
|
|
93
|
+
const target = rows.find((p) => (match.url ? p.url === match.url : p.id === match.id));
|
|
94
|
+
if (!target)
|
|
95
|
+
throw new NuvioError(`Plugin not found on profile ${profileId}`);
|
|
96
|
+
const before = toPushShape(rows);
|
|
97
|
+
const after = before.map((p) => p.url === target.url
|
|
98
|
+
? {
|
|
99
|
+
url: p.url,
|
|
100
|
+
name: changes.name !== undefined ? changes.name : p.name,
|
|
101
|
+
enabled: changes.enabled !== undefined ? changes.enabled : p.enabled,
|
|
102
|
+
sort_order: changes.sort_order !== undefined ? changes.sort_order : p.sort_order,
|
|
103
|
+
repo_type: changes.repo_type !== undefined ? changes.repo_type : p.repo_type,
|
|
104
|
+
}
|
|
105
|
+
: p);
|
|
106
|
+
return commit(client, profileId, originId, before, after, apply);
|
|
107
|
+
}
|
|
108
|
+
export async function togglePlugin(client, profileId, url, enabled, originId, apply) {
|
|
109
|
+
const before = toPushShape(await listPlugins(client, profileId));
|
|
110
|
+
if (!before.some((p) => p.url === url))
|
|
111
|
+
throw new NuvioError(`Plugin not found: ${url}`);
|
|
112
|
+
const after = before.map((p) => (p.url === url ? { ...p, enabled } : p));
|
|
113
|
+
return commit(client, profileId, originId, before, after, apply);
|
|
114
|
+
}
|
|
115
|
+
export async function reorderPlugins(client, profileId, orderedUrls, originId, apply) {
|
|
116
|
+
const before = toPushShape(await listPlugins(client, profileId));
|
|
117
|
+
const current = before.map((p) => p.url).sort();
|
|
118
|
+
const wanted = [...orderedUrls].sort();
|
|
119
|
+
if (current.length !== wanted.length || current.some((u, i) => u !== wanted[i])) {
|
|
120
|
+
throw new NuvioError('Reorder must list every installed plugin URL exactly once.');
|
|
121
|
+
}
|
|
122
|
+
const after = orderedUrls.map((url, index) => ({
|
|
123
|
+
...before.find((p) => p.url === url),
|
|
124
|
+
sort_order: index,
|
|
125
|
+
}));
|
|
126
|
+
return commit(client, profileId, originId, before, after, apply);
|
|
127
|
+
}
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
import { NuvioError } from '../errors.js';
|
|
2
|
+
import { deepMerge, writeSettings } from './settings.js';
|
|
3
|
+
export const SETUP_PLATFORMS = ['tv', 'mobile', 'desktop'];
|
|
4
|
+
const DEFAULT_MAPPINGS = SETUP_PLATFORMS.map((p) => ({ from: p, to: p }));
|
|
5
|
+
async function readPlatformSettings(client, profileId, platform) {
|
|
6
|
+
const rows = await client.readRpc('sync_pull_profile_settings_blob', { p_profile_id: profileId, p_platform: platform });
|
|
7
|
+
return {
|
|
8
|
+
json: rows[0]?.settings_json ?? null,
|
|
9
|
+
updatedAt: rows[0]?.updated_at ?? null,
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
async function readCredentials(client, profileId) {
|
|
13
|
+
return client.readRpc('sync_pull_provider_credentials', { p_profile_id: profileId });
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Copy a profile's setup to another profile: per-platform settings (deep-merged
|
|
17
|
+
* or replaced) and optionally provider credentials (merged or replaced).
|
|
18
|
+
*
|
|
19
|
+
* Transactional: every source/target state is read up front; on any write
|
|
20
|
+
* failure the already-written platforms/credentials are rolled back. Settings
|
|
21
|
+
* use the guarded writer so a concurrent change on the target is not clobbered.
|
|
22
|
+
*/
|
|
23
|
+
export async function copySetup(client, input, originId, apply) {
|
|
24
|
+
const source = input.source_profile_id;
|
|
25
|
+
const target = input.target_profile_id;
|
|
26
|
+
if (source === target && !input.platforms?.some((m) => m.from !== m.to)) {
|
|
27
|
+
return { applied: false, changed: false, before: {}, after: {}, diff: [] };
|
|
28
|
+
}
|
|
29
|
+
const mappings = input.platforms?.length ? input.platforms : DEFAULT_MAPPINGS;
|
|
30
|
+
const mode = input.settings_mode ?? 'merge';
|
|
31
|
+
const credMode = input.provider_credentials ?? 'none';
|
|
32
|
+
const before = { settings: {}, provider_credentials: [] };
|
|
33
|
+
const after = { settings: {}, provider_credentials: [] };
|
|
34
|
+
const diff = [];
|
|
35
|
+
// Read every source and target state BEFORE writing anything, so a chain like
|
|
36
|
+
// tv -> mobile, mobile -> desktop still uses the original mobile source state.
|
|
37
|
+
const sourceStates = new Map();
|
|
38
|
+
const targetStates = new Map();
|
|
39
|
+
for (const mapping of mappings) {
|
|
40
|
+
if (!sourceStates.has(mapping.from))
|
|
41
|
+
sourceStates.set(mapping.from, await readPlatformSettings(client, source, mapping.from));
|
|
42
|
+
if (!targetStates.has(mapping.to))
|
|
43
|
+
targetStates.set(mapping.to, await readPlatformSettings(client, target, mapping.to));
|
|
44
|
+
}
|
|
45
|
+
const desired = new Map();
|
|
46
|
+
for (const mapping of mappings) {
|
|
47
|
+
const src = sourceStates.get(mapping.from);
|
|
48
|
+
const tgt = targetStates.get(mapping.to);
|
|
49
|
+
before.settings[mapping.to] = tgt.json;
|
|
50
|
+
if (src.json === null)
|
|
51
|
+
continue;
|
|
52
|
+
const next = mode === 'replace' ? src.json : deepMerge(tgt.json ?? {}, src.json);
|
|
53
|
+
desired.set(mapping.to, { before: tgt, after: next });
|
|
54
|
+
after.settings[mapping.to] = next;
|
|
55
|
+
if (JSON.stringify(tgt.json) !== JSON.stringify(next)) {
|
|
56
|
+
diff.push(`~ ${mapping.from} -> ${mapping.to} settings (${mode}) on profile ${target}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
let targetCredsBefore = [];
|
|
60
|
+
const pushCredentials = [];
|
|
61
|
+
const deleteProviders = [];
|
|
62
|
+
if (credMode !== 'none') {
|
|
63
|
+
const srcCreds = await readCredentials(client, source);
|
|
64
|
+
const tgtCreds = await readCredentials(client, target);
|
|
65
|
+
targetCredsBefore = tgtCreds;
|
|
66
|
+
before.provider_credentials = tgtCreds;
|
|
67
|
+
if (credMode === 'replace') {
|
|
68
|
+
const wanted = new Set(srcCreds.map((c) => c.provider));
|
|
69
|
+
for (const cred of tgtCreds)
|
|
70
|
+
if (!wanted.has(cred.provider))
|
|
71
|
+
deleteProviders.push(cred.provider);
|
|
72
|
+
}
|
|
73
|
+
for (const cred of srcCreds)
|
|
74
|
+
pushCredentials.push(cred);
|
|
75
|
+
after.provider_credentials = srcCreds;
|
|
76
|
+
if (pushCredentials.length > 0) {
|
|
77
|
+
diff.push(`~ provider credentials (${credMode}): ${pushCredentials.map((c) => c.provider).join(', ')}`);
|
|
78
|
+
}
|
|
79
|
+
for (const provider of deleteProviders)
|
|
80
|
+
diff.push(`- provider credential ${provider}`);
|
|
81
|
+
}
|
|
82
|
+
if (!apply || diff.length === 0) {
|
|
83
|
+
return { applied: apply && diff.length > 0, changed: diff.length > 0, before, after, diff };
|
|
84
|
+
}
|
|
85
|
+
const toWrite = [...desired.entries()].filter(([, d]) => JSON.stringify(d.before.json) !== JSON.stringify(d.after));
|
|
86
|
+
const writtenPlatforms = [];
|
|
87
|
+
let credentialsTouched = false;
|
|
88
|
+
const restoreProviders = async () => {
|
|
89
|
+
const beforeProviders = new Set(targetCredsBefore.map((c) => c.provider));
|
|
90
|
+
for (const cred of pushCredentials) {
|
|
91
|
+
if (!beforeProviders.has(cred.provider)) {
|
|
92
|
+
await client.rpc('sync_delete_provider_credentials', {
|
|
93
|
+
p_profile_id: target,
|
|
94
|
+
p_provider: cred.provider,
|
|
95
|
+
p_origin_client_id: originId,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (targetCredsBefore.length > 0) {
|
|
100
|
+
await client.rpc('sync_push_provider_credentials', {
|
|
101
|
+
p_profile_id: target,
|
|
102
|
+
p_credentials: targetCredsBefore.map((c) => ({
|
|
103
|
+
provider: c.provider,
|
|
104
|
+
credential_json: c.credential_json,
|
|
105
|
+
})),
|
|
106
|
+
p_origin_client_id: originId,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
try {
|
|
111
|
+
for (const [platform, d] of toWrite) {
|
|
112
|
+
const result = await writeSettings(client, target, platform, d.after, d.before.updatedAt, originId);
|
|
113
|
+
writtenPlatforms.push({
|
|
114
|
+
platform,
|
|
115
|
+
revision: result.revision,
|
|
116
|
+
guarded: result.guarded,
|
|
117
|
+
beforeJson: d.before.json,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
if (deleteProviders.length > 0 || pushCredentials.length > 0) {
|
|
121
|
+
credentialsTouched = true;
|
|
122
|
+
for (const provider of deleteProviders) {
|
|
123
|
+
await client.rpc('sync_delete_provider_credentials', {
|
|
124
|
+
p_profile_id: target,
|
|
125
|
+
p_provider: provider,
|
|
126
|
+
p_origin_client_id: originId,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
if (pushCredentials.length > 0) {
|
|
130
|
+
await client.rpc('sync_push_provider_credentials', {
|
|
131
|
+
p_profile_id: target,
|
|
132
|
+
p_credentials: pushCredentials,
|
|
133
|
+
p_origin_client_id: originId,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
const original = error instanceof Error ? error.message : String(error);
|
|
140
|
+
const failures = [];
|
|
141
|
+
for (const w of [...writtenPlatforms].reverse()) {
|
|
142
|
+
try {
|
|
143
|
+
if (w.guarded && w.revision) {
|
|
144
|
+
await writeSettings(client, target, w.platform, w.beforeJson ?? {}, w.revision, originId);
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
await client.rpc('sync_push_profile_settings_blob', {
|
|
148
|
+
p_profile_id: target,
|
|
149
|
+
p_platform: w.platform,
|
|
150
|
+
p_settings_json: w.beforeJson ?? {},
|
|
151
|
+
p_origin_client_id: originId,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
catch (rollbackError) {
|
|
156
|
+
failures.push(`${w.platform}: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (credentialsTouched) {
|
|
160
|
+
try {
|
|
161
|
+
await restoreProviders();
|
|
162
|
+
}
|
|
163
|
+
catch (rollbackError) {
|
|
164
|
+
failures.push(`providers: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (failures.length > 0) {
|
|
168
|
+
throw new NuvioError(`copy_setup failed and rollback was incomplete (partially applied): ${failures.join('; ')}. ` +
|
|
169
|
+
`Original error: ${original}`);
|
|
170
|
+
}
|
|
171
|
+
throw new NuvioError(`copy_setup failed and was rolled back: ${original}`);
|
|
172
|
+
}
|
|
173
|
+
return { applied: true, changed: true, before, after, diff };
|
|
174
|
+
}
|
|
175
|
+
export const CLIENT_MAX_PROFILES = 6;
|
|
176
|
+
export async function listProfiles(client) {
|
|
177
|
+
return client.readRpc('sync_pull_profiles', {});
|
|
178
|
+
}
|
|
179
|
+
function toPushShape(profiles) {
|
|
180
|
+
return profiles
|
|
181
|
+
.map((p) => ({
|
|
182
|
+
profile_index: p.profile_index,
|
|
183
|
+
name: p.name,
|
|
184
|
+
avatar_color_hex: p.avatar_color_hex,
|
|
185
|
+
uses_primary_addons: p.uses_primary_addons,
|
|
186
|
+
uses_primary_plugins: p.uses_primary_plugins,
|
|
187
|
+
avatar_id: p.avatar_id,
|
|
188
|
+
avatar_url: p.avatar_url,
|
|
189
|
+
}))
|
|
190
|
+
.sort((a, b) => a.profile_index - b.profile_index);
|
|
191
|
+
}
|
|
192
|
+
function diffProfiles(before, after) {
|
|
193
|
+
const diff = [];
|
|
194
|
+
const b = new Map(before.map((p) => [p.profile_index, p]));
|
|
195
|
+
const a = new Map(after.map((p) => [p.profile_index, p]));
|
|
196
|
+
for (const [idx, p] of a) {
|
|
197
|
+
const prev = b.get(idx);
|
|
198
|
+
if (!prev)
|
|
199
|
+
diff.push(`+ profile ${idx} "${p.name}"`);
|
|
200
|
+
else {
|
|
201
|
+
if (prev.name !== p.name)
|
|
202
|
+
diff.push(`~ profile ${idx} name "${prev.name}" -> "${p.name}"`);
|
|
203
|
+
if (prev.avatar_color_hex !== p.avatar_color_hex)
|
|
204
|
+
diff.push(`~ profile ${idx} color ${prev.avatar_color_hex} -> ${p.avatar_color_hex}`);
|
|
205
|
+
if (prev.uses_primary_addons !== p.uses_primary_addons)
|
|
206
|
+
diff.push(`~ profile ${idx} uses_primary_addons ${prev.uses_primary_addons} -> ${p.uses_primary_addons}`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
for (const idx of b.keys())
|
|
210
|
+
if (!a.has(idx))
|
|
211
|
+
diff.push(`- profile ${idx}`);
|
|
212
|
+
return diff.sort();
|
|
213
|
+
}
|
|
214
|
+
export async function createProfile(client, input, originId, apply) {
|
|
215
|
+
if (!input.name.trim())
|
|
216
|
+
throw new NuvioError('Profile name is required');
|
|
217
|
+
const before = toPushShape(await listProfiles(client));
|
|
218
|
+
const used = new Set(before.map((p) => p.profile_index));
|
|
219
|
+
if (used.size >= CLIENT_MAX_PROFILES) {
|
|
220
|
+
throw new NuvioError(`All ${CLIENT_MAX_PROFILES} profile slots are in use.`);
|
|
221
|
+
}
|
|
222
|
+
let index = input.profile_index;
|
|
223
|
+
if (index === undefined) {
|
|
224
|
+
index = 1;
|
|
225
|
+
while (used.has(index))
|
|
226
|
+
index += 1;
|
|
227
|
+
}
|
|
228
|
+
if (index < 1 || index > CLIENT_MAX_PROFILES)
|
|
229
|
+
throw new NuvioError('profile_index must be 1..6');
|
|
230
|
+
if (used.has(index))
|
|
231
|
+
throw new NuvioError(`Profile slot ${index} is already in use.`);
|
|
232
|
+
const after = [
|
|
233
|
+
...before,
|
|
234
|
+
{
|
|
235
|
+
profile_index: index,
|
|
236
|
+
name: input.name.trim(),
|
|
237
|
+
avatar_color_hex: input.avatar_color_hex ?? null,
|
|
238
|
+
uses_primary_addons: index === 1 ? false : (input.uses_primary_addons ?? false),
|
|
239
|
+
uses_primary_plugins: false,
|
|
240
|
+
avatar_id: input.avatar_id ?? null,
|
|
241
|
+
avatar_url: input.avatar_url ?? null,
|
|
242
|
+
},
|
|
243
|
+
].sort((x, y) => x.profile_index - y.profile_index);
|
|
244
|
+
// Invariant: every previously existing profile is preserved verbatim.
|
|
245
|
+
if (before.some((p) => !after.find((q) => q.profile_index === p.profile_index))) {
|
|
246
|
+
throw new NuvioError('Internal invariant failed (profile create)');
|
|
247
|
+
}
|
|
248
|
+
const diff = diffProfiles(before, after);
|
|
249
|
+
if (apply) {
|
|
250
|
+
await client.rpc('sync_push_profiles', {
|
|
251
|
+
p_client_max_profiles: CLIENT_MAX_PROFILES,
|
|
252
|
+
p_origin_client_id: originId,
|
|
253
|
+
p_profiles: after,
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
return { applied: apply, changed: diff.length > 0, before, after, diff };
|
|
257
|
+
}
|
|
258
|
+
export async function updateProfile(client, profileIndex, patch, apply) {
|
|
259
|
+
const profiles = await listProfiles(client);
|
|
260
|
+
const target = profiles.find((p) => p.profile_index === profileIndex);
|
|
261
|
+
if (!target)
|
|
262
|
+
throw new NuvioError(`Profile ${profileIndex} not found`);
|
|
263
|
+
const args = { p_profile_id: profileIndex };
|
|
264
|
+
if (patch.name !== undefined)
|
|
265
|
+
args.p_name = patch.name;
|
|
266
|
+
if (patch.avatar_color_hex !== undefined)
|
|
267
|
+
args.p_avatar_color_hex = patch.avatar_color_hex;
|
|
268
|
+
if (patch.uses_primary_addons !== undefined)
|
|
269
|
+
args.p_uses_primary_addons = patch.uses_primary_addons;
|
|
270
|
+
if ('avatar_id' in patch) {
|
|
271
|
+
args.p_avatar_id = patch.avatar_id;
|
|
272
|
+
args.p_avatar_id_provided = true;
|
|
273
|
+
}
|
|
274
|
+
if ('avatar_url' in patch) {
|
|
275
|
+
args.p_avatar_url = patch.avatar_url;
|
|
276
|
+
args.p_avatar_url_provided = true;
|
|
277
|
+
}
|
|
278
|
+
const preview = { ...target, ...patch };
|
|
279
|
+
const diff = [];
|
|
280
|
+
if (patch.name !== undefined && patch.name !== target.name)
|
|
281
|
+
diff.push(`name "${target.name}" -> "${patch.name}"`);
|
|
282
|
+
if (patch.avatar_color_hex !== undefined && patch.avatar_color_hex !== target.avatar_color_hex)
|
|
283
|
+
diff.push(`color ${target.avatar_color_hex} -> ${patch.avatar_color_hex}`);
|
|
284
|
+
if ('avatar_id' in patch && patch.avatar_id !== target.avatar_id)
|
|
285
|
+
diff.push(`avatar_id -> ${patch.avatar_id}`);
|
|
286
|
+
if ('avatar_url' in patch && patch.avatar_url !== target.avatar_url)
|
|
287
|
+
diff.push(`avatar_url -> ${patch.avatar_url}`);
|
|
288
|
+
if (patch.uses_primary_addons !== undefined && patch.uses_primary_addons !== target.uses_primary_addons)
|
|
289
|
+
diff.push(`uses_primary_addons -> ${patch.uses_primary_addons}`);
|
|
290
|
+
if (apply && diff.length > 0) {
|
|
291
|
+
await client.rpc('sync_patch_profile', args);
|
|
292
|
+
}
|
|
293
|
+
return {
|
|
294
|
+
applied: apply && diff.length > 0,
|
|
295
|
+
changed: diff.length > 0,
|
|
296
|
+
before: target,
|
|
297
|
+
after: preview,
|
|
298
|
+
diff,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
export async function deleteProfile(client, profileIndex, originId, apply) {
|
|
302
|
+
if (profileIndex === 1) {
|
|
303
|
+
throw new NuvioError('Profile 1 is the primary profile and cannot be deleted.');
|
|
304
|
+
}
|
|
305
|
+
const profiles = await listProfiles(client);
|
|
306
|
+
const target = profiles.find((p) => p.profile_index === profileIndex);
|
|
307
|
+
if (!target)
|
|
308
|
+
throw new NuvioError(`Profile ${profileIndex} not found`);
|
|
309
|
+
const diff = [
|
|
310
|
+
`- profile ${profileIndex} "${target.name}" and ALL of its data (addons, settings, collections, library, history)`,
|
|
311
|
+
];
|
|
312
|
+
if (apply) {
|
|
313
|
+
await client.rpc('sync_delete_profile_data', {
|
|
314
|
+
p_profile_id: profileIndex,
|
|
315
|
+
p_origin_client_id: originId,
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
return { applied: apply, changed: true, before: profileIndex, after: profileIndex, diff };
|
|
319
|
+
}
|
|
320
|
+
export async function copyProfileSetup(client, sourceProfileId, targetProfileId, options, originId, apply) {
|
|
321
|
+
if (sourceProfileId === targetProfileId) {
|
|
322
|
+
throw new NuvioError('Source and target profile must differ.');
|
|
323
|
+
}
|
|
324
|
+
const before = {
|
|
325
|
+
source_profile_id: sourceProfileId,
|
|
326
|
+
target_profile_id: targetProfileId,
|
|
327
|
+
tv: options.copy_tv ?? true,
|
|
328
|
+
mobile: options.copy_mobile ?? true,
|
|
329
|
+
desktop: options.copy_desktop ?? false,
|
|
330
|
+
provider_credentials: options.copy_provider_credentials ?? false,
|
|
331
|
+
replace_provider_credentials: options.replace_provider_credentials ?? false,
|
|
332
|
+
};
|
|
333
|
+
const diff = [
|
|
334
|
+
`~ copy setup ${sourceProfileId} -> ${targetProfileId}`,
|
|
335
|
+
` tv=${before.tv} mobile=${before.mobile} desktop=${before.desktop} credentials=${before.provider_credentials}`,
|
|
336
|
+
];
|
|
337
|
+
if (apply) {
|
|
338
|
+
await client.rpc('sync_copy_profile_setup', {
|
|
339
|
+
p_source_profile_id: sourceProfileId,
|
|
340
|
+
p_target_profile_id: targetProfileId,
|
|
341
|
+
p_copy_tv: before.tv,
|
|
342
|
+
p_copy_mobile: before.mobile,
|
|
343
|
+
p_copy_desktop: before.desktop,
|
|
344
|
+
p_copy_provider_credentials: before.provider_credentials,
|
|
345
|
+
p_replace_provider_credentials: before.replace_provider_credentials,
|
|
346
|
+
p_origin_client_id: originId,
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
return { applied: apply, changed: true, before, after: before, diff };
|
|
350
|
+
}
|
|
351
|
+
export async function setProfilePin(client, profileIndex, pin, currentPin, apply) {
|
|
352
|
+
if (!pin)
|
|
353
|
+
throw new NuvioError('pin is required');
|
|
354
|
+
if (apply) {
|
|
355
|
+
await client.rpc('set_profile_pin', {
|
|
356
|
+
p_profile_id: profileIndex,
|
|
357
|
+
p_pin: pin,
|
|
358
|
+
p_current_pin: currentPin ?? null,
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
return {
|
|
362
|
+
applied: apply,
|
|
363
|
+
changed: true,
|
|
364
|
+
before: profileIndex,
|
|
365
|
+
after: profileIndex,
|
|
366
|
+
diff: [`~ set PIN for profile ${profileIndex}`],
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
export async function clearProfilePin(client, profileIndex, currentPin, apply) {
|
|
370
|
+
if (apply) {
|
|
371
|
+
await client.rpc('clear_profile_pin', {
|
|
372
|
+
p_profile_id: profileIndex,
|
|
373
|
+
p_current_pin: currentPin ?? null,
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
return {
|
|
377
|
+
applied: apply,
|
|
378
|
+
changed: true,
|
|
379
|
+
before: profileIndex,
|
|
380
|
+
after: profileIndex,
|
|
381
|
+
diff: [`- clear PIN for profile ${profileIndex}`],
|
|
382
|
+
};
|
|
383
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { NuvioError } from '../errors.js';
|
|
2
|
+
import { fetchJsonFromPublicUrl } from '../safe-fetch.js';
|
|
3
|
+
function looksLikeKey(value) {
|
|
4
|
+
const v = value.trim();
|
|
5
|
+
return v.length >= 8 && v.length <= 200 && !/\s/.test(v);
|
|
6
|
+
}
|
|
7
|
+
/** Provider endpoints that can validate a key with a cheap read. */
|
|
8
|
+
const LIVE_CHECKS = {
|
|
9
|
+
tmdb: (key) => `https://api.themoviedb.org/3/configuration?api_key=${encodeURIComponent(key)}`,
|
|
10
|
+
mdblist: (key) => `https://mdblist.com/api/?apikey=${encodeURIComponent(key)}`,
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Verify a provider credential WITHOUT storing it. Never returns or logs the
|
|
14
|
+
* secret itself. Live checks are best-effort and only run for providers with a
|
|
15
|
+
* cheap public endpoint; others report format validation only.
|
|
16
|
+
*/
|
|
17
|
+
export async function testProviderCredential(provider, value) {
|
|
18
|
+
const key = provider.trim().toLowerCase();
|
|
19
|
+
if (!PROVIDER_CREDENTIAL_FIELD[key]) {
|
|
20
|
+
throw new NuvioError(`Unsupported provider "${provider}". Supported: ${Object.keys(PROVIDER_CREDENTIAL_FIELD).join(', ')}`);
|
|
21
|
+
}
|
|
22
|
+
if (!value.trim())
|
|
23
|
+
return { provider: key, format_valid: false, verified: false, detail: 'Credential is empty.' };
|
|
24
|
+
const formatValid = looksLikeKey(value);
|
|
25
|
+
if (!formatValid) {
|
|
26
|
+
return { provider: key, format_valid: false, verified: false, detail: 'Credential looks malformed.' };
|
|
27
|
+
}
|
|
28
|
+
const check = LIVE_CHECKS[key];
|
|
29
|
+
if (!check) {
|
|
30
|
+
return {
|
|
31
|
+
provider: key,
|
|
32
|
+
format_valid: true,
|
|
33
|
+
verified: null,
|
|
34
|
+
detail: 'Format accepted; this provider cannot be verified without a live call.',
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
await fetchJsonFromPublicUrl(check(value.trim()));
|
|
39
|
+
return { provider: key, format_valid: true, verified: true, detail: 'Provider accepted the credential.' };
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
43
|
+
// Do not echo the secret; the message comes from the HTTP layer only.
|
|
44
|
+
return { provider: key, format_valid: true, verified: false, detail: `Verification failed: ${message}` };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/** Provider credential layouts accepted by the backend. */
|
|
48
|
+
export const PROVIDER_CREDENTIAL_FIELD = {
|
|
49
|
+
'debrid:torbox': 'api_key',
|
|
50
|
+
'debrid:premiumize': 'api_key',
|
|
51
|
+
'debrid:realdebrid': 'api_key',
|
|
52
|
+
tmdb: 'api_key',
|
|
53
|
+
mdblist: 'api_key',
|
|
54
|
+
introdb: 'api_key',
|
|
55
|
+
animeskip: 'client_id',
|
|
56
|
+
};
|
|
57
|
+
export async function listProviderCredentials(client, profileId) {
|
|
58
|
+
return client.readRpc('sync_pull_provider_credentials', { p_profile_id: profileId });
|
|
59
|
+
}
|
|
60
|
+
function normalizeProvider(provider) {
|
|
61
|
+
const key = provider.trim().toLowerCase();
|
|
62
|
+
const field = PROVIDER_CREDENTIAL_FIELD[key];
|
|
63
|
+
if (!field) {
|
|
64
|
+
throw new NuvioError(`Unsupported provider "${provider}". Supported: ${Object.keys(PROVIDER_CREDENTIAL_FIELD).join(', ')}`);
|
|
65
|
+
}
|
|
66
|
+
return { provider: key, field };
|
|
67
|
+
}
|
|
68
|
+
export async function setProviderCredential(client, profileId, provider, value, originId, apply) {
|
|
69
|
+
const { provider: key, field } = normalizeProvider(provider);
|
|
70
|
+
if (!value.trim())
|
|
71
|
+
throw new NuvioError('Credential value cannot be empty');
|
|
72
|
+
const before = await listProviderCredentials(client, profileId);
|
|
73
|
+
const credential_json = { [field]: value.trim() };
|
|
74
|
+
const exists = before.some((c) => c.provider === key);
|
|
75
|
+
const after = exists
|
|
76
|
+
? before.map((c) => (c.provider === key ? { ...c, credential_json } : c))
|
|
77
|
+
: [...before, { provider: key, credential_json }];
|
|
78
|
+
const diff = [exists ? `~ update credential for ${key}` : `+ add credential for ${key}`];
|
|
79
|
+
if (apply) {
|
|
80
|
+
await client.rpc('sync_push_provider_credentials', {
|
|
81
|
+
p_profile_id: profileId,
|
|
82
|
+
p_credentials: [{ provider: key, credential_json }],
|
|
83
|
+
p_origin_client_id: originId,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
return { applied: apply, changed: true, before, after, diff };
|
|
87
|
+
}
|
|
88
|
+
export async function deleteProviderCredential(client, profileId, provider, originId, apply) {
|
|
89
|
+
const { provider: key } = normalizeProvider(provider);
|
|
90
|
+
const before = await listProviderCredentials(client, profileId);
|
|
91
|
+
if (!before.some((c) => c.provider === key)) {
|
|
92
|
+
throw new NuvioError(`No credential stored for provider "${key}" on profile ${profileId}`);
|
|
93
|
+
}
|
|
94
|
+
const after = before.filter((c) => c.provider !== key);
|
|
95
|
+
if (apply) {
|
|
96
|
+
await client.rpc('sync_delete_provider_credentials', {
|
|
97
|
+
p_profile_id: profileId,
|
|
98
|
+
p_provider: key,
|
|
99
|
+
p_origin_client_id: originId,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
return { applied: apply, changed: true, before, after, diff: [`- remove credential for ${key}`] };
|
|
103
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { NuvioError } from '../errors.js';
|
|
2
|
+
/**
|
|
3
|
+
* Shared, complete readers used by both the mutation/snapshot paths and the
|
|
4
|
+
* public list tools. A reader that cannot prove it fetched the whole resource
|
|
5
|
+
* throws instead of silently returning a truncated state (which would make undo
|
|
6
|
+
* unsound).
|
|
7
|
+
*/
|
|
8
|
+
export const READ_PAGE = 1000;
|
|
9
|
+
export const MAX_SAFE_PAGES = 25; // 25_000 rows
|
|
10
|
+
export const PROGRESS_FETCH_LIMIT = 20000;
|
|
11
|
+
export async function readAllLibrary(client, profileId) {
|
|
12
|
+
const all = [];
|
|
13
|
+
for (let page = 0; page < MAX_SAFE_PAGES; page += 1) {
|
|
14
|
+
const offset = page * READ_PAGE;
|
|
15
|
+
const rows = await client.readRpc('sync_pull_library', {
|
|
16
|
+
p_profile_id: profileId,
|
|
17
|
+
p_limit: READ_PAGE,
|
|
18
|
+
p_offset: offset,
|
|
19
|
+
});
|
|
20
|
+
all.push(...rows);
|
|
21
|
+
if (rows.length < READ_PAGE)
|
|
22
|
+
return all;
|
|
23
|
+
}
|
|
24
|
+
throw new NuvioError('Cannot create a complete reversible snapshot: library exceeds the safety limit of ' +
|
|
25
|
+
`${MAX_SAFE_PAGES * READ_PAGE} rows.`);
|
|
26
|
+
}
|
|
27
|
+
export async function readAllWatchHistory(client, profileId) {
|
|
28
|
+
const all = [];
|
|
29
|
+
for (let page = 1; page <= MAX_SAFE_PAGES; page += 1) {
|
|
30
|
+
const rows = await client.readRpc('sync_pull_watched_items', {
|
|
31
|
+
p_profile_id: profileId,
|
|
32
|
+
p_page: page,
|
|
33
|
+
p_page_size: READ_PAGE,
|
|
34
|
+
});
|
|
35
|
+
all.push(...rows);
|
|
36
|
+
if (rows.length < READ_PAGE)
|
|
37
|
+
return all;
|
|
38
|
+
}
|
|
39
|
+
throw new NuvioError('Cannot create a complete reversible snapshot: watch history exceeds the safety limit of ' +
|
|
40
|
+
`${MAX_SAFE_PAGES * READ_PAGE} rows.`);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* The backend exposes no pagination contract for watch progress. We request a
|
|
44
|
+
* large batch; if it comes back full we cannot prove it is complete, so a
|
|
45
|
+
* reversible mutation must refuse rather than snapshot a partial state.
|
|
46
|
+
*/
|
|
47
|
+
export async function readAllWatchProgress(client, profileId, options = {}) {
|
|
48
|
+
const rows = await client.readRpc('sync_pull_watch_progress', {
|
|
49
|
+
p_profile_id: profileId,
|
|
50
|
+
p_limit: PROGRESS_FETCH_LIMIT,
|
|
51
|
+
});
|
|
52
|
+
if (options.requireComplete && rows.length >= PROGRESS_FETCH_LIMIT) {
|
|
53
|
+
throw new NuvioError('Cannot create a complete reversible snapshot: watch progress may be truncated at the fetch limit of ' +
|
|
54
|
+
`${PROGRESS_FETCH_LIMIT}.`);
|
|
55
|
+
}
|
|
56
|
+
return rows;
|
|
57
|
+
}
|