-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcontent.js
More file actions
342 lines (295 loc) · 11.4 KB
/
Copy pathcontent.js
File metadata and controls
342 lines (295 loc) · 11.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
/**
* Criterion Channel Enhancer — content script
*
* Transforms the "Directed by" and "Starring" metadata lines on movie pages
* into clickable search links, respecting per-field settings from the popup.
* Optionally fetches genre tags from TMDB and displays them below the metadata.
*
* Search URL pattern: https://www.criterionchannel.com/search?q=QUERY
*
* DOM structure (both lines share one <p>, separated by <br>):
* <p>Directed by Taylor Hackford • 1997 • United States
* <br>Starring Keanu Reeves, Al Pacino, Charlize Theron</p>
*/
const SEARCH_BASE = 'https://www.criterionchannel.com/search?q=';
const TMDB_SEARCH_URL = 'https://api.themoviedb.org/3/search/movie';
const TMDB_GENRES_URL = 'https://api.themoviedb.org/3/genre/movie/list';
const GENRE_FILTER_BASE = 'https://films.criterionchannel.com/?genre=';
// Maps TMDB genre names to Criterion's URL slugs.
// TMDB genres with no Criterion equivalent (Family, History, Mystery, TV Movie)
// are intentionally omitted — they will be silently dropped from the tag row.
const TMDB_TO_CRITERION = {
'Action': 'action-adventure',
'Adventure': 'action-adventure',
'Animation': 'animation',
'Comedy': 'comedy',
'Crime': 'crime',
'Documentary': 'documentary',
'Drama': 'drama',
'Fantasy': 'fantasy',
'Horror': 'horror',
'Music': 'musical',
'Romance': 'romance',
'Science Fiction': 'science-fiction',
'Thriller': 'thriller',
'War': 'war',
'Western': 'western',
};
const DEFAULT_SETTINGS = {
linkDirector: true,
linkYear: true,
linkCountry: true,
linkActors: true,
showGenres: false,
tmdbApiKey: '',
};
let currentSettings = { ...DEFAULT_SETTINGS };
// In-memory cache of TMDB genre id → name, shared across page navigations
let tmdbGenreMap = null;
// ─── Search link helpers ──────────────────────────────────────────────────────
function searchLink(text) {
const a = document.createElement('a');
a.href = SEARCH_BASE + encodeURIComponent(text);
a.textContent = text;
a.style.color = 'inherit';
a.style.textDecoration = 'underline';
a.style.cursor = 'pointer';
return a;
}
function textOrLink(text, enabled) {
return enabled ? searchLink(text) : document.createTextNode(text);
}
// ─── Metadata fragment builders ───────────────────────────────────────────────
/**
* Builds a fragment for: "Directed by Taylor Hackford • 1997 • United States"
* Handles multiple directors (comma-separated) and optional year/country fields.
* Year segments are detected by /^\d{4}$/, everything else is treated as country.
* Returns null if the text doesn't match.
*/
function buildDirectedByFragment(text, settings) {
const match = text.match(/^Directed by\s+(.+)$/);
if (!match) return null;
const segments = match[1].split(/\s+•\s+/);
const directors = segments[0].split(/,\s*|\s+and\s+/).map(d => d.trim()).filter(Boolean);
const meta = segments.slice(1);
const frag = document.createDocumentFragment();
frag.appendChild(document.createTextNode('Directed by '));
directors.forEach((director, i) => {
frag.appendChild(textOrLink(director, settings.linkDirector));
if (i < directors.length - 1) {
frag.appendChild(document.createTextNode(' and '));
}
});
for (const segment of meta) {
frag.appendChild(document.createTextNode(' • '));
const isYear = /^\d{4}$/.test(segment.trim());
if (isYear) {
frag.appendChild(textOrLink(segment, settings.linkYear));
} else {
// Country segment may contain multiple values: "United Kingdom, United States"
const countries = segment.split(/,\s*/);
countries.forEach((country, i) => {
frag.appendChild(textOrLink(country.trim(), settings.linkCountry));
if (i < countries.length - 1) {
frag.appendChild(document.createTextNode(', '));
}
});
}
}
return frag;
}
/**
* Builds a fragment for: "Starring Keanu Reeves, Al Pacino, Charlize Theron"
* Returns null if the text doesn't match.
*/
function buildStarringFragment(text, settings) {
const match = text.match(/^Starring\s+(.+)$/);
if (!match) return null;
const actors = match[1].split(/,\s*/);
const frag = document.createDocumentFragment();
frag.appendChild(document.createTextNode('Starring '));
actors.forEach((actor, i) => {
frag.appendChild(textOrLink(actor.trim(), settings.linkActors));
if (i < actors.length - 1) {
frag.appendChild(document.createTextNode(', '));
}
});
return frag;
}
// ─── Metadata block enhancement ───────────────────────────────────────────────
/**
* Processes a single element that contains the metadata block.
* Saves original innerHTML before first modification so settings changes
* can re-process from the original text.
*/
function enhanceMetaBlock(el, settings) {
if (!el.dataset.ccOriginalHtml) {
el.dataset.ccOriginalHtml = el.innerHTML;
}
const frag = document.createDocumentFragment();
for (const node of [...el.childNodes]) {
if (node.nodeType === Node.TEXT_NODE) {
const text = node.textContent.trim();
const dirFrag = /^Directed by\s+/.test(text) && buildDirectedByFragment(text, settings);
const starFrag = /^Starring\s+/.test(text) && buildStarringFragment(text, settings);
if (dirFrag) {
frag.appendChild(dirFrag);
} else if (starFrag) {
frag.appendChild(starFrag);
} else {
frag.appendChild(document.createTextNode(node.textContent));
}
} else {
frag.appendChild(node.cloneNode(true));
}
}
el.replaceChildren(frag);
el.dataset.ccEnhanced = 'true';
}
// ─── Genre row ────────────────────────────────────────────────────────────────
/**
* Returns the element after which the genre row should be inserted.
* If the metadata element is inside a .read-more-wrap container (which has a
* max-height that would clip the description), we insert after that container
* instead of after metaEl, so genre tags don't consume the description's space.
*/
function getGenreAnchor(metaEl) {
let el = metaEl.parentElement;
while (el) {
if (el.classList.contains('read-more-wrap')) return el;
el = el.parentElement;
}
return metaEl;
}
function removeGenreRow(metaEl) {
const anchor = getGenreAnchor(metaEl);
const sibling = anchor.previousElementSibling;
if (sibling && sibling.dataset.ccGenreRow) sibling.remove();
}
function renderGenreRow(metaEl, genres) {
removeGenreRow(metaEl);
if (!genres || genres.length === 0) return;
const row = document.createElement('p');
row.dataset.ccGenreRow = 'true';
row.style.marginBottom = '6px';
const seenSlugs = new Set();
for (const genre of genres) {
const slug = TMDB_TO_CRITERION[genre];
if (!slug || seenSlugs.has(slug)) continue;
seenSlugs.add(slug);
const a = document.createElement('a');
a.href = GENRE_FILTER_BASE + slug;
a.textContent = genre;
a.style.cssText = [
'display:inline-block',
'margin:0 4px 4px 0',
'padding:2px 8px',
'border:1px solid #666',
'border-radius:12px',
'font-size:0.85em',
'color:#ccc',
'text-decoration:none',
].join(';');
row.appendChild(a);
}
getGenreAnchor(metaEl).insertAdjacentElement('beforebegin', row);
}
// ─── TMDB fetching ────────────────────────────────────────────────────────────
async function fetchTmdbGenreMap(apiKey) {
if (tmdbGenreMap) return tmdbGenreMap;
try {
const res = await fetch(`${TMDB_GENRES_URL}?api_key=${apiKey}&language=en-US`);
if (!res.ok) return null;
const data = await res.json();
tmdbGenreMap = {};
for (const g of data.genres) tmdbGenreMap[g.id] = g.name;
return tmdbGenreMap;
} catch {
return null;
}
}
async function fetchGenresForMovie(title, year, apiKey) {
try {
const params = new URLSearchParams({ api_key: apiKey, query: title, language: 'en-US', page: '1' });
if (year) params.set('year', year);
const res = await fetch(`${TMDB_SEARCH_URL}?${params}`);
if (!res.ok) return null;
const data = await res.json();
if (!data.results || data.results.length === 0) return null;
const map = await fetchTmdbGenreMap(apiKey);
if (!map) return null;
return data.results[0].genre_ids.map(id => map[id]).filter(Boolean);
} catch {
return null;
}
}
function getMovieTitle() {
for (const h1 of document.querySelectorAll('h1')) {
const text = h1.textContent.trim();
if (text) return text;
}
return document.title.split(/\s*[|\-–]\s*/)[0].trim();
}
async function maybeAddGenres(metaEl) {
const { showGenres, tmdbApiKey } = currentSettings;
if (!showGenres || !tmdbApiKey) return;
const title = getMovieTitle();
const yearMatch = (metaEl.dataset.ccOriginalHtml || '').match(/\b(\d{4})\b/);
const year = yearMatch ? yearMatch[1] : null;
const genres = await fetchGenresForMovie(title, year, tmdbApiKey);
renderGenreRow(metaEl, genres);
}
// ─── Page scanning ────────────────────────────────────────────────────────────
/**
* Scans the page for the metadata <p> containing both lines.
*/
function enhancePage() {
for (const el of document.querySelectorAll('p')) {
if (el.dataset.ccEnhanced) continue;
const text = el.textContent;
if (/Directed by\s+/.test(text) || /Starring\s+/.test(text)) {
enhanceMetaBlock(el, currentSettings);
maybeAddGenres(el);
}
}
}
/**
* Re-processes all previously enhanced elements from their saved original HTML,
* then re-runs enhancePage with updated settings.
*/
function reEnhancePage() {
for (const el of document.querySelectorAll('[data-cc-enhanced]')) {
removeGenreRow(el);
el.innerHTML = el.dataset.ccOriginalHtml;
delete el.dataset.ccEnhanced;
}
enhancePage();
}
// ─── Initialisation ───────────────────────────────────────────────────────────
// Load settings before first pass, then kick off page enhancement.
chrome.storage.sync.get(DEFAULT_SETTINGS, (settings) => {
currentSettings = settings;
enhancePage();
});
// Re-process whenever the user changes a setting in the popup.
chrome.storage.onChanged.addListener((changes) => {
for (const key of Object.keys(changes)) {
if (key in DEFAULT_SETTINGS) {
currentSettings[key] = changes[key].newValue;
}
}
reEnhancePage();
});
/**
* MutationObserver handles SPA navigation — content loads dynamically.
* Debounced to prevent re-entrancy when replaceChildren triggers mutations.
*/
let debounceTimer;
const observer = new MutationObserver(() => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(enhancePage, 100);
});
observer.observe(document.body, {
childList: true,
subtree: true,
});