first commit

This commit is contained in:
hina ntki 2024-08-03 19:29:46 +09:00
commit 7e580d75d0
19 changed files with 1829 additions and 0 deletions

176
.gitignore vendored Normal file
View File

@ -0,0 +1,176 @@
# Created by https://www.toptal.com/developers/gitignore/api/python
# Edit at https://www.toptal.com/developers/gitignore?templates=python
### Python ###
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/#use-with-ide
.pdm.toml
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
### Python Patch ###
# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration
poetry.toml
# ruff
.ruff_cache/
# LSP config files
pyrightconfig.json
# End of https://www.toptal.com/developers/gitignore/api/python

19
bot/Dockerfile Normal file
View File

@ -0,0 +1,19 @@
# ベースイメージを指定
FROM python:3.9-slim
# 作業ディレクトリを設定
WORKDIR /bot
# 必要なパッケージをインストール
RUN apt-get update && \
apt-get install -y gcc libc-dev ffmpeg
# 依存関係をインストール
COPY requirements.txt requirements.txt
RUN pip install --no-cache-dir -r requirements.txt
# アプリケーションのコピー
COPY . .
# アプリケーションの実行
CMD ["python", "main.py"]

174
bot/app.py Normal file
View File

@ -0,0 +1,174 @@
from flask import Flask, request, jsonify
import logging
import discord
from discord.ext import commands, tasks
from discord import VoiceChannel
import asyncio
app = Flask(__name__)
app.config['DEBUG'] = True
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# Discord Botインスタンスを保持するための変数
bot_instance = None
quiz_message_id = None
def create_response(error_message=None, success_message=None):
if error_message:
logger.error(error_message)
return jsonify({"error": error_message}), 500
if success_message:
logger.info(success_message)
return jsonify({"message": success_message}), 200
@app.route('/join', methods=['POST'])
def join_via_api():
if bot_instance is None:
return create_response(error_message="BOTが初期化されていません")
channel_id = request.json.get('channel_id')
logger.info(f"リクエストされたチャンネルID: {channel_id}")
if not channel_id:
return create_response(error_message="チャンネルIDが必要です")
try:
channel_id = int(channel_id)
except ValueError:
return create_response(error_message=f"無効なチャンネルIDです: {channel_id}")
channel = bot_instance.get_channel(channel_id)
if channel:
logger.info(f"チャンネルが見つかりました: {channel.name}")
if isinstance(channel, VoiceChannel):
bot_instance.loop.create_task(channel.connect())
return create_response(success_message=f'{channel.name}に参加します')
else:
return create_response(error_message=f"ID {channel_id} はボイスチャンネルではありません")
else:
return create_response(error_message="指定されたチャンネルが見つかりません")
@app.route('/leave', methods=['POST'])
def leave_via_api():
if bot_instance is None:
return create_response(error_message="BOTが初期化されていません")
if bot_instance.voice_clients:
for vc in bot_instance.voice_clients:
bot_instance.loop.create_task(vc.disconnect())
return create_response(success_message="ボイスチャンネルから切断しました")
return create_response(error_message="ボイスチャンネルに接続されていません")
@app.route('/play', methods=['POST'])
def play_via_api():
if bot_instance is None:
return create_response(error_message="BOTが初期化されていません")
filename = request.json.get('filename')
volume = request.json.get('volume', 100)
try:
volume = float(volume) / 100.0
except ValueError:
return create_response(error_message="無効な音量値です")
if bot_instance.voice_clients:
for vc in bot_instance.voice_clients:
audio_source = discord.FFmpegPCMAudio(f'bucket/{filename}', options=f"-filter:a 'volume={volume}'")
vc.play(audio_source)
return create_response(success_message=f'再生中: {filename}')
return create_response(error_message="ボイスチャンネルに接続されていません")
@app.route('/start_quiz', methods=['POST'])
def start_quiz():
if bot_instance is None:
return create_response(error_message="BOTが初期化されていません")
data = request.json
intro_audio_name = data.get('intro_audio_name')
volume = data.get('volume', 100)
if not intro_audio_name:
return create_response(error_message="イントロ音源名が必要です")
try:
volume = float(volume) / 100.0
except ValueError:
return create_response(error_message="無効な音量値です")
if bot_instance.voice_clients:
for vc in bot_instance.voice_clients:
if not vc.is_connected():
return create_response(error_message="ボイスチャンネルに接続されていません")
# クイズ回答用のメッセージをVCチャットに送信
asyncio.run_coroutine_threadsafe(send_quiz_message(vc.channel, intro_audio_name, volume), bot_instance.loop)
return create_response(success_message=f'イントロ音源再生準備中: {intro_audio_name}')
return create_response(error_message="ボイスチャンネルに接続されていません")
@app.route('/stop_audio', methods=['POST'])
def stop_audio():
if bot_instance is None:
return create_response(error_message="BOTが初期化されていません")
if bot_instance.voice_clients:
for vc in bot_instance.voice_clients:
if vc.is_playing():
vc.stop()
return create_response(success_message="再生を停止しました")
return create_response(error_message="再生中の音源がありません")
async def send_quiz_message(channel, intro_audio_name, volume):
global quiz_message_id
button = discord.ui.Button(label="回答", custom_id="quiz_answer")
view = discord.ui.View()
view.add_item(button)
message = await channel.send("クイズが開始されました!", view=view)
quiz_message_id = message.id
# インターバルを設ける
await asyncio.sleep(5) # 5秒間待機
# イントロ音源を再生
for vc in bot_instance.voice_clients:
if vc.channel == channel:
audio_source = discord.FFmpegPCMAudio(f'bucket/{intro_audio_name}', options=f"-filter:a 'volume={volume}'")
vc.play(audio_source)
await disable_button_after_timeout(message)
async def disable_button_after_timeout(message):
await asyncio.sleep(20)
view = discord.ui.View()
for action_row in message.components:
for item in action_row.children:
item.disabled = True
view.add_item(discord.ui.Button.from_component(item))
await message.edit(view=view)
await message.delete()
def run_app():
app.run(host='0.0.0.0', port=80, use_reloader=False)
def set_bot_instance(bot):
global bot_instance
bot_instance = bot
@bot.event
async def on_interaction(interaction):
global quiz_message_id
global bot_instance
if interaction.data.get('custom_id') == "quiz_answer":
if interaction.message.id == quiz_message_id:
quiz_message_id = None
await interaction.response.send_message(f"{interaction.user.mention}が回答ボタンを押しました!", ephemeral=False)
if bot_instance.voice_clients:
for vc in bot_instance.voice_clients:
if vc.is_playing():
vc.stop()
view = discord.ui.View()
for action_row in interaction.message.components:
for item in action_row.children:
item.disabled = True
view.add_item(discord.ui.Button.from_component(item))
await interaction.message.edit(view=view)

