Skip to content

Commit 8183230

Browse files
committed
Restore YouTube viewing progress estimates
1 parent 7f5e7bc commit 8183230

18 files changed

Lines changed: 1041 additions & 76 deletions

README.md

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ above use webp (≈10× smaller than the SVG), so this page loads fast.
103103
- `GET /api/wrapped/{year}.json` — machine-readable annual recap
104104
- `POST /api/ingest/events` — authenticated private-ingest service (JSON)
105105
- `POST /api/ingest/youtube/capture` — dedicated-token Chrome viewing capture
106+
- `POST /api/ingest/youtube/progress` — explicit history progress import
106107
- `POST /mcp` — stateless MCP Streamable HTTP endpoint
107108
- `GET /healthz` — freshness-aware health check (`healthy`, `degraded`, or `unhealthy`)
108109

@@ -174,9 +175,15 @@ five non-ad playback seconds, and sends cumulative measured watch time
174175
every 30 seconds. Failed requests remain in `chrome.storage.local`, retry with
175176
bounded exponential backoff, and survive browser restarts. One session is
176177
idempotently updated server-side, so retries never add duplicate watch events.
177-
The dedicated token can only access the capture endpoint. Search terms,
178-
playback position, cookies, and browsing outside `www.youtube.com` are not
179-
collected.
178+
The dedicated token can only access the capture and progress endpoints. Search
179+
terms, cookies, and browsing outside `www.youtube.com` are not collected.
180+
181+
The popup's explicit **Import history** action opens the signed-in YouTube
182+
History page and imports only video ids plus the resume/progress and duration
183+
shown there. It does not import titles, channels, history timestamps, searches,
184+
or event order. These rows remain private and contribute only aggregate
185+
content-coverage statistics. Automatic viewing capture does not collect
186+
playback position.
180187

181188
### Behind a reverse proxy
182189

chrome-extension/background.js

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { DEFAULT_ENDPOINT, mergeQueue, retryDelayMs } from './queue.js';
33
const QUEUE_KEY = 'captureQueue';
44
const SETTINGS_KEY = 'captureSettings';
55
const STATUS_KEY = 'captureStatus';
6+
const HISTORY_STATUS_KEY = 'historyImportStatus';
67
let queueMutation = Promise.resolve();
78
let flushPromise = null;
89

@@ -143,6 +144,122 @@ function flushQueue() {
143144
return flushPromise;
144145
}
145146

