Skip to content

Commit 15e8df5

Browse files
v2.1.2: pin video title to its source tab (fix multi-tab mismatch)
In a multi-tab session, clicking Send to NAS for a URL detected in tab A could attach tab B's title if the user switched tabs before clicking — the side panel was reading the active tab's title at click time. The background service worker now stamps each detected URL's urlInfo with the page title captured at detection time (chrome.tabs.get on the URL's own tabId, not the active tab) and looks it up via getStoredPageTitle() when handling sendToNAS. The side panel no longer queries the active tab for the title. Adds a Vitest regression test pinning the lookup behaviour: two URLs registered under different tabIds resolve to their own page titles. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent b5a3937 commit 15e8df5

5 files changed

Lines changed: 85 additions & 8 deletions

File tree

README.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
344344
<details>
345345
<summary><strong>Full Changelog (click to expand)</strong></summary>
346346

347+
### [2.1.2] - 2026-04-27
348+
349+
#### Fixed
350+
- **Multi-tab title mismatch**: when several tabs were open, sending a video to NAS could attach the wrong tab's title to the URL — the side panel was using the *active* tab's title at click time instead of the title of the tab where the URL was actually detected. The background service worker now records the page title at URL-detection time and uses that as the source of truth, so switching tabs before clicking *Send* no longer poisons the filename
351+
347352
### [2.1.1] - 2026-04-27
348353

349354
#### Fixed
@@ -616,7 +621,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
616621

617622
---
618623

619-
**Version**: 2.1.1
624+
**Version**: 2.1.2
620625
**Last Updated**: 2026-04-27
621626
**Port**: 52052 (NAS host port → API container :8000)
622627

chrome-extension/background.js

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -457,6 +457,22 @@ function isCandidateVideoUrl(rawUrl) {
457457
return true;
458458
}
459459