24
bot/bot.py Normal file
View File

@ -0,0 +1,24 @@
import discord
from discord.ext import commands
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
intents = discord.Intents.default()
intents.messages = True
intents.guilds = True
intents.voice_states = True
intents.message_content = True
intents.reactions = True
bot = commands.Bot(command_prefix='!', intents=intents)
@bot.event
async def on_ready():
logger.info(f'{bot.user.name} がログインしました')
def run_bot(token):
bot.run(token)
def get_bot():
return bot

16
bot/main.py Normal file
View File

@ -0,0 +1,16 @@
import os
import threading
from app import run_app, set_bot_instance
from bot import run_bot, get_bot
if __name__ == '__main__':
bot_instance = get_bot()
set_bot_instance(bot_instance)
threading.Thread(target=run_app).start()
token = os.getenv('DISCORD_TOKEN')
if not token:
raise ValueError("Discord BOT トークンが設定されていません")
run_bot(token)

4
bot/requirements.txt Normal file
View File

@ -0,0 +1,4 @@
discord.py
flask
flask-cors
pynacl

18
docker-compose.yml Normal file
View File

@ -0,0 +1,18 @@
services:
bot:
build: ./bot
volumes:
- ./bot:/bot
- ./bucket:/bot/bucket
environment:
- DISCORD_TOKEN=MTIyNjIwMTA4ODg1NjgyMTg4MQ.GH0q-p.5A8k4UCGXK5UT6g4uCksFNHhAky9EvRQLuZckQ
web:
build: ./web
volumes:
- ./web:/web
- ./bucket:/web/bucket
- ./web/data:/web/data
ports:
- "5000:80"
links:
- bot

22
web/Dockerfile Normal file
View File

@ -0,0 +1,22 @@
# Pythonベースのイメージを使用
FROM python:3.9-slim
# 作業ディレクトリを作成
WORKDIR /web
# 必要なパッケージをインストール
COPY requirements.txt requirements.txt
RUN pip install --no-cache-dir -r requirements.txt
# アプリケーションコードをコピー
COPY . .
# Flaskのホットリロードのための環境変数設定
ENV FLASK_ENV=development
ENV FLASK_APP=app.py
# Flaskサーバーの起動コマンド
CMD ["python", "app.py"]
# 標準出力と標準エラー出力を一つに統合
CMD ["sh", "-c", "python app.py > /proc/1/fd/1 2>&1"]

343
web/app.py Normal file
View File

