diff --git a/src/routes/(authed)/tools/adv-upload/+page.svelte b/src/routes/(authed)/tools/adv-upload/+page.svelte index 0b7d1e6..b4ce370 100644 --- a/src/routes/(authed)/tools/adv-upload/+page.svelte +++ b/src/routes/(authed)/tools/adv-upload/+page.svelte @@ -3,100 +3,141 @@ 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 { Upload, X, CheckCircle, AlertCircle, RefreshCw, Play, Video, MonitorPlay, Eye } from '@lucide/svelte/icons'; + import Progress from '$lib/components/ui/progress/progress.svelte'; + import { Upload, X, Video, CheckCircle, AlertCircle, MonitorPlay } 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 LIST_PROXY_ENDPOINT = '/api/adv-list'; - const FILE_PROXY_ENDPOINT = '/api/adv-file'; + const MANIFEST_PROXY_ENDPOINT = '/api/adv-manifest'; + const MANIFEST_FILENAME = 'sync_1.file'; const ALLOWED_EXTENSIONS = ['.mp4']; + const MACHINE_PROJECT_DIR = '/sdcard/coffeevending/taobin_project'; // ───────────────────────────────────────────────────────────────────────── - // 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. + // 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'; // ───────────────────────────────────────────────────────────────────────── - 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 layout). - const MACHINE_PROJECT_DIR = '/sdcard/coffeevending/taobin_project'; + // international country uses inter//adv (matches the on-machine and + // taobin_project source structure). function machineAdvDir(country: string): string { return country === 'tha' ? `${MACHINE_PROJECT_DIR}/adv` : `${MACHINE_PROJECT_DIR}/inter/${country}/adv`; } - 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'; + // 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'; error?: string; + category: AdvCategory; + expectedWidth?: number; + expectedHeight?: number; width?: number; height?: number; dimChecked: boolean; dimOk: boolean; } - function emptySlot(i: number): Slot { - return { - index: i, - name: slotFilename(i), - file: null, - preview: null, - status: 'empty', - dimChecked: false, - dimOk: false - }; - } + let files = $state([]); + let uploading = $state(false); + let uploadProgress = $state({ current: 0, total: 0 }); + let dragOver = $state(false); - 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. + // Push-to-machine (ADB) state let isAdbConnected = $derived(Boolean(AdbInstance.instance)); let pushingToMachine = $state(false); let pushProgress = $state({ current: 0, total: 0, name: '', percent: 0 }); let connecting = $state(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'; + 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; + } } - async function authHeaders(): Promise> { - const token = await $auth?.getIdToken?.(); - return token ? { Authorization: `Bearer ${token}` } : {}; + 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' }; } function readVideoDimensions(file: File): Promise<{ width: number; height: number }> { @@ -116,182 +157,164 @@ }); } - // 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; + function handleFileSelect(event: Event) { + const input = event.target as HTMLInputElement; + if (input.files) { + addFiles(Array.from(input.files)); } + input.value = ''; + } - const prev = slots[index]; - if (prev.preview) URL.revokeObjectURL(prev.preview); + function handleDrop(event: DragEvent) { + event.preventDefault(); + dragOver = false; + if (event.dataTransfer?.files) { + addFiles(Array.from(event.dataTransfer.files)); + } + } - slots[index] = { - ...emptySlot(index), - file, - preview: URL.createObjectURL(file), - status: 'ready' - }; + function handleDragOver(event: DragEvent) { + event.preventDefault(); + dragOver = true; + } - 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 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 }); - } - - 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; - 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' }); + if (toAdd.length === 0) return; + files = [...files, ...toAdd]; - 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; + // 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'; + }); } } - // 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 removeFile(id: string) { + const item = files.find((f) => f.id === id); + if (item) URL.revokeObjectURL(item.preview); + files = files.filter((f) => f.id !== id); } - 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; - } + function clearAllFiles() { + files.forEach((f) => URL.revokeObjectURL(f.preview)); + files = []; } - // 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. + // 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. async function pushToMachine() { if (!AdbInstance.instance) { addNotification('ERR:Machine not connected (ADB)'); return; } - const targets = slots.filter((s) => s.file); + const targets = files.filter(isValidVideo); if (targets.length === 0) { - addNotification('WARN:No slots to push'); + addNotification('WARN:No valid videos 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 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 - }; - }); + 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 + }; + } + ); if (ok) { success++; - pushProgress = { current: i + 1, total: targets.length, name: slot.name, percent: 100 }; + pushProgress = { current: i + 1, total: targets.length, name: item.file.name, percent: 100 }; } else { - addNotification(`ERR:Push failed: ${slot.name}`); + addNotification(`ERR:Push failed: ${item.file.name}`); } } + if (success > 0) { addNotification(`INFO:Pushed ${success} video(s) to machine (${targetDir})`); } @@ -303,510 +326,558 @@ } } - // ───────────────────────────────────────────────────────────────────────── - // 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 authHeaders(): Promise> { + const token = await $auth?.getIdToken?.(); + return token ? { Authorization: `Bearer ${token}` } : {}; } - 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 }); + async function uploadFiles() { + const currentUser = $auth; + if (!currentUser) { + addNotification('ERR:Not logged in'); + 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 pendingFiles = files.filter(isUploadable); + if (pendingFiles.length === 0) { + addNotification('WARN:No valid files to upload'); + return; + } - function formatBytes(bytes: number): string { - if (!bytes) return '0 MB'; - return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; - } + const uid = currentUser.uid; + const displayName = currentUser.displayName || 'unknown'; + const email = currentUser.email || 'unknown@email.com'; - // List-only fetch (no blobs) — cheap, just to know what each slot currently has. - async function refreshServerIndex() { - try { - 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] refresh server index error:', error); + // 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); + } } } - // 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 loadServerVideos() { - loadingServerVideos = true; + // 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; try { - 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 err = await res.json().catch(() => ({ detail: res.statusText })); - throw new Error(err.detail || 'Failed to list videos'); - } - 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(); + const text = buildManifestText(active); + await uploadManifestText(text, uid, displayName, email); + addNotification(`INFO:Manifest uploaded (${active.length} active file(s))`); } catch (error) { - logger.error('[Adv] list server videos error:', error); - addNotification(`ERR:Load videos failed: ${error instanceof Error ? error.message : 'unknown'}`); + logger.error('[Adv] selected manifest error:', error); + addNotification(`ERR:Manifest failed: ${error instanceof Error ? error.message : 'unknown'}`); } finally { - loadingServerVideos = false; + generatingManifest = 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; + // 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 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 }; - } + const lines = ['total 0']; + for (const r of rows) { + lines.push(`-rw-rw---- 1 root sdcard_rw ${r.size} ${fmt(r.mtime)} ${r.name}`); } - await Promise.all(Array.from({ length: Math.min(CONCURRENCY, names.length) }, worker)); + return lines.join('\n') + '\n'; } - async function playServerVideo(name: string) { - if (videoBlobUrls[name]) return; // already loaded - loadingVideoName = 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; 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'); + // 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 }; } - const blob = await res.blob(); - videoBlobUrls = { ...videoBlobUrls, [name]: URL.createObjectURL(blob) }; + addNotification(`INFO:Machine adv folder synced (${targets.length} file(s))`); + return true; } catch (error) { - logger.error('[Adv] fetch server video error:', error); - addNotification(`ERR:Load "${name}" failed: ${error instanceof Error ? error.message : 'unknown'}`); + logger.error('[Adv] machine sync error:', error); + addNotification(`ERR:Machine sync failed: ${error instanceof Error ? error.message : 'unknown'}`); + return false; } finally { - loadingVideoName = null; + pushingToMachine = false; } } - // Refresh the per-slot "on server" indicators on mount and whenever the - // selected country changes. - $effect(() => { - void selectedCountry; - refreshServerIndex(); - }); + // 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 + }); + if (!res.ok) { + const e = await res.json().catch(() => ({ detail: res.statusText })); + throw new Error(e.detail || 'Manifest upload failed'); + } + addNotification('INFO:Manifest (from machine) uploaded to server'); + } catch (error) { + logger.error('[Adv] machine manifest error:', error); + addNotification(`ERR:Machine manifest failed: ${error instanceof Error ? error.message : 'unknown'}`); + } finally { + generatingManifest = false; + } + } $effect(() => { return () => { - for (const s of slots) if (s.preview) URL.revokeObjectURL(s.preview); - for (const url of Object.values(videoBlobUrls)) URL.revokeObjectURL(url); + files.forEach((f) => URL.revokeObjectURL(f.preview)); }; }); -
+
-
-
-
-
-
-
-
-

