const REQUEST_TIMEOUT_MS = 30_000; const MEDIA_TYPES = new Set(['movie', 'tv']); const UUID = /^[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}$/i; const WORLD_ID = /^cw_[a-f0-9]{32}$/; const TMDB_LOGO = 'https://www.themoviedb.org/assets/2/v4/logos/v2/blue_square_2-d537fb228cf3ded904ef09b136fe3fec72548ebc1fea3fbbd1ad9e36364db38b.svg'; function text(value, limit = 500) { return typeof value === 'string' ? value.replace(/[\u0000-\u001f\u007f]/g, ' ').trim().slice(0, limit) : ''; } function scalarText(value, limit = 120) { if (typeof value === 'number' && Number.isFinite(value)) return String(value).slice(0, limit); return text(value, limit); } function positiveId(value) { const id = typeof value === 'number' ? value : Number(value); return Number.isSafeInteger(id) && id > 0 ? id : 0; } function safePoster(value) { if (typeof value !== 'string') return ''; try { const url = new URL(value); return url.protocol === 'https:' && url.hostname === 'image.tmdb.org' && !url.username && !url.password && !url.port && /^\/t\/p\/(?:w\d+|original)\/[A-Za-z0-9_./-]+\.(?:jpe?g|png|webp)$/i.test(url.pathname) && !url.search && !url.hash ? url.href : ''; } catch { return ''; } } function safeImdbUrl(value) { if (typeof value !== 'string') return ''; try { const url = new URL(value); return url.protocol === 'https:' && url.hostname === 'www.imdb.com' && !url.username && !url.password && !url.port && /^\/title\/tt\d{7,12}\/?$/.test(url.pathname) && !url.search && !url.hash ? url.href : ''; } catch { return ''; } } function safeNotionUrl(value) { if (typeof value !== 'string') return ''; try { const url = new URL(value); const host = url.hostname.toLowerCase(); return url.protocol === 'https:' && ['www.notion.so', 'notion.so', 'app.notion.com'].includes(host) && !url.username && !url.password && !url.port ? url.href : ''; } catch { return ''; } } function node(tag, className, label) { const element = document.createElement(tag); if (className) element.className = className; if (label !== undefined) element.textContent = label; return element; } export function peopleForPreview(value, aliases = {}, roleKey = '') { const people = Array.isArray(value) ? value : []; return people.flatMap(person => { if (!person || typeof person !== 'object') return []; const name = text(person.name, 240); const originalName = text(person.originalName, 240); const alias = aliases[originalName] || aliases[name]; const shownName = text(alias, 240) || name || originalName; if (!shownName) return []; return [{ name: shownName, originalName: originalName && originalName !== shownName ? originalName : '', role: text(Array.isArray(person[roleKey]) ? person[roleKey].map(value => text(value, 160)).filter(Boolean).join(' / ') : person[roleKey], 160), }]; }); } export function seasonsForPreview(seasons) { return (Array.isArray(seasons) ? seasons : []).flatMap(season => { if (!season || typeof season !== 'object') return []; const number = positiveId(season.number); const title = text(season.name, 200) || (number ? `シーズン${number}` : '特別編'); const episodes = positiveId(season.episodes); const meta = [scalarText(season.year, 20), episodes ? `${episodes}話` : ''].filter(Boolean).join(' · '); return [{ title, meta }]; }); } function addStyles() { const style = node('style'); style.textContent = ` .imdb-search-open{background:transparent;color:#aab6bf;border:1px solid #3b444c;border-radius:999px;padding:9px 12px;font-size:10px;font-weight:600;letter-spacing:.6px} .imdb-search-open:hover{color:#d6e4e8;border-color:#64737b;background:#22282d} dialog.imdb-dialog{width:min(760px,calc(100vw - 24px));max-width:none;max-height:calc(100dvh - 24px);margin:auto;padding:0;overflow:auto;border:1px solid #46515a;border-radius:16px;background:#202327;color:#e4e8ec;box-shadow:0 24px 90px #000b} dialog.imdb-dialog::backdrop{background:#080b0ed9;backdrop-filter:blur(3px)} .imdb-dialog,.imdb-dialog *{box-sizing:border-box} .imdb-dialog-inner{padding:20px clamp(16px,4vw,28px) 24px} .imdb-dialog-head{display:flex;align-items:flex-start;gap:12px;position:sticky;top:0;z-index:2;background:#202327;padding-bottom:8px} .imdb-dialog-head>div{flex:1;min-width:0} .imdb-dialog h2{font-size:15px;letter-spacing:1.2px;margin:0 0 6px} .imdb-dialog h3{font-size:12px;letter-spacing:.5px;margin:17px 0 8px;color:#cde5e9} .imdb-close{width:32px;height:32px;flex:0 0 32px;border:1px solid #424a52;border-radius:9px;background:#252a30;color:#d5dce1;font-size:21px;line-height:1} .imdb-close:hover{background:#303840} .imdb-note,.imdb-muted,.imdb-limitations,.imdb-error,.imdb-status{color:#a6b0ba;font-size:11px;line-height:1.65} .imdb-note{margin:0 0 16px;max-width:650px} .imdb-form{display:flex;gap:8px;margin:13px 0 7px} .imdb-query{min-width:0;flex:1;border:1px solid #46515a;border-radius:10px;background:#171a1e;padding:10px 12px;color:#edf1f3;font-size:12px} .imdb-query::placeholder{color:#78838d} .imdb-submit{border:1px solid #3e7781;border-radius:10px;background:#23454b;color:#c8f4f6;padding:0 14px;font-size:11px;font-weight:600;white-space:nowrap} .imdb-submit:hover{background:#2b5960} .imdb-submit:disabled{opacity:.6;cursor:wait} .imdb-status{min-height:1.7em;margin:5px 0 10px} .imdb-results{display:grid;gap:7px;list-style:none;margin:0;padding:0;max-height:34vh;overflow:auto} .imdb-result{width:100%;display:grid;grid-template-columns:54px minmax(0,1fr) auto;gap:11px;align-items:center;text-align:left;border:1px solid #353d44;border-radius:11px;background:#24292e;padding:7px;color:inherit;cursor:pointer} .imdb-result:hover,.imdb-result[aria-pressed="true"]{border-color:#68bdc9;background:#263136} .imdb-poster{width:54px;height:75px;object-fit:cover;border-radius:5px;background:#171a1d} .imdb-poster-empty{display:grid;place-items:center;color:#68737d;font-size:8px;letter-spacing:.4px} .imdb-result-title{display:block;font-size:12px;font-weight:600;line-height:1.4;overflow-wrap:anywhere} .imdb-result-meta{display:block;margin-top:4px;color:#9ca8b2;font-size:10px;line-height:1.4} .imdb-result-arrow{color:#86dbe5;font-size:16px;padding:0 5px} .imdb-preview{margin-top:16px;padding-top:15px;border-top:1px solid #353d44;scroll-margin-top:50px} .imdb-preview-top{display:grid;grid-template-columns:105px minmax(0,1fr);gap:15px;align-items:start} .imdb-preview-poster{width:105px;max-height:155px;object-fit:cover;border-radius:8px;background:#171a1d} .imdb-preview-title{font-size:17px;line-height:1.35;margin:2px 0 5px;overflow-wrap:anywhere} .imdb-preview-original{font-size:11px;color:#a8b2bb;margin:0 0 8px;overflow-wrap:anywhere} .imdb-preview-meta{font-size:11px;color:#c1cbd1;line-height:1.7} .imdb-external{display:inline-block;margin-top:8px;font-size:11px} .imdb-data-list{display:grid;gap:5px;list-style:none;padding:0;margin:0} .imdb-data-list li{font-size:11px;line-height:1.55;overflow-wrap:anywhere} .imdb-disclosure{margin:12px 0;border-top:1px solid #353d44;padding-top:10px} .imdb-disclosure>summary{color:#c4d0d6;cursor:pointer;font-size:11px;line-height:1.6} .imdb-disclosure[open]>summary{margin-bottom:10px} .imdb-role{margin-left:6px;color:#a6b0ba} .imdb-limitations{margin:15px 0 0;padding:9px 12px;border-left:2px solid #697580;background:#24282d} .imdb-limitations ul{margin:5px 0 0;padding-left:18px} .imdb-error{margin:8px 0;padding:10px 12px;border:1px solid #70494e;border-radius:9px;background:#302326;color:#ffc0bd} .imdb-error[hidden],.imdb-limitations[hidden]{display:none} .imdb-action{margin-top:16px;padding-top:14px;border-top:1px solid #353d44} .imdb-action-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap} .imdb-action .imdb-submit{min-height:36px} .imdb-action input{min-height:36px} .imdb-save-result{margin-top:10px;padding:10px 12px;border:1px solid #3e7781;border-radius:9px;background:#1e3034;color:#d8f4f5;font-size:11px;line-height:1.6} .imdb-save-result a{margin-right:12px} .imdb-save-result button{border:1px solid #52656b;border-radius:7px;background:#242d32;padding:5px 8px;color:#dce7e9;font-size:10px} .imdb-save-result ul{margin:5px 0 0;padding-left:18px;color:#c4d3d6} .imdb-credits{margin-top:22px;border-top:1px solid #353d44;padding-top:12px;color:#a6b0ba;font-size:10px;line-height:1.6} .imdb-credits summary{color:#c4d0d6;cursor:pointer} .imdb-credits-content{display:flex;align-items:center;gap:12px;margin-top:10px} .imdb-tmdb-logo{width:40px;height:auto;flex:0 0 auto} .imdb-credits p{margin:0} @media(max-width:520px){.imdb-search-open{padding:8px 10px;font-size:9px}.imdb-dialog-inner{padding:16px 14px 20px}.imdb-result{grid-template-columns:44px minmax(0,1fr) auto;gap:8px}.imdb-poster{width:44px;height:63px}.imdb-preview-top{grid-template-columns:78px minmax(0,1fr);gap:11px}.imdb-preview-poster{width:78px;max-height:120px}.imdb-form{gap:6px}.imdb-submit{padding:0 10px}} `; document.head.append(style); } function initializeImdbSearch() { const trigger = document.getElementById('discoverNav') || document.getElementById('imdbSearchOpen'); if (!trigger || document.getElementById('imdbSearchDialog')) return; addStyles(); if (trigger.id !== 'discoverNav') trigger.textContent = '+ ADD WORK'; trigger.title = '作品検索(TMDb)'; trigger.setAttribute('aria-label', '作品検索してNotionへ追加'); const notionSource = document.getElementById('notionForm'); let notionHref = ''; if (notionSource) { try { const url = new URL(notionSource.href); if (url.protocol === 'https:' && url.hostname === 'app.notion.com') notionHref = url.href; } catch { /* The legacy save form is optional. */ } } const dialog = node('dialog', 'imdb-dialog'); dialog.id = 'imdbSearchDialog'; dialog.setAttribute('aria-modal', 'true'); dialog.setAttribute('aria-labelledby', 'imdbDialogTitle'); dialog.setAttribute('aria-describedby', 'imdbDialogNote'); const inner = node('div', 'imdb-dialog-inner'); const header = node('div', 'imdb-dialog-head'); const headCopy = node('div'); const heading = node('h2', '', 'ADD WORK · 作品検索'); heading.id = 'imdbDialogTitle'; const note = node('p', 'imdb-note', '日本語の作品名でTMDbを検索し、候補を選んで内容を確認してからNotionへ保存します。候補の自動選択はありません。'); note.id = 'imdbDialogNote'; headCopy.append(heading); const closeButton = node('button', 'imdb-close', '×'); closeButton.type = 'button'; closeButton.setAttribute('aria-label', '作品検索を閉じる'); header.append(headCopy, closeButton); inner.append(header, note); const form = node('form', 'imdb-form'); const input = node('input', 'imdb-query'); input.id = 'imdbQuery'; input.type = 'search'; input.name = 'q'; input.maxLength = 120; input.autocomplete = 'off'; input.placeholder = '日本語の作品名'; input.setAttribute('aria-label', 'TMDbで検索する作品名'); const submit = node('button', 'imdb-submit', '検索'); submit.type = 'submit'; form.append(input, submit); const status = node('p', 'imdb-status', '日本語タイトルを入力して検索してください。'); status.setAttribute('role', 'status'); status.setAttribute('aria-live', 'polite'); const fallback = node('div', 'imdb-muted'); fallback.hidden = true; if (notionHref) { fallback.append(document.createTextNode('手動でNotionフォームを開く場合: ')); const link = node('a', '', 'Notion保存フォーム ↗'); link.href = notionHref; link.target = '_blank'; link.rel = 'noopener noreferrer'; fallback.append(link); } const results = node('ul', 'imdb-results'); results.setAttribute('aria-label', 'TMDb検索候補'); const searchLimitations = node('div'); const preview = node('section', 'imdb-preview'); preview.hidden = true; preview.setAttribute('aria-live', 'polite'); const error = node('div', 'imdb-error'); error.hidden = true; error.setAttribute('role', 'alert'); inner.append(form, status, fallback, results, searchLimitations, preview, error); const credits = node('details', 'imdb-credits'); const creditsSummary = node('summary', '', 'About / Credits · TMDb'); const creditsContent = node('div', 'imdb-credits-content'); const tmdbLink = node('a'); tmdbLink.href = 'https://www.themoviedb.org'; tmdbLink.target = '_blank'; tmdbLink.rel = 'noopener noreferrer'; const tmdbLogo = node('img', 'imdb-tmdb-logo'); tmdbLogo.src = TMDB_LOGO; tmdbLogo.alt = 'The Movie Database (TMDB)'; tmdbLogo.loading = 'lazy'; tmdbLogo.referrerPolicy = 'no-referrer'; tmdbLink.append(tmdbLogo); const creditCopy = node('div'); creditCopy.append( node('p', '', 'This product uses the TMDB API but is not endorsed or certified by TMDB.'), node('p', '', 'TMDbの日本語作品情報と画像を利用しています。使用曲の完全な一覧ではありません。'), ); creditsContent.append(tmdbLink, creditCopy); credits.append(creditsSummary, creditsContent); inner.append(credits); dialog.append(inner); document.body.append(dialog); let activeElementBeforeOpen = null; let searchRequest = null; let detailRequest = null; let saveRequest = null; let searchVersion = 0; let detailVersion = 0; let selectedKey = ''; let saveInProgress = false; let activeWorldId = null; const stopRequest = request => { if (!request) return; clearTimeout(request.timer); request.controller.abort(); }; const makeRequest = () => { const controller = new AbortController(); const request = { controller, timedOut: false, timer: 0 }; request.timer = setTimeout(() => { request.timedOut = true; controller.abort(); }, REQUEST_TIMEOUT_MS); return request; }; const requestJSON = async (path, request, options = {}) => { const response = await fetch(path, { method: options.method || 'GET', credentials: 'same-origin', cache: 'no-store', redirect: 'error', headers: { Accept: 'application/json', ...(options.body ? { 'Content-Type': 'application/json' } : {}) }, ...(options.body ? { body: JSON.stringify(options.body) } : {}), signal: request.controller.signal, }); let payload = null; try { payload = await response.json(); } catch { /* A clear API error is shown below. */ } if (!response.ok) { const explanation = payload && typeof payload === 'object' ? text(payload.message || payload.error, 300) : ''; if (response.status === 503 && /^\/api\/media\//.test(path)) { const setup = 'Cloudflare WorkersのTMDB_TOKEN設定を確認してください。キーをこの画面に入力しないでください。'; throw new Error(explanation ? `${explanation}${/TMDB_TOKEN/i.test(explanation) ? '' : ` ${setup}`}` : `TMDb連携を利用できません。${setup}`); } const failure = new Error(explanation || `リクエストに失敗しました(HTTP ${response.status})。`); failure.payload = payload; throw failure; } if (!payload || typeof payload !== 'object' || Array.isArray(payload)) throw new Error('APIから正しいデータを受け取れませんでした。'); return payload; }; const showFailure = (message, displayNode = null) => { error.replaceChildren(); if (displayNode) error.append(displayNode); else error.textContent = message; error.hidden = false; fallback.hidden = false; }; const clearFailure = () => { error.replaceChildren(); error.hidden = true; fallback.hidden = true; }; const safeMediaType = value => MEDIA_TYPES.has(value) ? value : ''; const cleanItems = value => Array.isArray(value) ? value.flatMap(item => { if (!item || typeof item !== 'object') return []; const id = positiveId(item.id), mediaType = safeMediaType(item.mediaType), title = text(item.title, 300); if (!id || !mediaType || !title) return []; return [{ id, mediaType, title, originalTitle: text(item.originalTitle, 300), year: scalarText(item.year, 40), format: text(item.format, 80), poster: safePoster(item.poster) }]; }) : []; const imageElement = (url, label, className) => { const safe = safePoster(url); if (!safe) return node('span', `imdb-poster imdb-poster-empty${className ? ` ${className}` : ''}`, 'NO IMAGE'); const image = node('img', className || 'imdb-poster'); image.src = safe; image.alt = label; image.loading = 'lazy'; image.referrerPolicy = 'no-referrer'; image.addEventListener('error', () => image.replaceWith(node('span', `imdb-poster imdb-poster-empty${className ? ` ${className}` : ''}`, 'NO IMAGE')), { once: true }); return image; }; const resultMeta = item => [item.mediaType === 'tv' ? 'テレビシリーズ' : '映画', scalarText(item.year, 40), text(item.format, 80)].filter(Boolean).join(' · '); const drawResults = items => { results.replaceChildren(); for (const item of items) { const key = `${item.mediaType}:${item.id}`; const row = node('li'); row.dataset.mediaKey = key; const button = node('button', 'imdb-result'); button.type = 'button'; button.setAttribute('aria-pressed', String(key === selectedKey)); button.append(imageElement(item.poster, `${text(item.title, 160)} ポスター`, 'imdb-poster')); const copy = node('span'); copy.append(node('span', 'imdb-result-title', text(item.title, 300))); const meta = resultMeta(item); if (item.originalTitle && item.originalTitle !== item.title) copy.append(node('span', 'imdb-result-meta', `原題: ${item.originalTitle}`)); copy.append(node('span', 'imdb-result-meta', meta)); button.append(copy, node('span', 'imdb-result-arrow', '›')); button.setAttribute('aria-label', [text(item.title, 200), meta, '詳細を確認'].filter(Boolean).join(' · ')); button.addEventListener('click', () => showDetails(item)); row.append(button); results.append(row); } }; const setSelectedResult = key => { for (const row of results.children) { const button = row.querySelector('.imdb-result'); if (button) button.setAttribute('aria-pressed', String(row.dataset.mediaKey === key)); } }; const setSaveFlowLocked = locked => { input.disabled = locked; submit.disabled = locked; closeButton.disabled = locked; for (const button of results.querySelectorAll('.imdb-result')) button.disabled = locked; }; const drawLimitations = (parent, limitations) => { const messages = Array.isArray(limitations) ? limitations.map(item => text(item, 400)).filter(Boolean).slice(0, 12) : []; if (!messages.length) return; const box = node('details', 'imdb-limitations'); box.append(node('summary', '', '取得情報の制限')); const list = node('ul'); for (const message of messages) list.append(node('li', '', message)); box.append(list); parent.append(box); }; let aliasesPromise; const getAliases = () => aliasesPromise ||= (async () => { try { const response = await fetch('/sync-metadata.json', { credentials: 'same-origin', cache: 'default', redirect: 'error', signal: AbortSignal.timeout(2_500), headers: { Accept: 'application/json' } }); const payload = response.ok ? await response.json() : null; return payload && typeof payload === 'object' && payload.directorAliases && typeof payload.directorAliases === 'object' ? payload.directorAliases : {}; } catch { return {}; } })(); const addPeople = (parent, title, value, aliases = {}, roleKey = '', collapsible = false) => { const valid = peopleForPreview(value, aliases, roleKey); if (!valid.length) return; const list = node('ul', 'imdb-data-list'); for (const person of valid) { const item = node('li'); item.append(node('span', '', person.name)); if (person.originalName) item.append(node('span', 'imdb-role', `(${person.originalName})`)); if (person.role) item.append(node('span', 'imdb-role', person.role)); list.append(item); } if (collapsible) { const disclosure = node('details', 'imdb-disclosure'); disclosure.append(node('summary', '', `${title} · ${valid.length}人`), list); parent.append(disclosure); } else { parent.append(node('h3', '', title), list); } }; const drawSeasons = (parent, seasons) => { const valid = seasonsForPreview(seasons); if (!valid.length) return; const disclosure = node('details', 'imdb-disclosure'); disclosure.append(node('summary', '', `シーズン · ${valid.length}件`)); const list = node('ul', 'imdb-data-list'); for (const season of valid) { const item = node('li'); item.append(node('span', '', season.title)); if (season.meta) item.append(node('span', 'imdb-role', season.meta)); list.append(item); } disclosure.append(list); parent.append(disclosure); }; const createSaveSection = work => { const section = node('section', 'imdb-action'); section.append(node('h3', '', 'Notionに保存')); section.append(node('p', 'imdb-muted', 'TMDbの作品IDを使って作品ページを作成します。StatusやRatingなどの値は設定しません。')); const row = node('div', 'imdb-action-row'); const saveButton = node('button', 'imdb-submit', 'Notionへ保存'); saveButton.type = 'button'; row.append(saveButton); section.append(row); const result = node('div', 'imdb-save-result'); result.hidden = true; result.setAttribute('role', 'status'); const saveError = node('div', 'imdb-error'); saveError.hidden = true; saveError.setAttribute('role', 'alert'); section.append(result, saveError); const saveSelected = async (selectedPageId = null) => { if (saveInProgress) return; const worldId = activeWorldId; saveInProgress = true; setSaveFlowLocked(true); saveButton.disabled = true; saveButton.textContent = '保存中…'; saveError.textContent = ''; saveError.hidden = true; result.replaceChildren(); result.hidden = true; const request = makeRequest(); saveRequest = request; try { const payload = await requestJSON('/api/works/save', request, { method: 'POST', body: { mediaType: work.mediaType, tmdbId: work.id, ...(selectedPageId ? {pageId:selectedPageId} : {}) }, }); if (!['created', 'updated', 'existing'].includes(payload.status)) throw new Error('Notionの保存結果を確認できませんでした。'); const pageId = text(payload.pageId, 64); if (!UUID.test(pageId)) throw new Error('NotionページIDの形式が正しくありません。'); const url = safeNotionUrl(payload.url) || `https://www.notion.so/${pageId.replace(/-/g, '')}`; const label = payload.status === 'created' ? 'Notionに新しい作品を保存しました。' : payload.status === 'updated' ? 'Notionの既存作品を更新しました。' : '同じTMDb IDの作品はNotionに保存済みです。'; result.append(node('span', '', label)); const pageLink = node('a', '', 'Notionで開く ↗'); pageLink.href = url; pageLink.target = '_blank'; pageLink.rel = 'noopener noreferrer'; result.append(pageLink); const refreshButton = node('button', '', '作品を読み直す'); refreshButton.type = 'button'; refreshButton.addEventListener('click', () => window.dispatchEvent(new CustomEvent('mediaorbit:refresh'))); result.append(refreshButton); const filled = Array.isArray(payload.filled) ? payload.filled.map(item => text(item, 120)).filter(Boolean).slice(0, 30) : []; const needsReview = Array.isArray(payload.needsReview) ? payload.needsReview.map(item => text(item, 160)).filter(Boolean).slice(0, 30) : []; if (filled.length || needsReview.length) { const list = node('ul'); for (const item of filled) list.append(node('li', '', `保存済み: ${item}`)); for (const item of needsReview) list.append(node('li', '', `要確認: ${item}`)); result.append(list); } result.hidden = false; window.dispatchEvent(new CustomEvent('mediaorbit:work-saved', {detail:{pageId,status:payload.status,work:{...work,provider:'tmdb',tmdbId:work.id},...(worldId?{worldId}:{})}})); closeDialog(true); } catch (cause) { const message = request.timedOut ? 'Notion保存がタイムアウトしました。作品一覧を確認してください。' : cause instanceof Error ? cause.message : 'Notionへの保存に失敗しました。'; saveError.textContent = message; saveError.hidden = false; const candidates = Array.isArray(cause?.payload?.candidates) ? cause.payload.candidates : []; for (const candidate of candidates.slice(0,20)) { const candidateId = text(candidate.pageId,64); if (!UUID.test(candidateId)) continue; const actions = node('div','imdb-action-row'); const choose = node('button','imdb-submit',`${text(candidate.title,120)||'既存作品'} の空欄を補完`); choose.type='button'; choose.addEventListener('click',()=>saveSelected(candidateId)); const link=node('a','','Notionで確認 ↗'); link.href=`https://www.notion.so/${candidateId.replace(/-/g,'')}`; link.target='_blank';link.rel='noopener noreferrer'; actions.append(choose,link);saveError.append(actions); } } finally { clearTimeout(request.timer); if (saveRequest === request) saveRequest = null; saveInProgress = false; setSaveFlowLocked(false); saveButton.disabled = false; saveButton.textContent = 'Notionへ保存'; } }; saveButton.addEventListener('click',()=>saveSelected()); return section; }; const drawPreview = (work, limitations, aliases) => { preview.replaceChildren(); const top = node('div', 'imdb-preview-top'); top.append(imageElement(work.poster, `${text(work.title, 160)} ポスター`, 'imdb-preview-poster')); const copy = node('div'); copy.append(node('p', 'imdb-preview-title', text(work.title, 300) || 'タイトル未取得')); const originalTitle = text(work.originalTitle, 300); if (originalTitle && originalTitle !== text(work.title, 300)) copy.append(node('p', 'imdb-preview-original', `原題: ${originalTitle}`)); const metadata = [work.mediaType === 'tv' ? 'テレビシリーズ' : '映画', scalarText(work.year, 40), text(work.format, 80)].filter(Boolean).join(' · '); copy.append(node('p', 'imdb-preview-meta', metadata)); const tmdbLink = node('a', 'imdb-external', 'TMDbで開く ↗'); tmdbLink.href = `https://www.themoviedb.org/${work.mediaType}/${work.id}`; tmdbLink.target = '_blank'; tmdbLink.rel = 'noopener noreferrer'; copy.append(tmdbLink); const imdbUrl = safeImdbUrl(work.url); if (imdbUrl) { const imdbLink = node('a', 'imdb-external', 'IMDbで開く ↗'); imdbLink.href = imdbUrl; imdbLink.target = '_blank'; imdbLink.rel = 'noopener noreferrer'; copy.append(document.createTextNode(' · '), imdbLink); } top.append(copy); preview.append(top); preview.append(createSaveSection(work)); if (text(work.overview, 2400)) { preview.append(node('h3', '', 'あらすじ')); preview.append(node('p', 'imdb-muted', text(work.overview, 2400))); } addPeople(preview, '監督', work.directors, aliases); addPeople(preview, '原作者', work.authors, aliases, 'role'); addPeople(preview, '脚本', work.writers, aliases, 'roles'); addPeople(preview, work.format?.includes('Anime') ? '声優(原語版クレジット)' : '俳優', work.cast, aliases, 'characters', true); const context = [ ['公開日 / 初回放送日', work.releaseDate || work.firstAirDate], ['制作国', (work.productionCountries || []).join(' / ')], ]; for (const [label, value] of context) if (value) preview.append(node('h3', '', label), node('p', 'imdb-muted', value)); drawSeasons(preview, work.seasons); preview.append(node('p', 'imdb-muted', '取得できた人物・役名・顔写真・あらすじ・シーズン情報も保存します。日本語吹替は、出典を確認できた情報を別に登録します。')); drawLimitations(preview, limitations); preview.hidden = false; }; const showDetails = async item => { if (saveInProgress) return; const key = `${item.mediaType}:${item.id}`; selectedKey = key; setSelectedResult(key); preview.replaceChildren(node('p', 'imdb-status', `${text(item.title, 240)} の詳細を読み込んでいます…`)); preview.hidden = false; clearFailure(); status.textContent = '作品の詳細を取得しています…'; stopRequest(detailRequest); const version = ++detailVersion; const request = makeRequest(); detailRequest = request; try { const [payload, aliases] = await Promise.all([ requestJSON(`/api/media/title/${item.mediaType}/${item.id}`, request), getAliases(), ]); if (version !== detailVersion || !dialog.open) return; const work = payload.work; if (!work || typeof work !== 'object' || positiveId(work.id) !== item.id || safeMediaType(work.mediaType) !== item.mediaType || work.provider !== 'tmdb') { throw new Error('選択した候補と詳細データが一致しませんでした。'); } drawPreview(work, payload.limitations, aliases); status.textContent = '選択した作品の詳細です。内容を確認してからNotionへ保存してください。'; preview.scrollIntoView({ block: 'start' }); } catch (cause) { if (version !== detailVersion || !dialog.open || request.controller.signal.aborted && !request.timedOut) return; preview.replaceChildren(); preview.hidden = true; const message = request.timedOut ? '作品詳細の取得がタイムアウトしました。' : cause instanceof Error ? cause.message : '作品詳細の取得に失敗しました。'; showFailure(message, cause instanceof Error ? cause.displayNode : null); status.textContent = '詳細を取得できませんでした。'; } finally { clearTimeout(request.timer); if (detailRequest === request) detailRequest = null; } }; form.addEventListener('submit', async event => { event.preventDefault(); if (saveInProgress) return; const query = input.value.trim(); if (!query) { status.textContent = '検索する日本語の作品名を入力してください。'; input.focus(); return; } if (query.length > 120) { status.textContent = '検索語は120文字以内で入力してください。'; return; } stopRequest(searchRequest); stopRequest(detailRequest); detailRequest = null; ++detailVersion; selectedKey = ''; preview.replaceChildren(); preview.hidden = true; results.replaceChildren(); searchLimitations.replaceChildren(); clearFailure(); status.textContent = 'TMDbの日本語候補を検索しています…'; submit.disabled = true; const version = ++searchVersion; const request = makeRequest(); searchRequest = request; try { const payload = await requestJSON(`/api/media/search?q=${encodeURIComponent(query)}`, request); if (version !== searchVersion || !dialog.open) return; if (payload.provider !== 'tmdb' || !Array.isArray(payload.items)) throw new Error('TMDb検索APIから候補一覧を受け取れませんでした。'); const items = cleanItems(payload.items); if (items.length) { drawResults(items); status.textContent = `${items.length}件の候補が見つかりました。タイトルと公開年を確認して選んでください。`; } else status.textContent = '一致する候補がありませんでした。作品名を変えて再検索してください。'; drawLimitations(searchLimitations, payload.limitations); } catch (cause) { if (version !== searchVersion || !dialog.open || request.controller.signal.aborted && !request.timedOut) return; const message = request.timedOut ? 'TMDb検索がタイムアウトしました。' : cause instanceof Error ? cause.message : 'TMDb検索に失敗しました。'; showFailure(message, cause instanceof Error ? cause.displayNode : null); status.textContent = '候補を取得できませんでした。'; } finally { clearTimeout(request.timer); if (searchRequest === request) searchRequest = null; if (version === searchVersion) submit.disabled = false; } }); const openDialog = ({worldId=null}={}) => { if (saveInProgress) return; void getAliases(); activeWorldId = typeof worldId === 'string' && WORLD_ID.test(worldId) ? worldId : null; activeElementBeforeOpen = document.activeElement; clearFailure(); status.textContent = '日本語の作品名を入力してください。'; try { dialog.showModal(); input.focus(); } catch { showFailure('作品検索ダイアログを開けませんでした。ページを再読み込みしてください。'); } }; const closeDialog = (afterSave = false) => { if (saveInProgress && !afterSave) return; if (dialog.open) dialog.close(); activeWorldId = null; searchVersion++; detailVersion++; stopRequest(searchRequest); stopRequest(detailRequest); stopRequest(saveRequest); searchRequest = null; detailRequest = null; saveRequest = null; submit.disabled = false; if (trigger.isConnected) trigger.focus(); else if (activeElementBeforeOpen instanceof HTMLElement && activeElementBeforeOpen.isConnected) activeElementBeforeOpen.focus(); }; window.addEventListener('mediaorbit:add-requested', event => { if (saveInProgress) return; const item={id:positiveId(event.detail?.tmdbId),mediaType:safeMediaType(event.detail?.mediaType),title:text(event.detail?.title,300),year:event.detail?.year,poster:event.detail?.poster}; if(!item.id||!item.mediaType)return; const worldId=typeof event.detail?.worldId==='string'&&WORLD_ID.test(event.detail.worldId)?event.detail.worldId:null; openDialog({worldId});input.value=item.title;drawResults([item]);void showDetails(item); }); trigger.addEventListener('click', () => openDialog()); closeButton.addEventListener('click', () => closeDialog()); dialog.addEventListener('cancel', event => { event.preventDefault(); if (!saveInProgress) closeDialog(); }); dialog.addEventListener('click', event => { if (event.target === dialog && !saveInProgress) closeDialog(); }); dialog.addEventListener('keydown', event => { if (event.key !== 'Tab') return; const focusable = [...dialog.querySelectorAll('button:not(:disabled),input:not(:disabled),a[href],summary')] .filter(element => !element.hidden && !element.closest('[hidden]') && element.getAttribute('aria-hidden') !== 'true'); if (!focusable.length) { event.preventDefault(); dialog.focus(); return; } const first = focusable[0], last = focusable[focusable.length - 1]; if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); } else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); } }); } if (typeof document !== 'undefined') { if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', initializeImdbSearch, { once: true }); else initializeImdbSearch(); }