diff --git a/src/routes/(authed)/tools/adv-upload/+page.svelte b/src/routes/(authed)/tools/adv-upload/+page.svelte index b4ce370..0b7d1e6 100644 --- a/src/routes/(authed)/tools/adv-upload/+page.svelte +++ b/src/routes/(authed)/tools/adv-upload/+page.svelte @@ -3,141 +3,100 @@ import { auth } from '$lib/core/stores/auth'; import { addNotification } from '$lib/core/stores/noti'; import Button from '$lib/components/ui/button/button.svelte'; - import Label from '$lib/components/ui/label/label.svelte'; - import * as Card from '$lib/components/ui/card/index.js'; import * as Select from '$lib/components/ui/select/index.js'; import Badge from '$lib/components/ui/badge/badge.svelte'; import Spinner from '$lib/components/ui/spinner/spinner.svelte'; - import Progress from '$lib/components/ui/progress/progress.svelte'; - import { Upload, X, Video, CheckCircle, AlertCircle, MonitorPlay } from '@lucide/svelte/icons'; + import { Upload, X, CheckCircle, AlertCircle, RefreshCw, Play, Video, MonitorPlay, Eye } from '@lucide/svelte/icons'; import * as adb from '$lib/core/adb/adb'; import { AdbInstance } from '../../../state.svelte'; const UPLOAD_PROXY_ENDPOINT = '/api/adv-upload'; - const MANIFEST_PROXY_ENDPOINT = '/api/adv-manifest'; - const MANIFEST_FILENAME = 'sync_1.file'; + const LIST_PROXY_ENDPOINT = '/api/adv-list'; + const FILE_PROXY_ENDPOINT = '/api/adv-file'; const ALLOWED_EXTENSIONS = ['.mp4']; - const MACHINE_PROJECT_DIR = '/sdcard/coffeevending/taobin_project'; // ───────────────────────────────────────────────────────────────────────── - // CONFIG — choose how the sync_1.file manifest is built (change this value). - // Either way the manifest lists ONLY the selected/active set, never the whole - // FTP folder (production keeps many inactive variants in the folder). - // 'ftp_listdir' = manifest built (in the browser) from the files you upload - // this session. No ADB, doesn't touch the machine. Recommended. - // 'machine' = original flow. On Upload it: rm -rf the machine adv folder, - // pushes the selected .mp4 (from the browser), then - // `ls -l > sync_1.file` on the machine, pulls it, uploads it. - // ⚠️ FULL REPLACE — requires ADB; select the COMPLETE adv set. - const MANIFEST_MODE: 'ftp_listdir' | 'machine' = 'ftp_listdir'; - //const MANIFEST_MODE: 'ftp_listdir' | 'machine' = 'machine'; + // Slot-based upload. There are 21 fixed slots (00–20). Each slot has a fixed + // TARGET filename — the source file's own name is ignored; it is renamed to + // the slot's name (taobin_adv_menu_NN.mp4) right before upload. Every upload + // asks the backend to regenerate sync_1.file from the FTP listing. // ───────────────────────────────────────────────────────────────────────── + const SLOT_COUNT = 21; // 00 .. 20 + const MENU_SPEC = { width: 1080, height: 380 }; // taobin_adv_menu_* banner size + + function slotFilename(i: number): string { + return `taobin_adv_menu_${String(i).padStart(2, '0')}.mp4`; + } + + // Only Thailand is enabled for now (other countries need their SFTP config on + // the backend before being added). + const COUNTRIES = [{ value: 'tha', label: 'Thailand (tha)' }]; + let selectedCountry = $state('tha'); // adv folder on the machine. Domestic Thailand uses the flat folder; every - // international country uses inter//adv (matches the on-machine and - // taobin_project source structure). + // international country uses inter//adv (matches the on-machine layout). + const MACHINE_PROJECT_DIR = '/sdcard/coffeevending/taobin_project'; function machineAdvDir(country: string): string { return country === 'tha' ? `${MACHINE_PROJECT_DIR}/adv` : `${MACHINE_PROJECT_DIR}/inter/${country}/adv`; } - // Each country can target a different SFTP host (configured on the backend - // via ADV_SFTP_COUNTRY_CONFIG). The selected country is sent with the upload. - // NOTE: only Thailand is enabled for now — other countries need their SFTP - // config (host/password/remote_dir) set on the backend before being added. - // To enable a country later, uncomment it here AND add it to ADV_SFTP_COUNTRY_CONFIG. - const COUNTRIES = [ - { value: 'tha', label: 'Thailand (tha)' } - // { value: 'mys', label: 'Malaysia (mys)' }, - // { value: 'aus', label: 'Australia (aus)' }, - // { value: 'sgp', label: 'Singapore (sgp)' }, - // { value: 'hkg', label: 'Hong Kong (hkg)' }, - // { value: 'gbr', label: 'United Kingdom (gbr)' }, - // { value: 'uae_dubai', label: 'UAE Dubai (uae_dubai)' }, - // { value: 'ltu', label: 'Lithuania (ltu)' }, - // { value: 'rou', label: 'Romania (rou)' }, - // { value: 'lva', label: 'Latvia (lva)' }, - // { value: 'est', label: 'Estonia (est)' } - ]; - - let selectedCountry = $state('tha'); - - // Expected dimensions are derived from the filename convention. - const ADV_SPECS = { - menu: { width: 1080, height: 380, label: 'Menu banner', pattern: /^taobin_adv_menu_[a-z0-9]+\.mp4$/i }, - fullscreen: { width: 1080, height: 608, label: 'Fullscreen / Idle', pattern: /^taobin_adv_[a-z0-9]+\.mp4$/i } - } as const; - - type AdvCategory = 'menu' | 'fullscreen' | 'invalid'; - - interface AdvFileItem { - id: string; - file: File; - preview: string; - status: 'pending' | 'uploading' | 'success' | 'error'; + interface Slot { + index: number; + name: string; // fixed target filename + file: File | null; + preview: string | null; // object URL of the chosen file + status: 'empty' | 'ready' | 'uploading' | 'success' | 'error'; error?: string; - category: AdvCategory; - expectedWidth?: number; - expectedHeight?: number; width?: number; height?: number; dimChecked: boolean; dimOk: boolean; } - let files = $state([]); - let uploading = $state(false); - let uploadProgress = $state({ current: 0, total: 0 }); - let dragOver = $state(false); + function emptySlot(i: number): Slot { + return { + index: i, + name: slotFilename(i), + file: null, + preview: null, + status: 'empty', + dimChecked: false, + dimOk: false + }; + } - // Push-to-machine (ADB) state + let slots = $state(Array.from({ length: SLOT_COUNT }, (_, i) => emptySlot(i))); + let uploadingAll = $state(false); + + const filledCount = $derived(slots.filter((s) => s.file).length); + const pendingCount = $derived(slots.filter((s) => s.file && s.status !== 'success').length); + // Push-to-machine (ADB) — preview the videos on a connected machine. let isAdbConnected = $derived(Boolean(AdbInstance.instance)); let pushingToMachine = $state(false); let pushProgress = $state({ current: 0, total: 0, name: '', percent: 0 }); let connecting = $state(false); - async function connectMachine() { - if (AdbInstance.instance) { - addNotification('INFO:Machine already connected'); - return; - } - connecting = true; - try { - await adb.connnectViaWebUSB(false); - addNotification( - AdbInstance.instance ? 'INFO:Machine connected' : 'WARN:No machine selected' - ); - } catch (error) { - logger.error('[Adv] connect error:', error); - addNotification(`ERR:Connect failed: ${error instanceof Error ? error.message : 'unknown'}`); - } finally { - connecting = false; - } + const anyBusy = $derived( + uploadingAll || pushingToMachine || slots.some((s) => s.status === 'uploading') + ); + // How many of the 21 slot names currently have a video on the server. + const onServerCount = $derived(slots.filter((s) => serverFilesByName[s.name] !== undefined).length); + + // Left-accent colour that encodes a slot's state at a glance. + function slotAccent(s: Slot): string { + if (s.status === 'success') return 'border-l-green-500'; + if (s.status === 'error') return 'border-l-red-500'; + if (s.file) return 'border-l-primary'; + // No local file yet, but a video already lives on the server for this slot. + if (serverFilesByName[s.name] !== undefined) return 'border-l-green-500'; + return 'border-l-border'; } - let generatingManifest = $state(false); - - function generateId() { - return Math.random().toString(36).substring(2, 9); - } - - // Classify by filename. Menu must be checked before fullscreen because a menu - // name also satisfies the broader fullscreen pattern. - function classify(name: string): { category: AdvCategory; width?: number; height?: number } { - const lower = name.toLowerCase(); - if (!lower.endsWith('.mp4')) return { category: 'invalid' }; - if (ADV_SPECS.menu.pattern.test(lower)) { - return { category: 'menu', width: ADV_SPECS.menu.width, height: ADV_SPECS.menu.height }; - } - if (ADV_SPECS.fullscreen.pattern.test(lower)) { - return { - category: 'fullscreen', - width: ADV_SPECS.fullscreen.width, - height: ADV_SPECS.fullscreen.height - }; - } - return { category: 'invalid' }; + async function authHeaders(): Promise> { + const token = await $auth?.getIdToken?.(); + return token ? { Authorization: `Bearer ${token}` } : {}; } function readVideoDimensions(file: File): Promise<{ width: number; height: number }> { @@ -157,164 +116,182 @@ }); } - function handleFileSelect(event: Event) { - const input = event.target as HTMLInputElement; - if (input.files) { - addFiles(Array.from(input.files)); + // Assign a chosen file to a slot. The source name is irrelevant — only .mp4 is + // required. Dimensions are checked against the banner spec as a soft warning. + function pickSlotFile(index: number, fileList: FileList | null) { + const file = fileList?.[0]; + if (!file) return; + + const ext = '.' + (file.name.split('.').pop()?.toLowerCase() ?? ''); + if (!ALLOWED_EXTENSIONS.includes(ext)) { + addNotification(`WARN:${file.name} - Only .mp4 allowed`); + return; } - input.value = ''; - } - function handleDrop(event: DragEvent) { - event.preventDefault(); - dragOver = false; - if (event.dataTransfer?.files) { - addFiles(Array.from(event.dataTransfer.files)); - } - } + const prev = slots[index]; + if (prev.preview) URL.revokeObjectURL(prev.preview); - function handleDragOver(event: DragEvent) { - event.preventDefault(); - dragOver = true; - } + slots[index] = { + ...emptySlot(index), + file, + preview: URL.createObjectURL(file), + status: 'ready' + }; - function handleDragLeave() { - dragOver = false; - } - - function addFiles(newFiles: File[]) { - const toAdd: AdvFileItem[] = []; - - for (const file of newFiles) { - const ext = '.' + file.name.split('.').pop()?.toLowerCase(); - if (!ALLOWED_EXTENSIONS.includes(ext)) { - addNotification(`WARN:${file.name} - Only .mp4 allowed`); - continue; - } - if (files.some((f) => f.file.name === file.name)) { - addNotification(`WARN:${file.name} - Already added`); - continue; - } - - const { category, width, height } = classify(file.name); - if (category === 'invalid') { - addNotification( - `WARN:${file.name} - Name must be taobin_adv_*.mp4 or taobin_adv_menu_*.mp4` - ); - continue; - } - - toAdd.push({ - id: generateId(), - file, - preview: URL.createObjectURL(file), - status: 'pending', - category, - expectedWidth: width, - expectedHeight: height, - dimChecked: false, - dimOk: false + readVideoDimensions(file) + .then(({ width, height }) => { + if (slots[index].file !== file) return; // slot changed meanwhile + slots[index].width = width; + slots[index].height = height; + slots[index].dimChecked = true; + slots[index].dimOk = width === MENU_SPEC.width && height === MENU_SPEC.height; + }) + .catch(() => { + if (slots[index].file !== file) return; + slots[index].dimChecked = true; + slots[index].dimOk = false; }); + } + + function clearSlot(index: number) { + const slot = slots[index]; + if (slot.preview) URL.revokeObjectURL(slot.preview); + slots[index] = emptySlot(index); + } + + // Upload a single slot: rename the file to the slot's fixed name, then POST. + async function uploadSlot(index: number): Promise { + const currentUser = $auth; + if (!currentUser) { + addNotification('ERR:Not logged in'); + return false; } + const slot = slots[index]; + if (!slot.file) return false; - if (toAdd.length === 0) return; - files = [...files, ...toAdd]; + slots[index].status = 'uploading'; + slots[index].error = undefined; + try { + // Ignore the source filename — send it under the slot's target name. + const renamed = new File([slot.file], slot.name, { type: 'video/mp4' }); - // Read each video's real dimensions and validate against the spec. - for (const item of toAdd) { - readVideoDimensions(item.file) - .then(({ width, height }) => { - const index = files.findIndex((f) => f.id === item.id); - if (index === -1) return; - const ok = width === item.expectedWidth && height === item.expectedHeight; - files[index].width = width; - files[index].height = height; - files[index].dimChecked = true; - files[index].dimOk = ok; - if (!ok) { - files[index].status = 'error'; - files[index].error = `Size ${width}x${height}, expected ${item.expectedWidth}x${item.expectedHeight}`; - } - }) - .catch(() => { - const index = files.findIndex((f) => f.id === item.id); - if (index === -1) return; - files[index].dimChecked = true; - files[index].dimOk = false; - files[index].status = 'error'; - files[index].error = 'Cannot read video dimensions'; - }); + const formData = new FormData(); + formData.append('country', selectedCountry); + formData.append('uid', currentUser.uid); + formData.append('displayName', currentUser.displayName || 'unknown'); + formData.append('email', currentUser.email || 'unknown@email.com'); + formData.append('file', renamed); + // regenerate=true → backend rebuilds sync_1.file from the FTP listing. + formData.append('regenerate', 'true'); + + const res = await fetch(UPLOAD_PROXY_ENDPOINT, { + method: 'POST', + headers: await authHeaders(), + body: formData + }); + if (!res.ok) { + const e = await res.json().catch(() => ({ detail: res.statusText })); + throw new Error(e.detail || e.message || 'Upload failed'); + } + + slots[index].status = 'success'; + addNotification(`INFO:Uploaded ${slot.name}`); + // The server now has (a new) file for this slot — drop the stale preview + // blob and refresh the indicator so "View current" shows the fresh one. + if (videoBlobUrls[slot.name]) { + URL.revokeObjectURL(videoBlobUrls[slot.name]); + const { [slot.name]: _drop, ...rest } = videoBlobUrls; + videoBlobUrls = rest; + } + refreshServerIndex(); + return true; + } catch (error) { + slots[index].status = 'error'; + slots[index].error = error instanceof Error ? error.message : 'Unknown error'; + logger.error(`[Adv] slot ${index} upload error:`, error); + return false; } } - function removeFile(id: string) { - const item = files.find((f) => f.id === id); - if (item) URL.revokeObjectURL(item.preview); - files = files.filter((f) => f.id !== id); + // Upload every filled slot that hasn't succeeded yet, one at a time. + async function uploadAllFilled() { + if (!$auth) { + addNotification('ERR:Not logged in'); + return; + } + const targets = slots.filter((s) => s.file && s.status !== 'success'); + if (targets.length === 0) { + addNotification('WARN:No slots to upload'); + return; + } + uploadingAll = true; + try { + let ok = 0; + let fail = 0; + for (const slot of targets) { + const success = await uploadSlot(slot.index); + success ? ok++ : fail++; + } + if (fail === 0) addNotification(`INFO:Uploaded ${ok} slot(s) successfully`); + else addNotification(`WARN:Uploaded ${ok}, failed ${fail}`); + } finally { + uploadingAll = false; + } } - function clearAllFiles() { - files.forEach((f) => URL.revokeObjectURL(f.preview)); - files = []; + async function connectMachine() { + if (AdbInstance.instance) { + addNotification('INFO:Machine already connected'); + return; + } + connecting = true; + try { + await adb.connnectViaWebUSB(false); + addNotification(AdbInstance.instance ? 'INFO:Machine connected' : 'WARN:No machine selected'); + } catch (error) { + logger.error('[Adv] connect error:', error); + addNotification(`ERR:Connect failed: ${error instanceof Error ? error.message : 'unknown'}`); + } finally { + connecting = false; + } } - // A video that passed name + dimension validation (eligible for push/upload). - function isValidVideo(item: AdvFileItem) { - return item.dimChecked && item.dimOk && item.category !== 'invalid'; - } - - function isUploadable(item: AdvFileItem) { - return isValidVideo(item) && (item.status === 'pending' || item.status === 'error'); - } - - const uploadableCount = $derived(files.filter(isUploadable).length); - const validVideoCount = $derived(files.filter(isValidVideo).length); - - // Step 1 (mirrors original sync.sh): push the valid videos to the connected - // machine via ADB so you can preview them before sending to the server. + // Push every filled slot's video to the connected machine's adv folder (under + // the slot's fixed name) so it can be previewed on the machine before upload. async function pushToMachine() { if (!AdbInstance.instance) { addNotification('ERR:Machine not connected (ADB)'); return; } - const targets = files.filter(isValidVideo); + const targets = slots.filter((s) => s.file); if (targets.length === 0) { - addNotification('WARN:No valid videos to push'); + addNotification('WARN:No slots to push'); return; } - const targetDir = machineAdvDir(selectedCountry); - pushingToMachine = true; pushProgress = { current: 0, total: targets.length, name: '', percent: 0 }; - let success = 0; try { for (let i = 0; i < targets.length; i++) { - const item = targets[i]; - pushProgress = { current: i, total: targets.length, name: item.file.name, percent: 0 }; - - const bytes = new Uint8Array(await item.file.arrayBuffer()); - const ok = await adb.pushBinary( - `${targetDir}/${item.file.name}`, - bytes, - (sent, total) => { - pushProgress = { - current: i, - total: targets.length, - name: item.file.name, - percent: total > 0 ? Math.round((sent / total) * 100) : 0 - }; - } - ); + const slot = targets[i]; + pushProgress = { current: i, total: targets.length, name: slot.name, percent: 0 }; + const bytes = new Uint8Array(await slot.file!.arrayBuffer()); + const ok = await adb.pushBinary(`${targetDir}/${slot.name}`, bytes, (sent, total) => { + pushProgress = { + current: i, + total: targets.length, + name: slot.name, + percent: total > 0 ? Math.round((sent / total) * 100) : 0 + }; + }); if (ok) { success++; - pushProgress = { current: i + 1, total: targets.length, name: item.file.name, percent: 100 }; + pushProgress = { current: i + 1, total: targets.length, name: slot.name, percent: 100 }; } else { - addNotification(`ERR:Push failed: ${item.file.name}`); + addNotification(`ERR:Push failed: ${slot.name}`); } } - if (success > 0) { addNotification(`INFO:Pushed ${success} video(s) to machine (${targetDir})`); } @@ -326,558 +303,510 @@ } } - async function authHeaders(): Promise> { - const token = await $auth?.getIdToken?.(); - return token ? { Authorization: `Bearer ${token}` } : {}; + // ───────────────────────────────────────────────────────────────────────── + // Preview adv videos already on the server (FTP). + // Adv videos live only on the FTP server — no nginx/git copy — so we fetch + // each one through the auth-guarded proxy as a Blob and play it via an object + // URL. Lets you verify an upload actually landed correctly. + // ───────────────────────────────────────────────────────────────────────── + interface ServerVideo { + name: string; + size: number; + mtime: number; } - async function uploadFiles() { - const currentUser = $auth; - if (!currentUser) { - addNotification('ERR:Not logged in'); - return; - } + let serverVideos = $state([]); + let loadingServerVideos = $state(false); + let serverVideosLoaded = $state(false); + // name -> object URL (created automatically after the list loads) + let videoBlobUrls = $state>({}); + let loadingVideoName = $state(null); + let videoLoadProgress = $state({ current: 0, total: 0 }); - const pendingFiles = files.filter(isUploadable); - if (pendingFiles.length === 0) { - addNotification('WARN:No valid files to upload'); - return; - } + // Lightweight name→size map of what is on the server RIGHT NOW, used to show a + // per-slot "on server" indicator (so you can preview the current video before + // overwriting a slot). Refreshed on mount, on country change, and after upload. + let serverFilesByName = $state>({}); + // The server video currently open in the preview lightbox (or null). + let viewingName = $state(null); - const uid = currentUser.uid; - const displayName = currentUser.displayName || 'unknown'; - const email = currentUser.email || 'unknown@email.com'; - - // Method 2 mirrors the original flow: make the machine's adv folder hold - // EXACTLY the selected set (rm -rf + push) BEFORE its ls -l manifest is - // generated — so machine = FTP = manifest. The .mp4 still come from the - // browser (the dragged files), only the manifest comes from the machine. - if (MANIFEST_MODE === 'machine') { - const synced = await syncMachineFolder(pendingFiles); - if (!synced) return; - } - - uploading = true; - uploadProgress = { current: 0, total: pendingFiles.length }; - - for (let i = 0; i < pendingFiles.length; i++) { - const item = pendingFiles[i]; - const index = files.findIndex((f) => f.id === item.id); - if (index === -1) continue; - - files[index].status = 'uploading'; - files[index].error = undefined; - - try { - const formData = new FormData(); - formData.append('country', selectedCountry); - formData.append('uid', uid); - formData.append('displayName', displayName); - formData.append('email', email); - formData.append('file', item.file); - // Manifest is built from the selected set (method 1) or the machine - // (method 2) — never from the whole FTP folder. So tell the backend - // not to rebuild it from the FTP listing. - formData.append('regenerate', 'false'); - - const response = await fetch(UPLOAD_PROXY_ENDPOINT, { - method: 'POST', - headers: await authHeaders(), - body: formData - }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({ detail: response.statusText })); - throw new Error(errorData.detail || errorData.message || 'Upload failed'); - } - - files[index].status = 'success'; - uploadProgress = { current: i + 1, total: pendingFiles.length }; - } catch (error) { - files[index].status = 'error'; - files[index].error = error instanceof Error ? error.message : 'Unknown error'; - logger.error(`Upload error for ${item.file.name}:`, error); - } - } - - uploading = false; - - const successCount = pendingFiles.filter((f) => f.status === 'success').length; - const errorCount = pendingFiles.filter((f) => f.status === 'error').length; - - if (errorCount === 0) { - addNotification(`INFO:Uploaded ${successCount} adv video(s) successfully`); - } else { - addNotification(`WARN:Uploaded ${successCount}, failed ${errorCount}`); - } - - // Build the manifest (sync_1.file) — only from the selected/active set, - // never the whole FTP folder (production keeps many inactive variants there). - if (successCount > 0) { - if (MANIFEST_MODE === 'machine') { - // Method 2: ls -l on the (rm -rf + pushed) machine, then upload. - await generateMachineManifest(uid, displayName, email); - } else { - // Method 1: manifest = the successfully-uploaded files in the UI list. - await uploadSelectedManifest(uid, displayName, email); - } - } + function formatBytes(bytes: number): string { + if (!bytes) return '0 MB'; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } - // Method 1 — build sync_1.file from the active set the user assembled in the - // UI (the successfully-uploaded files), NOT the whole FTP folder. The FTP keeps - // any other/variant files; the manifest lists only what you chose. - async function uploadSelectedManifest(uid: string, displayName: string, email: string) { - const active = files.filter((f) => f.status === 'success'); - if (active.length === 0) return; - generatingManifest = true; + // List-only fetch (no blobs) — cheap, just to know what each slot currently has. + async function refreshServerIndex() { try { - const text = buildManifestText(active); - await uploadManifestText(text, uid, displayName, email); - addNotification(`INFO:Manifest uploaded (${active.length} active file(s))`); + const res = await fetch(LIST_PROXY_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...(await authHeaders()) }, + body: JSON.stringify({ country: selectedCountry }) + }); + if (!res.ok) return; + const data = await res.json(); + const map: Record = {}; + for (const f of (data.files ?? []) as ServerVideo[]) map[f.name] = f.size; + serverFilesByName = map; } catch (error) { - logger.error('[Adv] selected manifest error:', error); - addNotification(`ERR:Manifest failed: ${error instanceof Error ? error.message : 'unknown'}`); - } finally { - generatingManifest = false; + logger.error('[Adv] refresh server index error:', error); } } - // ls -l style line the machine's FileSyncServer parses (size at field 4, name - // at field 7). sync_1.file lists itself as size 0 so the machine skips it. - function buildManifestText(items: AdvFileItem[]): string { - const pad = (n: number) => String(n).padStart(2, '0'); - const fmt = (ms: number) => { - const d = new Date(ms); - return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`; - }; - const now = Date.now(); - const rows = [ - { name: MANIFEST_FILENAME, size: 0, mtime: now }, - ...items.map((it) => ({ - name: it.file.name, - size: it.file.size, - mtime: it.file.lastModified || now - })) - ].sort((a, b) => a.name.localeCompare(b.name)); - - const lines = ['total 0']; - for (const r of rows) { - lines.push(`-rw-rw---- 1 root sdcard_rw ${r.size} ${fmt(r.mtime)} ${r.name}`); - } - return lines.join('\n') + '\n'; + // Open the current server video for a slot in the lightbox. + async function viewCurrent(name: string) { + await playServerVideo(name); // loads the blob if not already + if (videoBlobUrls[name]) viewingName = name; } - async function uploadManifestText( - text: string, - uid: string, - displayName: string, - email: string - ) { - const fd = new FormData(); - fd.append('country', selectedCountry); - fd.append('uid', uid); - fd.append('displayName', displayName); - fd.append('email', email); - fd.append('file', new File([text], MANIFEST_FILENAME, { type: 'text/plain' })); - - const res = await fetch(MANIFEST_PROXY_ENDPOINT, { - method: "POST", - headers: await authHeaders(), - body: fd - }); - if (!res.ok) { - const e = await res.json().catch(() => ({ detail: res.statusText })); - throw new Error(e.detail || 'Manifest upload failed'); - } - } - - // Method 2 step 1 — make the machine's adv folder hold EXACTLY the selected - // files (rm -rf + push), like the original `rm -Rf adv` + push of the whole - // folder. After this, the machine's `ls -l` matches what we upload to the FTP. - // ⚠️ This WIPES the machine's adv folder — method 2 is a full replace, so the - // dragged set must be the complete intended adv set. - async function syncMachineFolder(targets: AdvFileItem[]): Promise { - if (!AdbInstance.instance) { - addNotification('ERR:Method 2 (machine manifest) needs the machine connected via ADB'); - return false; - } - const advDir = machineAdvDir(selectedCountry); - pushingToMachine = true; + async function loadServerVideos() { + loadingServerVideos = true; try { - // Wipe + recreate so only the selected set remains on the machine. - await adb.executeCmd(`rm -rf "${advDir}" && mkdir -p "${advDir}"`); - - for (let i = 0; i < targets.length; i++) { - const item = targets[i]; - pushProgress = { current: i, total: targets.length, name: item.file.name, percent: 0 }; - const bytes = new Uint8Array(await item.file.arrayBuffer()); - const ok = await adb.pushBinary( - `${advDir}/${item.file.name}`, - bytes, - (sent, total) => { - pushProgress = { - current: i, - total: targets.length, - name: item.file.name, - percent: total > 0 ? Math.round((sent / total) * 100) : 0 - }; - } - ); - if (!ok) { - addNotification(`ERR:Push to machine failed: ${item.file.name}`); - return false; - } - pushProgress = { current: i + 1, total: targets.length, name: item.file.name, percent: 100 }; - } - addNotification(`INFO:Machine adv folder synced (${targets.length} file(s))`); - return true; - } catch (error) { - logger.error('[Adv] machine sync error:', error); - addNotification(`ERR:Machine sync failed: ${error instanceof Error ? error.message : 'unknown'}`); - return false; - } finally { - pushingToMachine = false; - } - } - - // Method 2 step 2 — generate the manifest from the (now synced) machine adv - // folder (`ls -l > sync_1.file`), pull it, and upload it to the FTP. - async function generateMachineManifest(uid: string, displayName: string, email: string) { - if (!AdbInstance.instance) { - addNotification('ERR:Machine not connected — cannot generate manifest from machine'); - return; - } - const advDir = machineAdvDir(selectedCountry); - generatingManifest = true; - try { - // ls -l > sync_1.file on the machine (shell truncates the manifest first, - // so it lists itself as size 0 — exactly what the original flow produces). - await adb.executeCmd(`cd ${advDir} && ls -l > ${MANIFEST_FILENAME}`); - - const manifestText = await adb.pull(`${advDir}/${MANIFEST_FILENAME}`); - if (!manifestText || manifestText.trim().length === 0) { - addNotification('ERR:Failed to read sync_1.file from machine'); - return; - } - - const fd = new FormData(); - fd.append('country', selectedCountry); - fd.append('uid', uid); - fd.append('displayName', displayName); - fd.append('email', email); - fd.append( - 'file', - new File([manifestText], MANIFEST_FILENAME, { type: 'text/plain' }) - ); - - const res = await fetch(MANIFEST_PROXY_ENDPOINT, { - method: "POST", - headers: await authHeaders(), - body: fd - }); + const res = await fetch(LIST_PROXY_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...(await authHeaders()) }, + body: JSON.stringify({ country: selectedCountry }) + }); if (!res.ok) { - const e = await res.json().catch(() => ({ detail: res.statusText })); - throw new Error(e.detail || 'Manifest upload failed'); + const err = await res.json().catch(() => ({ detail: res.statusText })); + throw new Error(err.detail || 'Failed to list videos'); } - addNotification('INFO:Manifest (from machine) uploaded to server'); + const data = await res.json(); + // Reset any previously loaded blobs (folder/country may have changed). + for (const url of Object.values(videoBlobUrls)) URL.revokeObjectURL(url); + videoBlobUrls = {}; + serverVideos = (data.files ?? []) as ServerVideo[]; + serverVideosLoaded = true; + // Auto-fetch every video so each shows a ready-to-play player without a + // manual click. Limited concurrency keeps it from hammering the SFTP. + await autoLoadAllVideos(); } catch (error) { - logger.error('[Adv] machine manifest error:', error); - addNotification(`ERR:Machine manifest failed: ${error instanceof Error ? error.message : 'unknown'}`); + logger.error('[Adv] list server videos error:', error); + addNotification(`ERR:Load videos failed: ${error instanceof Error ? error.message : 'unknown'}`); } finally { - generatingManifest = false; + loadingServerVideos = false; } } + // Fetch every server video's blob with a small concurrency pool (each request + // opens its own SFTP session on the backend, so we don't want 25 at once). + async function autoLoadAllVideos() { + const names = serverVideos.map((v) => v.name).filter((n) => !videoBlobUrls[n]); + videoLoadProgress = { current: 0, total: names.length }; + if (names.length === 0) return; + + const CONCURRENCY = 3; + let next = 0; + async function worker() { + while (next < names.length) { + const i = next++; + await playServerVideo(names[i]); + videoLoadProgress = { ...videoLoadProgress, current: videoLoadProgress.current + 1 }; + } + } + await Promise.all(Array.from({ length: Math.min(CONCURRENCY, names.length) }, worker)); + } + + async function playServerVideo(name: string) { + if (videoBlobUrls[name]) return; // already loaded + loadingVideoName = name; + try { + const url = `${FILE_PROXY_ENDPOINT}?country=${encodeURIComponent(selectedCountry)}&filename=${encodeURIComponent(name)}`; + const res = await fetch(url, { headers: await authHeaders() }); + if (!res.ok) { + const err = await res.json().catch(() => ({ detail: res.statusText })); + throw new Error(err.detail || 'Failed to fetch video'); + } + const blob = await res.blob(); + videoBlobUrls = { ...videoBlobUrls, [name]: URL.createObjectURL(blob) }; + } catch (error) { + logger.error('[Adv] fetch server video error:', error); + addNotification(`ERR:Load "${name}" failed: ${error instanceof Error ? error.message : 'unknown'}`); + } finally { + loadingVideoName = null; + } + } + + // Refresh the per-slot "on server" indicators on mount and whenever the + // selected country changes. + $effect(() => { + void selectedCountry; + refreshServerIndex(); + }); + $effect(() => { return () => { - files.forEach((f) => URL.revokeObjectURL(f.preview)); + for (const s of slots) if (s.preview) URL.revokeObjectURL(s.preview); + for (const url of Object.values(videoBlobUrls)) URL.revokeObjectURL(url); }; }); -
+
-
-
-
-

