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.
Files changed (2) hide show
  1. package/package.json +5 -1
  2. package/serve.js +148 -10
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "siril-cloud",
3
- "version": "0.1.0",
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
- const { createServer } = await import('node:http');
2
- const { readFileSync, existsSync } = await import('node:fs');
3
- const { join } = await import('node:path');
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 MIME = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.png': 'image/png', '.svg': 'image/svg+xml', '.ico': 'image/x-icon', '.woff2': 'font/woff2' };
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 PORT = process.env.PORT || 9559;
8
- const DIST = new URL('./dist', import.meta.url).pathname;
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, 'http://localhost').pathname;
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 = url.split('.').pop();
16
- res.writeHead(200, { 'Content-Type': MIME['.' + ext] || 'application/octet-stream' });
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 UI → http://localhost:${PORT}`));
160
+ }).listen(PORT, () => console.log(`Siril Cloud Client → http://localhost:${PORT}`));