@ -0,0 +1,343 @@
import os
import sys
import traceback
import secrets
import random
import requests
from flask import Flask, session, redirect, url_for, g, request, render_template, jsonify, flash
import logging
from datetime import timedelta
from db import get_db, close_connection, initialize_db, reset_active_sessions
from auth import login_required, check_login, login, logout, create_account
from handlers import handle_file_upload, delete_file, play_file, join_channel, leave_channel, index, get_files, get_quiz_master, update_quiz_master, add_quiz_master, delete_quiz_master, get_unique_keywords
from init_account import create_initial_account
from models import ActiveSession, QuizMaster
from sqlalchemy.sql import func
import csv
app = Flask(__name__)
app.config['UPLOAD_FOLDER'] = 'bucket'
app.config['SECRET_KEY'] = secrets.token_hex(16)
app.config['DATABASE'] = '/web/data/database.db'
app.config['BOT_API_URL'] = 'http://bot:80'
app.config['SESSION_TIMEOUT_MINUTES'] = 30
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
drawn_quiz_ids = set()
if not os.path.exists(app.config['UPLOAD_FOLDER']):
os.makedirs(app.config['UPLOAD_FOLDER'])
logger.info(f"{app.config['UPLOAD_FOLDER']}ディレクトリを作成しました")
with app.app_context():
create_initial_account(app.config['DATABASE'])
reset_active_sessions(logger)
initialize_db(logger)
@app.teardown_appcontext
def close_db_connection(exception):
close_connection(exception)
@app.before_request
def before_request():
if 'session_id' not in session:
session['session_id'] = secrets.token_hex(16)
logger.info(f"新しいセッションIDが生成されました: {session['session_id']}")
#else:
#logger.info(f"既存のセッションIDが存在します: {session['session_id']}")
session.permanent = True
app.permanent_session_lifetime = timedelta(minutes=app.config['SESSION_TIMEOUT_MINUTES'])
session.modified = True
db = get_db()
if session.get('logged_in'):
active_session = db.query(ActiveSession).filter_by(session_id=session['session_id']).first()
if active_session:
active_session.last_active = func.now()
db.commit()
elif request.endpoint not in ('login_route', 'static'):
return redirect(url_for('login_route'))
@app.route('/login', methods=['GET', 'POST'])
def login_route():
return login()
@app.route('/logout', methods=['POST'])
def logout_route():
return logout()
@app.route('/create_account', methods=['POST'])
def create_account_route():
return create_account()
@app.route('/')
@login_required
def index_route():
return index()
@app.route('/upload', methods=['POST'])
@login_required
def upload_file_route():
return handle_file_upload()
@app.route('/uploaded_files', methods=['GET'])
@login_required
def uploaded_files_route():
return render_template('files.html')
@app.route('/uploaded_files_list', methods=['GET'])
@login_required
def uploaded_files_list_route():
files = os.listdir(app.config['UPLOAD_FOLDER'])
return jsonify(files)
@app.route('/used_files_list', methods=['GET'])
@login_required
def used_files_list_route():
db = get_db()
used_files = db.query(QuizMaster.full_audio_name, QuizMaster.intro_audio_name).all()
return jsonify([file for sublist in used_files for file in sublist])
@app.route('/delete/<filename>', methods=['POST'])
@login_required
def delete_file_route(filename):
return delete_file(filename)
@app.route('/play/<filename>', methods=['POST'])
@login_required
def play_file_route(filename):
volume = request.json.get('volume', 100)
return play_file(filename, volume)
@app.route('/stop_audio', methods=['POST'])
@login_required
def stop_audio_route():
response = requests.post(f"{app.config['BOT_API_URL']}/stop_audio")
return jsonify(response.json())
@app.route('/join', methods=['POST'])
@login_required
def join_channel_route():
return join_channel()
@app.route('/leave', methods=['POST'])
@login_required
def leave_channel_route():
return leave_channel()
@app.route('/files', methods=['GET'])
@login_required
def get_files_route():
return get_files()
@app.route('/quiz_master_page', methods=['GET'])
@login_required
def quiz_master_page_route():
return render_template('quiz_master.html')
@app.route('/quiz_master', methods=['GET'])
@login_required
def get_quiz_master_route():
return get_quiz_master()
@app.route('/unique_keywords', methods=['GET'])
@login_required
def unique_keywords_route():
return get_unique_keywords()
@app.route('/update_quiz_master', methods=['POST'])
@login_required
def update_quiz_master_route():
return update_quiz_master()
@app.route('/add_quiz_master', methods=['POST'])
@login_required
def add_quiz_master_route():
return add_quiz_master()
@app.route('/delete_quiz_master', methods=['POST'])
@login_required
def delete_quiz_master_route():
return delete_quiz_master()
@app.route('/random_quiz', methods=['POST'])
@login_required
def random_quiz_route():
data = request.json
keyword = data.get('keyword')
db = get_db()
quiz_master_data = db.query(QuizMaster).filter(
(QuizMaster.keyword1 == keyword) |
(QuizMaster.keyword2 == keyword) |
(QuizMaster.keyword3 == keyword)
).all()
if not quiz_master_data:
return jsonify({'error': '指定されたキーワードでクイズマスターが見つかりません'}), 404
available_quizzes = [quiz for quiz in quiz_master_data if quiz.id not in drawn_quiz_ids]
if not available_quizzes:
return jsonify({'error': '指定されたキーワードで利用可能なクイズが見つかりません'}), 404
selected_quiz = random.choice(available_quizzes)
drawn_quiz_ids.add(selected_quiz.id)
return jsonify({
'id': selected_quiz.id,
'title': selected_quiz.title,
'full_audio_name': selected_quiz.full_audio_name,
'intro_audio_name': selected_quiz.intro_audio_name,
'keyword1': selected_quiz.keyword1,
'keyword2': selected_quiz.keyword2,
'keyword3': selected_quiz.keyword3
})
@app.route('/reset_quiz', methods=['POST'])
@login_required
def reset_quiz_route():
global drawn_quiz_ids
drawn_quiz_ids = set()
return jsonify({'message': 'クイズがリセットされました'})
@app.route('/start_quiz', methods=['POST'])
@login_required
def start_quiz_route():
data = request.json
quiz_id = data.get('quiz_id')
intro_audio_name = data.get('intro_audio_name')
volume = data.get('volume', 100)
if not quiz_id or not intro_audio_name:
return jsonify({'error': 'クイズIDとイントロ音源名が必要です'}), 400
response = requests.post(f"{app.config['BOT_API_URL']}/start_quiz", json={'quiz_id': quiz_id, 'intro_audio_name': intro_audio_name, 'volume': volume})
if response.status_code == 200:
return jsonify({'message': 'クイズを開始しました'})
else:
return jsonify({'error': 'クイズの開始に失敗しました'}), response.status_code
@app.route('/search_quiz', methods=['GET'])
@login_required
def search_quiz_route():
title = request.args.get('title', '')
db = get_db()
results = db.query(QuizMaster).filter(QuizMaster.title.like(f'%{title}%')).all()
quizzes = [{'id': quiz.id, 'title': quiz.title, 'full_audio_name': quiz.full_audio_name, 'intro_audio_name': quiz.intro_audio_name,
'keyword1': quiz.keyword1, 'keyword2': quiz.keyword2, 'keyword3': quiz.keyword3} for quiz in results]
return jsonify(quizzes)
@app.route('/select_quiz', methods=['POST'])
@login_required
def select_quiz_route():
data = request.json
quiz_id = data.get('quiz_id')
db = get_db()
quiz = db.query(QuizMaster).filter_by(id=quiz_id).first()
if not quiz:
return jsonify({'error': '指定されたIDのクイズが見つかりません'}), 404
selected_quiz = {
'id': quiz.id,
'title': quiz.title,
'full_audio_name': quiz.full_audio_name,
'intro_audio_name': quiz.intro_audio_name,
'keyword1': quiz.keyword1,
'keyword2': quiz.keyword2,
'keyword3': quiz.keyword3
}
return jsonify(selected_quiz)
@app.route('/upload_quiz_master', methods=['POST'])
@login_required
def upload_quiz_master_route():
global uploaded_file_path
try:
if 'file' not in request.files:
logger.debug('No file part in the request')
flash('No file part')
return redirect(request.url)
file = request.files['file']
if file.filename == '':
logger.debug('No selected file')
flash('No selected file')
return redirect(request.url)
if file and file.filename.endswith('.csv'):
uploaded_file_path = os.path.join(app.config['UPLOAD_FOLDER'], file.filename)
file.save(uploaded_file_path)
logger.debug(f'File {file.filename} uploaded successfully to {uploaded_file_path}')
flash('File uploaded successfully')
return redirect(url_for('quiz_master_page_route'))
logger.debug('Invalid file format')
flash('Invalid file format')
return redirect(request.url)
except Exception as e:
logger.error(f'Error during file upload: {str(e)}')
flash('Error during file upload')
return redirect(request.url)
@app.route('/import_quiz_master', methods=['POST'])
@login_required
def import_quiz_master_route():
global uploaded_file_path
try:
if uploaded_file_path and os.path.exists(uploaded_file_path):
logger.debug(f'Starting import of file {uploaded_file_path}')
process_csv(uploaded_file_path)
logger.debug('File imported successfully')
flash('File imported successfully')
uploaded_file_path = None # Reset after processing
return redirect(url_for('quiz_master_page_route'))
logger.debug('No file uploaded to import')
flash('No file uploaded to import')
return redirect(url_for('quiz_master_page_route'))
except Exception as e:
logger.error(f'Error during file import: {str(e)}')
flash('Error during file import')
return redirect(url_for('quiz_master_page_route'))
def process_csv(file_path):
try:
with open(file_path, newline='', encoding='utf-8') as csvfile:
reader = csv.DictReader(csvfile)
db = get_db()
for row in reader:
# BOT格納状況が「格納済」のデータをスキップ
if row['BOT格納状況'] == '格納済':
logger.debug(f'Skipping row due to BOT格納状況 being 格納済: {row}')
continue
# 必要な情報がそろっているかチェック
if not (row['曲名'] and row['フル音源名'] and row['イントロ音源名']):
logger.debug(f'Skipping row due to missing information: {row}')
continue
# キーワード1からキーワード3のうちどれか1つが入力されているかチェック
if not (row['キーワード1(歌唱)'] or row['キーワード2(種別)'] or row['キーワード3']):
logger.debug(f'Skipping row due to missing keywords: {row}')
continue
# データベースに保存
quiz_master = QuizMaster(
title=row['曲名'],
full_audio_name=f"{row['フル音源名']}.mp3",
intro_audio_name=f"{row['イントロ音源名']}.mp3",
keyword1=row['キーワード1(歌唱)'],
keyword2=row['キーワード2(種別)'],
keyword3=row['キーワード3']
)
db.add(quiz_master)
logger.debug(f'Added row to database: {row}')
db.commit()
logger.debug('CSV file processed and committed to database')
except Exception as e:
logger.error(f'Error processing CSV file: {str(e)}')
if __name__ == "__main__":
try:
app.run(debug=True, host='0.0.0.0', port=80)
except ImportError as e:
logger.error(f"ImportErrorが発生しました: {e}")
traceback.print_exc(file=sys.stderr)
except Exception as e:
logger.error(f"例外が発生しました: {e}")
traceback.print_exc(file=sys.stderr)

