Add Danime episode scraping functionality and UI integration

This commit is contained in:
Nik Afiq 2025-12-06 20:00:53 +09:00
parent 88f4ee3d4a
commit 138e0d5eb7
6 changed files with 225 additions and 4 deletions

View File

@ -18,6 +18,9 @@ The server builds its DSN from:
# Run locally (expects Postgres per env)
go run ./cmd/server
# Fetch one dアニメ episode to stdout
go run ./cmd/danime-episode <partId>
# Run migrations
go run ./cmd/migrate
@ -40,4 +43,5 @@ Compose uses the same image for `api` and the one-off `migrate` job.
- `POST /api/v1/current` — set current episode (expects `{ id, start_time? }`)
- `GET /api/v1/shows` — list of episodes
- `POST /api/v1/shows` — create a new episode (expects `{ ep_num, ep_title, season_name, start_time, playback_length }`)
- `GET /api/v1/danime?part_id=...` — scrape dアニメ episode metadata (returns `{ ep_num, ep_title, season_name, playback_length }`)
- `GET /healthz` — health check

View File

@ -5,5 +5,6 @@ export const API_ENDPOINT = {
CURRENT: buildApiUrl("/v1/current"),
SHOWS: buildApiUrl("/v1/shows"),
TIME: buildApiUrl("/v1/time"),
DANIME: buildApiUrl("/v1/danime"),
},
} as const;

View File

@ -21,3 +21,10 @@ export type ShowItem = {
playback_length: string;
date_created: string;
};
export type DanimeEpisode = {
ep_num: number;
ep_title: string;
season_name: string;
playback_length: string;
};

View File

