From 775f04d61589e0ec2c5e6c7c2968d6eceab10928 Mon Sep 17 00:00:00 2001 From: Yopi Cahya Date: Wed, 14 Jan 2026 12:34:35 +0700 Subject: [PATCH 01/18] =?UTF-8?q?feat:=20improve=20media=20downloader=20?= =?UTF-8?q?=E2=80=94=20progress=20UI,=20album=20download,=20and=20robustne?= =?UTF-8?q?ss?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Summary: Add download progress UI, persistent album-scan-and-download feature, and more robust media extraction and downloading logic. - Key improvements: Implemented progress indicators (`createProgressBar`, `updateProgress`, `completeProgress`, `AbortProgress`); chunked downloads using `Range` headers and optional `showSaveFilePicker` support for large streams; separate handlers for audio/video/image (`tel_download_audio`, `tel_download_video`, `tel_download_image`). - Album support: Added a "Scanned/Downloaded" badge for albums, persistent album state in `localStorage` (key `tel_album_states_v1`), and the ability to download all items in an album. - Robustness & UX: More reliable probing of viewer streams with suppression of noisy `MediaError` rejections; special-case support for webz/webk stories, pinned audio, and multiple Telegram DOM variants. - Logging & error handling: Improved logging and safer error handling during probing and downloads. --- src/tel_download.js | 383 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 383 insertions(+) diff --git a/src/tel_download.js b/src/tel_download.js index 8971bba..6287dac 100644 --- a/src/tel_download.js +++ b/src/tel_download.js @@ -481,6 +481,19 @@ logger.info("Initialized"); + // Global handler to optionally suppress noisy MediaError unhandled rejections + let telSuppressMediaError = false; + window.addEventListener('unhandledrejection', (ev) => { + try { + const reason = ev && ev.reason; + const msg = (reason && reason.message) || String(reason || ''); + if (telSuppressMediaError && msg && msg.includes('Failed to init decoder')) { + ev.preventDefault && ev.preventDefault(); + logger.info('Suppressed MediaError: ' + msg); + } + } catch (e) {} + }); + // For webz /a/ webapp setInterval(() => { // Stories @@ -846,5 +859,375 @@ body.appendChild(container); })(); + // Persistent album state helpers + const ALBUM_STORAGE_KEY = 'tel_album_states_v1'; + const loadAlbumStates = () => { + try { + const raw = localStorage.getItem(ALBUM_STORAGE_KEY); + return raw ? JSON.parse(raw) : {}; + } catch (e) { + logger.error('Failed to parse album states: ' + (e.message || e)); + return {}; + } + }; + const saveAlbumStates = (states) => { + try { + localStorage.setItem(ALBUM_STORAGE_KEY, JSON.stringify(states)); + } catch (e) { + logger.error('Failed to save album states: ' + (e.message || e)); + } + }; + const getAlbumState = (albumMid) => { + const states = loadAlbumStates(); + return states[albumMid] || { status: null, items: {} }; + }; + const setAlbumState = (albumMid, state) => { + const states = loadAlbumStates(); + states[albumMid] = state; + saveAlbumStates(states); + }; + + const createBadgeForAlbum = (album, initStatus = null) => { + try { + if (getComputedStyle(album).position === 'static') { + album.style.position = 'relative'; + } + const existing = album.querySelector('.tel-album-scanned-badge'); + const albumMid = album.getAttribute && album.getAttribute('data-mid'); + let state = albumMid ? getAlbumState(albumMid) : { status: null, items: {} }; + if (initStatus && !state.status) { + state.status = initStatus; + if (albumMid) setAlbumState(albumMid, state); + } + if (existing) { + existing.innerText = state.status === 'downloaded' ? 'Downloaded' : 'Scanned'; + return existing; + } + + const badge = document.createElement('button'); + badge.className = 'tel-album-scanned-badge'; + badge.title = 'Download album'; + badge.style.position = 'absolute'; + badge.style.top = '8px'; + badge.style.right = '8px'; + badge.style.zIndex = 9999; + badge.style.padding = '4px 8px'; + badge.style.borderRadius = '12px'; + badge.style.background = '#6093B5'; + badge.style.color = 'white'; + badge.style.border = 'none'; + badge.style.cursor = 'pointer'; + badge.innerText = state.status === 'downloaded' ? 'Downloaded' : 'Scanned'; + + const updateBadgeText = (s) => (badge.innerText = s); + + badge.addEventListener('click', async (ev) => { + ev.stopPropagation(); + badge.disabled = true; + // Gather album items; if none (single message), treat the attachment itself as one item + let albumItems = Array.from(album.querySelectorAll('.album-item.grouped-item')); + if (albumItems.length === 0) { + const attach = album.querySelector('.attachment, .album-item, .album-item-media, .media-container'); + if (attach) albumItems = [attach]; + } + + // load latest state + state = albumMid ? getAlbumState(albumMid) : state; + for (const item of albumItems) { + // For single-message attachments the item may not have its own data-mid, + // so fall back to album's data-mid + const itemMid = (item.getAttribute && item.getAttribute('data-mid')) || (album.getAttribute && album.getAttribute('data-mid')); + if (itemMid && state.items && state.items[itemMid]) { + logger.info('Skipping already downloaded item: ' + itemMid); + continue; + } + + const isVideo = !!item.querySelector('.video-time') || item.classList.contains('video') || album.classList.contains('video'); + if (isVideo) { + // try to find source + let src = + item.querySelector('video')?.currentSrc || + item.querySelector('video')?.src || + item.querySelector('video source')?.src || + item.querySelector('a')?.href || + item.querySelector('[data-src]')?.getAttribute('data-src'); + + if (!src) { + const bg = item.style.backgroundImage || getComputedStyle(item).backgroundImage; + const m = bg && bg.match(/url\(["']?(.*?)['"]?\)/); + if (m && m[1]) src = m[1]; + } + + if (src) { + logger.info('Downloading media: ' + src); + tel_download_video(src); + if (itemMid) { + state.items = state.items || {}; + state.items[itemMid] = true; + if (albumMid) setAlbumState(albumMid, state); + } + await new Promise((r) => setTimeout(r, 300)); + continue; + } + + // open viewer to extract streaming url with robust handling + const opener = item.querySelector('a') || item; + + // Enable global suppression of MediaError unhandled rejections while probing + telSuppressMediaError = true; + + try { + try { + opener.click(); + } catch (e) { + logger.info('Opener click failed: ' + (e?.message || e)); + } + + const timeout = 8000; + const start = Date.now(); + let found = null; + try { + while (Date.now() - start < timeout) { + const v = document.querySelector('#MediaViewer .MediaViewerSlide--active video, .media-viewer-whole video, video.media-video, .ckin__player video'); + if (v) { + v.addEventListener('error', () => { + (async () => { + try { + const url = v.currentSrc || v.src; + if (!url) { + if (!telSuppressMediaError) logger.info('Video element reported an error with no src while probing viewer'); + return; + } + + // Try HEAD to see if URL returns HTML (service worker) or media + let contentType = null; + try { + const headRes = await fetch(url, { method: 'HEAD' }); + contentType = headRes.headers.get('Content-Type') || ''; + } catch (e) { + // HEAD may fail due to CORS or service worker; only log when suppression is off + if (!telSuppressMediaError) logger.info('HEAD check failed for ' + url + ': ' + (e?.message || e)); + } + + if (contentType && contentType.indexOf('text/html') === 0) { + // Non-media response (HTML) — skip silently when suppressed + if (!telSuppressMediaError) logger.info('Viewer returned HTML, skipping video src: ' + url); + return; + } + + // Not an obvious HTML response — log only if suppression is disabled + if (!telSuppressMediaError) logger.info('Video element error while probing viewer for: ' + url + ' (Content-Type: ' + (contentType || 'unknown') + ')'); + } catch (e) { + logger.error('Error in video error handler: ' + (e?.message || e)); + } + })(); + }, { once: true }); + } + + const s = v && (v.currentSrc || v.src); + if (s) { + found = s; + break; + } + + const streamLink = document.querySelector('#MediaViewer a[href*="stream/"]')?.href || document.querySelector('.media-viewer-whole a[href*="stream/"]')?.href; + if (streamLink) { + found = streamLink; + break; + } + + await new Promise((r) => setTimeout(r, 200)); + } + } catch (e) { + logger.error('Error while probing viewer: ' + (e?.message || e)); + } + + if (found) { + try { + // Quick HEAD check to avoid HTML responses that browsers can't decode as media + let contentType = null; + try { + const headRes = await fetch(found, { method: 'HEAD' }); + contentType = headRes.headers.get('Content-Type') || ''; + } catch (e) { + logger.info('HEAD check failed for: ' + found + ' (' + (e?.message || e) + ')'); + } + + if (contentType && contentType.indexOf('text/html') === 0) { + logger.info('Found URL returns text/html, skipping: ' + found); + } else { + if (!(state.items && state.items[itemMid])) { + logger.info('Found video in viewer, starting download: ' + found); + tel_download_video(found); + if (itemMid) { + state.items = state.items || {}; + state.items[itemMid] = true; + if (albumMid) setAlbumState(albumMid, state); + } + } else { + logger.info('Skipping already downloaded item (by mid) while processing found video'); + } + } + } catch (e) { + logger.error('Error processing found video URL: ' + (e?.message || e)); + } + await new Promise((r) => setTimeout(r, 300)); + } else { + logger.info('Unable to extract video URL from viewer within timeout'); + } + } finally { + // Close viewer and remove handler + try { + const closeBtn = document.querySelector('#MediaViewer button[aria-label="Close"], #MediaViewer button[title="Close"], .media-viewer-whole .close'); + if (closeBtn) { + closeBtn.click(); + } else { + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + } + } catch (e) { + logger.error('Error closing viewer: ' + (e?.message || e)); + } + // Disable global suppression when done probing + telSuppressMediaError = false; + } + + // small pause to ensure viewer closed before next item + await new Promise((r) => setTimeout(r, 250)); + } else { + const imgEl = item.querySelector('img'); + if (imgEl && imgEl.src) { + const src = imgEl.src; + if (!(itemMid && state.items && state.items[itemMid])) { + logger.info('Downloading media: ' + src); + tel_download_image(src); + if (itemMid) { + state.items = state.items || {}; + state.items[itemMid] = true; + if (albumMid) setAlbumState(albumMid, state); + } + await new Promise((r) => setTimeout(r, 150)); + continue; + } else { + logger.info('Skipping duplicate media: ' + src); + continue; + } + } + + const bg = item.style.backgroundImage || getComputedStyle(item).backgroundImage; + const m = bg && bg.match(/url\(["']?(.*?)["']?\)/); + if (m && m[1]) { + const src = m[1]; + if (!(itemMid && state.items && state.items[itemMid])) { + logger.info('Downloading media: ' + src); + tel_download_image(src); + if (itemMid) { + state.items = state.items || {}; + state.items[itemMid] = true; + if (albumMid) setAlbumState(albumMid, state); + } + await new Promise((r) => setTimeout(r, 150)); + continue; + } else { + logger.info('Skipping duplicate media: ' + src); + continue; + } + } + + logger.info('No media detected in album item.'); + } + } + + // Update album status + const total = album.querySelectorAll('.album-item.grouped-item').length; + const downloaded = Object.keys(state.items || {}).length; + state.status = downloaded >= total ? 'downloaded' : 'scanned'; + if (albumMid) setAlbumState(albumMid, state); + updateBadgeText(state.status === 'downloaded' ? 'Downloaded' : 'Scanned'); + + badge.disabled = false; + }); + + album.appendChild(badge); + return badge; + } catch (e) { + logger.error('createBadgeForAlbum error: ' + (e.message || e)); + } + }; + + // Load existing badges from storage on start + const loadSavedBadges = () => { + const states = loadAlbumStates(); + Object.keys(states).forEach((albumMid) => { + // Look for both album and single-message (group-first) elements + const album = document.querySelector('.is-album[data-mid="' + albumMid + '"]') || document.querySelector('.is-group-first[data-mid="' + albumMid + '"]'); + if (album) createBadgeForAlbum(album); + }); + }; + + // Album scanning & badge download feature + // When a `.media-photo` is clicked inside an `.is-album` parent, mark the album with + // a clickable "Scanned" badge that downloads all media inside that album. + const addAlbumScanFeature = () => { + document.body.addEventListener('click', (e) => { + try { + const clicked = e.target; + const media = clicked.closest && clicked.closest('.media-photo'); + if (!media) return; + // Support both multi-item albums (.is-album) and single messages marked with + // .is-group-first (single video/photo bubble in a grouped thread) + const album = + media.closest && + (media.closest('.is-album') || media.closest('.is-group-first')); + if (!album) return; + + const albumMid = album.getAttribute && album.getAttribute('data-mid'); + // create or update badge and mark scanned + const badge = createBadgeForAlbum(album, 'scanned'); + if (albumMid) { + const st = getAlbumState(albumMid); + st.status = st.status || 'scanned'; + setAlbumState(albumMid, st); + } + } catch (err) { + logger.error(err?.message || err); + } + }, true); + + loadSavedBadges(); + + // Observe DOM for new albums and restore badge if saved state exists + const observer = new MutationObserver((mutations) => { + const states = loadAlbumStates(); + for (const m of mutations) { + // Handle newly added nodes + for (const node of m.addedNodes) { + if (!(node instanceof Element)) continue; + if (node.matches && (node.matches('.is-album') || node.matches('.is-group-first'))) { + const albumMid = node.getAttribute('data-mid'); + if (albumMid && states[albumMid]) createBadgeForAlbum(node); + } + const albums = node.querySelectorAll && node.querySelectorAll('.is-album, .is-group-first'); + if (albums && albums.length) { + albums.forEach((a) => { + const albumMid = a.getAttribute('data-mid'); + if (albumMid && states[albumMid]) createBadgeForAlbum(a); + }); + } + } + + // Handle attribute changes (e.g., data-mid or class added later) + if (m.type === 'attributes' && m.target instanceof Element) { + const el = m.target; + if ((el.matches && (el.matches('.is-album') || el.matches('.is-group-first'))) && el.getAttribute('data-mid')) { + const albumMid = el.getAttribute('data-mid'); + if (albumMid && states[albumMid]) createBadgeForAlbum(el); + } + } + } + }); + observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['class', 'data-mid'] }); + }; + addAlbumScanFeature(); + logger.info("Completed script setup."); })(); From 10e684091c990037b738ef5dcba37860ad40a349 Mon Sep 17 00:00:00 2001 From: Yopi Cahya Date: Wed, 14 Jan 2026 13:40:02 +0700 Subject: [PATCH 02/18] Enhance album downloads, streaming robustness, and progress UI - Add album scanning and persistent badge to batch-download album media (saved state in localStorage). - Implement chunked Range downloads for video/audio with optional File System Access API support. - Add progress bar UI with completion and abort handling; improved logging. - Improve viewer probing to extract streaming URLs and suppress noisy MediaError rejections during probing. - Add support for stories and webk/webz UI variations; reveal native hidden download buttons. --- src/tel_download.js | 204 +++++++++++++++++++++++++++++--------------- 1 file changed, 135 insertions(+), 69 deletions(-) diff --git a/src/tel_download.js b/src/tel_download.js index 6287dac..d34f3d5 100644 --- a/src/tel_download.js +++ b/src/tel_download.js @@ -129,22 +129,43 @@ progressBar.querySelector("div").style.width = progress + "%"; }; + const downloadCompletionResolvers = new Map(); + const completeProgress = (videoId) => { - const progressBar = document - .getElementById("tel-downloader-progress-" + videoId) - .querySelector("div.progress"); + const container = document.getElementById("tel-downloader-progress-" + videoId); + if (!container) return; + const progressBar = container.querySelector("div.progress"); progressBar.querySelector("p").innerText = "Completed"; progressBar.querySelector("div").style.backgroundColor = "#B6C649"; progressBar.querySelector("div").style.width = "100%"; + + const r = downloadCompletionResolvers.get(videoId); + if (r && r.resolve) { + r.resolve(); + downloadCompletionResolvers.delete(videoId); + } + + // Remove the progress UI after a short delay so users can see completion + setTimeout(() => { + try { + container.remove(); + } catch (e) {} + }, 800); }; - const AbortProgress = (videoId) => { + const AbortProgress = (videoId, err) => { const progressBar = document .getElementById("tel-downloader-progress-" + videoId) .querySelector("div.progress"); progressBar.querySelector("p").innerText = "Aborted"; progressBar.querySelector("div").style.backgroundColor = "#D16666"; progressBar.querySelector("div").style.width = "100%"; + + const r = downloadCompletionResolvers.get(videoId); + if (r && r.reject) { + r.reject(err || new Error('Aborted')); + downloadCompletionResolvers.delete(videoId); + } }; const tel_download_video = (url) => { @@ -159,6 +180,11 @@ Date.now().toString(); let fileName = hashCode(url).toString(36) + "." + _file_extension; + // Promise that resolves when download completes (or rejects on abort) + const completionPromise = new Promise((resolve, reject) => { + downloadCompletionResolvers.set(videoId, { resolve, reject }); + }); + // Some video src is in format: // 'stream/{"dcId":5,"location":{...},"size":...,"mimeType":"video/mp4","fileName":"xxxx.MP4"}' try { @@ -307,17 +333,21 @@ }) .catch((err) => { console.error(err.name, err.message); + AbortProgress(videoId, err); }); }) .catch((err) => { if (err.name !== "AbortError") { console.error(err.name, err.message); + AbortProgress(videoId, err); } }); } else { fetchNextPart(null); createProgressBar(videoId); } + + return completionPromise; }; const tel_download_audio = (url) => { @@ -477,6 +507,7 @@ document.body.removeChild(a); logger.info("Download triggered", fileName); + return Promise.resolve(); }; logger.info("Initialized"); @@ -960,11 +991,15 @@ if (src) { logger.info('Downloading media: ' + src); - tel_download_video(src); - if (itemMid) { - state.items = state.items || {}; - state.items[itemMid] = true; - if (albumMid) setAlbumState(albumMid, state); + try { + await tel_download_video(src); + if (itemMid) { + state.items = state.items || {}; + state.items[itemMid] = true; + if (albumMid) setAlbumState(albumMid, state); + } + } catch (e) { + logger.error('Download failed for: ' + src + ' - ' + (e?.message || e)); } await new Promise((r) => setTimeout(r, 300)); continue; @@ -977,69 +1012,96 @@ telSuppressMediaError = true; try { - try { - opener.click(); - } catch (e) { - logger.info('Opener click failed: ' + (e?.message || e)); - } - - const timeout = 8000; - const start = Date.now(); + // Attempt to start playback in-item if there's a play button to encourage the stream to start + const playBtn = item.querySelector('.video-play, .btn-circle.video-play, .btn-circle.video-play.position-center, .toggle'); + const maxAttempts = 3; + const baseTimeout = 12000; // ms let found = null; - try { - while (Date.now() - start < timeout) { - const v = document.querySelector('#MediaViewer .MediaViewerSlide--active video, .media-viewer-whole video, video.media-video, .ckin__player video'); - if (v) { - v.addEventListener('error', () => { - (async () => { - try { - const url = v.currentSrc || v.src; - if (!url) { - if (!telSuppressMediaError) logger.info('Video element reported an error with no src while probing viewer'); - return; - } - - // Try HEAD to see if URL returns HTML (service worker) or media - let contentType = null; - try { - const headRes = await fetch(url, { method: 'HEAD' }); - contentType = headRes.headers.get('Content-Type') || ''; - } catch (e) { - // HEAD may fail due to CORS or service worker; only log when suppression is off - if (!telSuppressMediaError) logger.info('HEAD check failed for ' + url + ': ' + (e?.message || e)); - } - - if (contentType && contentType.indexOf('text/html') === 0) { - // Non-media response (HTML) — skip silently when suppressed - if (!telSuppressMediaError) logger.info('Viewer returned HTML, skipping video src: ' + url); - return; - } - - // Not an obvious HTML response — log only if suppression is disabled - if (!telSuppressMediaError) logger.info('Video element error while probing viewer for: ' + url + ' (Content-Type: ' + (contentType || 'unknown') + ')'); - } catch (e) { - logger.error('Error in video error handler: ' + (e?.message || e)); - } - })(); - }, { once: true }); + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + if (playBtn) { + try { + playBtn.click(); + logger.info('Clicked in-item play button (attempt ' + attempt + ')'); + } catch (e) { + logger.info('Play button click failed: ' + (e?.message || e)); + } } - const s = v && (v.currentSrc || v.src); - if (s) { - found = s; - break; + try { + opener.click(); + } catch (e) { + logger.info('Opener click failed: ' + (e?.message || e)); } - const streamLink = document.querySelector('#MediaViewer a[href*="stream/"]')?.href || document.querySelector('.media-viewer-whole a[href*="stream/"]')?.href; - if (streamLink) { - found = streamLink; - break; + const timeout = baseTimeout; + const start = Date.now(); + + try { + while (Date.now() - start < timeout) { + const v = document.querySelector('#MediaViewer .MediaViewerSlide--active video, .media-viewer-whole video, video.media-video, .ckin__player video'); + if (v) { + v.addEventListener('error', () => { + (async () => { + try { + const url = v.currentSrc || v.src; + if (!url) { + if (!telSuppressMediaError) logger.info('Video element reported an error with no src while probing viewer'); + return; + } + + // Try HEAD to see if URL returns HTML (service worker) or media + let contentType = null; + try { + const headRes = await fetch(url, { method: 'HEAD' }); + contentType = headRes.headers.get('Content-Type') || ''; + } catch (e) { + if (!telSuppressMediaError) logger.info('HEAD check failed for ' + url + ': ' + (e?.message || e)); + } + + if (contentType && contentType.indexOf('text/html') === 0) { + if (!telSuppressMediaError) logger.info('Viewer returned HTML, skipping video src: ' + url); + return; + } + + if (!telSuppressMediaError) logger.info('Video element error while probing viewer for: ' + url + ' (Content-Type: ' + (contentType || 'unknown') + ')'); + } catch (e) { + logger.error('Error in video error handler: ' + (e?.message || e)); + } + })(); + }, { once: true }); + } + + const s = v && (v.currentSrc || v.src); + if (s) { + found = s; + break; + } + + const streamLink = document.querySelector('#MediaViewer a[href*="stream/"]')?.href || document.querySelector('.media-viewer-whole a[href*="stream/"]')?.href; + if (streamLink) { + found = streamLink; + break; + } + + await new Promise((r) => setTimeout(r, 200)); + } + } catch (e) { + logger.error('Error while probing viewer: ' + (e?.message || e)); } - await new Promise((r) => setTimeout(r, 200)); + if (found) break; // success + + // retry backoff if not found + if (attempt < maxAttempts) { + const backoff = 500 * attempt; + logger.info('Viewer probe no result, retrying after ' + backoff + 'ms (attempt ' + (attempt + 1) + ')'); + await new Promise((r) => setTimeout(r, backoff)); + } + } catch (e) { + logger.error('Unexpected error in probe attempt: ' + (e?.message || e)); } - } catch (e) { - logger.error('Error while probing viewer: ' + (e?.message || e)); } if (found) { @@ -1058,11 +1120,15 @@ } else { if (!(state.items && state.items[itemMid])) { logger.info('Found video in viewer, starting download: ' + found); - tel_download_video(found); - if (itemMid) { - state.items = state.items || {}; - state.items[itemMid] = true; - if (albumMid) setAlbumState(albumMid, state); + try { + await tel_download_video(found); + if (itemMid) { + state.items = state.items || {}; + state.items[itemMid] = true; + if (albumMid) setAlbumState(albumMid, state); + } + } catch (e) { + logger.error('Download failed for: ' + found + ' - ' + (e?.message || e)); } } else { logger.info('Skipping already downloaded item (by mid) while processing found video'); From c8779352e3fb279fcab366885ca6eae537b8edf8 Mon Sep 17 00:00:00 2001 From: Yopi Cahya Date: Wed, 14 Jan 2026 13:54:26 +0700 Subject: [PATCH 03/18] Add album scanning, persistent states, and download progress UI - Add album scanning UI with a clickable "Scanned" badge to batch-download album media. - Persist album download state in localStorage (tel_album_states_v1) and support redownloads. - Implement per-download progress bar with completion/abort handling and filesystem save support. - Improve video/audio download flows (range requests, blob concatenation) and better MIME checks. - Add robust viewer probing (retry/backoff) and suppress noisy MediaError unhandled rejections while probing. - Webk/Webz compatibility tweaks and enhanced logging for troubleshooting. --- src/tel_download.js | 188 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 183 insertions(+), 5 deletions(-) diff --git a/src/tel_download.js b/src/tel_download.js index d34f3d5..e3dcc6b 100644 --- a/src/tel_download.js +++ b/src/tel_download.js @@ -931,7 +931,8 @@ if (albumMid) setAlbumState(albumMid, state); } if (existing) { - existing.innerText = state.status === 'downloaded' ? 'Downloaded' : 'Scanned'; + const label = state.status === 'downloaded' ? 'Downloaded' : (state.status === 'partial' ? 'Partial downloaded' : 'Scanned'); + existing.innerText = label; return existing; } @@ -948,10 +949,51 @@ badge.style.color = 'white'; badge.style.border = 'none'; badge.style.cursor = 'pointer'; - badge.innerText = state.status === 'downloaded' ? 'Downloaded' : 'Scanned'; + badge.innerText = state.status === 'downloaded' ? 'Downloaded' : (state.status === 'partial' ? 'Partial downloaded' : 'Scanned'); const updateBadgeText = (s) => (badge.innerText = s); + // Ensure redownload button exists when album is partial or downloaded + const ensureRedownloadButton = () => { + try { + const existingR = badge.parentNode && badge.parentNode.querySelector('.tel-album-redownload'); + if (existingR) existingR.remove(); + + if (state.status === 'downloaded' || state.status === 'partial') { + const red = document.createElement('button'); + red.className = 'tel-album-redownload'; + red.title = 'Redownload album'; + red.style.marginLeft = '8px'; + red.style.padding = '4px 8px'; + red.style.borderRadius = '12px'; + red.style.background = '#D16666'; + red.style.color = 'white'; + red.style.border = 'none'; + red.style.cursor = 'pointer'; + red.innerText = 'Redownload'; + + red.addEventListener('click', async (ev) => { + ev.stopPropagation(); + red.disabled = true; + badge.disabled = true; + try { + await forceRedownloadAlbum(album, albumMid); + } catch (e) { + logger.error('Redownload album failed: ' + (e?.message || e)); + } finally { + red.disabled = false; + badge.disabled = false; + ensureRedownloadButton(); + } + }); + + badge.after(red); + } + } catch (e) { + logger.error('ensureRedownloadButton error: ' + (e?.message || e)); + } + }; + badge.addEventListener('click', async (ev) => { ev.stopPropagation(); badge.disabled = true; @@ -1204,22 +1246,158 @@ } // Update album status - const total = album.querySelectorAll('.album-item.grouped-item').length; + const total = album.querySelectorAll('.album-item.grouped-item').length || albumItems.length; const downloaded = Object.keys(state.items || {}).length; - state.status = downloaded >= total ? 'downloaded' : 'scanned'; + if (downloaded >= total) { + state.status = 'downloaded'; + } else if (downloaded > 0) { + state.status = 'partial'; + } else { + state.status = 'scanned'; + } if (albumMid) setAlbumState(albumMid, state); - updateBadgeText(state.status === 'downloaded' ? 'Downloaded' : 'Scanned'); + updateBadgeText(state.status === 'downloaded' ? 'Downloaded' : (state.status === 'partial' ? 'Partial downloaded' : 'Scanned')); badge.disabled = false; }); album.appendChild(badge); + // Ensure redownload UI is present when appropriate + ensureRedownloadButton(); return badge; } catch (e) { logger.error('createBadgeForAlbum error: ' + (e.message || e)); } }; + // Force redownload helper: re-download items regardless of storage and override only those re-downloaded + const forceRedownloadAlbum = async (album, albumMid) => { + try { + const state = albumMid ? getAlbumState(albumMid) : { status: null, items: {} }; + state.items = state.items || {}; + + let albumItems = Array.from(album.querySelectorAll('.album-item.grouped-item')); + if (albumItems.length === 0) { + const attach = album.querySelector('.attachment, .album-item, .album-item-media, .media-container'); + if (attach) albumItems = [attach]; + } + + for (const item of albumItems) { + const itemMid = (item.getAttribute && item.getAttribute('data-mid')) || null; + const isVideo = !!item.querySelector('.video-time') || item.classList.contains('video') || album.classList.contains('video'); + + if (!isVideo) { + // Image — download immediately + const imgEl = item.querySelector('img') || item.querySelector('canvas.media-photo'); + const bg = (item.style && item.style.backgroundImage) || getComputedStyle(item).backgroundImage; + const m = bg && bg.match(/url\(["']?(.*?)['"]?\)/); + const src = (imgEl && imgEl.src) || (m && m[1]); + if (src) { + logger.info('Redownloading image: ' + src); + try { + await tel_download_image(src); + if (itemMid) { + state.items[itemMid] = true; + if (albumMid) setAlbumState(albumMid, state); + } + } catch (e) { + logger.error('Redownload image failed: ' + (e?.message || e)); + } + } + await new Promise(r => setTimeout(r, 200)); + continue; + } + + // Video — try direct src first + let src = item.querySelector('video')?.currentSrc || item.querySelector('video')?.src || item.querySelector('video source')?.src || item.querySelector('a')?.href || item.querySelector('[data-src]')?.getAttribute('data-src'); + if (!src) { + const bg = item.style.backgroundImage || getComputedStyle(item).backgroundImage; + const mm = bg && bg.match(/url\(["']?(.*?)['"]?\)/); + if (mm && mm[1]) src = mm[1]; + } + + if (src) { + logger.info('Redownloading video (direct): ' + src); + try { + await tel_download_video(src); + if (itemMid) { + state.items[itemMid] = true; + if (albumMid) setAlbumState(albumMid, state); + } + } catch (e) { + logger.error('Redownload video failed: ' + (e?.message || e)); + } + await new Promise(r => setTimeout(r, 200)); + continue; + } + + // Otherwise open viewer and extract + telSuppressMediaError = true; + try { + const opener = item.querySelector('a') || item; + try { opener.click(); } catch (e) { logger.info('Opener click failed during redownload: ' + (e?.message || e)); } + + const timeout = 12000; // wait longer for redownload + const start = Date.now(); + let found = null; + while (Date.now() - start < timeout) { + const v = document.querySelector('#MediaViewer .MediaViewerSlide--active video, .media-viewer-whole video, video.media-video, .ckin__player video'); + const s = v && (v.currentSrc || v.src); + if (s) { + try { found = new URL(s, location.href).href; } catch(e) { found = s; } + break; + } + const streamLink = document.querySelector('#MediaViewer a[href*="stream/"]')?.href || document.querySelector('.media-viewer-whole a[href*="stream/"]')?.href; + if (streamLink) { found = streamLink; break; } + await new Promise(r => setTimeout(r, 300)); + } + + if (found) { + logger.info('Redownloading video (viewer): ' + found); + try { + await tel_download_video(found); + if (itemMid) { + state.items[itemMid] = true; + if (albumMid) setAlbumState(albumMid, state); + } + } catch (e) { + logger.error('Redownload video (viewer) failed: ' + (e?.message || e)); + } + } else { + logger.info('Redownload: unable to extract video URL within timeout'); + } + } finally { + // close viewer + try { + const closeBtn = document.querySelector('#MediaViewer button[aria-label="Close"], #MediaViewer button[title="Close"], .media-viewer-whole .close'); + if (closeBtn) closeBtn.click(); else document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + } catch (e) {} + telSuppressMediaError = false; + await new Promise(r => setTimeout(r, 250)); + } + } + + // After redownloads, update album status (only modify items we changed above) + const total = album.querySelectorAll('.album-item.grouped-item').length || albumItems.length; + const downloaded = Object.keys(state.items || {}).filter(k => state.items[k] === true).length; + state.status = downloaded >= total ? 'downloaded' : (downloaded > 0 ? 'partial' : 'scanned'); + if (albumMid) setAlbumState(albumMid, state); + + // Update badge text + const b = album.querySelector('.tel-album-scanned-badge'); + if (b) b.innerText = state.status === 'downloaded' ? 'Downloaded' : (state.status === 'partial' ? 'Partial downloaded' : 'Scanned'); + // Ensure redownload button remains when appropriate + if (b) { + const existingR = b.parentNode && b.parentNode.querySelector('.tel-album-redownload'); + if (existingR) existingR.remove(); + if (state.status === 'downloaded' || state.status === 'partial') createBadgeForAlbum(album); + } + + } catch (e) { + logger.error('forceRedownloadAlbum error: ' + (e?.message || e)); + } + }; + // Load existing badges from storage on start const loadSavedBadges = () => { const states = loadAlbumStates(); From 89d3cce452f4e7905167195d418a6884ba3c186a Mon Sep 17 00:00:00 2001 From: Yopi Cahya Date: Wed, 14 Jan 2026 14:12:20 +0700 Subject: [PATCH 04/18] Add album persistence, progress UI, and robust media download handling - Migrate legacy aggregated album storage to per-album keys and persist album states. - Add album "Scanned"/"Downloaded"/"Partial downloaded" badge with a Redownload action. - Implement progress bar UI with completion and abort handling for downloads. - Improve video/audio download logic: ranged fetch with Content-Range handling, HEAD/content-type checks, fallback probing of viewer streams, and optional File System Access API support. - Suppress noisy MediaError rejections while probing viewers and harden viewer probing/retry logic. - Various UX and robustness fixes for Webz/Webk viewers and stories. --- src/tel_download.js | 277 ++++++++++++++++++++++++++++++-------------- 1 file changed, 187 insertions(+), 90 deletions(-) diff --git a/src/tel_download.js b/src/tel_download.js index e3dcc6b..e447b77 100644 --- a/src/tel_download.js +++ b/src/tel_download.js @@ -890,32 +890,53 @@ body.appendChild(container); })(); - // Persistent album state helpers - const ALBUM_STORAGE_KEY = 'tel_album_states_v1'; - const loadAlbumStates = () => { + // Persistent album state helpers (per-album keys) + const ALBUM_STORAGE_KEY_BASE = 'tel_album_states_v1'; + + // Migrate legacy aggregated storage key to per-album keys + const migrateAlbumStates = () => { try { - const raw = localStorage.getItem(ALBUM_STORAGE_KEY); - return raw ? JSON.parse(raw) : {}; + const raw = localStorage.getItem(ALBUM_STORAGE_KEY_BASE); + if (!raw) return; // nothing to migrate + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === 'object') { + Object.keys(parsed).forEach((albumMid) => { + try { + localStorage.setItem( + `${ALBUM_STORAGE_KEY_BASE}_${albumMid}`, + JSON.stringify(parsed[albumMid]) + ); + } catch (e) { + logger.error('Failed to migrate album state for ' + albumMid + ': ' + (e?.message || e)); + } + }); + // Remove legacy aggregated key after migrating + localStorage.removeItem(ALBUM_STORAGE_KEY_BASE); + logger.info('Migrated album states to per-album keys'); + } } catch (e) { - logger.error('Failed to parse album states: ' + (e.message || e)); - return {}; + logger.error('Migration failed: ' + (e?.message || e)); } }; - const saveAlbumStates = (states) => { + + const getAlbumState = (albumMid) => { try { - localStorage.setItem(ALBUM_STORAGE_KEY, JSON.stringify(states)); + const key = `${ALBUM_STORAGE_KEY_BASE}_${albumMid}`; + const raw = localStorage.getItem(key); + return raw ? JSON.parse(raw) : { status: null, items: {} }; } catch (e) { - logger.error('Failed to save album states: ' + (e.message || e)); + logger.error('Failed to get album state: ' + (e?.message || e)); + return { status: null, items: {} }; } }; - const getAlbumState = (albumMid) => { - const states = loadAlbumStates(); - return states[albumMid] || { status: null, items: {} }; - }; + const setAlbumState = (albumMid, state) => { - const states = loadAlbumStates(); - states[albumMid] = state; - saveAlbumStates(states); + try { + const key = `${ALBUM_STORAGE_KEY_BASE}_${albumMid}`; + localStorage.setItem(key, JSON.stringify(state)); + } catch (e) { + logger.error('Failed to set album state: ' + (e?.message || e)); + } }; const createBadgeForAlbum = (album, initStatus = null) => { @@ -933,16 +954,31 @@ if (existing) { const label = state.status === 'downloaded' ? 'Downloaded' : (state.status === 'partial' ? 'Partial downloaded' : 'Scanned'); existing.innerText = label; + // Ensure it's inside a badge wrapper for proper layout + try { + let wrap = existing.parentNode; + if (!wrap || !wrap.classList || !wrap.classList.contains('tel-album-badge-wrap')) { + wrap = document.createElement('div'); + wrap.className = 'tel-album-badge-wrap'; + wrap.style.position = 'absolute'; + wrap.style.top = '8px'; + wrap.style.right = '8px'; + wrap.style.zIndex = 9999; + wrap.style.display = 'flex'; + wrap.style.alignItems = 'center'; + wrap.style.gap = '8px'; + try { existing.remove(); } catch (e) {} + wrap.appendChild(existing); + album.appendChild(wrap); + } + } catch (e) {} return existing; } const badge = document.createElement('button'); badge.className = 'tel-album-scanned-badge'; badge.title = 'Download album'; - badge.style.position = 'absolute'; - badge.style.top = '8px'; - badge.style.right = '8px'; - badge.style.zIndex = 9999; + // badge will be placed inside a wrapper for correct layout badge.style.padding = '4px 8px'; badge.style.borderRadius = '12px'; badge.style.background = '#6093B5'; @@ -951,7 +987,27 @@ badge.style.cursor = 'pointer'; badge.innerText = state.status === 'downloaded' ? 'Downloaded' : (state.status === 'partial' ? 'Partial downloaded' : 'Scanned'); - const updateBadgeText = (s) => (badge.innerText = s); + // create wrapper and append badge into it + const badgeWrap = document.createElement('div'); + badgeWrap.className = 'tel-album-badge-wrap'; + badgeWrap.style.position = 'absolute'; + badgeWrap.style.top = '8px'; + badgeWrap.style.right = '8px'; + badgeWrap.style.zIndex = 9999; + badgeWrap.style.display = 'flex'; + badgeWrap.style.alignItems = 'center'; + badgeWrap.style.gap = '8px'; + badgeWrap.appendChild(badge); + album.appendChild(badgeWrap); + + const updateBadgeText = (s) => { + try { + badge.innerText = s; + } catch (e) {} + try { + if (typeof ensureRedownloadButton === 'function') ensureRedownloadButton(); + } catch (e) {} + }; // Ensure redownload button exists when album is partial or downloaded const ensureRedownloadButton = () => { @@ -963,15 +1019,39 @@ const red = document.createElement('button'); red.className = 'tel-album-redownload'; red.title = 'Redownload album'; - red.style.marginLeft = '8px'; - red.style.padding = '4px 8px'; - red.style.borderRadius = '12px'; - red.style.background = '#D16666'; - red.style.color = 'white'; - red.style.border = 'none'; - red.style.cursor = 'pointer'; red.innerText = 'Redownload'; + // Copy computed styles from the main badge so the buttons match exactly + try { + const cs = window.getComputedStyle(badge); + red.style.padding = cs.padding || '4px 8px'; + red.style.borderRadius = cs.borderRadius || '12px'; + red.style.fontSize = cs.fontSize || '0.9rem'; + red.style.lineHeight = cs.lineHeight || '1'; + red.style.height = cs.height || 'auto'; + red.style.display = 'inline-flex'; + red.style.alignItems = 'center'; + red.style.justifyContent = 'center'; + red.style.whiteSpace = 'nowrap'; + red.style.boxSizing = 'border-box'; + red.style.marginLeft = '8px'; + // color & background (red variant) + red.style.background = '#D16666'; + red.style.color = cs.color || 'white'; + red.style.border = 'none'; + red.style.cursor = 'pointer'; + } catch (e) { + // Fallback styles + red.style.marginLeft = '8px'; + red.style.padding = badge.style.padding || '4px 8px'; + red.style.borderRadius = badge.style.borderRadius || '12px'; + red.style.background = '#D16666'; + red.style.color = 'white'; + red.style.border = 'none'; + red.style.cursor = 'pointer'; + red.style.display = 'inline-block'; + } + red.addEventListener('click', async (ev) => { ev.stopPropagation(); red.disabled = true; @@ -987,7 +1067,13 @@ } }); - badge.after(red); + // append red to same wrapper as the main badge for correct sizing and alignment + try { + const wrap = badge.parentNode && badge.parentNode.classList && badge.parentNode.classList.contains('tel-album-badge-wrap') ? badge.parentNode : null; + if (wrap) wrap.appendChild(red); else badge.after(red); + } catch (e) { + try { badge.after(red); } catch (e) {} + } } } catch (e) { logger.error('ensureRedownloadButton error: ' + (e?.message || e)); @@ -1015,36 +1101,22 @@ continue; } - const isVideo = !!item.querySelector('.video-time') || item.classList.contains('video') || album.classList.contains('video'); + // Detect video specifically by presence of .album-item-media .video-time as primary signal + // Identify video only when the album-item-media contains .video-time (primary signal) + const isVideo = !!item.querySelector('.album-item-media .video-time'); if (isVideo) { - // try to find source - let src = + // try to find a direct source as a fallback, but DO NOT use it immediately — we want to open the player first + let directSrc = item.querySelector('video')?.currentSrc || item.querySelector('video')?.src || item.querySelector('video source')?.src || item.querySelector('a')?.href || item.querySelector('[data-src]')?.getAttribute('data-src'); - if (!src) { + if (!directSrc) { const bg = item.style.backgroundImage || getComputedStyle(item).backgroundImage; const m = bg && bg.match(/url\(["']?(.*?)['"]?\)/); - if (m && m[1]) src = m[1]; - } - - if (src) { - logger.info('Downloading media: ' + src); - try { - await tel_download_video(src); - if (itemMid) { - state.items = state.items || {}; - state.items[itemMid] = true; - if (albumMid) setAlbumState(albumMid, state); - } - } catch (e) { - logger.error('Download failed for: ' + src + ' - ' + (e?.message || e)); - } - await new Promise((r) => setTimeout(r, 300)); - continue; + if (m && m[1]) directSrc = m[1]; } // open viewer to extract streaming url with robust handling @@ -1160,7 +1232,7 @@ if (contentType && contentType.indexOf('text/html') === 0) { logger.info('Found URL returns text/html, skipping: ' + found); } else { - if (!(state.items && state.items[itemMid])) { + if (!(state.items && state.items[itemMid] === true)) { logger.info('Found video in viewer, starting download: ' + found); try { await tel_download_video(found); @@ -1179,9 +1251,23 @@ } catch (e) { logger.error('Error processing found video URL: ' + (e?.message || e)); } - await new Promise((r) => setTimeout(r, 300)); + await new Promise((r) => setTimeout(r, 200)); } else { logger.info('Unable to extract video URL from viewer within timeout'); + // Fallback to direct src if probe failed + if (directSrc) { + try { + logger.info('Falling back to direct src for download: ' + directSrc); + await tel_download_video(directSrc); + if (itemMid) { + state.items = state.items || {}; + state.items[itemMid] = true; + if (albumMid) setAlbumState(albumMid, state); + } + } catch (e) { + logger.error('Fallback download failed for: ' + directSrc + ' - ' + (e?.message || e)); + } + } } } finally { // Close viewer and remove handler @@ -1284,7 +1370,8 @@ for (const item of albumItems) { const itemMid = (item.getAttribute && item.getAttribute('data-mid')) || null; - const isVideo = !!item.querySelector('.video-time') || item.classList.contains('video') || album.classList.contains('video'); + // Identify video only when the album-item-media contains .video-time (primary signal) + const isVideo = !!item.querySelector('.album-item-media .video-time'); if (!isVideo) { // Image — download immediately @@ -1308,27 +1395,12 @@ continue; } - // Video — try direct src first - let src = item.querySelector('video')?.currentSrc || item.querySelector('video')?.src || item.querySelector('video source')?.src || item.querySelector('a')?.href || item.querySelector('[data-src]')?.getAttribute('data-src'); - if (!src) { + // Video — open the viewer first and probe for a stream; fall back to direct src if probing fails + let directSrc = item.querySelector('video')?.currentSrc || item.querySelector('video')?.src || item.querySelector('video source')?.src || item.querySelector('a')?.href || item.querySelector('[data-src]')?.getAttribute('data-src'); + if (!directSrc) { const bg = item.style.backgroundImage || getComputedStyle(item).backgroundImage; const mm = bg && bg.match(/url\(["']?(.*?)['"]?\)/); - if (mm && mm[1]) src = mm[1]; - } - - if (src) { - logger.info('Redownloading video (direct): ' + src); - try { - await tel_download_video(src); - if (itemMid) { - state.items[itemMid] = true; - if (albumMid) setAlbumState(albumMid, state); - } - } catch (e) { - logger.error('Redownload video failed: ' + (e?.message || e)); - } - await new Promise(r => setTimeout(r, 200)); - continue; + if (mm && mm[1]) directSrc = mm[1]; } // Otherwise open viewer and extract @@ -1365,6 +1437,18 @@ } } else { logger.info('Redownload: unable to extract video URL within timeout'); + if (directSrc) { + try { + logger.info('Falling back to direct src for redownload: ' + directSrc); + await tel_download_video(directSrc); + if (itemMid) { + state.items[itemMid] = true; + if (albumMid) setAlbumState(albumMid, state); + } + } catch (e) { + logger.error('Fallback redownload failed for: ' + directSrc + ' - ' + (e?.message || e)); + } + } } } finally { // close viewer @@ -1398,14 +1482,20 @@ } }; - // Load existing badges from storage on start + // Load existing badges from storage on start (scan per-album keys) const loadSavedBadges = () => { - const states = loadAlbumStates(); - Object.keys(states).forEach((albumMid) => { - // Look for both album and single-message (group-first) elements - const album = document.querySelector('.is-album[data-mid="' + albumMid + '"]') || document.querySelector('.is-group-first[data-mid="' + albumMid + '"]'); - if (album) createBadgeForAlbum(album); - }); + try { + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (!key || !key.startsWith(ALBUM_STORAGE_KEY_BASE + '_')) continue; + const albumMid = key.substring((ALBUM_STORAGE_KEY_BASE + '_').length); + // Look for both album and single-message (group-first) elements + const album = document.querySelector('.is-album[data-mid="' + albumMid + '"]') || document.querySelector('.is-group-first[data-mid="' + albumMid + '"]'); + if (album) createBadgeForAlbum(album); + } + } catch (e) { + logger.error('loadSavedBadges error: ' + (e?.message || e)); + } }; // Album scanning & badge download feature @@ -1439,38 +1529,45 @@ loadSavedBadges(); - // Observe DOM for new albums and restore badge if saved state exists + // Observe DOM for new albums and restore badge if saved state exists (per-album keys) const observer = new MutationObserver((mutations) => { - const states = loadAlbumStates(); for (const m of mutations) { // Handle newly added nodes for (const node of m.addedNodes) { if (!(node instanceof Element)) continue; + const checkAndCreate = (el) => { + const albumMid = el.getAttribute && el.getAttribute('data-mid'); + if (!albumMid) return; + const key = `${ALBUM_STORAGE_KEY_BASE}_${albumMid}`; + if (localStorage.getItem(key)) createBadgeForAlbum(el); + }; + if (node.matches && (node.matches('.is-album') || node.matches('.is-group-first'))) { - const albumMid = node.getAttribute('data-mid'); - if (albumMid && states[albumMid]) createBadgeForAlbum(node); + checkAndCreate(node); } const albums = node.querySelectorAll && node.querySelectorAll('.is-album, .is-group-first'); if (albums && albums.length) { - albums.forEach((a) => { - const albumMid = a.getAttribute('data-mid'); - if (albumMid && states[albumMid]) createBadgeForAlbum(a); - }); + albums.forEach((a) => checkAndCreate(a)); } } // Handle attribute changes (e.g., data-mid or class added later) if (m.type === 'attributes' && m.target instanceof Element) { const el = m.target; - if ((el.matches && (el.matches('.is-album') || el.matches('.is-group-first'))) && el.getAttribute('data-mid')) { + if (el.matches && (el.matches('.is-album') || el.matches('.is-group-first'))) { const albumMid = el.getAttribute('data-mid'); - if (albumMid && states[albumMid]) createBadgeForAlbum(el); + if (albumMid) { + const key = `${ALBUM_STORAGE_KEY_BASE}_${albumMid}`; + if (localStorage.getItem(key)) createBadgeForAlbum(el); + } } } } }); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['class', 'data-mid'] }); }; + // Perform one-time migration and then initialize features + migrateAlbumStates(); addAlbumScanFeature(); logger.info("Completed script setup."); From 58608657e427e4403820b662f64809ae0e542764 Mon Sep 17 00:00:00 2001 From: Yopi Cahya Date: Wed, 14 Jan 2026 14:15:32 +0700 Subject: [PATCH 05/18] fix badge --- src/tel_download.js | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/tel_download.js b/src/tel_download.js index e447b77..ff4b029 100644 --- a/src/tel_download.js +++ b/src/tel_download.js @@ -985,6 +985,11 @@ badge.style.color = 'white'; badge.style.border = 'none'; badge.style.cursor = 'pointer'; + badge.style.display = 'inline-flex'; + badge.style.alignItems = 'center'; + badge.style.justifyContent = 'center'; + badge.style.whiteSpace = 'nowrap'; + badge.style.boxSizing = 'border-box'; badge.innerText = state.status === 'downloaded' ? 'Downloaded' : (state.status === 'partial' ? 'Partial downloaded' : 'Scanned'); // create wrapper and append badge into it @@ -1028,7 +1033,8 @@ red.style.borderRadius = cs.borderRadius || '12px'; red.style.fontSize = cs.fontSize || '0.9rem'; red.style.lineHeight = cs.lineHeight || '1'; - red.style.height = cs.height || 'auto'; + // Only set explicit height if computed height is pixels (avoid % or auto which can stretch) + if (/^\d+px$/.test(cs.height)) red.style.height = cs.height; red.style.display = 'inline-flex'; red.style.alignItems = 'center'; red.style.justifyContent = 'center'; @@ -1347,8 +1353,7 @@ badge.disabled = false; }); - album.appendChild(badge); - // Ensure redownload UI is present when appropriate + // Badge already appended inside wrapper; ensure redownload UI is present when appropriate ensureRedownloadButton(); return badge; } catch (e) { From 9d24b9c1d1b9eed197fa9dcb30bb364feb1f75bb Mon Sep 17 00:00:00 2001 From: Yopi Cahya Date: Wed, 14 Jan 2026 15:00:42 +0700 Subject: [PATCH 06/18] feat: enhance video download handling with filename sanitization and direct source preference --- src/tel_download.js | 136 +++++++++++++++++++++++++++++++++++++------- 1 file changed, 115 insertions(+), 21 deletions(-) diff --git a/src/tel_download.js b/src/tel_download.js index ff4b029..e09a92a 100644 --- a/src/tel_download.js +++ b/src/tel_download.js @@ -52,6 +52,12 @@ return h >>> 0; }; + const sanitizeFilename = (s) => { + try { + return String(s).replace(/[^a-z0-9\.\-_]/gi, '_'); + } catch (e) { return String(s); } + }; + const createProgressBar = (videoId, fileName) => { const isDarkMode = document.querySelector("html").classList.contains("night") || @@ -168,7 +174,7 @@ } }; - const tel_download_video = (url) => { + const tel_download_video = (url, filenameHint) => { let _blobs = []; let _next_offset = 0; let _total_size = null; @@ -178,7 +184,10 @@ (Math.random() + 1).toString(36).substring(2, 10) + "_" + Date.now().toString(); - let fileName = hashCode(url).toString(36) + "." + _file_extension; + + // Prefer a provided filename hint (sanitized), otherwise fall back to hash-based name + const baseName = filenameHint ? sanitizeFilename(filenameHint) : hashCode(url).toString(36); + let fileName = baseName + "." + _file_extension; // Promise that resolves when download completes (or rejects on abort) const completionPromise = new Promise((resolve, reject) => { @@ -1107,9 +1116,9 @@ continue; } - // Detect video specifically by presence of .album-item-media .video-time as primary signal - // Identify video only when the album-item-media contains .video-time (primary signal) - const isVideo = !!item.querySelector('.album-item-media .video-time'); + // Detect video by presence of .video-time anywhere inside the item or a