64
web/auth.py Normal file
View File

@ -0,0 +1,64 @@
from flask import request, session, redirect, url_for, render_template, g, jsonify
import bcrypt
import secrets
from db import get_db
from models import Account, ActiveSession
import logging
logger = logging.getLogger(__name__)
def login_required(f):
def decorated_function(*args, **kwargs):
if not check_login(session, logger):
return redirect(url_for('login_route'))
return f(*args, **kwargs)
decorated_function.__name__ = f.__name__
return decorated_function
def check_login(session, logger):
if 'logged_in' not in session or not session['logged_in']:
logger.info("セッションにログイン情報がありません")
return False
db = get_db()
active_session_count = db.query(ActiveSession).filter_by(session_id=session['session_id']).count()
if active_session_count != 1:
logger.info(f"セッションID {session['session_id']} のアクティブセッション数: {active_session_count}")
return False
return True
def login():
if request.method == 'POST':
user_id = request.form['user_id']
password = request.form['password'].encode('utf-8')
db = get_db()
account = db.query(Account).filter_by(user_id=user_id).first()
if account and bcrypt.checkpw(password, account.password.encode('utf-8')):
active_sessions = db.query(ActiveSession).count()
if active_sessions >= 1:
return "他のユーザーが既にログインしています。", 403
session['logged_in'] = True
new_session = ActiveSession(session_id=session['session_id'])
db.add(new_session)
db.commit()
return redirect(url_for('index_route'))
else:
return "Invalid credentials", 403
return render_template('login.html')
def logout():
db = get_db()
db.query(ActiveSession).filter_by(session_id=session['session_id']).delete()
db.commit()
session.clear()
return redirect(url_for('login_route'))
def create_account():
user_id = secrets.token_hex(8)
password = secrets.token_hex(16)
hashed_password = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt())
db = get_db()
new_account = Account(user_id=user_id, password=hashed_password.decode('utf-8'))
db.add(new_account)
db.commit()
return jsonify({'message': 'アカウントが作成されました', 'user_id': user_id, 'password': password})

30
web/db.py Normal file
View File

@ -0,0 +1,30 @@
import os
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, scoped_session
from flask import g, current_app
from models import Base, Account, ActiveSession, ChannelId, QuizMaster
def get_engine():
return create_engine(f"sqlite:///{current_app.config['DATABASE']}")
def get_db():
if 'db' not in g:
engine = get_engine()
g.db = scoped_session(sessionmaker(bind=engine))
return g.db
def close_connection(exception):
db = g.pop('db', None)
if db is not None:
db.remove()
def initialize_db(logger):
engine = get_engine()
Base.metadata.create_all(engine)
logger.info("データベースの初期化が完了しました")
def reset_active_sessions(logger):
db = get_db()
db.query(ActiveSession).delete()
db.commit()
logger.info("アクティブセッションをリセットしました")

142
web/handlers.py Normal file
View File

