配信の取得の不具合修正と配信一覧表示の修正
This commit is contained in:
parent
86a1adee2c
commit
c804494541
2
.gitignore
vendored
2
.gitignore
vendored
@ -142,3 +142,5 @@ dist
|
||||
.svelte-kit
|
||||
|
||||
# End of https://www.toptal.com/developers/gitignore/api/node
|
||||
|
||||
public/thumbnails/
|
@ -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
7
package-lock.json
generated
@ -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",
|
||||
|
@ -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
59
public/css/schedule.css
Normal 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;
|
||||
}
|
@ -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, () => {
|
||||
|
@ -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
34
src/routes/schedule.ts
Normal 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;
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
};
|
||||
|
@ -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
34
views/schedule.ejs
Normal 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>
|
Loading…
Reference in New Issue
Block a user