Add Danime episode scraping functionality and UI integration
This commit is contained in:
parent
88f4ee3d4a
commit
138e0d5eb7
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
@ -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",
|
||||
});
|
||||
}
|
||||
|
||||
@ -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; }
|
||||
|
||||
|
||||
@ -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)を入力し、「現在のエピソードに設定」を押してください。
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user