@ -0,0 +1,142 @@
import os
import urllib.parse
from flask import request, jsonify, session, render_template, current_app
import requests
from db import get_db
from models import ChannelId, QuizMaster
def handle_file_upload():
if 'files' not in request.files:
return jsonify({'error': 'ファイルが見つかりません'})
files = request.files.getlist('files')
if not files or files[0].filename == '':
return jsonify({'error': '選択されたファイルがありません'})
for file in files:
filename = file.filename
file.save(os.path.join(current_app.config['UPLOAD_FOLDER'], filename))
return jsonify({'message': 'ファイルがアップロードされました'})
def delete_file(filename):
filename = urllib.parse.unquote(filename)
file_path = os.path.join(current_app.config['UPLOAD_FOLDER'], filename)
if os.path.exists(file_path):
os.remove(file_path)
return jsonify({'message': 'ファイルが削除されました'})
return jsonify({'error': 'ファイルが見つかりません'})
def play_file(filename, volume):
filename = urllib.parse.unquote(filename)
response = requests.post(f"{current_app.config['BOT_API_URL']}/play", json={'filename': filename, 'volume': volume})
return jsonify(response.json())
def join_channel():
channel_id = request.json.get('channel_id')
db = get_db()
new_channel = ChannelId(channel_id=channel_id)
db.add(new_channel)
db.commit()
response = requests.post(f"{current_app.config['BOT_API_URL']}/join", json={'channel_id': channel_id})
return jsonify(response.json())
def leave_channel():
response = requests.post(f"{current_app.config['BOT_API_URL']}/leave")
return jsonify(response.json())
def index():
files = os.listdir(current_app.config['UPLOAD_FOLDER'])
db = get_db()
last_channel_id = db.query(ChannelId).order_by(ChannelId.id.desc()).first()
last_channel_id = last_channel_id.channel_id if last_channel_id else ""
return render_template('index.html', files=files, last_channel_id=last_channel_id)
def get_files():
files = os.listdir(current_app.config['UPLOAD_FOLDER'])
return jsonify(files)
def get_quiz_master():
db = get_db()
quiz_master_data = db.query(QuizMaster).all()
result = []
for qm in quiz_master_data:
result.append({
'id': qm.id,
'title': qm.title,
'full_audio_name': qm.full_audio_name,
'intro_audio_name': qm.intro_audio_name,
'keyword1': qm.keyword1,
'keyword2': qm.keyword2,
'keyword3': qm.keyword3
})
return jsonify(result)
def get_unique_keywords():
db = get_db()
keywords = set()
quiz_master_data = db.query(QuizMaster).all()
for qm in quiz_master_data:
if qm.keyword1:
keywords.add(qm.keyword1)
if qm.keyword2:
keywords.add(qm.keyword2)
if qm.keyword3:
keywords.add(qm.keyword3)
sorted_keywords = sorted(keywords)
return jsonify(sorted_keywords)
def update_quiz_master():
data = request.json
db = get_db()
quiz_master = db.query(QuizMaster).filter_by(id=data['id']).first()
if quiz_master:
quiz_master.title = data['title']
quiz_master.full_audio_name = data['full_audio_name']
quiz_master.intro_audio_name = data['intro_audio_name']
quiz_master.keyword1 = data['keyword1']
quiz_master.keyword2 = data['keyword2']
quiz_master.keyword3 = data['keyword3']
db.commit()
return jsonify({'message': '更新が完了しました'})
def add_quiz_master():
data = request.json
db = get_db()
new_quiz_master = QuizMaster(
title=data['title'],
full_audio_name=data['full_audio_name'],
intro_audio_name=data['intro_audio_name'],
keyword1=data['keyword1'],
keyword2=data['keyword2'],
keyword3=data['keyword3']
)
db.add(new_quiz_master)
db.commit()
return jsonify({'message': '追加が完了しました'})
def delete_quiz_master():
data = request.json
db = get_db()
db.query(QuizMaster).filter_by(id=data['id']).delete()
db.commit()
return jsonify({'message': '削除が完了しました'})
def draw_random_quiz():
data = request.json
keyword = data.get('keyword')
selected_quiz = random_quiz(keyword)
if not selected_quiz:
return jsonify({'error': '利用可能なクイズがありません'})
result = {
'id': selected_quiz.id,
'title': selected_quiz.title,
'full_audio_name': selected_quiz.full_audio_name,
'intro_audio_name': selected_quiz.intro_audio_name,
'keyword1': selected_quiz.keyword1,
'keyword2': selected_quiz.keyword2,
'keyword3': selected_quiz.keyword3
}
return jsonify(result)

28
web/init_account.py Normal file
View File

@ -0,0 +1,28 @@
import os
import secrets
import bcrypt
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from models import Base, Account, ActiveSession, ChannelId
def create_initial_account(db_path):
if not os.path.exists(os.path.dirname(db_path)):
os.makedirs(os.path.dirname(db_path), exist_ok=True)
engine = create_engine(f"sqlite:///{db_path}")
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine)
session = Session()
if session.query(Account).count() == 0:
user_id = secrets.token_hex(8)
password = secrets.token_hex(16)
hashed_password = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt())
new_account = Account(user_id=user_id, password=hashed_password.decode('utf-8'))
session.add(new_account)
session.commit()
with open("/web/data/initial_account.txt", "w") as f:
f.write(f"User ID: {user_id}\nPassword: {password}\n")
session.close()

31
web/models.py Normal file
View File

@ -0,0 +1,31 @@
from sqlalchemy import Column, Integer, String, TIMESTAMP
from sqlalchemy.ext.declarative import declarative_base
Base = declarative_base()
class Account(Base):
__tablename__ = 'accounts'
id = Column(Integer, primary_key=True)
user_id = Column(String)
password = Column(String)
class ActiveSession(Base):
__tablename__ = 'active_sessions'
id = Column(Integer, primary_key=True)
session_id = Column(String)
last_active = Column(TIMESTAMP)
class ChannelId(Base):
__tablename__ = 'channel_ids'
id = Column(Integer, primary_key=True)
channel_id = Column(String)
class QuizMaster(Base):
__tablename__ = 'quiz_master'
id = Column(Integer, primary_key=True, autoincrement=True)
title = Column(String)
full_audio_name = Column(String)
intro_audio_name = Column(String)
keyword1 = Column(String)
keyword2 = Column(String)
keyword3 = Column(String)

5
web/requirements.txt Normal file
View File

@ -0,0 +1,5 @@
Flask
werkzeug
requests
bcrypt
sqlalchemy

136
web/templates/files.html Normal file
View File

