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)
|
# Run locally (expects Postgres per env)
|
||||||
go run ./cmd/server
|
go run ./cmd/server
|
||||||
|
|
||||||
|
# Fetch one dアニメ episode to stdout
|
||||||
|
go run ./cmd/danime-episode <partId>
|
||||||
|
|
||||||
# Run migrations
|
# Run migrations
|
||||||
go run ./cmd/migrate
|
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? }`)
|
- `POST /api/v1/current` — set current episode (expects `{ id, start_time? }`)
|
||||||
- `GET /api/v1/shows` — list of episodes
|
- `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 }`)
|
- `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
|
- `GET /healthz` — health check
|
||||||
|
|||||||
@ -5,5 +5,6 @@ export const API_ENDPOINT = {
|
|||||||
CURRENT: buildApiUrl("/v1/current"),
|
CURRENT: buildApiUrl("/v1/current"),
|
||||||
SHOWS: buildApiUrl("/v1/shows"),
|
SHOWS: buildApiUrl("/v1/shows"),
|
||||||
TIME: buildApiUrl("/v1/time"),
|
TIME: buildApiUrl("/v1/time"),
|
||||||
|
DANIME: buildApiUrl("/v1/danime"),
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@ -21,3 +21,10 @@ export type ShowItem = {
|
|||||||
playback_length: string;
|
playback_length: string;
|
||||||
date_created: 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 { API_ENDPOINT } from "./endpoint";
|
||||||
import { ApiError, apiFetch } from "./client";
|
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) {
|
function asNumber(v: unknown, fallback = 0) {
|
||||||
const n = typeof v === "number" ? v : Number(v);
|
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) {
|
export async function fetchSchedule(signal?: AbortSignal) {
|
||||||
const data = await apiFetch<ScheduleResponse>(API_ENDPOINT.v1.CURRENT, {
|
const data = await apiFetch<ScheduleResponse>(API_ENDPOINT.v1.CURRENT, {
|
||||||
signal,
|
signal,
|
||||||
@ -95,3 +108,31 @@ export async function fetchServerNow(signal?: AbortSignal): Promise<number> {
|
|||||||
if (n < 1e12) n = n * 1000; // seconds → ms
|
if (n < 1e12) n = n * 1000; // seconds → ms
|
||||||
return n;
|
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;
|
background: var(--accent); color:#0b0f14; border:none;
|
||||||
}
|
}
|
||||||
.primary:disabled { opacity:0.5; cursor:not-allowed; }
|
.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; }
|
.footer a { text-decoration: underline; }
|
||||||
|
|
||||||
|
|||||||
@ -2,12 +2,13 @@ import React, { useEffect, useMemo, useState, useRef } from "react";
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { logApiError } from "../utils/logger";
|
import { logApiError } from "../utils/logger";
|
||||||
import { toastError, toastInfo } from "../utils/toastBus";
|
import { toastError, toastInfo } from "../utils/toastBus";
|
||||||
import { fetchShows, postCurrentEpisode } from "../api/watchparty";
|
import { createShow, fetchDanimeEpisode, fetchShows, postCurrentEpisode } from "../api/watchparty";
|
||||||
import type { ShowItem } from "../api/watchparty";
|
import type { DanimeEpisode, ShowItem } from "../api/watchparty";
|
||||||
|
|
||||||
const REDIRECT_DELAY_S = 3;
|
const REDIRECT_DELAY_S = 3;
|
||||||
|
|
||||||
const HHMM = /^(\d{1,2}):([0-5]\d)$/;
|
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 {
|
function toHHMMSS(v: string): string | null {
|
||||||
const m = v.trim().match(HHMM);
|
const m = v.trim().match(HHMM);
|
||||||
@ -25,6 +26,32 @@ function toHHMM(v: string): string | null {
|
|||||||
return `${String(h).padStart(2, "0")}:${m[2]}`;
|
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() {
|
export default function ShowsPage() {
|
||||||
const [shows, setShows] = useState<ShowItem[]>([]);
|
const [shows, setShows] = useState<ShowItem[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@ -38,6 +65,12 @@ export default function ShowsPage() {
|
|||||||
// フォーム状態
|
// フォーム状態
|
||||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||||
const [startTime, setStartTime] = useState("");
|
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 {
|
function formatPlaybackLen(v: string): string {
|
||||||
const parts = v.split(":").map(Number);
|
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 (
|
return (
|
||||||
<div className="shows-page">
|
<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>
|
<h2 className="h1" style={{ marginBottom: 8 }}>エピソード一覧</h2>
|
||||||
<p className="subtle" style={{ marginTop: 0 }}>
|
<p className="subtle" style={{ marginTop: 0 }}>
|
||||||
エピソードを選び、必要に応じて開始時刻(HH:MM)を入力し、「現在のエピソードに設定」を押してください。
|
エピソードを選び、必要に応じて開始時刻(HH:MM)を入力し、「現在のエピソードに設定」を押してください。
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user