管理画面の作成と管理画面からの配信情報取得ができるようにした

This commit is contained in:
ntki72 2024-12-25 21:24:28 +09:00
parent 31b5d6dd6e
commit 86a1adee2c
31 changed files with 1104 additions and 266 deletions

8
.sequelizerc Normal file
View File

@ -0,0 +1,8 @@
const path = require('path');
module.exports = {
'config': path.resolve('config/config.js'),
'models-path': path.resolve('src/models'),
'migrations-path': path.resolve('src/migrations'),
'seeders-path': path.resolve('src/seeders'),
};

View File

@ -3,9 +3,13 @@ require('dotenv').config();
module.exports = { module.exports = {
development: { development: {
username: process.env.DB_USER, username: process.env.DB_USER,
password: process.env.DB_PASSWORD, password: process.env.DB_PASS,
database: process.env.DB_NAME, database: process.env.DB_NAME,
host: process.env.DB_HOST, host: process.env.DB_HOST,
dialect: "mysql" dialect: "mysql",
migrationStorage: "sequelize", // マイグレーションの保存形式
seederStorage: "sequelize", // シードデータの保存形式
migrationStoragePath: "src/migrations", // マイグレーションのディレクトリ
seederStoragePath: "src/seeders" // シードデータのディレクトリ
} }
}; };

View File

@ -13,6 +13,8 @@ services:
DB_USER: ${DB_USER} DB_USER: ${DB_USER}
DB_PASS: ${DB_PASS} DB_PASS: ${DB_PASS}
DB_HOST: db DB_HOST: db
volumes:
- ./public/thumbnails:/app/public/thumbnails
db: db:
image: mysql:8.0 image: mysql:8.0
volumes: volumes:

View File

@ -1,24 +0,0 @@
'use strict';
const {
Model
} = require('sequelize');
module.exports = (sequelize, DataTypes) => {
class Channel extends Model {
/**
* Helper method for defining associations.
* This method is not a part of Sequelize lifecycle.
* The `models/index` file will call this method automatically.
*/
static associate(models) {
// define association here
}
}
Channel.init({
name: DataTypes.STRING,
youtube_id: DataTypes.STRING
}, {
sequelize,
modelName: 'Channel',
});
return Channel;
};

View File

@ -1,43 +0,0 @@
'use strict';
const fs = require('fs');
const path = require('path');
const Sequelize = require('sequelize');
const process = require('process');
const basename = path.basename(__filename);
const env = process.env.NODE_ENV || 'development';
const config = require(__dirname + '/../config/config.json')[env];
const db = {};
let sequelize;
if (config.use_env_variable) {
sequelize = new Sequelize(process.env[config.use_env_variable], config);
} else {
sequelize = new Sequelize(config.database, config.username, config.password, config);
}
fs
.readdirSync(__dirname)
.filter(file => {
return (
file.indexOf('.') !== 0 &&
file !== basename &&
file.slice(-3) === '.js' &&
file.indexOf('.test.js') === -1
);
})
.forEach(file => {
const model = require(path.join(__dirname, file))(sequelize, Sequelize.DataTypes);
db[model.name] = model;
});
Object.keys(db).forEach(modelName => {
if (db[modelName].associate) {
db[modelName].associate(db);
}
});
db.sequelize = sequelize;
db.Sequelize = Sequelize;
module.exports = db;

View File

@ -1,26 +0,0 @@
'use strict';
const {
Model
} = require('sequelize');
module.exports = (sequelize, DataTypes) => {
class LiveSchedule extends Model {
/**
* Helper method for defining associations.
* This method is not a part of Sequelize lifecycle.
* The `models/index` file will call this method automatically.
*/
static associate(models) {
// define association here
}
}
LiveSchedule.init({
channelId: DataTypes.INTEGER,
title: DataTypes.STRING,
start_time: DataTypes.DATE,
thumbnail_url: DataTypes.STRING
}, {
sequelize,
modelName: 'LiveSchedule',
});
return LiveSchedule;
};