@ -0,0 +1,136 @@
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>アップロードされたファイル一覧</title>
<style>
body {
font-family: Arial, sans-serif;
margin: 20px;
}
table {
width: 100%;
border-collapse: collapse;
margin-bottom: 20px;
}
th, td {
padding: 10px;
border: 1px solid #ddd;
text-align: left;
}
th {
background-color: #f2f2f2;
}
button {
margin-right: 5px;
}
</style>
<script>
async function handleFileUpload(event) {
event.preventDefault();
const form = document.getElementById('uploadForm');
const formData = new FormData(form);
const progressBar = document.getElementById('uploadProgress');
progressBar.style.display = 'block';
progressBar.value = 0;
const xhr = new XMLHttpRequest();
xhr.open('POST', '/upload', true);
xhr.upload.onprogress = function(event) {
if (event.lengthComputable) {
const percentComplete = (event.loaded / event.total) * 100;
progressBar.value = percentComplete;
}
};
xhr.onload = function() {
if (xhr.status === 200) {
const result = JSON.parse(xhr.responseText);
progressBar.style.display = 'none';
if (result.error) {
document.getElementById('message').innerText = result.error;
} else {
updateFileList();
}
} else {
document.getElementById('message').innerText = 'エラーが発生しました: ' + xhr.statusText;
}
};
xhr.onerror = function() {
document.getElementById('message').innerText = 'エラーが発生しました: ネットワークエラー';
};
xhr.send(formData);
}
async function handleAction(event, endpoint) {
event.preventDefault();
try {
const response = await fetch(endpoint, { method: 'POST' });
const result = await response.json();
document.getElementById('message').innerText = result.message || result.error;
if (!result.error) {
updateFileList();
}
} catch (error) {
document.getElementById('message').innerText = 'エラーが発生しました: ' + error.message;
}
}
async function updateFileList() {
try {
const response = await fetch('/files');
const files = await response.json();
const fileList = document.getElementById('fileList');
fileList.innerHTML = '';
files.forEach(file => {
const encodedFile = encodeURIComponent(file);
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${file}</td>
<td>
<form onsubmit="handleAction(event, '/delete/${encodedFile}')">
<button type="submit">削除</button>
</form>
<!--
<form onsubmit="handleAction(event, '/play/${encodedFile}')">
<button type="submit">再生</button>
</form>
-->
</td>
`;
fileList.appendChild(tr);
});
} catch (error) {
document.getElementById('message').innerText = 'ファイルリストの更新中にエラーが発生しました: ' + error.message;
}
}
document.addEventListener('DOMContentLoaded', updateFileList);
</script>
</head>
<body>
<h1>アップロードされたファイル一覧</h1>
<div id="message" style="color: red; margin-top: 20px;"></div>
<button onclick="location.href='/'">トップページに戻る</button>
<form id="uploadForm" onsubmit="handleFileUpload(event)">
<label for="files">音源をアップロード:</label>
<input type="file" name="files" id="files" multiple>
<button type="submit">アップロード</button>
</form>
<progress id="uploadProgress" value="0" max="100" style="display:none; width: 100%;"></progress>
<table>
<thead>
<tr>
<th>ファイル名</th>
<th>アクション</th>
</tr>
</thead>
<tbody id="fileList">
<!-- ファイルリストはここに動的に挿入されます -->
</tbody>
</table>
</body>
</html>

307
web/templates/index.html Normal file
View File

@ -0,0 +1,307 @@
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>イントロクイズBOT管理ページ</title>
<style>
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: inline-block;
width: 120px;
}
.form-group input, .form-group select {
width: calc(100% - 130px);
}
.form-group select {
height: 100px;
}
.form-container, .table-container {
margin-left: 0;
}
.button-group {
text-align: left;
}
table {
width: 100%;
border-collapse: collapse.
}
th, td {
padding: 8px;
text-align: left;
border: 1px solid #ddd;
white-space: nowrap;
}
th {
background-color: #f2f2f2;
}
#randomQuizResult, #selectedQuizResult {
display: none;
}
#message {
font-size: 24px;
color: red;
margin-top: 20px;
}
#searchResults div {
cursor: pointer;
padding: 5px;
border: 1px solid #ddd;
margin-top: -1px;
}
#searchResults div:hover {
background-color: #f2f2f2;
}
</style>
<script>
async function handleFormSubmit(event, formId, endpoint) {
event.preventDefault();
const form = document.getElementById(formId);
const formData = new FormData(form);
const payload = {};
formData.forEach((value, key) => {
payload[key] = value;
});
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
});
const result = await response.json();
document.getElementById('message').innerText = result.message || result.error;
if (result.user_id && result.password) {
document.getElementById('accountInfo').innerText = `UserID: ${result.user_id}, Password: ${result.password}`;
}
} catch (error) {
document.getElementById('message').innerText = 'エラーが発生しました: ' + error.message;
}
}
async function handleAction(event, endpoint) {
event.preventDefault();
try {
const response = await fetch(endpoint, { method: 'POST' });
const result = await response.json();
document.getElementById('message').innerText = result.message || result.error;
} catch (error) {
document.getElementById('message').innerText = 'エラーが発生しました: ' + error.message;
}
}
async function updateUniqueKeywords() {
try {
const response = await fetch('/unique_keywords');
const keywords = await response.json();
keywords.sort(); // ここでキーワードを五十音順にソート
const keywordList = document.getElementById('uniqueKeywordsList');
keywordList.innerHTML = '';
keywords.forEach(keyword => {
const option = document.createElement('option');
option.value = keyword;
option.textContent = keyword;
keywordList.appendChild(option);
});
} catch (error) {
document.getElementById('message').innerText = 'ユニークキーワードの更新中にエラーが発生しました: ' + error.message;
}
}
async function drawRandomQuiz(event) {
event.preventDefault();
const keyword = document.getElementById('uniqueKeywordsList').value;
if (!keyword) {
document.getElementById('message').innerText = 'キーワードを選択してください';
return;
}
try {
const response = await fetch('/random_quiz', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ keyword })
});
const result = await response.json();
if (result.error) {
document.getElementById('message').innerText = result.error;
document.getElementById('randomQuizResult').style.display = 'none';
} else {
document.getElementById('randomQuizResult').innerHTML = `
<p>ID: ${result.id}</p>
<p>タイトル: ${result.title}</p>
<p>フル音源名: ${result.full_audio_name}</p>
<p>イントロ音源名: ${result.intro_audio_name}</p>
<p>キーワード1: ${result.keyword1}</p>
<p>キーワード2: ${result.keyword2}</p>
<p>キーワード3: ${result.keyword3}</p>
<button onclick="startQuiz(${result.id}, '${result.intro_audio_name}')">クイズを開始</button>
<button onclick="stopAudio()">再生停止</button>
<button onclick="playFullAudio('${result.full_audio_name}')">フル音源を再生</button>
`;
document.getElementById('randomQuizResult').style.display = 'block';
}
} catch (error) {
document.getElementById('message').innerText = 'クイズの抽選中にエラーが発生しました: ' + error.message;
document.getElementById('randomQuizResult').style.display = 'none';
}
}
async function startQuiz(quizId, introAudioName) {
const volume = document.getElementById('quizVolume').value;
try {
const response = await fetch('/start_quiz', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ quiz_id: quizId, intro_audio_name: introAudioName, volume: volume })
});
const result = await response.json();
document.getElementById('message').innerText = result.message || result.error;
} catch (error) {
document.getElementById('message').innerText = 'クイズの開始中にエラーが発生しました: ' + error.message;
}
}
async function stopAudio() {
try {
const response = await fetch('/stop_audio', { method: 'POST' });
const result = await response.json();
document.getElementById('message').innerText = result.message || result.error;
} catch (error) {
document.getElementById('message').innerText = '再生停止中にエラーが発生しました: ' + error.message;
}
}
async function playFullAudio(fullAudioName) {
const volume = document.getElementById('fullAudioVolume').value;
try {
const encodedFilename = encodeURIComponent(fullAudioName);
const response = await fetch(`/play/${encodedFilename}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ volume: volume })
});
const result = await response.json();
document.getElementById('message').innerText = result.message || result.error;
} catch (error) {
document.getElementById('message').innerText = 'フル音源の再生中にエラーが発生しました: ' + error.message;
}
}
async function resetQuiz() {
try {
const response = await fetch('/reset_quiz', { method: 'POST' });
const result = await response.json();
document.getElementById('message').innerText = result.message || result.error;
document.getElementById('randomQuizResult').style.display = 'none';
document.getElementById('selectedQuizResult').style.display = 'none';
} catch (error) {
document.getElementById('message').innerText = 'クイズのリセット中にエラーが発生しました: ' + error.message;
}
}
async function searchQuizByTitle(event) {
const title = document.getElementById('searchTitle').value;
if (!title) {
document.getElementById('message').innerText = 'タイトルを入力してください';
return;
}
try {
const response = await fetch(`/search_quiz?title=${encodeURIComponent(title)}`);
const results = await response.json();
const searchResults = document.getElementById('searchResults');
searchResults.innerHTML = '';
results.forEach(result => {
const resultDiv = document.createElement('div');
resultDiv.innerHTML = `
<p>ID: ${result.id}</p>
<p>タイトル: ${result.title}</p>
<p>フル音源名: ${result.full_audio_name}</p>
<p>イントロ音源名: ${result.intro_audio_name}</p>
<p>キーワード1: ${result.keyword1}</p>
<p>キーワード2: ${result.keyword2}</p>
<p>キーワード3: ${result.keyword3}</p>
`;
resultDiv.addEventListener('click', () => selectQuiz(result));
searchResults.appendChild(resultDiv);
});
} catch (error) {
document.getElementById('message').innerText = 'クイズの検索中にエラーが発生しました: ' + error.message;
}
}
async function selectQuiz(result) {
document.getElementById('selectedQuizResult').innerHTML = `
<p>ID: ${result.id}</p>
<p>タイトル: ${result.title}</p>
<p>フル音源名: ${result.full_audio_name}</p>
<p>イントロ音源名: ${result.intro_audio_name}</p>
<p>キーワード1: ${result.keyword1}</p>
<p>キーワード2: ${result.keyword2}</p>
<p>キーワード3: ${result.keyword3}</p>
<button onclick="startQuiz(${result.id}, '${result.intro_audio_name}')">クイズを開始</button>
<button onclick="stopAudio()">再生停止</button>
<button onclick="playFullAudio('${result.full_audio_name}')">フル音源を再生</button>
`;
document.getElementById('selectedQuizResult').style.display = 'block';
}
document.addEventListener('DOMContentLoaded', () => {
updateUniqueKeywords();
});
</script>
</head>
<body>
<h1>イントロクイズBOT管理ページ</h1>
<div id="message" style="color: red; margin-top: 20px;"></div>
<button onclick="location.href='/quiz_master_page'">マスタデータ管理ページに移動</button>
<button onclick="location.href='/uploaded_files'">ファイル管理ページに移動</button>
<h2>ボイスチャンネル操作</h2>
<form id="joinForm" onsubmit="handleFormSubmit(event, 'joinForm', '/join')">
<label for="channel_id">チャンネルID:</label>
<input type="text" name="channel_id" id="channel_id" required>
<button type="submit">参加</button>
</form>
<form id="leaveForm" onsubmit="handleAction(event, '/leave')">
<button type="submit">退出</button>
</form>
<div class="form-group">
<label for="quizVolume">クイズ音量:</label>
<input type="text" id="quizVolume" name="quizVolume" value="13">
</div>
<div class="form-group">
<label for="fullAudioVolume">フル音源音量:</label>
<input type="text" id="fullAudioVolume" name="fullAudioVolume" value="3">
</div>
<h2>クイズ出題</h2>
<h4>グループランダム選択</h4>
<select id="uniqueKeywordsList">
<!-- ユニークキーワードはここに動的に挿入されます -->
</select>
<button onclick="drawRandomQuiz(event)">ランダムにクイズを抽選</button>
<button onclick="resetQuiz()">クイズをリセット</button>
<div id="randomQuizResult" style="color: green; margin-top: 20px;"></div>
<h4>タイトル検索</h4>
<form id="searchForm" onsubmit="searchQuizByTitle(event)">
<div class="form-group">
<input type="text" id="searchTitle" name="searchTitle" oninput="searchQuizByTitle(event)" required>
</div>
</form>
<div id="searchResults" style="color: green; margin-top: 20px;"></div>
<div id="selectedQuizResult" style="color: green; margin-top: 20px;"></div>
<h2>ログアウト</h2>
<form id="logoutForm" action="/logout" method="POST">
<button type="submit">ログアウト</button>
</form>
</body>
</html>

