diff --git a/.gitignore b/.gitignore index 3502ef7..1eb2e3b 100644 --- a/.gitignore +++ b/.gitignore @@ -142,3 +142,5 @@ dist .svelte-kit # End of https://www.toptal.com/developers/gitignore/api/node + +public/thumbnails/ \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index f61f35a..232bc1e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,6 +13,7 @@ services: DB_USER: ${DB_USER} DB_PASS: ${DB_PASS} DB_HOST: db + TZ: Asia/Tokyo volumes: - ./public/thumbnails:/app/public/thumbnails db: diff --git a/package-lock.json b/package-lock.json index 7d4b4ba..c0d4d35 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "dependencies": { "axios": "^1.7.9", + "dayjs": "^1.11.13", "dotenv": "^16.4.7", "ejs": "^3.1.10", "express": "^4.18.2", @@ -622,6 +623,12 @@ "node": ">=0.12" } }, + "node_modules/dayjs": { + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", + "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==", + "license": "MIT" + }, "node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", diff --git a/package.json b/package.json index af53a8f..a765f22 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ }, "dependencies": { "axios": "^1.7.9", + "dayjs": "^1.11.13", "dotenv": "^16.4.7", "ejs": "^3.1.10", "express": "^4.18.2", diff --git a/public/css/schedule.css b/public/css/schedule.css new file mode 100644 index 0000000..f283db1 --- /dev/null +++ b/public/css/schedule.css @@ -0,0 +1,59 @@ +.schedule-container { + width: 80%; + margin: 0 auto; + } + + .date-header { + font-size: 1.5em; + margin: 20px 0; + font-weight: bold; + } + + .banners { + display: flex; + flex-wrap: wrap; + gap: 20px; + } + + .banner { + display: flex; + flex-direction: row; + align-items: center; + text-decoration: none; + color: black; + background-color: #f9f9f9; + border: 1px solid #ddd; + border-radius: 8px; + overflow: hidden; + width: calc(50% - 10px); /* 2列になるように調整 */ + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + transition: transform 0.2s, box-shadow 0.2s; + } + + .banner:hover { + transform: scale(1.02); + box-shadow: 0 6px 10px rgba(0, 0, 0, 0.15); + } + + .thumbnail img { + width: 150px; + height: auto; + object-fit: cover; + } + + .details { + padding: 10px; + flex: 1; + } + + .time-channel { + font-size: 1em; + margin-bottom: 5px; + color: #333; + } + + .title { + font-size: 1.2em; + font-weight: bold; + color: #555; + } diff --git a/src/app.ts b/src/app.ts index 9c78104..d1e1e5b 100644 --- a/src/app.ts +++ b/src/app.ts @@ -7,6 +7,7 @@ import basicAuth from "express-basic-auth"; import { runMigrations } from "./utils/migrate"; import methodOverride from "method-override"; import { updateAllLiveSchedules } from "./services/updateLiveSchedules"; +import scheduleRoutes from "./routes/schedule"; const app = express(); @@ -48,8 +49,13 @@ const startServer = async () => { // 管理画面 API app.use("/admin/api", apiRoutes); + // ユーザー向けルート + // トップページ + app.use("/", scheduleRoutes); + // 静的ファイルの提供 app.use("/thumbnails", express.static("public/thumbnails")); + app.use("/css", express.static("public/css")); // サーバー起動 app.listen(WEB_PORT, () => { diff --git a/src/routes/admin.ts b/src/routes/admin.ts index a9397b1..a714aee 100644 --- a/src/routes/admin.ts +++ b/src/routes/admin.ts @@ -1,6 +1,7 @@ import { Router } from "express"; import { Channel } from "../models"; -import { getChannelIdByHandle } from "../utils/youtube"; +import { getChannelIdByHandle, fetchPastLiveStreams } from "../utils/youtube"; +import { saveLiveSchedules } from "../services/liveScheduleService"; import { updateLiveSchedulesForChannel } from "../services/liveScheduleService"; @@ -122,8 +123,8 @@ router.delete("/channels/:id", async (req, res) => { } }); -// チャンネルのテスト -router.get("/channels/:id/test", async (req, res) => { +// チャンネルの配信スケジュールを更新 +router.get("/channels/:id/run", async (req, res) => { try { const channel = await Channel.findByPk(req.params.id); @@ -140,4 +141,29 @@ router.get("/channels/:id/test", async (req, res) => { } }); +// 配信履歴取得 +router.post("/channels/:id/fetch-history", async (req, res) => { + try { + const channelId = req.params.id; + const { startDate, endDate } = req.body; + + if (!startDate || !endDate) { + return res.status(400).json({ error: "Start date and end date are required." }); + } + + const channel = await Channel.findByPk(channelId); + if (!channel) { + return res.status(404).json({ error: "Channel not found." }); + } + + const historyStreams = await fetchPastLiveStreams(channel.youtube_id, startDate, endDate); + await saveLiveSchedules(channel.id!, historyStreams); + + res.json({ message: "History fetched and saved successfully." }); + } catch (error) { + console.error("Failed to fetch history:", error); + res.status(500).json({ error: "Failed to fetch history." }); + } +}); + export default router; diff --git a/src/routes/schedule.ts b/src/routes/schedule.ts new file mode 100644 index 0000000..ed5212e --- /dev/null +++ b/src/routes/schedule.ts @@ -0,0 +1,34 @@ +import express from "express"; +import { LiveSchedule, Channel } from "../models"; +import dayjs from "dayjs"; +import timezone from "dayjs/plugin/timezone"; +import utc from "dayjs/plugin/utc"; + +dayjs.extend(utc); +dayjs.extend(timezone); + +const router = express.Router(); + +router.get("/", async (req, res) => { + try { + const liveSchedules = await LiveSchedule.findAll({ + include: [{ model: Channel }], + order: [["start_time", "ASC"]], + }); + + // スケジュールを日付ごとにグループ化 + const groupedSchedules = liveSchedules.reduce((acc: any, schedule: any) => { + const date = dayjs(schedule.start_time).tz("Asia/Tokyo").format("YYYY-MM-DD"); + if (!acc[date]) acc[date] = []; + acc[date].push(schedule); + return acc; + }, {}); + + res.render("schedule", { groupedSchedules, dayjs }); + } catch (error) { + console.error("Failed to render schedule page:", error); + res.status(500).send("Internal Server Error"); + } +}); + +export default router; diff --git a/src/services/liveScheduleService.ts b/src/services/liveScheduleService.ts index 1391002..c687e2e 100644 --- a/src/services/liveScheduleService.ts +++ b/src/services/liveScheduleService.ts @@ -1,6 +1,12 @@ import { Channel, LiveSchedule } from "../models"; import { fetchUpcomingLiveStreams } from "../utils/youtube"; import { downloadThumbnail } from "../utils/downloadThumbnail"; +import dayjs from "dayjs"; +import utc from "dayjs/plugin/utc"; +import timezone from "dayjs/plugin/timezone"; + +dayjs.extend(utc); +dayjs.extend(timezone); // ライブスケジュールを保存する共通処理 export const saveLiveSchedules = async (channel_id: number, liveStreams: any[]) => { @@ -25,7 +31,7 @@ export const saveLiveSchedules = async (channel_id: number, liveStreams: any[]) channel_id, video_id: stream.video_id, title: stream.title, - start_time: new Date(stream.start_time), + start_time: dayjs(stream.start_time).tz("Asia/Tokyo", true).toDate(), // JST に正しく変換 thumbnail_url: cachedThumbnail, }); } diff --git a/src/utils/youtube.ts b/src/utils/youtube.ts index c707716..c2376ec 100644 --- a/src/utils/youtube.ts +++ b/src/utils/youtube.ts @@ -1,15 +1,22 @@ import axios from "axios"; import { YOUTUBE_API_KEY } from "../config/env"; +import dayjs from "dayjs"; +import utc from "dayjs/plugin/utc"; +import timezone from "dayjs/plugin/timezone"; const BASE_URL = "https://www.googleapis.com/youtube/v3"; +dayjs.extend(utc); +dayjs.extend(timezone); + // ライブスケジュールを取得する関数 -export const fetchUpcomingLiveStreams = async (youtube_id: string) => { +export const fetchUpcomingLiveStreams = async (channel_id: string) => { try { - const response = await axios.get(`${BASE_URL}/search`, { + // search エンドポイントでライブ動画の一覧を取得 + const searchResponse = await axios.get(`${BASE_URL}/search`, { params: { part: "snippet", - channelId: youtube_id, + channelId: channel_id, eventType: "upcoming", type: "video", maxResults: 10, @@ -17,15 +24,85 @@ export const fetchUpcomingLiveStreams = async (youtube_id: string) => { }, }); - const items = response.data.items || []; - return items.map((item: any) => ({ - video_id: item.id?.videoId, - title: item.snippet?.title, - start_time: item.snippet?.publishedAt, - thumbnail_url: item.snippet?.thumbnails?.high?.url, + const items = searchResponse.data.items; + + // videoId を抽出して videos エンドポイントで詳細情報を取得 + const videoIds = items.map((item: any) => item.id.videoId).join(","); + const videoResponse = await axios.get(`${BASE_URL}/videos`, { + params: { + part: "snippet,liveStreamingDetails", + id: videoIds, + key: YOUTUBE_API_KEY, + }, + }); + + // 必要な情報を整形して返す + return videoResponse.data.items.map((item: any) => ({ + video_id: item.id, + title: item.snippet.title, + start_time: item.liveStreamingDetails.scheduledStartTime, // 配信開始予定時刻 + thumbnail_url: item.snippet.thumbnails.high?.url || null, })); } catch (error) { - console.error(`Failed to fetch live streams for channel ${youtube_id}:`, error); + console.error(`Failed to fetch live streams for channel ${channel_id}:`, error); + throw error; + } +}; + +// 指定日時以降の過去のライブ配信を取得 +export const fetchPastLiveStreams = async ( + youtubeId: string, + startDate: string, + endDate: string +) => { + try { + // search エンドポイントで履歴の一覧を取得 + const searchResponse = await axios.get(`${BASE_URL}/search`, { + params: { + part: "snippet", + channelId: youtubeId, + publishedAfter: new Date(startDate).toISOString(), + publishedBefore: new Date(endDate).toISOString(), + eventType: "completed", + type: "video", + key: YOUTUBE_API_KEY, + maxResults: 50, + }, + }); + + const items = searchResponse.data.items || []; + const videoIds = items.map((item: any) => item.id.videoId).join(","); + + if (!videoIds) { + console.warn("No past live streams found for the specified period."); + return []; + } + + // videos エンドポイントで詳細情報を取得 + const videoResponse = await axios.get(`${BASE_URL}/videos`, { + params: { + part: "snippet,liveStreamingDetails", + id: videoIds, + key: YOUTUBE_API_KEY, + }, + }); + + // 必要な情報を整形して返す + return videoResponse.data.items.map((item: any) => { + const actualStartTimeUTC = item.liveStreamingDetails.actualStartTime; // 実際の配信開始時間(UTC) + const startTimeJST = actualStartTimeUTC + ? dayjs(actualStartTimeUTC).tz("Asia/Tokyo").format() + : null; + + return { + video_id: item.id, + title: item.snippet.title, + start_time: startTimeJST, // JST に変換された実際の配信開始時刻 + thumbnail_url: item.snippet.thumbnails.high?.url || null, + }; + }); + } catch (error: any) { + console.error("Failed to fetch past live streams:", error.response?.data || error.message); throw error; } }; diff --git a/views/admin/channels.ejs b/views/admin/channels.ejs index dd084f2..5e2c0cd 100644 --- a/views/admin/channels.ejs +++ b/views/admin/channels.ejs @@ -32,8 +32,13 @@
- + diff --git a/views/schedule.ejs b/views/schedule.ejs new file mode 100644 index 0000000..601acb4 --- /dev/null +++ b/views/schedule.ejs @@ -0,0 +1,34 @@ + + + + + +