460+
// Capture the tab's current title and stamp it onto the urlInfo. Async because
461+
// chrome.tabs.get is async — by the time we resolve, the urlInfo is already in
462+
// the store, so we mutate the live object. Best-effort; missing title is OK.
463+
function attachTabTitle(urlInfo, tabId) {
464+
if (tabId == null || tabId < 0) return;
465+
try {
466+
chrome.tabs.get(tabId, (tab) => {
467+
if (chrome.runtime.lastError || !tab) return;
468+
const title = tab.title;
469+
// Only overwrite if we got a real title — keeps a previously-captured
470+
// good title if a later fetch races with a transient empty state.
471+
if (title && title.trim()) urlInfo.pageTitle = title;
472+
});
473+
} catch (_) { /* ignore */ }
474+
}
475+
460476
// Register a detected video URL into per-tab and orphan stores.
461477
// `extra` may carry additional fields like `detectedFormat`.
462478
function registerDetectedUrl(details, extra) {
@@ -467,6 +483,7 @@ function registerDetectedUrl(details, extra) {
467483
tabId: isRealTab ? details.tabId : -1,
468484
timestamp: Date.now(),
469485
pageUrl: details.initiator || details.documentUrl,
486+
pageTitle: '', // populated async by attachTabTitle below
470487
requestType: details.type,
471488
frameId: details.frameId,
472489
method: details.method,
@@ -488,6 +505,7 @@ function registerDetectedUrl(details, extra) {
488505
if (!currentTabUrlKeys[details.tabId].has(details.url)) {
489506
currentTabUrlKeys[details.tabId].add(details.url);
490507
currentTabUrls[details.tabId].push(urlInfo);
508+
attachTabTitle(urlInfo, details.tabId);
491509
} else {
492510
const list = currentTabUrls[details.tabId];
493511
const existing = list.find(item => item && item.url === details.url);
@@ -501,6 +519,9 @@ function registerDetectedUrl(details, extra) {
501519
if (extra && extra.detectedFormat && !existing.detectedFormat) {
502520
existing.detectedFormat = extra.detectedFormat;
503521
}
522+
// Refresh title in case the first capture raced with a transient
523+
// empty-title state (loading SPA, etc).
524+
if (!existing.pageTitle) attachTabTitle(existing, details.tabId);
504525
notifyDetectedUrlsUpdated(details.tabId);
505526
}
506527
}
@@ -740,6 +761,23 @@ function getDetectedFormat(url) {
740761
return null;
741762
}
742763

764+
// Find the page title that was captured when a URL was first detected.
765+
// This is the source of truth for "what tab/page this URL came from" — using
766+
// it avoids the multi-tab bug where the active tab at click-time could be
767+
// different from the tab the URL was actually detected on.
768+
function getStoredPageTitle(url) {
769+
for (const tabId of Object.keys(currentTabUrls)) {
770+
const list = currentTabUrls[tabId];
771+
if (!Array.isArray(list)) continue;
772+
const item = list.find(x => x && x.url === url);
773+
if (item && item.pageTitle) return item.pageTitle;
774+
}
775+
for (const item of orphanUrlInfos) {
776+
if (item && item.url === url && item.pageTitle) return item.pageTitle;
777+
}
778+
return null;
779+
}
780+
743781
// Send URL to NAS
744782
async function sendToNAS(url, pageTitle, pageUrl) {
745783
try {
@@ -1167,7 +1205,14 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
11671205
}
11681206

11691207
if (request.action === 'sendToNAS') {
1170-
sendToNAS(request.url, request.title, request.pageUrl);
1208+
// Prefer the title that was captured when this URL was first detected —
1209+
// that pins the title to the URL's source tab and survives the user
1210+
// switching tabs before clicking Send. Fall back to whatever the caller
1211+
// sent (right-click context menu provides the correct tab.title), then
1212+
// to a generic placeholder.
1213+
const titleToUse =
1214+
getStoredPageTitle(request.url) || request.title || 'Untitled Video';
1215+
sendToNAS(request.url, titleToUse, request.pageUrl);
11711216
sendResponse({ success: true });
11721217
}
11731218

chrome-extension/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"manifest_version": 3,
33
"name": "WebVideo2NAS",
4-
"version": "2.1.1",
4+
"version": "2.1.2",
55
"description": "Send web videos (m3u8, mpd, mp4) to your NAS for download",
66
"permissions": [
77
"storage",

chrome-extension/sidepanel.js

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -817,14 +817,16 @@ async function sendToNAS(url, pageUrl) {
817817
}
818818

819819
try {
820-
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
821-
const title = tab.title || t('video.untitled');
822-
820+
// Don't pass the active tab's title — in a multi-tab session the active
821+
// tab may not be the tab this URL came from, leading to mismatched titles.
822+
// Background looks up the title that was captured when this URL was first
823+
// detected (getStoredPageTitle) and falls back to a placeholder if
824+
// missing. We still pass i18n-aware fallback so the language matches.
823825
chrome.runtime.sendMessage({
824826
action: 'sendToNAS',
825827
url: url,
826-
title: title,
827-
pageUrl: pageUrl || tab.url
828+
title: t('video.untitled'),
829+
pageUrl: pageUrl || ''
828830
});
829831

830832
showToast(t('toast.sending'));

chrome-extension/tests/background.test.js

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,4 +194,29 @@ describe('background.js pure helpers', () => {
194194
expect(ctx.safeOrigin('https://example.com/a')).toBe('https://example.com');
195195
expect(ctx.safeOrigin('not a url')).toBe(null);
196196
});
197+
198+
it('getStoredPageTitle pins the title to the URL\'s source tab (multi-tab regression)', () => {
199+
// Regression: previously the side panel passed the *active* tab's title
200+
// when sending to NAS, so a URL detected in tab A would get tab B's
201+
// title if the user switched tabs before clicking Send. Now background
202+
// looks the title up from the urlInfo that was registered when the URL
203+
// was first detected.
204+
const ctx = loadScriptIntoContext('background.js', {
205+
chrome: makeChromeStub(),
206+
});
207+
208+
const tabA = 100;
209+
const tabB = 200;
210+
ctx.__eval(`currentTabUrls[${tabA}] = ${JSON.stringify([
211+
{ url: 'https://cdn.example.com/v/episode-1.m3u8', pageTitle: 'Anime · Episode 1', timestamp: 1000 },
212+
])};`);
213+
ctx.__eval(`currentTabUrls[${tabB}] = ${JSON.stringify([
214+
{ url: 'https://cdn.example.com/v/episode-2.m3u8', pageTitle: 'News · Top Story', timestamp: 1000 },
215+
])};`);
216+
217+
expect(ctx.getStoredPageTitle('https://cdn.example.com/v/episode-1.m3u8')).toBe('Anime · Episode 1');
218+
expect(ctx.getStoredPageTitle('https://cdn.example.com/v/episode-2.m3u8')).toBe('News · Top Story');
219+
// Unknown URL → null (caller falls back to whatever the message had)
220+
expect(ctx.getStoredPageTitle('https://cdn.example.com/v/unknown.m3u8')).toBe(null);
221+
});
197222
});

0 commit comments

Comments
 (0)