pmtiles-swarm 0.2.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/src/warm.js ADDED
@@ -0,0 +1,251 @@
1
+ /**
2
+ * Pre-fetching a region so a node is useful the moment it enters rotation.
3
+ *
4
+ * A cache-mode node is slow exactly once per region: the first request pulls
5
+ * the pieces that region's tiles live in, and everything afterwards is served
6
+ * from what it now holds. Behind a load balancer that first request is paid
7
+ * separately by every node, which is the one real cost of scaling the serving
8
+ * tier horizontally.
9
+ *
10
+ * Warming moves that cost off the request path. Point it at the area you
11
+ * actually serve, wait, then add the node to the pool.
12
+ *
13
+ * Note the pieces do not have to come from the internet. Every node in the
14
+ * serving tier is a peer in the same swarm, so a node warming a region that a
15
+ * sibling already holds fetches it from that sibling — usually over the LAN,
16
+ * and far faster than from the original seed.
17
+ */
18
+
19
+ /** Hard ceiling on a single job, so a careless bbox cannot run forever. */
20
+ const DEFAULT_MAX_TILES = 5000;
21
+
22
+ /**
23
+ * Converts longitude to a tile column.
24
+ * @param {number} lon - Longitude in degrees.
25
+ * @param {number} z - Zoom level.
26
+ * @returns {number} - Tile column.
27
+ */
28
+ function lonToTileX(lon, z) {
29
+ return Math.floor(((lon + 180) / 360) * 2 ** z);
30
+ }
31
+
32
+ /**
33
+ * Converts latitude to a tile row.
34
+ * @param {number} lat - Latitude in degrees.
35
+ * @param {number} z - Zoom level.
36
+ * @returns {number} - Tile row.
37
+ */
38
+ function latToTileY(lat, z) {
39
+ const clamped = Math.max(-85.05112878, Math.min(85.05112878, lat));
40
+ const radians = (clamped * Math.PI) / 180;
41
+ return Math.floor(
42
+ ((1 - Math.log(Math.tan(radians) + 1 / Math.cos(radians)) / Math.PI) / 2) *
43
+ 2 ** z,
44
+ );
45
+ }
46
+
47
+ /**
48
+ * Enumerates the tiles covering a bounding box across a zoom range.
49
+ * @param {number[]} bounds - [west, south, east, north].
50
+ * @param {number} minZoom - Lowest zoom, inclusive.
51
+ * @param {number} maxZoom - Highest zoom, inclusive.
52
+ * @param {number} limit - Stop after this many tiles.
53
+ * @yields {{z: number, x: number, y: number}} - Each tile.
54
+ */
55
+ export function* tilesInBounds(bounds, minZoom, maxZoom, limit) {
56
+ const [west, south, east, north] = bounds;
57
+ let produced = 0;
58
+ for (let z = minZoom; z <= maxZoom; z++) {
59
+ const span = 2 ** z;
60
+ const clamp = (value) => Math.max(0, Math.min(span - 1, value));
61
+ const x0 = clamp(lonToTileX(west, z));
62
+ const x1 = clamp(lonToTileX(east, z));
63
+ // Tile rows run north to south, so the northern edge is the lower index.
64
+ const y0 = clamp(latToTileY(north, z));
65
+ const y1 = clamp(latToTileY(south, z));
66
+ for (let x = x0; x <= x1; x++) {
67
+ for (let y = y0; y <= y1; y++) {
68
+ if (produced++ >= limit) return;
69
+ yield { z, x, y };
70
+ }
71
+ }
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Counts the tiles a warm would visit, without fetching any.
77
+ * @param {number[]} bounds - [west, south, east, north].
78
+ * @param {number} minZoom - Lowest zoom.
79
+ * @param {number} maxZoom - Highest zoom.
80
+ * @param {number} limit - Ceiling.
81
+ * @returns {number} - Tile count, capped at the limit.
82
+ */
83
+ export function countTiles(bounds, minZoom, maxZoom, limit) {
84
+ let total = 0;
85
+ for (const _tile of tilesInBounds(bounds, minZoom, maxZoom, limit)) total++;
86
+ return total;
87
+ }
88
+
89
+ /**
90
+ * Runs and tracks warming jobs, one per archive.
91
+ */
92
+ export class WarmRunner {
93
+ #tiles;
94
+ #jobs = new Map();
95
+
96
+ /**
97
+ * @param {import('./tiles.js').TileStore} tiles - The tile reader.
98
+ */
99
+ constructor(tiles) {
100
+ this.#tiles = tiles;
101
+ }
102
+
103
+ /**
104
+ * Starts warming an archive.
105
+ * @param {object} entry - Catalog entry.
106
+ * @param {object} [options] - Bounds, zoom range, concurrency and ceiling.
107
+ * @returns {object} - The job state.
108
+ */
109
+ start(entry, options = {}) {
110
+ const existing = this.#jobs.get(entry.infoHash);
111
+ if (existing && existing.state === 'running') {
112
+ const error = new Error('a warm is already running for this archive');
113
+ error.status = 409;
114
+ throw error;
115
+ }
116
+
117
+ const summary = entry.pmtiles ?? {};
118
+ const bounds = options.bounds ??
119
+ summary.bounds ?? [-180, -85.051129, 180, 85.051129];
120
+ const minZoom = options.minZoom ?? summary.minZoom ?? 0;
121
+ // Warming every zoom to the archive's maximum is almost never what is
122
+ // wanted — the tile count quadruples per level — so stop a few levels
123
+ // short unless asked otherwise.
124
+ const maxZoom = Math.min(
125
+ options.maxZoom ?? Math.min(summary.maxZoom ?? 6, minZoom + 6),
126
+ summary.maxZoom ?? 22,
127
+ );
128
+ const limit = options.maxTiles ?? DEFAULT_MAX_TILES;
129
+
130
+ const controller = new AbortController();
131
+ const job = {
132
+ infoHash: entry.infoHash,
133
+ state: 'running',
134
+ bounds,
135
+ minZoom,
136
+ maxZoom,
137
+ total: countTiles(bounds, minZoom, maxZoom, limit),
138
+ done: 0,
139
+ hits: 0,
140
+ misses: 0,
141
+ errors: 0,
142
+ startedAt: new Date().toISOString(),
143
+ finishedAt: null,
144
+ error: null,
145
+ cancel: () => controller.abort(),
146
+ };
147
+ this.#jobs.set(entry.infoHash, job);
148
+
149
+ this.#run(job, controller.signal, options.concurrency ?? 4, limit).catch(
150
+ (error) => {
151
+ job.state = 'failed';
152
+ job.error = error.message;
153
+ job.finishedAt = new Date().toISOString();
154
+ },
155
+ );
156
+ return job;
157
+ }
158
+
159
+ /**
160
+ * Reports a job's progress.
161
+ * @param {string} infoHash - Which archive.
162
+ * @returns {object | null} - The job, or null if never warmed.
163
+ */
164
+ get(infoHash) {
165
+ const job = this.#jobs.get(infoHash);
166
+ return job ? publicView(job) : null;
167
+ }
168
+
169
+ /**
170
+ * Cancels a running job.
171
+ * @param {string} infoHash - Which archive.
172
+ * @returns {boolean} - Whether anything was cancelled.
173
+ */
174
+ cancel(infoHash) {
175
+ const job = this.#jobs.get(infoHash);
176
+ if (!job || job.state !== 'running') return false;
177
+ job.cancel();
178
+ return true;
179
+ }
180
+
181
+ /**
182
+ * Cancels every running job.
183
+ * @returns {void}
184
+ */
185
+ stop() {
186
+ for (const job of this.#jobs.values()) {
187
+ if (job.state === 'running') job.cancel();
188
+ }
189
+ }
190
+
191
+ /**
192
+ * Fetches the job's tiles, a few at a time.
193
+ * @param {object} job - The job to run.
194
+ * @param {AbortSignal} signal - Cancellation.
195
+ * @param {number} concurrency - Simultaneous reads.
196
+ * @param {number} limit - Tile ceiling.
197
+ * @returns {Promise<void>} - Resolves once finished.
198
+ */
199
+ async #run(job, signal, concurrency, limit) {
200
+ const queue = tilesInBounds(job.bounds, job.minZoom, job.maxZoom, limit);
201
+
202
+ /**
203
+ * Pulls tiles off the shared iterator until it runs dry.
204
+ * @returns {Promise<void>} - Resolves when the iterator is exhausted.
205
+ */
206
+ const worker = async () => {
207
+ for (const tile of queue) {
208
+ if (signal.aborted) return;
209
+ try {
210
+ const result = await this.#tiles.getTile(
211
+ job.infoHash,
212
+ tile.z,
213
+ tile.x,
214
+ tile.y,
215
+ { signal },
216
+ );
217
+ if (result) job.hits++;
218
+ else job.misses++;
219
+ } catch (error) {
220
+ if (error.name === 'AbortError') return;
221
+ job.errors++;
222
+ // One unreadable tile should not abandon the region — a sparse
223
+ // archive throws for all sorts of reasons. But an archive that has
224
+ // never once succeeded is not going to start, and grinding through
225
+ // thousands of tiles to prove it wastes the swarm's time.
226
+ if (job.errors > 25 && job.hits === 0 && job.misses === 0) {
227
+ throw error;
228
+ }
229
+ }
230
+ job.done++;
231
+ }
232
+ };
233
+
234
+ await Promise.all(
235
+ Array.from({ length: Math.max(1, concurrency) }, () => worker()),
236
+ );
237
+
238
+ job.state = signal.aborted ? 'cancelled' : 'complete';
239
+ job.finishedAt = new Date().toISOString();
240
+ }
241
+ }
242
+
243
+ /**
244
+ * Strips internals from a job before it goes over the wire.
245
+ * @param {object} job - The internal job.
246
+ * @returns {object} - A serialisable view.
247
+ */
248
+ function publicView(job) {
249
+ const { cancel: _cancel, ...rest } = job;
250
+ return rest;
251
+ }
package/src/watch.js ADDED
@@ -0,0 +1,98 @@
1
+ import chokidar from 'chokidar';
2
+
3
+ /**
4
+ * Watches folders for new PMTiles archives and imports them automatically.
5
+ *
6
+ * The subtlety is that a map build writes its output over minutes or hours, and
7
+ * hashing a half-written archive produces a torrent for bytes that no longer
8
+ * exist. chokidar's awaitWriteFinish handles that: nothing is imported until
9
+ * the file has stopped changing for a sustained period. The default here is
10
+ * deliberately generous, because a stalled network copy can pause for a long
11
+ * time mid-file.
12
+ */
13
+ export class WatchManager {
14
+ #library;
15
+ #watchers = [];
16
+ #importing = new Set();
17
+
18
+ /**
19
+ * Creates the manager.
20
+ * @param {import('./library.js').Library} library - Where imports go.
21
+ */
22
+ constructor(library) {
23
+ this.#library = library;
24
+ }
25
+
26
+ /**
27
+ * Starts watching the configured folders.
28
+ * @param {object[]} folders - Entries of {path, category, webSeedBase, stabilitySeconds}.
29
+ * @returns {void}
30
+ */
31
+ start(folders = []) {
32
+ for (const folder of folders) {
33
+ const stability = (folder.stabilitySeconds ?? 30) * 1000;
34
+ const watcher = chokidar.watch(folder.path, {
35
+ ignoreInitial: false,
36
+ depth: folder.recursive === false ? 0 : undefined,
37
+ awaitWriteFinish: {
38
+ stabilityThreshold: stability,
39
+ pollInterval: 1000,
40
+ },
41
+ });
42
+
43
+ watcher.on('add', (file) => {
44
+ if (!/\.pmtiles$/i.test(file)) return;
45
+ this.#import(file, folder);
46
+ });
47
+ watcher.on('error', (error) => {
48
+ console.error(`[watch] ${folder.path}: ${error.message}`);
49
+ });
50
+
51
+ this.#watchers.push(watcher);
52
+ console.log(
53
+ `[watch] watching ${folder.path}${folder.category ? ` as "${folder.category}"` : ''}`,
54
+ );
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Imports one archive, guarding against overlapping imports of the same file.
60
+ * @param {string} file - Path to the archive.
61
+ * @param {object} folder - The watch-folder configuration.
62
+ * @returns {Promise<void>} - Resolves once imported or skipped.
63
+ */
64
+ async #import(file, folder) {
65
+ if (this.#importing.has(file)) return;
66
+ this.#importing.add(file);
67
+ try {
68
+ // A web seed makes a brand-new archive usable before anyone has it, so
69
+ // publish one whenever the folder is also served over HTTP.
70
+ const webSeeds = folder.webSeedBase
71
+ ? [
72
+ `${folder.webSeedBase.replace(/\/$/, '')}/${encodeURIComponent(
73
+ file.split(/[\\/]/).pop(),
74
+ )}`,
75
+ ]
76
+ : [];
77
+
78
+ const entry = await this.#library.addLocalArchive(file, {
79
+ category: folder.category,
80
+ webSeeds,
81
+ });
82
+ console.log(`[watch] imported ${entry.name} (${entry.infoHash})`);
83
+ } catch (error) {
84
+ console.error(`[watch] failed to import ${file}: ${error.message}`);
85
+ } finally {
86
+ this.#importing.delete(file);
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Stops all watchers.
92
+ * @returns {Promise<void>} - Resolves once closed.
93
+ */
94
+ async stop() {
95
+ await Promise.all(this.#watchers.map((watcher) => watcher.close()));
96
+ this.#watchers = [];
97
+ }
98
+ }
@@ -0,0 +1,254 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>pmtiles-swarm</title>
7
+ <style>
8
+ :root {
9
+ color-scheme: light dark;
10
+ --bg: #fff;
11
+ --fg: #111;
12
+ --muted: #666;
13
+ --line: #ddd;
14
+ --accent: #2d6cdf;
15
+ --ok: #1a7f37;
16
+ --warn: #9a6700;
17
+ --bad: #cf222e;
18
+ }
19
+ @media (prefers-color-scheme: dark) {
20
+ :root {
21
+ --bg: #14161a;
22
+ --fg: #e8e8e8;
23
+ --muted: #9aa0a6;
24
+ --line: #2c3038;
25
+ --accent: #6ea8ff;
26
+ --ok: #3fb950;
27
+ --warn: #d29922;
28
+ --bad: #f85149;
29
+ }
30
+ }
31
+ * { box-sizing: border-box; }
32
+ body {
33
+ margin: 0;
34
+ background: var(--bg);
35
+ color: var(--fg);
36
+ font: 14px/1.5 system-ui, -apple-system, Segoe UI, sans-serif;
37
+ }
38
+ header {
39
+ display: flex;
40
+ align-items: baseline;
41
+ gap: 1rem;
42
+ padding: 1rem 1.25rem;
43
+ border-bottom: 1px solid var(--line);
44
+ flex-wrap: wrap;
45
+ }
46
+ h1 { font-size: 1.05rem; margin: 0; font-weight: 600; }
47
+ .status { color: var(--muted); font-size: 0.85rem; }
48
+ .status b { color: var(--fg); font-weight: 600; }
49
+ main { padding: 1.25rem; }
50
+ .bar { display: flex; gap: 0.5rem; margin-bottom: 1rem; flex-wrap: wrap; }
51
+ input, select, button {
52
+ font: inherit;
53
+ padding: 0.4rem 0.6rem;
54
+ border: 1px solid var(--line);
55
+ border-radius: 6px;
56
+ background: var(--bg);
57
+ color: var(--fg);
58
+ }
59
+ input { min-width: 22rem; flex: 1; }
60
+ button { cursor: pointer; }
61
+ button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
62
+ table { width: 100%; border-collapse: collapse; }
63
+ th, td {
64
+ text-align: left;
65
+ padding: 0.5rem 0.6rem;
66
+ border-bottom: 1px solid var(--line);
67
+ white-space: nowrap;
68
+ }
69
+ th { font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.03em; color: var(--muted); font-weight: 600; }
70
+ td.name { white-space: normal; min-width: 18rem; }
71
+ .sub { color: var(--muted); font-size: 0.8rem; }
72
+ .num { text-align: right; font-variant-numeric: tabular-nums; }
73
+ .pill { display: inline-block; padding: 0.05rem 0.45rem; border-radius: 999px; font-size: 0.75rem; border: 1px solid var(--line); }
74
+ .seeding { color: var(--ok); border-color: currentColor; }
75
+ .downloading { color: var(--accent); border-color: currentColor; }
76
+ .stalled, .checking, .paused { color: var(--warn); border-color: currentColor; }
77
+ .error { color: var(--bad); border-color: currentColor; }
78
+ .track { width: 90px; height: 6px; border-radius: 3px; background: var(--line); overflow: hidden; }
79
+ .fill { height: 100%; background: var(--accent); }
80
+ a { color: var(--accent); }
81
+ .empty { color: var(--muted); padding: 2rem 0; }
82
+ .wrap { overflow-x: auto; }
83
+ </style>
84
+ </head>
85
+ <body>
86
+ <header>
87
+ <h1>pmtiles-swarm</h1>
88
+ <div class="status" id="status">connecting…</div>
89
+ </header>
90
+ <main>
91
+ <div class="bar">
92
+ <input
93
+ id="source"
94
+ placeholder="local .pmtiles path, https:// URL, magnet:… or .torrent URL"
95
+ />
96
+ <input id="category" placeholder="category (optional)" style="min-width: 10rem" />
97
+ <button class="primary" id="add">Add</button>
98
+ <button id="adopt" title="Import torrents the engine already holds">Adopt existing</button>
99
+ <button id="refresh-feeds" title="Poll subscribed feeds now">Poll feeds</button>
100
+ <a id="feed-link" href="/feed.xml" target="_blank" rel="noopener">feed.xml</a>
101
+ </div>
102
+ <div class="wrap">
103
+ <table>
104
+ <thead>
105
+ <tr>
106
+ <th>Name</th><th>Size</th><th>Map</th><th>State</th>
107
+ <th>Progress</th><th class="num">Seeds</th><th class="num">Peers</th>
108
+ <th class="num">Down</th><th class="num">Up</th><th class="num">Ratio</th><th>Export</th>
109
+ </tr>
110
+ </thead>
111
+ <tbody id="rows"></tbody>
112
+ </table>
113
+ </div>
114
+ <div class="empty" id="empty" hidden>
115
+ Nothing yet. Add an archive above, or click <b>Adopt existing</b> to pull
116
+ in what your torrent client already seeds.
117
+ </div>
118
+ </main>
119
+
120
+ <script type="module">
121
+ const bytes = (n) => {
122
+ if (!n) return '—';
123
+ const u = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];
124
+ let v = n, i = 0;
125
+ while (v >= 1024 && i < u.length - 1) { v /= 1024; i++; }
126
+ return `${v.toFixed(i ? 1 : 0)} ${u[i]}`;
127
+ };
128
+ const rate = (n) => (n ? `${bytes(n)}/s` : '—');
129
+
130
+ async function api(path, options) {
131
+ const response = await fetch(path, options);
132
+ if (!response.ok) {
133
+ const body = await response.json().catch(() => ({}));
134
+ throw new Error(body.error ?? `${response.status} ${response.statusText}`);
135
+ }
136
+ return response.status === 204 ? null : response.json();
137
+ }
138
+
139
+ function mapCell(entry) {
140
+ const m = entry.pmtiles;
141
+ if (!m) return '<span class="sub">—</span>';
142
+ return `${m.format} <span class="sub">z${m.minZoom}–${m.maxZoom}</span>`;
143
+ }
144
+
145
+ function row(entry) {
146
+ const s = entry.status;
147
+ const pct = Math.round((s?.progress ?? 0) * 100);
148
+ const state = s?.state ?? 'unknown';
149
+ return `<tr>
150
+ <td class="name">${entry.name}
151
+ <div class="sub">${entry.category ? entry.category + ' · ' : ''}${entry.infoHash.slice(0, 12)}…</div>
152
+ </td>
153
+ <td>${bytes(entry.size)}</td>
154
+ <td>${mapCell(entry)}</td>
155
+ <td><span class="pill ${state}">${state}</span></td>
156
+ <td><div class="track"><div class="fill" style="width:${pct}%"></div></div>
157
+ <span class="sub">${pct}%</span></td>
158
+ <td class="num">${s?.seeds ?? '—'}</td>
159
+ <td class="num">${s?.peers ?? '—'}</td>
160
+ <td class="num">${rate(s?.downloadSpeed)}</td>
161
+ <td class="num">${rate(s?.uploadSpeed)}</td>
162
+ <td class="num">${s ? (s.ratio ?? 0).toFixed(2) : '—'}</td>
163
+ <td>
164
+ <a href="/api/torrents/${entry.infoHash}/file">.torrent</a> ·
165
+ <a href="#" data-magnet="${entry.infoHash}">magnet</a> ·
166
+ <a href="#" data-remove="${entry.infoHash}">remove</a>
167
+ </td>
168
+ </tr>`;
169
+ }
170
+
171
+ async function refresh() {
172
+ try {
173
+ const [status, torrents] = await Promise.all([
174
+ api('/api/status'),
175
+ api('/api/torrents'),
176
+ ]);
177
+ document.getElementById('status').innerHTML =
178
+ `engine <b>${status.engine.name}</b> ${status.engine.ok ? 'ok' : '<span style="color:var(--bad)">unavailable</span>'} · ` +
179
+ `<b>${status.archives}</b> archives · ` +
180
+ `${status.categories.length} categories · ${status.subscriptions.length} feeds`;
181
+ document.getElementById('rows').innerHTML = torrents.map(row).join('');
182
+ document.getElementById('empty').hidden = torrents.length > 0;
183
+ } catch (error) {
184
+ document.getElementById('status').textContent = `error: ${error.message}`;
185
+ }
186
+ }
187
+
188
+ document.getElementById('add').onclick = async () => {
189
+ const value = document.getElementById('source').value.trim();
190
+ if (!value) return;
191
+ const category = document.getElementById('category').value.trim() || undefined;
192
+ // Route by shape: the server accepts each of these on its own key.
193
+ const body = value.startsWith('magnet:')
194
+ ? { magnet: value, category }
195
+ : /\.torrent(\?|$)/i.test(value)
196
+ ? { torrentUrl: value, category }
197
+ : /^https?:\/\//i.test(value)
198
+ ? { url: value, category }
199
+ : { path: value, category };
200
+ try {
201
+ await api('/api/torrents', {
202
+ method: 'POST',
203
+ headers: { 'content-type': 'application/json' },
204
+ body: JSON.stringify(body),
205
+ });
206
+ document.getElementById('source').value = '';
207
+ refresh();
208
+ } catch (error) {
209
+ alert(`Could not add: ${error.message}`);
210
+ }
211
+ };
212
+
213
+ document.getElementById('adopt').onclick = async () => {
214
+ try {
215
+ const result = await api('/api/adopt', { method: 'POST' });
216
+ alert(`Adopted ${result.added} archive(s).`);
217
+ refresh();
218
+ } catch (error) {
219
+ alert(`Adopt failed: ${error.message}`);
220
+ }
221
+ };
222
+
223
+ document.getElementById('refresh-feeds').onclick = async () => {
224
+ try {
225
+ const result = await api('/api/subscriptions/refresh', { method: 'POST' });
226
+ alert(`Added ${result.added} archive(s) from feeds.`);
227
+ refresh();
228
+ } catch (error) {
229
+ alert(`Feed poll failed: ${error.message}`);
230
+ }
231
+ };
232
+
233
+ document.getElementById('rows').onclick = async (event) => {
234
+ const magnet = event.target.dataset?.magnet;
235
+ const remove = event.target.dataset?.remove;
236
+ if (magnet) {
237
+ event.preventDefault();
238
+ const uri = await fetch(`/api/torrents/${magnet}/magnet`).then((r) => r.text());
239
+ await navigator.clipboard.writeText(uri).catch(() => {});
240
+ prompt('Magnet link (copied to clipboard):', uri);
241
+ }
242
+ if (remove) {
243
+ event.preventDefault();
244
+ if (!confirm('Remove this archive from the catalog?')) return;
245
+ await api(`/api/torrents/${remove}`, { method: 'DELETE' });
246
+ refresh();
247
+ }
248
+ };
249
+
250
+ refresh();
251
+ setInterval(refresh, 3000);
252
+ </script>
253
+ </body>
254
+ </html>