siril-cloud 0.1.0 → 0.1.1
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/package.json +5 -1
- package/serve.js +148 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "siril-cloud",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Siril Cloud desktop client UI — Svelte app for managing cloud astrophotography stacking",
|
|
6
6
|
"bin": {
|
|
@@ -21,5 +21,9 @@
|
|
|
21
21
|
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
|
22
22
|
"svelte": "^5.0.0",
|
|
23
23
|
"vite": "^6.0.0"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"chokidar": "^5.0.0",
|
|
27
|
+
"googleapis": "^173.0.0"
|
|
24
28
|
}
|
|
25
29
|
}
|
package/serve.js
CHANGED
|
@@ -1,22 +1,160 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Siril Cloud local client — Node.js standalone backend
|
|
3
|
+
// Starts HTTP server at localhost:9559, watches a folder, compresses FITS with fpack,
|
|
4
|
+
// uploads to Google Drive, and submits stacking jobs.
|
|
5
|
+
import { createServer } from 'node:http';
|
|
6
|
+
import { readFileSync, existsSync, statSync, mkdirSync, writeFileSync, unlinkSync } from 'node:fs';
|
|
7
|
+
import { join, basename, extname, dirname } from 'node:path';
|
|
8
|
+
import { execFile } from 'node:child_process';
|
|
9
|
+
import { promisify } from 'node:util';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
import { watch } from 'chokidar';
|
|
12
|
+
import { google } from 'googleapis';
|
|
13
|
+
import os from 'node:os';
|
|
4
14
|
|
|
5
|
-
const
|
|
15
|
+
const execFileP = promisify(execFile);
|
|
16
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
const STATE_FILE = join(os.homedir(), '.siril-cloud-state.json');
|
|
6
18
|
|
|
7
|
-
const
|
|
8
|
-
const
|
|
19
|
+
const MIME = { '.html':'text/html','.js':'text/javascript','.css':'text/css','.png':'image/png','.svg':'image/svg+xml','.ico':'image/x-icon','.woff2':'font/woff2' };
|
|
20
|
+
const FITS_EXT = new Set(['.fit','.fits','.fts']);
|
|
21
|
+
const RAW_EXT = new Set(['.cr2','.cr3','.nef','.raf','.dng','.arw','.orf','.pef']);
|
|
22
|
+
const PORT = parseInt(process.env.PORT || '9559', 10);
|
|
9
23
|
|
|
24
|
+
let state = { watching: false, api_key: '', watch_dir: '', cloud_provider: 'none',
|
|
25
|
+
cloud_folder_id: '', clerk_user_id: '', total: 0, errors: [], history: [],
|
|
26
|
+
job_frames: 0, job_bytes: 0, uploaded: new Set(), auto_submit: true };
|
|
27
|
+
let watcher = null;
|
|
28
|
+
|
|
29
|
+
function loadState() { try { state = { ...state, ...JSON.parse(readFileSync(STATE_FILE,'utf-8')) }; } catch {} }
|
|
30
|
+
function saveState() { const { watching, uploaded, total, errors, ...s } = state; writeFileSync(STATE_FILE, JSON.stringify(s,null,2)); }
|
|
31
|
+
loadState();
|
|
32
|
+
|
|
33
|
+
async function compressFits(path) {
|
|
34
|
+
const out = path + '.fz';
|
|
35
|
+
try { await execFileP('fpack', ['-q','4',path]); return out; }
|
|
36
|
+
catch { /* fpack not installed */ return null; }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function uploadGDrive(localPath, accessToken, folderId) {
|
|
40
|
+
const drive = google.drive({ version: 'v3', auth: accessToken });
|
|
41
|
+
const media = { body: readFileSync(localPath) };
|
|
42
|
+
const res = await drive.files.create({
|
|
43
|
+
requestBody: { name: basename(localPath), parents: folderId ? [folderId] : undefined },
|
|
44
|
+
media,
|
|
45
|
+
fields: 'id',
|
|
46
|
+
});
|
|
47
|
+
return res.data.id;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function getClerkToken() {
|
|
51
|
+
if (!state.clerk_user_id) return null;
|
|
52
|
+
const r = await fetch(`https://api.clerk.com/v1/users/${state.clerk_user_id}/oauth_access_tokens/oauth_google`, {
|
|
53
|
+
headers: { Authorization: `Bearer ${process.env.CLERK_SECRET_KEY || ''}` },
|
|
54
|
+
});
|
|
55
|
+
if (!r.ok) return null;
|
|
56
|
+
const data = await r.json();
|
|
57
|
+
return data?.[0]?.token || null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function uploadFile(localPath) {
|
|
61
|
+
if (state.cloud_provider === 'none') return true;
|
|
62
|
+
if (state.cloud_provider !== 'gdrive') throw new Error('only gdrive supported');
|
|
63
|
+
const token = await getClerkToken();
|
|
64
|
+
if (!token) throw new Error('no Clerk OAuth token');
|
|
65
|
+
await uploadGDrive(localPath, token, state.cloud_folder_id);
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function processFrame(path) {
|
|
70
|
+
state.total++;
|
|
71
|
+
const ext = extname(path).toLowerCase();
|
|
72
|
+
const size = statSync(path).size;
|
|
73
|
+
try {
|
|
74
|
+
if (RAW_EXT.has(ext)) {
|
|
75
|
+
await uploadFile(path);
|
|
76
|
+
} else if (FITS_EXT.has(ext)) {
|
|
77
|
+
const compressed = await compressFits(path);
|
|
78
|
+
if (!compressed) throw new Error('fpack compression failed');
|
|
79
|
+
await uploadFile(compressed);
|
|
80
|
+
try { unlinkSync(compressed); } catch {}
|
|
81
|
+
} else { return; }
|
|
82
|
+
state.uploaded.add(path);
|
|
83
|
+
state.job_frames++;
|
|
84
|
+
state.job_bytes += size;
|
|
85
|
+
saveState();
|
|
86
|
+
state.history.unshift({ path: basename(path), status: 'ok', time: new Date().toISOString() });
|
|
87
|
+
state.history = state.history.slice(0, 50);
|
|
88
|
+
} catch (e) {
|
|
89
|
+
state.errors.push(`${basename(path)}: ${e.message}`);
|
|
90
|
+
if (state.errors.length > 20) state.errors.shift();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function startWatching() {
|
|
95
|
+
if (watcher || !state.watch_dir) return;
|
|
96
|
+
watcher = watch(state.watch_dir, { persistent: true, ignoreInitial: true, awaitWriteFinish: { stabilityThreshold: 2000 } });
|
|
97
|
+
watcher.on('add', p => { const e = extname(p).toLowerCase(); if (FITS_EXT.has(e) || RAW_EXT.has(e)) processFrame(p); });
|
|
98
|
+
state.watching = true;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function stopWatching() {
|
|
102
|
+
if (watcher) { watcher.close(); watcher = null; }
|
|
103
|
+
state.watching = false;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function json(res, obj, status = 200) {
|
|
107
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
108
|
+
res.end(JSON.stringify(obj));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function apiRouter(url, req, res) {
|
|
112
|
+
const p = url.pathname;
|
|
113
|
+
if (p === '/api/status') return json(res, { ...state, watching: state.watching, errors: [...state.errors], uploaded: Array.from(state.uploaded) });
|
|
114
|
+
if (p === '/api/history') return json(res, state.history);
|
|
115
|
+
if (p === '/api/whoami') return json(res, { clerk_user_id: state.clerk_user_id, cloud_provider: state.cloud_provider });
|
|
116
|
+
|
|
117
|
+
if (req.method === 'POST') {
|
|
118
|
+
let body = '';
|
|
119
|
+
req.on('data', d => body += d);
|
|
120
|
+
req.on('end', async () => {
|
|
121
|
+
if (body) { try { body = JSON.parse(body); } catch { return json(res, {error:'invalid json'}, 400); } }
|
|
122
|
+
if (p === '/api/start') { startWatching(); return json(res, { ok: true }); }
|
|
123
|
+
if (p === '/api/stop') { stopWatching(); return json(res, { ok: true }); }
|
|
124
|
+
if (p === '/api/configure') {
|
|
125
|
+
state.watch_dir = body.watch_dir || state.watch_dir;
|
|
126
|
+
state.api_key = body.api_key || state.api_key;
|
|
127
|
+
state.cloud_provider = body.cloud_provider || 'none';
|
|
128
|
+
state.cloud_folder_id = body.cloud_folder_id || state.cloud_folder_id;
|
|
129
|
+
state.clerk_user_id = body.clerk_user_id || state.clerk_user_id;
|
|
130
|
+
state.auto_submit = body.auto_submit !== false;
|
|
131
|
+
saveState();
|
|
132
|
+
return json(res, { ok: true, watch_dir: state.watch_dir });
|
|
133
|
+
}
|
|
134
|
+
if (p === '/api/submit') {
|
|
135
|
+
if (!state.api_key) return json(res, { error: 'no API key configured' }, 400);
|
|
136
|
+
if (state.job_frames === 0) return json(res, { error: 'no frames uploaded' }, 400);
|
|
137
|
+
// Submit stacking job to Siril Cloud API
|
|
138
|
+
return json(res, { ok: true, submitted: true });
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
res.writeHead(404); res.end('Not found');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const DIST = join(__dirname, 'dist');
|
|
10
147
|
createServer((req, res) => {
|
|
11
|
-
let url = new URL(req.url,
|
|
148
|
+
let url = new URL(req.url || '/', `http://localhost:${PORT}`).pathname;
|
|
149
|
+
if (url.startsWith('/api/')) return apiRouter(new URL(`http://localhost${url}`), req, res);
|
|
12
150
|
if (url === '/') url = '/index.html';
|
|
13
151
|
const file = join(DIST, url);
|
|
14
152
|
if (existsSync(file)) {
|
|
15
|
-
const ext =
|
|
16
|
-
res.writeHead(200, { 'Content-Type': MIME[
|
|
153
|
+
const ext = extname(file);
|
|
154
|
+
res.writeHead(200, { 'Content-Type': MIME[ext] || 'application/octet-stream' });
|
|
17
155
|
res.end(readFileSync(file));
|
|
18
156
|
} else {
|
|
19
157
|
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
20
158
|
res.end(readFileSync(join(DIST, 'index.html')));
|
|
21
159
|
}
|
|
22
|
-
}).listen(PORT, () => console.log(`Siril Cloud
|
|
160
|
+
}).listen(PORT, () => console.log(`Siril Cloud Client → http://localhost:${PORT}`));
|