Add save-to-device export, server export route and an On other devices P2P list
This commit is contained in:
@@ -38,6 +38,14 @@ JSON shapes mirror the Tauri Rust bridge exactly — don't change one side alone
|
||||
- Videos over `transcode.maxSeconds` are never re-encoded (lane would be tied up for hours).
|
||||
- Deferred ideas (Cloudflare Worker fetch, Android device download): `docs/deferred-ideas.md`.
|
||||
|
||||
## Save to device + "On other devices"
|
||||
- `exportToDevice(video)` (app.js, helpers in `frontend/export.js`): puts the file in Photos / Files / Downloads.
|
||||
Source order: copy saved in OPFS → server copy (`GET /api/export/:id?name=`, attachment + Range; direct
|
||||
download on desktop, via OPFS + share sheet on phones) → online P2P holder → normal save via server.
|
||||
Buttons appear only once the file is ready: `navigator.share` needs a fresh tap.
|
||||
- `GET /api/p2p/available` + view `{type:'p2p'}` = the "📡 On other devices" list (sidebar, web only):
|
||||
verified files with ≥1 sharing holder, online first, stale flagged. Rows play, export, or go to a playlist.
|
||||
|
||||
## Server media cache (`server/media-cache.js`)
|
||||
- Every played (`/api/streams`, LOW priority, ≤ `MEDIA_AUTO_MAX_SECONDS`, default 3 h) or saved
|
||||
(`/api/download`, HIGH, ≤ 3 h) video gets ONE copy: `$MEDIA_DIR/<id>.<gen>.mp4`
|
||||
|
||||
191
frontend/app.js
191
frontend/app.js
@@ -7179,6 +7179,7 @@ function renderList() {
|
||||
setListPaneHidden(view.type === 'search' && !searchResults.length && !searchAttempted);
|
||||
if (view.type === 'settings') { renderSettings(); return; }
|
||||
if (view.type === 'saved') { renderSaved(); return; }
|
||||
if (view.type === 'p2p') { renderP2pList(); return; }
|
||||
if (view.type === 'downloads') { renderDownloads(); return; }
|
||||
if (view.type === 'stats') { renderStats(); return; }
|
||||
if (view.type === 'notes') { renderNotesView(); return; }
|
||||
@@ -7391,6 +7392,180 @@ function addBrowseCta(empty, label) {
|
||||
// ============================================================================
|
||||
// Saved videos page — every offline-cached file with sizes + totals
|
||||
// ============================================================================
|
||||
|
||||
// ============================================================================
|
||||
// Save to this device (gallery / Files / Downloads) — docs: export.js
|
||||
// ============================================================================
|
||||
// Sources, best first: the copy already saved in this browser; the server's copy
|
||||
// (direct download on desktop, via the saved copy on phones so the share sheet
|
||||
// can put it in Photos); another device that holds it; finally a normal save
|
||||
// through the server. Buttons appear only when the file is ready so each one
|
||||
// runs inside its own tap (the share sheet needs a fresh user gesture).
|
||||
async function exportToDevice(video) {
|
||||
if (!video || !video.id || !window.DeviceExport) return;
|
||||
const id = video.id;
|
||||
const title = video.title || videoTitleById(id) || id;
|
||||
const box = document.createElement('div');
|
||||
box.className = 'export-box';
|
||||
box.innerHTML = '<div class="export-status" role="status"></div><div class="export-bar"><i></i></div><div class="export-actions"></div>';
|
||||
const status = box.querySelector('.export-status');
|
||||
const bar = box.querySelector('.export-bar');
|
||||
const fill = bar.firstChild;
|
||||
const actions = box.querySelector('.export-actions');
|
||||
const say = (t) => { status.textContent = t; };
|
||||
const pct = (p) => { bar.classList.remove('busy'); fill.style.width = Math.max(0, Math.min(100, p)) + '%'; };
|
||||
const busy = () => { bar.classList.add('busy'); fill.style.width = ''; };
|
||||
const button = (label, primary, fn) => {
|
||||
const b = document.createElement('button');
|
||||
b.className = 'btn' + (primary ? ' primary' : '');
|
||||
b.textContent = label;
|
||||
b.onclick = fn;
|
||||
actions.appendChild(b);
|
||||
return b;
|
||||
};
|
||||
let open = true;
|
||||
showModal('Save to this device', box, [{ label: 'Close', onClick: () => { open = false; closeModal(); } }]);
|
||||
|
||||
const DE = window.DeviceExport;
|
||||
const done = (msg) => { open = false; closeModal(); toast(msg, { duration: 4500 }); };
|
||||
const ready = (file) => {
|
||||
const ext = (String(file.name || '').match(/\.([a-z0-9]{2,5})$/i) || [, 'mp4'])[1];
|
||||
const name = DE.fileName(title, ext, id);
|
||||
pct(100);
|
||||
say(`${name} · ${fmtBytes(file.size)}`);
|
||||
actions.innerHTML = '';
|
||||
if (DE.canShareFile(file)) {
|
||||
button('Save to Photos / Files', true, async () => {
|
||||
try { if (await DE.share(file, name, title) === 'shared') done('Shared ✓ — choose “Save Video” or “Save to Files”'); }
|
||||
catch (e) { say('Could not open the share sheet: ' + (e && e.message ? e.message : e)); }
|
||||
});
|
||||
}
|
||||
if (DE.canPickLocation()) {
|
||||
button('Choose where to save…', !DE.canShareFile(file), async () => {
|
||||
try { if (await DE.pick(file, name) === 'saved') done('Saved to your device ✓'); }
|
||||
catch (e) { say('Could not save: ' + (e && e.message ? e.message : e)); }
|
||||
});
|
||||
}
|
||||
button('Download file', !DE.canShareFile(file) && !DE.canPickLocation(), () => {
|
||||
DE.download(file, name);
|
||||
done('Download started — see your Downloads folder');
|
||||
});
|
||||
};
|
||||
const localFile = async () => (cachedIds.has(id) && window.OPFS ? window.OPFS.getFileObject(id) : null);
|
||||
|
||||
try {
|
||||
say('Looking for the video…');
|
||||
busy();
|
||||
let file = await localFile();
|
||||
if (file) return ready(file);
|
||||
|
||||
// Is it on the server? (a 1-byte ranged probe; nothing is downloaded)
|
||||
let serverUrl = '';
|
||||
if (WEB && !video.custom) {
|
||||
const url = `/api/export/${encodeURIComponent(id)}?name=${encodeURIComponent(title)}${hevcCapable() ? '&hevc=1' : ''}`;
|
||||
const r = await fetch(url, { headers: { Range: 'bytes=0-0' } }).catch(() => null);
|
||||
if (r && (r.status === 206 || r.ok)) serverUrl = url;
|
||||
}
|
||||
const phone = DE.canShareFile(new File([], 'x.mp4', { type: 'video/mp4' }));
|
||||
if (serverUrl && !phone) {
|
||||
say('The server has this video. It downloads straight to your Downloads folder.');
|
||||
pct(100);
|
||||
actions.innerHTML = '';
|
||||
button('Download file', true, () => { DE.download(serverUrl, DE.fileName(title, 'mp4', id)); done('Download started — see your Downloads folder'); });
|
||||
return;
|
||||
}
|
||||
|
||||
// Other devices, only when the server can't help.
|
||||
if (!serverUrl && WEB && window.P2PTransfer && window.P2PCore && data.settings.p2pReceive !== false && !video.custom) {
|
||||
let payload = null;
|
||||
try { const r = await fetch(`/api/p2p/holders?v=${encodeURIComponent(id)}`); payload = r.ok ? await r.json() : null; } catch { /* offline */ }
|
||||
const c = payload && Array.isArray(payload.cids) ? payload.cids[0] : null;
|
||||
const peers = c ? window.P2PCore.pickPeers(payload, c.cid) : [];
|
||||
if (peers.length) {
|
||||
say(`Getting it from another device (${peers.length} online)…`);
|
||||
pct(0);
|
||||
const res = await getFromPeers(video, c, peers, (got, total) => { if (open) { pct((got / total) * 100); say(`Getting it from another device… ${Math.round((got / total) * 100)}%`); } });
|
||||
if (res.ok) { file = await localFile(); if (file) return ready(file); }
|
||||
say('That device could not send it — saving through the server instead…');
|
||||
}
|
||||
}
|
||||
|
||||
// Normal save (server copy or YouTube via the server), then export it.
|
||||
say(serverUrl ? 'Copying it to this device…' : 'Saving the video first — long videos take a while…');
|
||||
busy();
|
||||
await preload(video, { quiet: true });
|
||||
file = await localFile();
|
||||
if (!file) { say('Could not get this video. Check your connection and try again.'); pct(0); return; }
|
||||
ready(file);
|
||||
} catch (e) {
|
||||
say('Something went wrong: ' + (e && e.message ? e.message : e));
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// "On other devices" — videos held by sharing devices (P2P), as a playlist
|
||||
// ============================================================================
|
||||
async function renderP2pList() {
|
||||
els.listTitle.textContent = '📡 On other devices';
|
||||
els.listActions.innerHTML = '';
|
||||
els.status.classList.add('hidden');
|
||||
const c = els.cards;
|
||||
c.innerHTML = '<div class="status">Looking for videos on other devices…</div>';
|
||||
let res = null;
|
||||
try { const r = await fetch('/api/p2p/available?limit=100'); res = r.ok ? await r.json() : null; } catch { /* offline */ }
|
||||
if (view.type !== 'p2p') return;
|
||||
c.innerHTML = '';
|
||||
if (!res || !res.ok) {
|
||||
c.innerHTML = '<div class="empty-state"><div class="empty-icon">📡</div><h3 class="empty-title">Not available</h3><p class="empty-desc">Peer-to-peer sharing is off on this server, or you are offline.</p></div>';
|
||||
return;
|
||||
}
|
||||
const items = res.items || [];
|
||||
const list = items.map((it) => ({
|
||||
id: it.id, title: it.title || videoTitleById(it.id) || it.id, channel: it.channel || '',
|
||||
duration: it.duration || 0, thumbnail: `https://i.ytimg.com/vi/${it.id}/mqdefault.jpg`,
|
||||
}));
|
||||
const refresh = document.createElement('button');
|
||||
refresh.textContent = '↻ Refresh';
|
||||
refresh.onclick = () => renderP2pList();
|
||||
const playAll = document.createElement('button');
|
||||
playAll.textContent = '▶ Play all';
|
||||
playAll.className = 'la-primary';
|
||||
playAll.onclick = () => { if (list.length) playFromList(list, 0, 'p2p'); };
|
||||
els.listActions.append(playAll, refresh);
|
||||
if (!items.length) {
|
||||
c.innerHTML = '<div class="empty-state"><div class="empty-icon">📡</div><h3 class="empty-title">Nothing shared yet</h3><p class="empty-desc">Videos that other devices have saved and verified show up here. Save a video and keep sharing on, and yours will appear for everyone else.</p></div>';
|
||||
return;
|
||||
}
|
||||
const online = items.filter((i) => i.online).length;
|
||||
const sum = document.createElement('div');
|
||||
sum.className = 'saved-summary';
|
||||
sum.innerHTML = `<span class="saved-total">${items.length}</span><span class="saved-sub">video${items.length === 1 ? '' : 's'} on other devices · ${online} with a device online now</span>`;
|
||||
c.appendChild(sum);
|
||||
items.forEach((it, idx) => {
|
||||
const v = list[idx];
|
||||
const row = document.createElement('div');
|
||||
row.className = 'card saved-card p2p-card';
|
||||
row.dataset.id = it.id;
|
||||
const state = it.online ? `<b class="p2p-on">● ${it.online} online</b>` : '<span class="p2p-off">○ none online</span>';
|
||||
row.innerHTML = `
|
||||
<div class="thumb"><img loading="lazy" src="${escapeHtml(v.thumbnail)}" alt="" onerror="this.classList.add('no-art')" /></div>
|
||||
<div class="card-info">
|
||||
<div class="card-title"></div>
|
||||
<div class="card-channel">${state} · ${it.holders} device${it.holders === 1 ? '' : 's'} · ${fmtBytes(it.size)}${it.height ? ' · ' + it.height + 'p' : ''}${it.stale ? ' · <i>not checked lately</i>' : ''}</div>
|
||||
</div>
|
||||
<button class="card-act" data-act="export" title="Save to this device" aria-label="Save to this device">⤓</button>
|
||||
<button class="card-act" data-act="add" title="Add to a playlist" aria-label="Add to a playlist">+</button>`;
|
||||
row.querySelector('.card-title').textContent = v.title;
|
||||
row.addEventListener('click', (e) => {
|
||||
const act = e.target.closest('[data-act]');
|
||||
if (act) { e.stopPropagation(); if (act.dataset.act === 'export') exportToDevice(v); else openCardMenu(v); return; }
|
||||
playFromList(list, idx, 'p2p');
|
||||
});
|
||||
c.appendChild(row);
|
||||
});
|
||||
markPlayingCard();
|
||||
}
|
||||
|
||||
async function renderSaved() {
|
||||
els.listTitle.textContent = 'Saved videos';
|
||||
els.listActions.innerHTML = '';
|
||||
@@ -7480,9 +7655,11 @@ async function renderSaved() {
|
||||
<div class="card-title"></div>
|
||||
<div class="card-channel saved-size">${isCustom ? '✂ edited · ' : ''}${fmtBytes(it.size)}</div>
|
||||
</div>
|
||||
<button class="card-act" data-act="export" title="Save to this device" aria-label="Save to this device">⤓</button>
|
||||
<button class="card-del" title="Delete saved file">✕</button>`;
|
||||
row.querySelector('.card-title').textContent = v.title || it.id;
|
||||
row.addEventListener('click', (e) => {
|
||||
if (e.target.closest('[data-act="export"]')) { e.stopPropagation(); exportToDevice(v); return; }
|
||||
if (e.target.closest('.card-del')) return;
|
||||
playFromList([v], 0, 'saved');
|
||||
});
|
||||
@@ -8400,6 +8577,12 @@ function openCardMenu(video) {
|
||||
|
||||
const isCustom = !!video.custom;
|
||||
|
||||
const exportBtn = document.createElement('button');
|
||||
exportBtn.textContent = '⤓ Save to this device';
|
||||
exportBtn.title = 'Put the video file in your gallery, Files app or Downloads folder';
|
||||
exportBtn.onclick = () => { closeModal(); exportToDevice(video); };
|
||||
body.appendChild(exportBtn);
|
||||
|
||||
// Quick: save / remove offline. For a custom (edited) video there's no
|
||||
// source to re-download, so "remove" deletes the edit entirely.
|
||||
const saveBtn = document.createElement('button');
|
||||
@@ -9164,6 +9347,7 @@ document.querySelectorAll('.chip').forEach((c) => {
|
||||
await preload(current.meta);
|
||||
}
|
||||
});
|
||||
if ($('exportBtn')) $('exportBtn').addEventListener('click', () => { if (current && current.meta) exportToDevice(current.meta); });
|
||||
els.addPlaylistBtn.addEventListener('click', () => {
|
||||
if (current && current.meta) openCardMenu(current.meta);
|
||||
});
|
||||
@@ -9676,6 +9860,13 @@ function renderSmartSidebar() {
|
||||
item.addEventListener('click', () => { view = { type: 'smart', smartType: sp.id }; render(); });
|
||||
list.appendChild(item);
|
||||
});
|
||||
if (WEB && data.settings.p2pReceive !== false) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'playlist-item smart-item' + (view.type === 'p2p' ? ' active' : '');
|
||||
item.innerHTML = '<span class="pl-name">📡 On other devices</span>';
|
||||
item.addEventListener('click', () => { view = { type: 'p2p' }; render(); });
|
||||
list.appendChild(item);
|
||||
}
|
||||
}
|
||||
function getSmartList(smartType) {
|
||||
switch (smartType) {
|
||||
|
||||
75
frontend/export.js
Normal file
75
frontend/export.js
Normal file
@@ -0,0 +1,75 @@
|
||||
/* Put a video file on the device itself (Photos / Files / Downloads).
|
||||
* Pure helpers + the three ways a browser can do it; the app decides which
|
||||
* source (saved copy, server copy, other devices) the bytes come from.
|
||||
* DeviceExport.fileName(title, ext) → safe "Title.ext"
|
||||
* DeviceExport.canShareFile(file) → Web Share with files works here
|
||||
* DeviceExport.canPickLocation() → "Save as…" dialog exists (desktop Chromium)
|
||||
* DeviceExport.share(file, name, title) → share sheet ("Save Video" → Photos / Files)
|
||||
* DeviceExport.pick(file, name) → stream into a user-chosen file
|
||||
* DeviceExport.download(urlOrFile, name) → plain browser download
|
||||
*/
|
||||
(function (root) {
|
||||
'use strict';
|
||||
const BAD = /[\u0000-\u001f\\/:*?"<>|]+/g;
|
||||
|
||||
function fileName(title, ext, fallback) {
|
||||
const base = String(title || '').replace(BAD, ' ').replace(/\s+/g, ' ').trim().slice(0, 120)
|
||||
|| String(fallback || 'video');
|
||||
const e = String(ext || 'mp4').replace(/^\./, '').toLowerCase().replace(/[^a-z0-9]/g, '') || 'mp4';
|
||||
return `${base}.${e}`;
|
||||
}
|
||||
const mimeFor = (name) => (/\.(m4a|mp3|aac|opus|ogg|flac|wav)$/i.test(name) ? 'audio/mp4' : /\.webm$/i.test(name) ? 'video/webm' : 'video/mp4');
|
||||
const typed = (file, name) => (file.type ? file : new File([file], name, { type: mimeFor(name) }));
|
||||
|
||||
function canShareFile(file) {
|
||||
try {
|
||||
if (!navigator.canShare || !navigator.share || !file) return false;
|
||||
return navigator.canShare({ files: [typed(file, file.name || 'video.mp4')] });
|
||||
} catch { return false; }
|
||||
}
|
||||
const canPickLocation = () => typeof root.showSaveFilePicker === 'function' && !/Android|iPhone|iPad/i.test(navigator.userAgent || '');
|
||||
|
||||
// Resolves 'shared' | 'cancelled'; rejects on a real failure.
|
||||
async function share(file, name, title) {
|
||||
// new File() over an OPFS file does not copy it — it stays disk-backed.
|
||||
const f = new File([file], name, { type: file.type || mimeFor(name) });
|
||||
try {
|
||||
await navigator.share({ files: [f], title: title || name });
|
||||
return 'shared';
|
||||
} catch (e) {
|
||||
if (e && e.name === 'AbortError') return 'cancelled';
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async function pick(file, name) {
|
||||
let handle;
|
||||
try {
|
||||
handle = await root.showSaveFilePicker({ suggestedName: name, types: [{ description: 'Video', accept: { [mimeFor(name)]: ['.' + name.split('.').pop()] } }] });
|
||||
} catch (e) {
|
||||
if (e && e.name === 'AbortError') return 'cancelled';
|
||||
throw e;
|
||||
}
|
||||
const w = await handle.createWritable();
|
||||
await file.stream().pipeTo(w); // chunked: a multi-GB file is never held in memory
|
||||
return 'saved';
|
||||
}
|
||||
|
||||
// A File/Blob → object URL; a string → used as the href directly (server copy).
|
||||
function download(src, name) {
|
||||
const isStr = typeof src === 'string';
|
||||
const href = isStr ? src : URL.createObjectURL(src);
|
||||
const a = document.createElement('a');
|
||||
a.href = href;
|
||||
a.download = name;
|
||||
a.rel = 'noopener';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
if (!isStr) setTimeout(() => URL.revokeObjectURL(href), 10 * 60 * 1000);
|
||||
return 'started';
|
||||
}
|
||||
|
||||
root.DeviceExport = { fileName, canShareFile, canPickLocation, share, pick, download };
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = root.DeviceExport;
|
||||
})(typeof window !== 'undefined' ? window : globalThis);
|
||||
10
frontend/export.test.js
Normal file
10
frontend/export.test.js
Normal file
@@ -0,0 +1,10 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const { fileName } = require('./export.js');
|
||||
|
||||
test('fileName strips path and reserved characters and keeps the extension', () => {
|
||||
assert.strictEqual(fileName('AC/DC: Back "In" Black?', 'mp4'), 'AC DC Back In Black .mp4'.replace(' .', '.'));
|
||||
assert.strictEqual(fileName(' ', '.M4A', 'abc123'), 'abc123.m4a');
|
||||
assert.strictEqual(fileName('x'.repeat(300), 'mp4').length, 124);
|
||||
assert.strictEqual(fileName('Song', '../../x'), 'Song.x');
|
||||
});
|
||||
@@ -236,6 +236,7 @@
|
||||
<div class="np-actions">
|
||||
<button id="queueBtn" class="np-btn" title="Add to the temporary queue">+ Queue</button>
|
||||
<button id="saveBtn" class="np-btn" title="Save this video for offline playback">⬇ Save</button>
|
||||
<button id="exportBtn" class="np-btn" title="Put the video file in your gallery, Files app or Downloads folder">⤓ To device</button>
|
||||
<button id="editBtn" class="np-btn" title="Cut parts out and save a custom edited copy">✂ Edit & download</button>
|
||||
<button id="addPlaylistBtn" class="np-btn" title="Add to a playlist">+ Playlist</button>
|
||||
<button id="shareMomentBtn" class="np-btn" title="Share a link or GIF of this exact moment">🔗 Share moment</button>
|
||||
@@ -592,6 +593,7 @@
|
||||
<script src="video-edit.js"></script>
|
||||
<script src="lyrics-core.js"></script>
|
||||
<script src="flag-ui.js"></script>
|
||||
<script src="export.js"></script>
|
||||
<script src="stats-core.js"></script>
|
||||
<script src="sha256.js"></script>
|
||||
<script src="device-db.js"></script>
|
||||
|
||||
@@ -370,3 +370,21 @@
|
||||
border: 1px solid rgba(255, 180, 84, 0.5);
|
||||
}
|
||||
.lyr-line.flagged.mine::after { background: #ffb454; color: #1a1206; border-color: #ffb454; }
|
||||
|
||||
/* ---- Save to device + On other devices ---- */
|
||||
.card-act {
|
||||
background: var(--bg-3); border: 1px solid var(--line); color: var(--text-2);
|
||||
width: 36px; height: 36px; border-radius: 11px; flex-shrink: 0; align-self: center;
|
||||
font-size: 16px; line-height: 1; cursor: pointer; transition: all 0.16s var(--ease);
|
||||
}
|
||||
.card-act:hover { color: #fff; background: var(--accent); border-color: var(--accent); }
|
||||
.p2p-card .p2p-on { color: #7ee0a8; font-weight: 700; }
|
||||
.p2p-card .p2p-off { color: var(--text-dim); }
|
||||
.export-box { display: grid; gap: 14px; min-width: min(420px, 78vw); }
|
||||
.export-status { font-size: 14px; line-height: 1.45; color: var(--text-2); overflow-wrap: anywhere; }
|
||||
.export-bar { height: 8px; border-radius: 99px; background: var(--bg-3); overflow: hidden; }
|
||||
.export-bar i { display: block; height: 100%; width: 0; border-radius: inherit; background: linear-gradient(90deg, var(--accent-deep), var(--accent-bright)); transition: width 0.25s linear; }
|
||||
.export-bar.busy i { width: 40%; animation: export-slide 1.2s ease-in-out infinite; }
|
||||
@keyframes export-slide { 0% { margin-left: -40%; } 100% { margin-left: 100%; } }
|
||||
.export-actions { display: grid; gap: 8px; }
|
||||
.export-actions .btn { height: 48px; width: 100%; }
|
||||
|
||||
@@ -59,6 +59,7 @@ const SHELL = [
|
||||
'/service-ui.css',
|
||||
'/refresh.css',
|
||||
'/flag-ui.js',
|
||||
'/export.js',
|
||||
'/async-guard.js',
|
||||
'/sw-update.js',
|
||||
'/fingerprint.js',
|
||||
|
||||
@@ -279,3 +279,19 @@ export async function recentContent(limit = 30) {
|
||||
});
|
||||
return rowsOf(r).map((x) => ({ ...x, holders: Number(x.holders) || 0 }));
|
||||
}
|
||||
|
||||
// One verified file per video (newest), with how many sharing devices hold it —
|
||||
// the "On other devices" playlist. Holders themselves come from listHolders.
|
||||
export async function listSharedContent(limit = 100) {
|
||||
const r = await db.execute({
|
||||
sql: `SELECT c.cid, c.video_id, c.size, c.height, c.vcodec, c.duration, c.meta, c.verified_at,
|
||||
(SELECT COUNT(*) FROM p2p_holders h JOIN p2p_devices d ON d.device_id = h.device_id
|
||||
WHERE h.cid = c.cid AND h.status = 'active' AND d.share = 1) AS holders
|
||||
FROM p2p_content c
|
||||
WHERE c.status = 'verified'
|
||||
AND c.created_at = (SELECT MAX(c2.created_at) FROM p2p_content c2 WHERE c2.video_id = c.video_id AND c2.status = 'verified')
|
||||
ORDER BY c.verified_at DESC LIMIT ?`,
|
||||
args: [limit],
|
||||
});
|
||||
return rowsOf(r).map((x) => ({ ...x, holders: Number(x.holders) || 0 }));
|
||||
}
|
||||
|
||||
@@ -69,3 +69,17 @@ test('views per day and window sums', async () => {
|
||||
expect(s.content).toBe(1);
|
||||
expect(s.devices).toBe(1);
|
||||
});
|
||||
|
||||
test('listSharedContent: newest verified file per video, only with sharing holders counted', async () => {
|
||||
const V = 'sharedVid01', C1 = 'c'.repeat(64), C2 = 'd'.repeat(64);
|
||||
await P.createDevice({ deviceId: 'dev_00000000000000a1', secretHash: 'x', fingerprint: null, profile: null, now: T0 });
|
||||
await P.createDevice({ deviceId: 'dev_00000000000000a2', secretHash: 'x', fingerprint: null, profile: null, now: T0 });
|
||||
await P.touchDevice('dev_00000000000000a2', { now: T0, share: 0 });
|
||||
await P.upsertContent({ cid: C1, videoId: V, size: 10, duration: 5, meta: { title: 'Old' }, origin: 'server', now: T0 });
|
||||
await P.upsertContent({ cid: C2, videoId: V, size: 20, duration: 5, meta: { title: 'New' }, origin: 'intake', now: T0 + 5000 });
|
||||
await P.upsertHolder({ cid: C2, deviceId: 'dev_00000000000000a1', now: T0 });
|
||||
await P.upsertHolder({ cid: C2, deviceId: 'dev_00000000000000a2', now: T0 }); // does not share
|
||||
const rows = (await P.listSharedContent(50)).filter((r) => r.video_id === V);
|
||||
expect(rows.length).toBe(1);
|
||||
expect(rows[0]).toMatchObject({ cid: C2, holders: 1 });
|
||||
});
|
||||
|
||||
@@ -1214,6 +1214,30 @@ app.get('/api/media/:id', async (c) => {
|
||||
g ? 'public, max-age=31536000, immutable' : 'no-cache');
|
||||
});
|
||||
|
||||
// GET /api/export/:id?name=<file name>[&hevc=1] — the server's copy as a file
|
||||
// download (Content-Disposition: attachment), Range-aware so a big file can
|
||||
// resume. Serves a cached YouTube copy or one of the server's own uploads, so a
|
||||
// device can put it in its gallery / Files app without going through OPFS.
|
||||
app.get('/api/export/:id', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
let path = null, mime = 'video/mp4', ext = 'mp4';
|
||||
if (isUpload(id)) {
|
||||
const u = await notesDb.getUpload(id);
|
||||
if (u) { path = uploads.filePath(u); mime = u.mime; ext = u.ext; }
|
||||
} else {
|
||||
const row = await notesDb.getMedia(id);
|
||||
// An HEVC copy only goes to a client that said it can play one.
|
||||
if (row && row.status === 'ready' && (row.vcodec !== 'hevc' || c.req.query('hevc') === '1')) path = await media.filePath(id, null, 'mp4');
|
||||
}
|
||||
if (!path) return c.json({ ok: false, error: 'not cached on the server' }, 404);
|
||||
media.touch?.(id);
|
||||
const base = String(c.req.query('name') || id).replace(/\.[a-z0-9]{1,5}$/i, '')
|
||||
.replace(/[\u0000-\u001f\\/:*?"<>|]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 120) || id;
|
||||
const res = rangeFileResponse(c, path, mime, 'no-store');
|
||||
res.headers.set('Content-Disposition', `attachment; filename="${base.replace(/[^\x20-\x7e]/g, '_').replace(/"/g, '')}.${ext}"; filename*=UTF-8''${encodeURIComponent(base)}.${ext}`);
|
||||
return res;
|
||||
});
|
||||
|
||||
// GET /api/media/:id/status
|
||||
// Where the bytes for an id live: the validated YouTube copy in the media
|
||||
// cache, or one of the server's own uploads.
|
||||
@@ -1992,6 +2016,32 @@ app.get('/api/p2p/holders', p2p.gate, async (c) => {
|
||||
return c.json(payload, 200, { 'Cache-Control': 'no-store' });
|
||||
});
|
||||
|
||||
// GET /api/p2p/available — the "On other devices" playlist: every verified file
|
||||
// that at least one sharing device holds, online ones first. Holders are never
|
||||
// dropped for age; `stale` only flags an old check.
|
||||
app.get('/api/p2p/available', p2p.gate, async (c) => {
|
||||
const limit = Math.min(200, Math.max(1, Number(c.req.query('limit')) || 100));
|
||||
const rows = await p2pDb.listSharedContent(limit * 2);
|
||||
const out = [];
|
||||
for (const r of rows) {
|
||||
if (!r.holders) continue;
|
||||
const hs = (await p2pDb.listHolders(r.cid, 50)).filter((h) => Number(h.share) === 1);
|
||||
const online = hs.filter((h) => p2pHub.isOnline(h.device_id)).length;
|
||||
const newest = Math.max(0, ...hs.map((h) => Number(h.last_verified_at) || 0));
|
||||
let meta = {};
|
||||
try { meta = JSON.parse(r.meta || '{}'); } catch { /* no meta */ }
|
||||
out.push({
|
||||
id: r.video_id, cid: r.cid, title: meta.title || '', channel: meta.channel || '',
|
||||
duration: Number(r.duration) || 0, size: Number(r.size) || 0, height: Number(r.height) || 0,
|
||||
holders: hs.length, online, lastVerifiedAt: newest,
|
||||
stale: Date.now() - newest > P2P.staleDays * 86400_000,
|
||||
serverHas: !!(await fileForCid(r.cid)),
|
||||
});
|
||||
}
|
||||
out.sort((a, b) => (b.online - a.online) || (b.lastVerifiedAt - a.lastVerifiedAt));
|
||||
return c.json({ ok: true, staleDays: P2P.staleDays, items: out.slice(0, limit) }, 200, { 'Cache-Control': 'no-store' });
|
||||
});
|
||||
|
||||
// Device → server intake: the server hashes, validates (and scans when
|
||||
// P2P_MALWARE_SCAN=1) before a cid is admitted. docs/p2p-architecture.md flow 7.
|
||||
const intake = registerIntakeRoutes(app, {
|
||||
|
||||
Reference in New Issue
Block a user