446
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -7,17 +7,22 @@
"start": "node dist/app.js" "start": "node dist/app.js"
}, },
"dependencies": { "dependencies": {
"axios": "^1.7.9",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"ejs": "^3.1.10",
"express": "^4.18.2", "express": "^4.18.2",
"express-basic-auth": "^1.2.0", "express-basic-auth": "^1.2.0",
"method-override": "^3.0.0",
"mysql2": "^3.3.3", "mysql2": "^3.3.3",
"node-cron": "^3.0.0", "node-cron": "^3.0.3",
"sequelize": "^6.32.1" "sequelize": "^6.32.1",
"sequelize-cli": "^6.6.2"
}, },
"devDependencies": { "devDependencies": {
"@types/express": "^4.17.17", "@types/express": "^4.17.17",
"@types/method-override": "^3.0.0",
"@types/node": "^20.4.2", "@types/node": "^20.4.2",
"sequelize-cli": "^6.6.2", "@types/node-cron": "^3.0.11",
"typescript": "^5.2.2" "typescript": "^5.2.2"
} }
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

View File

@ -1,25 +1,64 @@
import { WEB_PORT, BASIC_AUTH_USER, BASIC_AUTH_PASS } from "./config/env";
import cron from "node-cron";
import express from "express"; import express from "express";
import sequelize from "./models"; import apiRoutes from "./routes/api";
import adminRoutes from "./routes/admin";
import basicAuth from "express-basic-auth";
import { runMigrations } from "./utils/migrate";
import methodOverride from "method-override";
import { updateAllLiveSchedules } from "./services/updateLiveSchedules";
const app = express(); const app = express();
const port = 3000;
app.use(express.json()); const startServer = async () => {
try {
// マイグレーション実行
await runMigrations();
// データベース接続テスト // // 定期実行タスクを設定1時間ごと
sequelize.authenticate() // cron.schedule("0 * * * *", async () => {
.then(() => { // console.log("Updating live schedules...");
console.log("Database connected successfully!"); // await updateAllLiveSchedules();
}) // });
.catch((err) => {
console.error("Unable to connect to the database:", err);
process.exit(1); // 接続エラーの場合はプロセスを終了
});
app.get("/", (req, res) => {
res.send("Server is running and database connection is active.");
});
app.listen(port, () => { // ベーシック認証
console.log(`Server is running on http://localhost:${port}`); app.use(
}); "/admin",
basicAuth({
users: { [BASIC_AUTH_USER]: BASIC_AUTH_PASS },
challenge: true,
})
);
// 管理画面ビューの設定
app.set("view engine", "ejs");
app.set("views", "./views");
// メソッドオーバーライドを設定
app.use(methodOverride("_method"));
// JSON と URL エンコードされたデータのパース
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// 管理画面ルート
app.use("/admin", adminRoutes);
// 管理画面 API
app.use("/admin/api", apiRoutes);
// 静的ファイルの提供
app.use("/thumbnails", express.static("public/thumbnails"));
// サーバー起動
app.listen(WEB_PORT, () => {
console.log(`Server is running on http://localhost:${WEB_PORT}`);
});
} catch (error) {
console.error("Failed to start the server:", error);
process.exit(1); // エラーが発生した場合は終了
}
};
startServer();

View File