Adv Upload

-

Upload advertisement videos (.mp4) to the server

-
+
+
+
+
+
+
+
+

Advertisement Upload

+ +
+
-
- - {isAdbConnected ? 'Machine connected' : 'Machine offline'} - +
+ + + {isAdbConnected ? 'Machine connected' : 'Machine offline'} + - {#if !isAdbConnected} - - {/if} + {#if !isAdbConnected} + + {/if} - {#if files.length > 0} - {/if} - - + + + {COUNTRIES.find((c) => c.value === selectedCountry)?.label || 'Country'} + + + {#each COUNTRIES as country} + {country.label} + {/each} + + - - + +
+ + + {#if pushingToMachine} +
+
+
+
+ + Pushing {pushProgress.name} · {pushProgress.percent}% + +
+ {/if}
-
+
-
-
- - - - Upload Settings - - -
- - - - {COUNTRIES.find((c) => c.value === selectedCountry)?.label || 'Select country'} - - - {#each COUNTRIES as country} - {country.label} - {/each} - - - -
- -
-
- - - - - Naming & Size Rules - - -
-
-

taobin_adv_menu_*.mp4

-

Menu banner ad

- 1080 × 380 -
-
-

taobin_adv_*.mp4

-

Fullscreen / Idle ad

- 1080 × 608 -
-
-
-
- - - - -
diff --git a/src/routes/api/adv-file/+server.ts b/src/routes/api/adv-file/+server.ts new file mode 100644 index 0000000..149a6da --- /dev/null +++ b/src/routes/api/adv-file/+server.ts @@ -0,0 +1,47 @@ +import { logger } from '$lib/core/utils/logger'; +import { error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { env } from '$env/dynamic/public'; +import { verifyAuthToken } from '$lib/server/auth'; + +// Adv videos are served by the same taobin_image service as menu images. +const ADV_API_BASE = env.PUBLIC_POST_IMAGE; + +// Stream a single adv .mp4 back to the browser. The frontend fetches this with a +// Bearer token and turns the response into a Blob/object URL for a