17
web/templates/login.html Normal file
View File

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>ログイン</title>
</head>
<body>
<h1>ログイン</h1>
<form method="POST">
<label for="user_id">ユーザーID:</label>
<input type="text" id="user_id" name="user_id" required><br>
<label for="password">パスワード:</label>
<input type="password" id="password" name="password" required><br>
<button type="submit">ログイン</button>
</form>
</body>
</html>

View File

@ -0,0 +1,273 @@
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>クイズマスタデータ管理</title>
<style>
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: inline-block;
width: 120px;
}
.form-group input, .form-group select {
width: calc(100% - 130px);
}
.form-group select {
height: 100px;
}
.form-container, .table-container {
margin-left: 0;
}
.button-group {
text-align: left;
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
padding: 8px;
text-align: left;
border: 1px solid #ddd;
white-space: nowrap;
}
th {
background-color: #f2f2f2;
}
</style>
<script>
let files = [];
let usedFiles = [];
async function fetchFiles() {
try {
const response = await fetch('/uploaded_files_list');
files = await response.json();
await fetchUsedFiles();
filterFiles();
} catch (error) {
console.error('Error fetching files:', error);
}
}
async function fetchUsedFiles() {
try {
const response = await fetch('/used_files_list');
usedFiles = await response.json();
} catch (error) {
console.error('Error fetching used files:', error);
}
}
function filterFiles() {
const fullAudioInput = document.getElementById('full_audio_name_input').value.toLowerCase();
const fullAudioSelect = document.getElementById('full_audio_name_select');
const introAudioSelect = document.getElementById('intro_audio_name_select');
fullAudioSelect.innerHTML = '';
introAudioSelect.innerHTML = '';
const filteredFiles = files.filter(file => file.toLowerCase().includes(fullAudioInput) && !usedFiles.includes(file));
filteredFiles.forEach(file => {
const optionFull = document.createElement('option');
optionFull.value = file;
optionFull.text = file;
fullAudioSelect.appendChild(optionFull);
const optionIntro = document.createElement('option');
optionIntro.value = file;
optionIntro.text = file;
introAudioSelect.appendChild(optionIntro);
});
// タイトルフィールドを更新(拡張子を削除)
const title = fullAudioInput.split('.').slice(0, -1).join('.');
document.getElementById('title').value = title;
// イントロ音源名を自動入力
const extension = fullAudioInput.split('.').pop();
const introAudioName = `${title}_intro.${extension}`;
if (files.includes(introAudioName)) {
document.getElementById('intro_audio_name_input').value = introAudioName;
document.getElementById('intro_audio_name_hidden').value = introAudioName;
}
// キーワード1を自動入力
const keyword1 = fullAudioInput.split(' - ')[0];
document.getElementById('keyword1').value = keyword1;
}
function selectFile(inputId, selectId, hiddenId) {
const select = document.getElementById(selectId);
const input = document.getElementById(inputId);
const hidden = document.getElementById(hiddenId);
input.value = select.value;
hidden.value = select.value;
// タイトルフィールドを更新(拡張子を削除)
if (inputId === 'full_audio_name_input') {
const title = select.value.split('.').slice(0, -1).join('.');
document.getElementById('title').value = title;
// イントロ音源名を自動入力
const extension = select.value.split('.').pop();
const introAudioName = `${title}_intro.${extension}`;
if (files.includes(introAudioName)) {
document.getElementById('intro_audio_name_input').value = introAudioName;
document.getElementById('intro_audio_name_hidden').value = introAudioName;
}
// キーワード1を自動入力
const keyword1 = select.value.split(' - ')[0];
document.getElementById('keyword1').value = keyword1;
}
}
async function handleFormSubmit(event, endpoint) {
event.preventDefault();
const form = event.target;
const formData = new FormData(form);
const payload = {};
formData.forEach((value, key) => {
payload[key] = value;
});
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
});
const result = await response.json();
document.getElementById('message').innerText = result.message || result.error;
if (!result.error) {
form.reset();
fetchQuizMasters();
await fetchUsedFiles();
filterFiles();
}
} catch (error) {
document.getElementById('message').innerText = 'エラーが発生しました: ' + error.message;
}
}
async function fetchQuizMasters() {
try {
const response = await fetch('/quiz_master');
const quizMasters = await response.json();
const quizMasterList = document.getElementById('quizMasterList');
quizMasterList.innerHTML = '';
quizMasters.forEach(qm => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${qm.title}</td>
<td>${qm.full_audio_name}</td>
<td>${qm.intro_audio_name}</td>
<td>${qm.keyword1}</td>
<td>${qm.keyword2}</td>
<td>${qm.keyword3}</td>
<td>
<form onsubmit="handleFormSubmit(event, '/delete_quiz_master')">
<input type="hidden" name="id" value="${qm.id}">
<button type="submit">削除</button>
</form>
</td>
`;
quizMasterList.appendChild(tr);
});
} catch (error) {
console.error('Error fetching quiz masters:', error);
}
}
document.addEventListener('DOMContentLoaded', async () => {
await fetchFiles();
fetchQuizMasters();
});
</script>
</head>
<body>
<div class="form-container">
<h1>クイズマスタデータ管理</h1>
<div id="message" style="color: red; margin-top: 20px;"></div>
<button onclick="location.href='/'">トップページに戻る</button>
<form id="addQuizMasterForm" onsubmit="handleFormSubmit(event, '/add_quiz_master')">
<div class="form-group">
<label for="title">タイトル:</label>
<input type="text" name="title" id="title" required>
</div>
<div class="form-group">
<label for="full_audio_name_input">フル音源名:</label>
<input type="text" id="full_audio_name_input" oninput="filterFiles()">
<select id="full_audio_name_select" onchange="selectFile('full_audio_name_input', 'full_audio_name_select', 'full_audio_name_hidden')" size="5"></select>
<input type="hidden" name="full_audio_name" id="full_audio_name_hidden" required>
</div>
<div class="form-group">
<label for="intro_audio_name_input">イントロ音源名:</label>
<input type="text" id="intro_audio_name_input" oninput="filterFiles()">
<select id="intro_audio_name_select" onchange="selectFile('intro_audio_name_input', 'intro_audio_name_select', 'intro_audio_name_hidden')" size="5"></select>
<input type="hidden" name="intro_audio_name" id="intro_audio_name_hidden" required>
</div>
<div class="form-group">
<label for="keyword1">キーワード1:</label>
<input type="text" name="keyword1" id="keyword1">
</div>
<div class="form-group">
<label for="keyword2">キーワード2:</label>
<input type="text" name="keyword2" id="keyword2">
</div>
<div class="form-group">
<label for="keyword3">キーワード3:</label>
<input type="text" name="keyword3" id="keyword3">
</div>
<div class="button-group">
<button type="submit">追加</button>
</div>
</form>
</div>
<div class="form-container">
<h2>CSVアップロード</h2>
<form action="/upload_quiz_master" method="post" enctype="multipart/form-data">
<div class="form-group">
<label for="file">CSVファイル:</label>
<input type="file" name="file" id="file" accept=".csv" required>
</div>
<div class="button-group">
<button type="submit">アップロード</button>
</div>
</form>
<form action="/import_quiz_master" method="post">
<div class="button-group">
<button type="submit">インポート</button>
</div>
</form>
</div>
<div class="table-container">
<h2>クイズマスタ一覧</h2>
<table>
<thead>
<tr>
<th>タイトル</th>
<th>フル音源名</th>
<th>イントロ音源名</th>
<th>キーワード1</th>
<th>キーワード2</th>
<th>キーワード3</th>
<th>アクション</th>
</tr>
</thead>
<tbody id="quizMasterList">
<!-- クイズマスタ一覧はここに動的に挿入されます -->
</tbody>
</table>
</div>
</body>
</html>