管理画面の作成と管理画面からの配信情報取得ができるようにした
This commit is contained in:
parent
31b5d6dd6e
commit
86a1adee2c
8
.sequelizerc
Normal file
8
.sequelizerc
Normal 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'),
|
||||||
|
};
|
@ -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" // シードデータのディレクトリ
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -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:
|
||||||
|
@ -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;
|
|
||||||
};
|
|
@ -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;
|
|
@ -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
446
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
11
package.json
11
package.json
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
BIN
public/thumbnails/EdJHvSsaoVc.jpg
Normal file
BIN
public/thumbnails/EdJHvSsaoVc.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 33 KiB |
75
src/app.ts
75
src/app.ts
@ -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();
|
||||||
|
@ -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;
|
||||||
|
@ -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: {
|
12
src/migrations/20241225081006-add-videoId-to-liveSchedule.js
Normal file
12
src/migrations/20241225081006-add-videoId-to-liveSchedule.js
Normal 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");
|
||||||
|
},
|
||||||
|
};
|
@ -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
65
src/models/channel.ts
Normal 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;
|
||||||
|
};
|
@ -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 };
|
||||||
|
70
src/models/liveschedule.ts
Normal file
70
src/models/liveschedule.ts
Normal 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;
|
||||||
|
};
|
@ -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
75
src/routes/api.ts
Normal 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;
|
57
src/services/liveScheduleService.ts
Normal file
57
src/services/liveScheduleService.ts
Normal 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;
|
||||||
|
}
|
||||||
|
};
|
17
src/services/updateLiveSchedules.ts
Normal file
17
src/services/updateLiveSchedules.ts
Normal 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;
|
||||||
|
}
|
||||||
|
};
|
32
src/utils/downloadThumbnail.ts
Normal file
32
src/utils/downloadThumbnail.ts
Normal 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
25
src/utils/migrate.ts
Normal 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
54
src/utils/youtube.ts
Normal 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
44
views/admin/channels.ejs
Normal 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>
|
23
views/admin/edit-channel.ejs
Normal file
23
views/admin/edit-channel.ejs
Normal 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
12
views/admin/index.ejs
Normal 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>
|
25
views/admin/new-channel.ejs
Normal file
25
views/admin/new-channel.ejs
Normal 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>
|
27
views/admin/test-result.ejs
Normal file
27
views/admin/test-result.ejs
Normal 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>
|
@ -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
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user