147+
function progressEndpoint(endpoint) {
148+
return endpoint.replace(/\/capture$/, '/progress');
149+
}
150+
151+
async function sendProgressBatch(payload) {
152+
const config = await settings();
153+
if (!config.enabled || !config.token) throw new Error('Capture token is not configured');
154+
let lastError = null;
155+
for (let attempt = 0; attempt < 3; attempt++) {
156+
try {
157+
const response = await fetch(progressEndpoint(config.endpoint), {
158+
method: 'POST',
159+
headers: {
160+
authorization: `Bearer ${config.token}`,
161+
'content-type': 'application/json',
162+
},
163+
body: JSON.stringify(payload),
164+
});
165+
if (response.ok) return await response.json();
166+
const body = await response.json().catch(() => ({}));
167+
throw new Error(body?.error || `Progress import failed: HTTP ${response.status}`);
168+
} catch (error) {
169+
lastError = error;
170+
if (attempt < 2) await new Promise((resolve) => setTimeout(resolve, 1000 * (2 ** attempt)));
171+
}
172+
}
173+
throw lastError;
174+
}
175+
176+
async function historyStatus(patch) {
177+
const stored = await chrome.storage.local.get(HISTORY_STATUS_KEY);
178+
await chrome.storage.local.set({
179+
[HISTORY_STATUS_KEY]: {
180+
...(stored[HISTORY_STATUS_KEY] ?? {}),
181+
...patch,
182+
},
183+
});
184+
}
185+
186+
async function waitForTab(tabId) {
187+
const current = await chrome.tabs.get(tabId);
188+
if (current.status === 'complete') return;
189+
await new Promise((resolve) => {
190+
const listener = (updatedId, changeInfo) => {
191+
if (updatedId !== tabId || changeInfo.status !== 'complete') return;
192+
chrome.tabs.onUpdated.removeListener(listener);
193+
resolve();
194+
};
195+
chrome.tabs.onUpdated.addListener(listener);
196+
});
197+
}
198+
199+
async function startHistoryImport() {
200+
const config = await settings();
201+
if (!config.enabled || !config.token) throw new Error('Capture token is not configured');
202+
const stored = await chrome.storage.local.get(HISTORY_STATUS_KEY);
203+
if (stored[HISTORY_STATUS_KEY]?.state === 'running') {
204+
throw new Error('A history import is already running');
205+
}
206+
const scanId = crypto.randomUUID();
207+
const observedAt = new Date().toISOString();
208+
const tab = await chrome.tabs.create({
209+
active: true,
210+
url: 'https://www.youtube.com/feed/history',
211+
});
212+
if (!tab.id) throw new Error('Could not open YouTube History');
213+
await historyStatus({
214+
state: 'running',
215+
scanId,
216+
observedAt,
217+
tabId: tab.id,
218+
videos: 0,
219+
pass: 0,
220+
lastError: '',
221+
});
222+
void (async () => {
223+
try {
224+
await waitForTab(tab.id);
225+
const result = await chrome.tabs.sendMessage(tab.id, {
226+
type: 'start-history-import',
227+
scanId,
228+
observedAt,
229+
});
230+
if (!result?.ok) throw new Error(result?.error || 'YouTube History import failed');
231+
await historyStatus({
232+
state: 'complete',
233+
videos: result.videos,
234+
completedAt: new Date().toISOString(),
235+
lastError: '',
236+
});
237+
} catch (error) {
238+
const storedStatus = await chrome.storage.local.get(HISTORY_STATUS_KEY);
239+
if (storedStatus[HISTORY_STATUS_KEY]?.state === 'cancelled') return;
240+
await historyStatus({
241+
state: 'error',
242+
completedAt: new Date().toISOString(),
243+
lastError: error instanceof Error ? error.message : String(error),
244+
});
245+
}
246+
})();
247+
return { scanId, observedAt, tabId: tab.id };
248+
}
249+
250+
async function cancelHistoryImport() {
251+
const stored = await chrome.storage.local.get(HISTORY_STATUS_KEY);
252+
const status = stored[HISTORY_STATUS_KEY] ?? {};
253+
if (status.tabId) {
254+
await chrome.tabs.sendMessage(status.tabId, { type: 'cancel-history-import' }).catch(() => {});
255+
}
256+
await historyStatus({
257+
state: 'cancelled',
258+
completedAt: new Date().toISOString(),
259+
lastError: '',
260+
});
261+
}
262+
146263
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
147264
if (message?.type === 'capture' && message.payload) {
148265
enqueue(message.payload)
@@ -156,6 +273,39 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
156273
.catch((error) => sendResponse({ ok: false, error: String(error) }));
157274
return true;
158275
}
276+
if (message?.type === 'history-import-start') {
277+
startHistoryImport()
278+
.then((result) => sendResponse({ ok: true, ...result }))
279+
.catch((error) => {
280+
sendResponse({ ok: false, error: error instanceof Error ? error.message : String(error) });
281+
});
282+
return true;
283+
}
284+
if (message?.type === 'history-import-cancel') {
285+
cancelHistoryImport()
286+
.then(() => sendResponse({ ok: true }))
287+
.catch((error) => sendResponse({ ok: false, error: String(error) }));
288+
return true;
289+
}
290+
if (message?.type === 'history-progress-batch' && message.payload) {
291+
sendProgressBatch(message.payload)
292+
.then((result) => sendResponse({ ok: true, result }))
293+
.catch((error) => sendResponse({
294+
ok: false,
295+
error: error instanceof Error ? error.message : String(error),
296+
}));
297+
return true;
298+
}
299+
if (message?.type === 'history-import-progress') {
300+
historyStatus({
301+
state: 'running',
302+
videos: message.videos,
303+
pass: message.pass,
304+
})
305+
.then(() => sendResponse({ ok: true }))
306+
.catch((error) => sendResponse({ ok: false, error: String(error) }));
307+
return true;
308+
}
159309
return false;
160310
});
161311