@ -3,7 +3,16 @@ import dotenv from "dotenv";
// .env ファイルを読み込む // .env ファイルを読み込む
dotenv.config(); dotenv.config();
const requiredEnvVars = ["DB_NAME", "DB_USER", "DB_PASS", "DB_HOST"]; const requiredEnvVars = [
"DB_NAME",
"DB_USER",
"DB_PASS",
"DB_HOST",
"BASIC_AUTH_USER",
"BASIC_AUTH_PASS",
"WEB_PORT",
"YOUTUBE_API_KEY",
];
requiredEnvVars.forEach((varName) => { requiredEnvVars.forEach((varName) => {
if (!process.env[varName]) { if (!process.env[varName]) {
@ -15,3 +24,7 @@ export const DB_NAME = process.env.DB_NAME as string;
export const DB_USER = process.env.DB_USER as string; export const DB_USER = process.env.DB_USER as string;
export const DB_PASS = process.env.DB_PASS as string; export const DB_PASS = process.env.DB_PASS as string;
export const DB_HOST = process.env.DB_HOST as string; export const DB_HOST = process.env.DB_HOST as string;
export const BASIC_AUTH_USER = process.env.BASIC_AUTH_USER as string;
export const BASIC_AUTH_PASS = process.env.BASIC_AUTH_PASS as string;
export const WEB_PORT = parseInt(process.env.WEB_PORT as string, 10);
export const YOUTUBE_API_KEY = process.env.YOUTUBE_API_KEY as string;

View File

@ -9,7 +9,7 @@ module.exports = {
primaryKey: true, primaryKey: true,
type: Sequelize.INTEGER type: Sequelize.INTEGER
}, },
channelId: { channel_id: {
type: Sequelize.INTEGER type: Sequelize.INTEGER
}, },
title: { title: {

View File

@ -0,0 +1,12 @@
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.addColumn("LiveSchedules", "video_id", {
type: Sequelize.STRING,
allowNull: false,
unique: true,
});
},
down: async (queryInterface, Sequelize) => {
await queryInterface.removeColumn("LiveSchedules", "video_id");
},
};

View File

@ -0,0 +1,12 @@
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.addColumn("Channels", "channel_handle", {
type: Sequelize.STRING,
unique: true,
});
},
down: async (queryInterface, Sequelize) => {
await queryInterface.removeColumn("Channels", "channel_handle");
},
};

65
src/models/channel.ts Normal file
View File

@ -0,0 +1,65 @@
import { Sequelize, Model, DataTypes, Optional } from "sequelize";
// チャンネルの属性インターフェース
interface ChannelAttributes {
id?: number; // 自動生成される場合はオプショナル
name: string; // チャンネル名(任意の説明用)
channel_handle: string; // チャンネルハンドル(@example
youtube_id: string; // チャンネルID (UC_xxx...)
createdAt?: Date;
updatedAt?: Date;
}
// オプショナル属性(作成時に不要な属性を定義)
interface ChannelCreationAttributes extends Optional<ChannelAttributes, "id"> {}
// チャンネルモデルクラス
class Channel
extends Model<ChannelAttributes, ChannelCreationAttributes>
implements ChannelAttributes {
public id?: number;
public name!: string;
public channel_handle!: string;
public youtube_id!: string;
public readonly createdAt!: Date;
public readonly updatedAt!: Date;
// 関連付けメソッド
static associate(models: any) {
// 1つのチャンネルが複数のライブスケジュールを持つ
Channel.hasMany(models.LiveSchedule, { foreignKey: "channel_id" });
}
}
// モデルを初期化
export default (sequelize: Sequelize) => {
Channel.init(
{
id: {
type: DataTypes.INTEGER,
autoIncrement: true,
primaryKey: true,
},
name: {
type: DataTypes.STRING,
allowNull: false,
},
channel_handle: {
type: DataTypes.STRING,
allowNull: true,
unique: true,
},
youtube_id: {
type: DataTypes.STRING,
allowNull: true,
unique: true,
},
},
{
sequelize,
modelName: "Channel",
}
);
return Channel;
};

View File

@ -1,5 +1,7 @@
import { Sequelize, DataTypes } from "sequelize";
import { DB_NAME, DB_USER, DB_PASS, DB_HOST } from "../config/env"; import { DB_NAME, DB_USER, DB_PASS, DB_HOST } from "../config/env";
import { Sequelize } from "sequelize"; import ChannelModel from "./channel";
import LiveScheduleModel from "./liveschedule";
const sequelize = new Sequelize(DB_NAME, DB_USER, DB_PASS, { const sequelize = new Sequelize(DB_NAME, DB_USER, DB_PASS, {
host: DB_HOST, host: DB_HOST,
@ -7,4 +9,14 @@ const sequelize = new Sequelize(DB_NAME, DB_USER, DB_PASS, {
logging: console.log, logging: console.log,
}); });
export default sequelize; // モデルを初期化
const Channel = ChannelModel(sequelize);
const LiveSchedule = LiveScheduleModel(sequelize);
// 関連付け
Channel.associate?.({ LiveSchedule });
LiveSchedule.associate?.({ Channel });
// エクスポート
export { sequelize, Channel, LiveSchedule };
export default { sequelize, Channel, LiveSchedule };

View File

@ -0,0 +1,70 @@
import { Model, Sequelize, DataTypes, Optional } from "sequelize";
// ライブスケジュールの属性インターフェース
interface LiveScheduleAttributes {
id?: number;
video_id: string;
channel_id: number;
title: string;
start_time: Date;
thumbnail_url: string | null;
createdAt?: Date;
updatedAt?: Date;
}
interface LiveScheduleCreationAttributes extends Optional<LiveScheduleAttributes, "id"> {}
class LiveSchedule extends Model<LiveScheduleAttributes, LiveScheduleCreationAttributes>
implements LiveScheduleAttributes {
public id?: number;
public video_id!: string;
public channel_id!: number;
public title!: string;
public start_time!: Date;
public thumbnail_url!: string;
public readonly createdAt!: Date;
public readonly updatedAt!: Date;
static associate(models: any) {
LiveSchedule.belongsTo(models.Channel, { foreignKey: "channel_id" });
}
}
export default (sequelize: Sequelize) => {
LiveSchedule.init(
{
id: {
type: DataTypes.INTEGER,
autoIncrement: true,
primaryKey: true,
},
video_id: {
type: DataTypes.STRING,
allowNull: false,
unique: true,
},
channel_id: {
type: DataTypes.INTEGER,
allowNull: false,
},
title: {
type: DataTypes.STRING,
allowNull: false,
},
start_time: {
type: DataTypes.DATE,
allowNull: false,
},
thumbnail_url: {
type: DataTypes.STRING,
allowNull: false,
},
},
{
sequelize,
modelName: "LiveSchedule",
}
);
return LiveSchedule;
};

View File

@ -0,0 +1,143 @@
import { Router } from "express";
import { Channel } from "../models";
import { getChannelIdByHandle } from "../utils/youtube";
import { updateLiveSchedulesForChannel } from "../services/liveScheduleService";
const router = Router();
// チャンネル一覧表示
router.get("/channels", async (req, res) => {
try {
const channels = await Channel.findAll();
res.render("admin/channels", { title: "Channels", channels });
} catch (error) {
console.error(error);
res.status(500).send("Failed to load channels");
}
});
// チャンネル登録フォームの表示
router.get("/channels/new", (req, res) => {
res.render("admin/new-channel", { title: "Add New Channel" });
});
// チャンネル登録処理
router.post("/channels/new", async (req, res) => {
try {
const { name, channel_handle, youtube_id } = req.body;
// 必須項目のバリデーション
if (!channel_handle && !youtube_id) {
return res
.status(400)
.send("Either 'channel_handle' or 'youtube_id' must be provided.");
}
let resolvedYoutubeId = youtube_id;
let resolvedChannelHandle = channel_handle;
// チャンネルハンドルから YouTube ID を取得
if (!youtube_id && channel_handle) {
resolvedYoutubeId = await getChannelIdByHandle(channel_handle);
if (!resolvedYoutubeId) {
return res.status(400).send("Failed to fetch YouTube ID from handle.");
}
}
// チャンネルIDからハンドルを取得する場合も将来的に実装可能
// if (!channel_handle && youtube_id) {
// resolvedChannelHandle = await getChannelHandleById(youtube_id);
// }
// チャンネルを保存
await Channel.create({
name,
channel_handle: resolvedChannelHandle,
youtube_id: resolvedYoutubeId,
});
res.redirect("/admin/channels");
} catch (error) {
console.error("Failed to create channel:", error);
res.status(500).send("An error occurred while creating the channel.");
}
});
// チャンネル編集フォームの表示
router.get("/channels/:id/edit", async (req, res) => {
try {
const { id } = req.params;
const channel = await Channel.findByPk(id);
if (!channel) {
return res.status(404).send("Channel not found");
}
res.render("admin/edit-channel", { title: "Edit Channel", channel });
} catch (error) {
console.error(error);
res.status(500).send("Failed to load channel for editing");
}
});
// チャンネル更新処理
router.put("/channels/:id", async (req, res) => {
try {
const { id } = req.params;
const { name, youtube_id } = req.body;
const channel = await Channel.findByPk(id);
if (!channel) {
return res.status(404).send("Channel not found");
}
channel.name = name;
channel.youtube_id = youtube_id;
await channel.save();
res.redirect("/admin/channels");
} catch (error) {
console.error(error);
res.status(500).send("Failed to update channel");
}
});
// チャンネル削除処理
router.delete("/channels/:id", async (req, res) => {
try {
const { id } = req.params;
const channel = await Channel.findByPk(id);
if (!channel) {
return res.status(404).send("Channel not found");
}
await channel.destroy();
res.redirect("/admin/channels");
} catch (error) {
console.error(error);
res.status(500).send("Failed to delete channel");
}
});
// チャンネルのテスト
router.get("/channels/:id/test", async (req, res) => {
try {
const channel = await Channel.findByPk(req.params.id);
if (!channel) {
return res.status(404).send("Channel not found");
}
await updateLiveSchedulesForChannel(channel);
res.send(`Live schedules successfully updated for channel: ${channel.name}`);
} catch (error) {
console.error("Error updating live schedules for channel:", error);
res.status(500).send("Failed to update live schedules for this channel.");
}
});
export default router;

75
src/routes/api.ts Normal file
View File

@ -0,0 +1,75 @@
import { Router } from "express";
import { Channel, LiveSchedule } from "../models"; // Sequelize モデルをインポート
const router = Router();
// チャンネル一覧を取得
router.get("/channels", async (req, res) => {
try {
const channels = await Channel.findAll();
res.json(channels);
} catch (error) {
res.status(500).json({ error: "Failed to fetch channels" });
}
});
// // チャンネルを登録
// router.post("/channels", async (req, res) => {
// try {
// const { name, youtube_id } = req.body;
// const newChannel = await Channel.create({ name, youtube_id });
// res.status(201).json(newChannel);
// } catch (error) {
// res.status(400).json({ error: "Failed to create channel" });
// }
// });
// // チャンネルを更新
// router.put("/channels/:id", async (req, res) => {
// try {
// const { id } = req.params;
// const { name, youtube_id } = req.body;
// const channel = await Channel.findByPk(id);
// if (!channel) {
// return res.status(404).json({ error: "Channel not found" });
// }
// channel.name = name;
// channel.youtube_id = youtube_id;
// await channel.save();
// res.json(channel);
// } catch (error) {
// res.status(400).json({ error: "Failed to update channel" });
// }
// });
// // チャンネルを削除
// router.delete("/channels/:id", async (req, res) => {
// try {
// const { id } = req.params;
// const channel = await Channel.findByPk(id);
// if (!channel) {
// return res.status(404).json({ error: "Channel not found" });
// }
// await channel.destroy();
// res.status(204).send();
// } catch (error) {
// res.status(500).json({ error: "Failed to delete channel" });
// }
// });
// ライブスケジュール一覧を取得
router.get("/live-schedules", async (req, res) => {
try {
const schedules = await LiveSchedule.findAll({ include: [Channel] });
res.json(schedules);
} catch (error) {
res.status(500).json({ error: "Failed to fetch live schedules" });
}
});
export default router;

View File

@ -0,0 +1,57 @@
import { Channel, LiveSchedule } from "../models";
import { fetchUpcomingLiveStreams } from "../utils/youtube";
import { downloadThumbnail } from "../utils/downloadThumbnail";
// ライブスケジュールを保存する共通処理
export const saveLiveSchedules = async (channel_id: number, liveStreams: any[]) => {
try {
const promises = liveStreams.map(async (stream) => {
if (!stream.video_id || !stream.title || !stream.start_time) {
console.error("Invalid stream data, skipping:", stream);
return;
}
const existingSchedule = await LiveSchedule.findOne({
where: { video_id: stream.video_id },
});
if (!existingSchedule) {
const cachedThumbnail = await downloadThumbnail(stream.thumbnail_url, stream.video_id).catch((error) => {
console.warn(`Failed to download thumbnail for video_id ${stream.video_id}:`, error);
return null;
});
await LiveSchedule.create({
channel_id,
video_id: stream.video_id,
title: stream.title,
start_time: new Date(stream.start_time),
thumbnail_url: cachedThumbnail,
});
}
});
await Promise.all(promises);
console.log("Live schedules saved successfully for channel_id:", channel_id);
} catch (error) {
console.error("Failed to save live schedules:", error);
throw error;
}
};
// 指定したチャンネルのスケジュールを更新
export const updateLiveSchedulesForChannel = async (channel: InstanceType<typeof Channel>) => {
try {
if (!channel.youtube_id) {
throw new Error(`YouTube ID is missing for channel: ${channel.name}`);
}
const liveStreams = await fetchUpcomingLiveStreams(channel.youtube_id);
await saveLiveSchedules(channel.id!, liveStreams);
console.log(`Live schedules updated for channel: ${channel.name}`);
} catch (error) {
console.error(`Failed to update live schedules for channel ${channel.name}:`, error);
throw error;
}
};

View File

@ -0,0 +1,17 @@
import { Channel } from "../models";
import { updateLiveSchedulesForChannel } from "./liveScheduleService";
export const updateAllLiveSchedules = async () => {
try {
const channels = await Channel.findAll();
for (const channel of channels) {
await updateLiveSchedulesForChannel(channel);
}
console.log("All live schedules updated successfully!");
} catch (error) {
console.error("Failed to update live schedules:", error);
throw error;
}
};

View File

@ -0,0 +1,32 @@
import fs from "fs";
import path from "path";
import axios from "axios";
const THUMBNAIL_DIR = path.resolve(__dirname, "../../public/thumbnails");
if (!fs.existsSync(THUMBNAIL_DIR)) {
fs.mkdirSync(THUMBNAIL_DIR, { recursive: true });
}
export const downloadThumbnail = async (url: string, video_id: string) => {
const filePath = path.join(THUMBNAIL_DIR, `${video_id}.jpg`);
if (fs.existsSync(filePath)) {
return `/thumbnails/${video_id}.jpg`; // キャッシュが存在する場合
}
try {
const response = await axios.get(url, { responseType: "stream" });
const writer = fs.createWriteStream(filePath);
response.data.pipe(writer);
return new Promise<string>((resolve, reject) => {
writer.on("finish", () => resolve(`/thumbnails/${video_id}.jpg`));
writer.on("error", reject);
});
} catch (error) {
console.error(`Failed to download thumbnail for ${video_id}:`, error);
throw error;
}
};

25
src/utils/migrate.ts Normal file
View File

@ -0,0 +1,25 @@
import dotenv from "dotenv";
dotenv.config();
import { exec } from "child_process";
import util from "util";
const execPromise = util.promisify(exec);
export const runMigrations = async () => {
try {
console.log("Starting migrations...");
const { stdout, stderr } = await execPromise(
"npx sequelize-cli db:migrate",
{ env: process.env } // 環境変数を渡す
);
if (stdout) console.log("Migration output:", stdout);
if (stderr) console.error("Migration error:", stderr);
console.log("Migrations completed successfully!");
} catch (error) {
console.error("Failed to run migrations:", error);
throw error;
}
};

54
src/utils/youtube.ts Normal file
View File

@ -0,0 +1,54 @@
import axios from "axios";
import { YOUTUBE_API_KEY } from "../config/env";
const BASE_URL = "https://www.googleapis.com/youtube/v3";
// ライブスケジュールを取得する関数
export const fetchUpcomingLiveStreams = async (youtube_id: string) => {
try {
const response = await axios.get(`${BASE_URL}/search`, {
params: {
part: "snippet",
channelId: youtube_id,
eventType: "upcoming",
type: "video",
maxResults: 10,
key: YOUTUBE_API_KEY,
},
});
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,
}));
} catch (error) {
console.error(`Failed to fetch live streams for channel ${youtube_id}:`, error);
throw error;
}
};
// チャンネルハンドルからチャンネルIDを取得
export const getChannelIdByHandle = async (channelHandle: string): Promise<string | null> => {
try {
const response = await axios.get(`${BASE_URL}/channels`, {
params: {
part: "id",
forHandle: channelHandle, // forHandle パラメータを使用
key: YOUTUBE_API_KEY,
},
});
if (response.data.items && response.data.items.length > 0) {
return response.data.items[0].id;
} else {
console.error("No items found in API response:", response.data);
return null;
}
} catch (error: any) {
console.error("Failed to fetch channel ID:", error.response?.data || error.message);
return null;
}
};

44
views/admin/channels.ejs Normal file
View File

@ -0,0 +1,44 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Channel List</title>
</head>
<body>
<h1>Channel List</h1>
<a href="/admin/channels/new">Add New Channel</a>
<table border="1">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Channel Handle</th>
<th>YouTube ID</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<% channels.forEach(channel => { %>
<tr>
<td><%= channel.id %></td>
<td><%= channel.name %></td>
<td><%= channel.channel_handle %></td>
<td><%= channel.youtube_id %></td>
<td>
<form action="/admin/channels/<%= channel.id %>/edit" method="GET" style="display:inline;">
<button type="submit">Edit</button>
</form>
<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>
</td>
</tr>
<% }) %>
</tbody>
</table>
</body>
</html>

View File

@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><%= title %></title>
</head>
<body>
<h1><%= title %></h1>
<form action="/admin/channels/<%= channel.id %>?_method=PUT" method="POST">
<label for="name">Channel Name:</label>
<input type="text" id="name" name="name" value="<%= channel.name %>" required>
<br>
<label for="channel_handle">Channel Handle:</label>
<input type="text" id="channel_handle" name="channel_handle" value="<%= channel.channel_handle %>" required>
<br>
<label for="youtube_id">YouTube ID:</label>
<input type="text" id="youtube_id" name="youtube_id" value="<%= channel.youtube_id %>" required>
<br>
<button type="submit">Update Channel</button>
</form>
</body>
</html>

12
views/admin/index.ejs Normal file
View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><%= title %></title>
</head>
<body>
<h1><%= title %></h1>
<p>Welcome to the Admin Dashboard!</p>
</body>
</html>

View File

@ -0,0 +1,25 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Add New Channel</title>
</head>
<body>
<h1>Add New Channel</h1>
<form action="/admin/channels/new" method="POST">
<label for="name">Channel Name (Optional):</label>
<input type="text" id="name" name="name">
<br>
<label for="channel_handle">Channel Handle (e.g., @example):</label>
<input type="text" id="channel_handle" name="channel_handle">
<br>
<label for="youtube_id">YouTube ID (e.g., UC_xxx...):</label>
<input type="text" id="youtube_id" name="youtube_id">
<br>
<p>Note: Either 'Channel Handle' or 'YouTube ID' must be provided.</p>
<button type="submit">Add Channel</button>
</form>
<a href="/admin/channels">Back to Channel List</a>
</body>
</html>

View File

@ -0,0 +1,27 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><%= title %></title>
</head>
<body>
<h1><%= title %></h1>
<h2>Channel: <%= channel.name %> (<%= channel.youtube_id %>)</h2>
<h3>Live Streams</h3>
<% if (liveStreams.length === 0) { %>
<p>No upcoming live streams found.</p>
<% } else { %>
<ul>
<% liveStreams.forEach(stream => { %>
<li>
<strong>Title:</strong> <%= stream.title %><br>
<strong>Start Time:</strong> <%= new Date(stream.start_time).toLocaleString() %><br>
<strong>Thumbnail:</strong> <img src="<%= stream.thumbnail_url %>" alt="Thumbnail" width="200">
</li>
<% }) %>
</ul>
<% } %>
<a href="/admin/channels">Back to Channel List</a>
</body>
</html>

View File

@ -9,7 +9,6 @@ port="$1"
shift shift
until nc -z "$host" "$port"; do until nc -z "$host" "$port"; do
echo "Waiting for $host:$port to be ready..."
sleep 1 sleep 1
done done