配信の取得の不具合修正と配信一覧表示の修正

This commit is contained in:
ntki72 2024-12-25 23:08:51 +09:00
parent 86a1adee2c
commit c804494541
12 changed files with 274 additions and 16 deletions

2
.gitignore vendored
View File

@ -142,3 +142,5 @@ dist
.svelte-kit
# End of https://www.toptal.com/developers/gitignore/api/node
public/thumbnails/

View File

@ -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:

7
package-lock.json generated
View File

@ -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",

View File

@ -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",

59
public/css/schedule.css Normal file
View File

@ -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;
}

View File

@ -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, () => {

View File

@ -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;

34
src/routes/schedule.ts Normal file
View File

@ -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;

View File

@ -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,
});
}

View File

@ -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;
}
};

View File

@ -32,8 +32,13 @@
<form action="/admin/channels/<%= channel.id %>?_method=DELETE" method="POST" style="display:inline;">
<button type="submit" onclick="return confirm('Are you sure you want to delete this channel?');">Delete</button>
</form>
<form action="/admin/channels/<%= channel.id %>/test" method="GET" style="display:inline;">
<button type="submit">Test</button>
<form action="/admin/channels/<%= channel.id %>/run" method="GET" style="display:inline;">
<button type="submit">Run</button>
</form>
<form action="/admin/channels/<%= channel.id %>/fetch-history" method="POST" style="display:inline;">
<input type="date" name="startDate" required>
<input type="date" name="endDate" required>
<button type="submit">Fetch History</button>
</form>
</td>
</tr>

34
views/schedule.ejs Normal file
View File

@ -0,0 +1,34 @@
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Schedule</title>
<link rel="stylesheet" href="/css/schedule.css">
</head>
<body>
<div class="schedule-container">
<% Object.keys(groupedSchedules).forEach(date => { %>
<div class="date-header">
<%= dayjs(date).tz("Asia/Tokyo").format("YYYY年MM月DD日 (ddd)") %>
</div>
<div class="banners">
<% groupedSchedules[date].forEach(schedule => { %>
<a href="https://www.youtube.com/watch?v=<%= schedule.video_id %>" class="banner" target="_blank">
<div class="thumbnail">
<img src="<%= schedule.thumbnail_url || '/default-thumbnail.jpg' %>" alt="Thumbnail">
</div>
<div class="details">
<div class="time-channel">
<span class="time"><%= dayjs(schedule.start_time).tz("Asia/Tokyo").format("HH:mm") %></span>
<span class="channel-name">/ <%= schedule.Channel.name %></span>
</div>
<div class="title"><%= schedule.title %></div>
</div>
</a>
<% }) %>
</div>
<% }) %>
</div>
</body>
</html>