@ -1,8 +1,8 @@
import { API_ENDPOINT } from "./endpoint";
import { ApiError, apiFetch } from "./client";
import type { ScheduleResponse, ShowItem, TimeResponse } from "./types";
import type { DanimeEpisode, ScheduleResponse, ShowItem, TimeResponse } from "./types";
export type { ScheduleResponse, ShowItem, TimeResponse } from "./types";
export type { DanimeEpisode, ScheduleResponse, ShowItem, TimeResponse } from "./types";
function asNumber(v: unknown, fallback = 0) {
const n = typeof v === "number" ? v : Number(v);
@ -52,6 +52,19 @@ function normalizeShows(data: unknown): ShowItem[] {
});
}
function normalizeDanimeEpisode(data: unknown): DanimeEpisode {
if (!data || typeof data !== "object") {
throw new ApiError("Bad danime payload", { url: API_ENDPOINT.v1.DANIME, data });
}
const obj = data as Record<string, unknown>;
return {
ep_num: asNumber(obj.ep_num, 0),
ep_title: asString(obj.ep_title, ""),
season_name: asString(obj.season_name, ""),
playback_length: asString(obj.playback_length, ""),
};
}
export async function fetchSchedule(signal?: AbortSignal) {
const data = await apiFetch<ScheduleResponse>(API_ENDPOINT.v1.CURRENT, {
signal,
@ -95,3 +108,31 @@ export async function fetchServerNow(signal?: AbortSignal): Promise<number> {
if (n < 1e12) n = n * 1000; // seconds → ms
return n;
}
export async function fetchDanimeEpisode(partId: string, signal?: AbortSignal): Promise<DanimeEpisode> {
const url = `${API_ENDPOINT.v1.DANIME}?part_id=${encodeURIComponent(partId)}`;
const data = await apiFetch<DanimeEpisode>(url, {
signal,
timeoutMs: 12_000,
logLabel: "fetch danime episode",
});
return normalizeDanimeEpisode(data);
}
export async function createShow(payload: {
ep_num: number;
ep_title: string;
season_name: string;
playback_length: string;
start_time?: string;
}, signal?: AbortSignal) {
await apiFetch<void>(API_ENDPOINT.v1.SHOWS, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
signal,
timeoutMs: 12_000,
expect: "void",
logLabel: "create show",
});
}

View File

@ -389,6 +389,25 @@ kbd {
background: var(--accent); color:#0b0f14; border:none;
}
.primary:disabled { opacity:0.5; cursor:not-allowed; }
.scrape-card {
padding: 14px;
border-radius: 14px;
background: rgba(255,255,255,0.04);
border: 1px solid rgba(255,255,255,0.1);
text-align: left;
}
.scrape-row { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
.scrape-result { margin-top: 10px; padding: 12px; border-radius: 12px; background: rgba(255,255,255,0.03); border:1px solid rgba(255,255,255,0.07); }
.scrape-meta { display:flex; gap:8px; flex-wrap:wrap; margin-bottom:6px; }
.pill {
padding: 6px 10px;
border-radius: 10px;
background: rgba(255,255,255,0.08);
border: 1px solid rgba(255,255,255,0.12);
font-weight: 700;
font-size: 12px;
}
.scrape-title { font-weight: 800; font-size: 16px; }
.footer a { text-decoration: underline; }

View File

@ -2,12 +2,13 @@ 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 { fetchShows, postCurrentEpisode } from "../api/watchparty";
import type { ShowItem } from "../api/watchparty";
import { createShow, fetchDanimeEpisode, fetchShows, postCurrentEpisode } from "../api/watchparty";
import type { DanimeEpisode, ShowItem } from "../api/watchparty";
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);
@ -25,6 +26,32 @@ function toHHMM(v: string): string | 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);
const partId = u.searchParams.get("partId") || u.searchParams.get("partid");
if (partId) return partId;
} catch {
// fallback to regex
}
const m = trimmed.match(/partId=(\d+)/i);
return m ? m[1] : null;
}
export default function ShowsPage() {
const [shows, setShows] = useState<ShowItem[]>([]);
const [loading, setLoading] = useState(true);
@ -38,6 +65,12 @@ export default function ShowsPage() {
// フォーム状態
const [selectedId, setSelectedId] = useState<number | null>(null);
const [startTime, setStartTime] = useState("");
const [danimeUrl, setDanimeUrl] = useState("");
const [danimeStart, setDanimeStart] = useState("");
const [danimeResult, setDanimeResult] = useState<DanimeEpisode | null>(null);
const [scraping, setScraping] = useState(false);
const [registeringScrape, setRegisteringScrape] = useState(false);
const [scrapeError, setScrapeError] = useState<string | null>(null);
function formatPlaybackLen(v: string): string {
const parts = v.split(":").map(Number);
@ -127,8 +160,124 @@ export default function ShowsPage() {
}
}
async function handleScrape() {
setScrapeError(null);
setDanimeResult(null);
const partId = extractPartId(danimeUrl);
if (!partId) {
setScrapeError("partId を含む dアニメのURLを入力してください。");
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);
}
}
return (
<div className="shows-page">
<div className="scrape-card">
<h2 className="h1" style={{ marginBottom: 8 }}>dアニメから登録</h2>
<p className="subtle" style={{ marginTop: 0 }}>
dアニメのエピソードURLを貼り付けて
</p>
<div className="scrape-row">
<input
className="input"
type="url"
placeholder="https://animestore.docomo.ne.jp/animestore/ci_pc?workId=...&partId=..."
value={danimeUrl}
onChange={e => setDanimeUrl(e.target.value)}
style={{ flex: 1, minWidth: 260 }}
/>
<button className="primary" disabled={scraping || !danimeUrl.trim()} onClick={handleScrape}>
{scraping ? "取得中…" : "メタデータ取得"}
</button>
</div>
{danimeResult && (
<div className="scrape-result">
<div className="scrape-meta">
<div className="pill">{danimeResult.ep_num}</div>
<div className="pill">{danimeResult.playback_length}</div>
</div>
<div className="scrape-title">{danimeResult.ep_title}</div>
<div className="subtle" style={{ margin: "4px 0 12px" }}>{danimeResult.season_name}</div>
<div className="scrape-row">
<input
className="input"
type="text"
inputMode="numeric"
placeholder="開始時刻 (HH:MM または HH:MM:SS、空欄可)"
value={danimeStart}
onChange={e => setDanimeStart(e.target.value)}
onBlur={e => {
const v = normalizeStartTimeInput(e.target.value);
if (v) setDanimeStart(v);
}}
style={{ flex: 1, minWidth: 220 }}
/>
<button
className="primary"
disabled={registeringScrape}
onClick={handleRegisterScraped}
>
{registeringScrape ? "登録中…" : "このデータで登録"}
</button>
</div>
</div>
)}
{scrapeError && (
<div className="timer-status" style={{ color: "#f88" }}>
{scrapeError}
</div>
)}
</div>
<h2 className="h1" style={{ marginBottom: 8 }}></h2>
<p className="subtle" style={{ marginTop: 0 }}>
HH:MM