Advertisement Upload

- -
-
- -
- - - {isAdbConnected ? 'Machine connected' : 'Machine offline'} - - - {#if !isAdbConnected} - - {/if} - - - - - - {COUNTRIES.find((c) => c.value === selectedCountry)?.label || 'Country'} - - - {#each COUNTRIES as country} - {country.label} - {/each} - - - - -
+
+
+
+

Adv Upload

+

Upload advertisement videos (.mp4) to the server

- - {#if pushingToMachine} -
-
-
-
- - Pushing {pushProgress.name} · {pushProgress.percent}% - -
- {/if} -
-
+
+ + {isAdbConnected ? 'Machine connected' : 'Machine offline'} + - -
-
- -
-
-
-

Slots

-

- Choose a video per slot — it's renamed to the slot's fixed name on upload. -

-
- {#if onServerCount > 0} - - - {onServerCount} on server - - {/if} -
- -
- {#each slots as slot (slot.index)} -
- -
- {String(slot.index).padStart(2, '0')} -
- - -
- {#if slot.preview} - - - {#if slot.status === 'uploading'} -
- -
- {:else if slot.status === 'success'} -
- -
- {/if} - {:else} - - {/if} -
- - -
-

- {slot.name} -

- {#if slot.file} -
- {#if !slot.dimChecked} - Reading… - {:else if slot.dimOk} - - {slot.width}×{slot.height} - - {:else} - - {slot.width ?? '?'}×{slot.height ?? '?'} · exp {MENU_SPEC.width}×{MENU_SPEC.height} - - {/if} - {formatBytes(slot.file.size)} -
- {#if slot.error} -

- - {slot.error} -

- {/if} - {:else} -

Empty · no file selected

- {/if} - - - {#if serverFilesByName[slot.name] !== undefined} -
- - - On server · {formatBytes(serverFilesByName[slot.name])} - - -
- {/if} -
- - -
- {#if slot.file} - - {#if slot.status !== 'uploading'} - - {/if} - {/if} -
-
- {/each} -
-
- - -
-
-
-

Videos on server

-

- Currently on the FTP folder for {selectedCountry} — loaded - automatically to verify. -

-
- -
- - {#if !serverVideosLoaded} -
-
- {:else if serverVideos.length === 0} -

No videos found on the server.

- {:else} -
- {#each serverVideos as video (video.name)} -
-
- {#if videoBlobUrls[video.name]} - - - {:else} - - {/if} -
-
- {video.name} - {formatBytes(video.size)} -
-
- {/each} -
{/if} -
+ + {#if files.length > 0} + + {/if} + + + + + + +
- - {#if viewingName} - - -
(viewingName = null)} - > - - -
e.stopPropagation()} - > -
- {viewingName} - + +
- {/if} +
diff --git a/src/routes/api/adv-file/+server.ts b/src/routes/api/adv-file/+server.ts deleted file mode 100644 index 149a6da..0000000 --- a/src/routes/api/adv-file/+server.ts +++ /dev/null @@ -1,47 +0,0 @@ -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