chrome-extension/content.js

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
let boundVideo = null;
77
let lastTickAt = performance.now();
88
let lastMediaTime = 0;
9+
let historyImportCancelled = false;
910

1011
function videoIdFromLocation() {
1112
const url = new URL(location.href);
@@ -158,6 +159,70 @@
158159
if (!video.paused) ensureSession(video);
159160
}
160161

162+
function wait(milliseconds) {
163+
return new Promise((resolve) => window.setTimeout(resolve, milliseconds));
164+
}
165+
166+
function progressFingerprint(item) {
167+
return [
168+
item.progressPercent ?? '',
169+
item.resumeSeconds ?? '',
170+
item.durationSeconds ?? '',
171+
].join(':');
172+
}
173+
174+
async function sendProgressBatch(scanId, observedAt, items, complete = false) {
175+
const response = await chrome.runtime.sendMessage({
176+
type: 'history-progress-batch',
177+
payload: { scanId, observedAt, items, complete },
178+
});
179+
if (!response?.ok) throw new Error(response?.error || 'Progress batch was rejected');
180+
return response;
181+
}
182+
183+
async function runHistoryImport(scanId, observedAt) {
184+
if (location.pathname !== '/feed/history') {
185+
throw new Error('History import must run on the YouTube History page');
186+
}
187+
historyImportCancelled = false;
188+
const sent = new Map();
189+
let unchangedPasses = 0;
190+
let lastHeight = 0;
191+
let lastItems = 0;
192+
for (let pass = 0; pass < 900; pass++) {
193+
if (historyImportCancelled) throw new Error('History import cancelled');
194+
const items = globalThis.infovoreYoutubeHistory.collectProgress();
195+
const changed = items.filter((item) => {
196+
const fingerprint = progressFingerprint(item);
197+
if (sent.get(item.videoId) === fingerprint) return false;
198+
sent.set(item.videoId, fingerprint);
199+
return true;
200+
});
201+
for (let index = 0; index < changed.length; index += 250) {
202+
await sendProgressBatch(scanId, observedAt, changed.slice(index, index + 250));
203+
}
204+
await chrome.runtime.sendMessage({
205+
type: 'history-import-progress',
206+
scanId,
207+
videos: sent.size,
208+
pass,
209+
});
210+
const height = document.documentElement.scrollHeight;
211+
if (height === lastHeight && items.length === lastItems && changed.length === 0) {
212+
unchangedPasses++;
213+
} else {
214+
unchangedPasses = 0;
215+
}
216+
if (unchangedPasses >= 5) break;
217+
lastHeight = height;
218+
lastItems = items.length;
219+
window.scrollTo({ top: height, behavior: 'instant' });
220+
await wait(700);
221+
}
222+
await sendProgressBatch(scanId, observedAt, [], true);
223+
return sent.size;
224+
}
225+
161226
function handleNavigation() {
162227
const videoId = videoIdFromLocation();
163228
if (state && state.videoId !== videoId) {
@@ -174,6 +239,22 @@
174239
lastMediaTime = boundVideo?.currentTime ?? 0;
175240
});
176241
window.addEventListener('pagehide', () => flush(true));
242+
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
243+
if (message?.type === 'start-history-import') {
244+
runHistoryImport(message.scanId, message.observedAt)
245+
.then((videos) => sendResponse({ ok: true, videos }))
246+
.catch((error) => sendResponse({
247+
ok: false,
248+
error: error instanceof Error ? error.message : String(error),
249+
}));
250+
return true;
251+
}
252+
if (message?.type === 'cancel-history-import') {
253+
historyImportCancelled = true;
254+
sendResponse({ ok: true });
255+
}
256+
return false;
257+
});
177258
new MutationObserver(bindVideo).observe(document.documentElement, {
178259
childList: true,
179260
subtree: true,

chrome-extension/history.js

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
(() => {
2+
function parseDurationText(value) {
3+
const parts = String(value ?? '').trim().split(':').map(Number);
4+
if (parts.length < 2 || parts.length > 3 || parts.some(Number.isNaN)) return null;
5+
return parts.reduce((total, part) => total * 60 + part, 0);
6+
}
7+
8+
function progressFromLockup(root) {
9+
const link = root.querySelector('h3 a[href*="/watch?v="], a[href*="/shorts/"]');
10+
if (!link) return null;
11+
const url = new URL(link.href, location.origin);
12+
const videoId = url.searchParams.get('v') || url.pathname.match(/^\/shorts\/([^/?]+)/)?.[1];
13+
if (!/^[A-Za-z0-9_-]{11}$/.test(videoId ?? '')) return null;
14+
const durationText = [...root.querySelectorAll('.ytBadgeShapeText')]
15+
.map((element) => element.textContent?.trim() ?? '')
16+
.find((text) => /^(?:\d+:){1,2}\d{2}$/.test(text));
17+
const durationSeconds = parseDurationText(durationText);
18+
const progressStyle = root.querySelector(
19+
'.ytThumbnailOverlayProgressBarHostWatchedProgressBarSegment'
20+
)?.getAttribute('style') ?? '';
21+
const progressMatch = progressStyle.match(/width:\s*([\d.]+)%/);
22+
const progressPercent = progressMatch
23+
? Math.max(0, Math.min(100, Number(progressMatch[1])))
24+
: null;
25+
const resumeLink = root.querySelector('a[href*="/watch?v="][href*="t="]');
26+
const resumeUrl = resumeLink ? new URL(resumeLink.href, location.origin) : url;
27+
const resumeValue = resumeUrl.searchParams.get('t')?.replace(/s$/, '') ?? '';
28+
const resumeSeconds = /^\d+$/.test(resumeValue) ? Number(resumeValue) : null;
29+
if (progressPercent === null && resumeSeconds === null) return null;
30+
return {
31+
videoId,
32+
progressPercent,
33+
resumeSeconds: durationSeconds === null || resumeSeconds === null
34+
? resumeSeconds
35+
: Math.min(durationSeconds, resumeSeconds),
36+
durationSeconds,
37+
};
38+
}
39+
40+
function mergeProgress(current, incoming) {
41+
if (!current) return incoming;
42+
const progressPercent = current.progressPercent === null
43+
? incoming.progressPercent
44+
: incoming.progressPercent === null
45+
? current.progressPercent
46+
: Math.max(current.progressPercent, incoming.progressPercent);
47+
const resumeSeconds = current.resumeSeconds === null
48+
? incoming.resumeSeconds
49+
: incoming.resumeSeconds === null
50+
? current.resumeSeconds
51+
: Math.max(current.resumeSeconds, incoming.resumeSeconds);
52+
return {
53+
videoId: current.videoId,
54+
progressPercent,
55+
resumeSeconds,
56+
durationSeconds: current.durationSeconds ?? incoming.durationSeconds,
57+
};
58+
}
59+
60+
function collectProgress(documentRoot = document) {
61+
const items = new Map();
62+
for (const root of documentRoot.querySelectorAll('yt-lockup-view-model')) {
63+
const item = progressFromLockup(root);
64+
if (item) items.set(item.videoId, mergeProgress(items.get(item.videoId), item));
65+
}
66+
return [...items.values()];
67+
}
68+
69+
globalThis.infovoreYoutubeHistory = {
70+
collectProgress,
71+
mergeProgress,
72+
parseDurationText,
73+
};
74+
})();

chrome-extension/manifest.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
{
22
"manifest_version": 3,
33
"name": "infovore YouTube Capture",
4-
"version": "1.0.0",
5-
"description": "Privately records YouTube viewing sessions and measured watch time to infovore.",
4+
"version": "1.1.0",
5+
"description": "Privately records measured YouTube watch time and imports saved viewing progress to infovore.",
66
"minimum_chrome_version": "120",
77
"permissions": [
88
"alarms",
@@ -26,6 +26,7 @@
2626
"https://www.youtube.com/*"
2727
],
2828
"js": [
29+
"history.js",
2930
"content.js"
3031
],
3132
"run_at": "document_idle"

0 commit comments

Comments
 (0)