diff --git a/backend/README.md b/backend/README.md index 0e30d99..255b6ee 100644 --- a/backend/README.md +++ b/backend/README.md @@ -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 + # 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 diff --git a/frontend/src/api/endpoint.ts b/frontend/src/api/endpoint.ts index 08c1aad..f753c3e 100644 --- a/frontend/src/api/endpoint.ts +++ b/frontend/src/api/endpoint.ts @@ -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; diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index fff625b..b26e03a 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -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; +}; diff --git a/frontend/src/api/watchparty.ts b/frontend/src/api/watchparty.ts index beffa9e..d8df361 100644 --- a/frontend/src/api/watchparty.ts +++ b/frontend/src/api/watchparty.ts @@ -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; + 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(API_ENDPOINT.v1.CURRENT, { signal, @@ -95,3 +108,31 @@ export async function fetchServerNow(signal?: AbortSignal): Promise { if (n < 1e12) n = n * 1000; // seconds → ms return n; } + +export async function fetchDanimeEpisode(partId: string, signal?: AbortSignal): Promise { + const url = `${API_ENDPOINT.v1.DANIME}?part_id=${encodeURIComponent(partId)}`; + const data = await apiFetch(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(API_ENDPOINT.v1.SHOWS, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + signal, + timeoutMs: 12_000, + expect: "void", + logLabel: "create show", + }); +} diff --git a/frontend/src/index.css b/frontend/src/index.css index 8925693..de0fa48 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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; } diff --git a/frontend/src/pages/ShowsPage.tsx b/frontend/src/pages/ShowsPage.tsx index 1500ca0..29b7b5e 100644 --- a/frontend/src/pages/ShowsPage.tsx +++ b/frontend/src/pages/ShowsPage.tsx @@ -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([]); const [loading, setLoading] = useState(true); @@ -38,6 +65,12 @@ export default function ShowsPage() { // フォーム状態 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); @@ -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 (
+
+

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)を入力し、「現在のエピソードに設定」を押してください。