import React, { useEffect, useMemo, useState, useRef } from "react"; import { useNavigate } from "react-router-dom"; import { logApiError } from "../utils/logger"; import { toastError, toastInfo } from "../utils/toastBus"; import { createShow, deleteShow, fetchDanimeEpisode, fetchShows, postCurrentEpisode } from "../api/watchparty"; import type { DanimeEpisode, ShowItem } from "../api/watchparty"; import { useAuth } from "../auth/AuthProvider"; const REDIRECT_DELAY_S = 3; const HHMM = /^(\d{1,2}):([0-5]\d)$/; const HHMMSS_OPT = /^(\d{1,2}):([0-5]\d)(?::([0-5]\d))?$/; function toHHMMSS(v: string): string | null { const m = v.trim().match(HHMM); if (!m) return null; const h = Math.abs(parseInt(m[1], 10)); if (h > 23) return null; const hh = String(h).padStart(2, "0"); const mm = m[2]; return `${hh}:${mm}:00`; } function toHHMM(v: string): string | null { const m = v.trim().match(/^(\d{1,2}):([0-5]\d)$/); if (!m) return null; const h = Number(m[1]); if (h > 23) return null; return `${String(h).padStart(2, "0")}:${m[2]}`; } function normalizeStartTimeInput(v: string): string | null { const t = v.trim(); if (!t) return ""; const m = t.match(HHMMSS_OPT); if (!m) return null; const h = Number(m[1]); if (h > 23) return null; const mm = m[2]; const ss = m[3] ?? "00"; return `${String(h).padStart(2, "0")}:${mm}:${ss}`; } function extractPartId(urlStr: string): string | null { const trimmed = urlStr.trim(); if (!trimmed) return null; try { const u = new URL(trimmed); if (!u.hostname.endsWith("animestore.docomo.ne.jp")) return null; const partId = u.searchParams.get("partId") || u.searchParams.get("partid"); if (partId) return partId; } catch { // fallback to regex } if (!trimmed.includes("animestore.docomo.ne.jp")) return null; const m = trimmed.match(/partId=(\d+)/i); return m ? m[1] : null; } export default function ShowsPage() { const [shows, setShows] = useState([]); const [loading, setLoading] = useState(true); const [posting, setPosting] = useState(false); const [error, setError] = useState(null); const [redirectIn, setRedirectIn] = useState(null); const [deletingId, setDeletingId] = useState(null); const redirectTid = useRef(null); const isRedirecting = redirectIn !== null; const navigate = useNavigate(); const { enabled: authEnabled, idToken, backendClaims, verifying } = useAuth(); const isAuthed = authEnabled && !!idToken; // フォーム状態 const [selectedId, setSelectedId] = useState(null); const [startTime, setStartTime] = useState(""); const [danimeUrl, setDanimeUrl] = useState(""); const [danimeStart, setDanimeStart] = useState(""); const [danimeResult, setDanimeResult] = useState(null); const [scraping, setScraping] = useState(false); const [registeringScrape, setRegisteringScrape] = useState(false); const [scrapeError, setScrapeError] = useState(null); function formatPlaybackLen(v: string): string { const parts = v.split(":").map(Number); if (parts.length === 2) { // already MM:SS const [mm, ss] = parts; return `${String(mm).padStart(2, "0")}:${String(ss).padStart(2, "0")}`; } if (parts.length === 3) { const [hh, mm, ss] = parts; if (hh <= 0) { // show MM:SS when hours are zero return `${String(mm).padStart(2, "0")}:${String(ss).padStart(2, "0")}`; } // show HH:MM:SS when hours exist return `${String(hh).padStart(2, "0")}:${String(mm).padStart(2, "0")}:${String(ss).padStart(2, "0")}`; } return v; // fallback (unexpected format) } const loadShows = React.useCallback(async () => { setError(null); try { setLoading(true); const data = await fetchShows(); data.sort((a, b) => a.id - b.id); // ASC setShows(data); } catch (e: unknown) { const msg = e instanceof Error ? e.message : "番組一覧を取得できませんでした。"; setError(msg); logApiError("fetch shows", e); toastError("番組一覧を取得できませんでした", msg); } finally { setLoading(false); } }, []); // 一覧取得 useEffect(() => { loadShows().catch(() => {}); }, [loadShows]); useEffect(() => { return () => { if (redirectTid.current) { window.clearInterval(redirectTid.current); } }; }, []); const current = useMemo(() => shows.find(s => s.id === selectedId) || null, [shows, selectedId]); async function submit() { setError(null); if (!selectedId) { setError("エピソードを選択してください。"); return; } const selected = shows.find(s => s.id === selectedId); if (!selected) { setError("選択中のエピソードが見つかりませんでした。"); return; } const payload: { id: number; start_time?: string } = { id: selectedId }; if (startTime.trim()) { const normalized = toHHMMSS(startTime); if (!normalized) { setError("開始時刻は HH:MM 形式で入力してください。"); return; } payload.start_time = normalized; } else { payload.start_time = selected.start_time; } try { setPosting(true); await postCurrentEpisode(payload); toastInfo("現在のエピソードを設定しました"); setRedirectIn(REDIRECT_DELAY_S); const start = Date.now(); redirectTid.current = window.setInterval(() => { const left = REDIRECT_DELAY_S - Math.floor((Date.now() - start) / 1000); if (left <= 0) { window.clearInterval(redirectTid.current!); navigate("/", { replace: true }); } else setRedirectIn(left); }, 250); } catch (e: unknown) { const msg = e instanceof Error ? e.message : "現在のエピソードを設定できませんでした。"; setError(msg); logApiError("set current episode", e); toastError("現在のエピソードを設定できませんでした", msg); } finally { setPosting(false); } } async function handleScrape() { setScrapeError(null); setDanimeResult(null); const partId = extractPartId(danimeUrl); if (!partId) { setScrapeError("animestore.docomo.ne.jp のエピソードURL(partId付き)を入力してください。"); return; } try { setScraping(true); const res = await fetchDanimeEpisode(partId); setDanimeResult(res); toastInfo("メタデータを取得しました"); } catch (e: unknown) { const msg = e instanceof Error ? e.message : "メタデータ取得に失敗しました。"; setScrapeError(msg); logApiError("fetch danime", e); toastError("メタデータ取得に失敗しました", msg); } finally { setScraping(false); } } async function handleRegisterScraped() { if (!danimeResult) { setScrapeError("先にメタデータを取得してください。"); return; } const normalized = normalizeStartTimeInput(danimeStart); if (normalized === null) { setScrapeError("開始時刻は HH:MM または HH:MM:SS で入力してください。"); return; } try { setRegisteringScrape(true); await createShow({ ep_num: danimeResult.ep_num, ep_title: danimeResult.ep_title, season_name: danimeResult.season_name, playback_length: danimeResult.playback_length, start_time: normalized || undefined, }); toastInfo("番組を登録しました"); setDanimeStart(""); setDanimeUrl(""); setDanimeResult(null); await loadShows(); } catch (e: unknown) { const msg = e instanceof Error ? e.message : "登録に失敗しました。"; setScrapeError(msg); logApiError("register show", e); toastError("登録に失敗しました", msg); } finally { setRegisteringScrape(false); } } async function handleDelete(showId: number) { if (!authEnabled) { toastError("認証が無効です", "管理者に確認してください"); return; } if (!idToken) { toastError("サインインしてください", "Googleでサインイン後に削除できます"); return; } try { setDeletingId(showId); await deleteShow(showId, idToken); toastInfo("エピソードを削除しました"); if (selectedId === showId) { setSelectedId(null); } await loadShows(); } catch (e: unknown) { const msg = e instanceof Error ? e.message : "削除に失敗しました。"; toastError("削除に失敗しました", msg); logApiError("delete show", e); } finally { setDeletingId(null); } } return (

dアニメから登録

dアニメのエピソードURLを貼り付けて「メタデータ取得」を押すと、話数・タイトル・再生時間を取得します。 開始時刻だけ任意で入力できます(空なら未指定で送信)。

setDanimeUrl(e.target.value)} style={{ flex: 1, minWidth: 260 }} />
{danimeResult && (
第{danimeResult.ep_num}話
{danimeResult.playback_length}
「{danimeResult.ep_title}」
{danimeResult.season_name}
setDanimeStart(e.target.value)} onBlur={e => { const v = normalizeStartTimeInput(e.target.value); if (v) setDanimeStart(v); }} style={{ flex: 1, minWidth: 220 }} />
)} {scrapeError && (
{scrapeError}
)}

エピソード一覧

エピソードを選び、必要に応じて開始時刻(HH:MM)を入力し、「現在のエピソードに設定」を押してください。 削除は認証後のみ実行できます。サイドバー下部の「管理用サインイン」からログインしてください。 {isAuthed && ( {" "} {verifying ? "トークン確認中…" : backendClaims ? "バックエンドで確認済み" : "未確認トークン"} )}
{loading &&
読み込み中…
} {error && (
{error}{" "}
)} {!loading && shows.length === 0 &&
エピソードがありません。
} {shows.length > 0 && (
{shows.map(s => (
{s.season_name}
開始時刻 {s.start_time.slice(0, 5)}・再生時間 {formatPlaybackLen(s.playback_length)}
))}
)}
setStartTime(e.target.value)} onBlur={(e) => { const v = toHHMM(e.target.value); if (v) setStartTime(v); }} />
{current && (
選択中:第{current.ep_num}話「{current.ep_title}」
)} ); }