はじめに
普段は主にクラウドサービスを使っているのですが、「ローカル環境のマシンをインターネットに公開する場合どうするんだろう」と思ったので勉強を兼ねてやってみました。
長くなったので
- インターネット公開編
- アプリ編
の2つに分けて書きました。これはアプリ編です。
インターネット公開編はこちらです。
アプリ紹介
ボタンを押すと曲(のキック)がランダムに1秒流れるアプリです。
全体構成
全体の構成は上記のような形です。
今記事では主に黄色枠部分について記載します。
環境
- サーバー : Raspberry Pi 5 8GB
- OS : Debian 12
- ルーター : TP-Link Archer AX55
- フロント&バックエンドライブラリ : Reflex
- DB : MariaDB
- CRUD API : Flask
- Webサーバーアプリ : Apache2
- クラウドストレージ : Amazon S3
- SSL証明書 : Let's Encrypt
- DNS : Lightsail DNS
アプリの作成
フロント&バックエンド部
Reflexというライブラリを使います
PythonだけでWebアプリケーションのいフロントエンドとバックエンドを実装できるライブラリです。
「ボタンを押したらランダムに曲が流れる」というのがコンセプトだったので、そんな感じで作りました(雑
Reflexではrx.State
サブクラスを使ってクラスを定義することで、何かしらのアクションからクラス内の変数の値を変えることができます。
私のアプリでは
- ボタンを押す
- get_contentsメソッドが実行
- Flask APIを介してDBにアクセス。メタデータを取得
- メタデータをもとに音楽はS3からデータの署名付きURLを発行
a. ReflexのAudioがURLでの対応な為 - 画像はS3からデータをダウンロードして描画
- アプリに反映
a. ジャケット
b. 楽曲
c. タイトル
d. 楽曲リンク(下部のシェアボタンに影響)
という形でStateを使っています。
class State(rx.State):
image = ""
audio = ""
title = ""
twitter_url = ""
push_text = ""
track_link = ""
processing = False
def get_contents_metadata(self):
url = f'{host_address}:5000/api/get-content'
# ヘッダーの設定
headers = {
'Accept': 'application/json'
}
# GETリクエストを送信
response = requests.get(url, headers=headers)
# レスポンスのステータスコードと内容を確認
if response.status_code == 200:
return response.json()
else:
response.raise_for_status()
def generate_presigned_url(self, uri):
s3 = boto3.client('s3', aws_access_key_id=aws_access_key, aws_secret_access_key=aws_secret_key, region_name='ap-northeast-1')
s3_parts = uri.replace("s3://", "").split("/",1)
bucket_name = s3_parts[0]
object_key = s3_parts[1]
response = s3.generate_presigned_url(
'get_object',
Params={'Bucket': bucket_name, 'Key': object_key},
ExpiresIn=600
)
return response
def get_data(self, uri):
s3 = boto3.client('s3', aws_access_key_id=aws_access_key, aws_secret_access_key=aws_secret_key, region_name='ap-northeast-1')
s3_parts = uri.replace("s3://", "").split("/",1)
bucket_name = s3_parts[0]
object_key = s3_parts[1]
response = s3.get_object(Bucket=bucket_name, Key=object_key)
return response
def get_contents(self):
self.processing = True
yield
contents_metadata = self.get_contents_metadata()
title = contents_metadata['title']
image_uri = contents_metadata['image_uri']
audio_uri = contents_metadata['audio_uri']
link = contents_metadata['link']
image_data = self.get_data(image_uri)['Body'].read()
self.image = Image.open(io.BytesIO(image_data))
self.audio = self.generate_presigned_url(audio_uri)
self.title = title
self.processing = False
self.track_link = link
self.twitter_url = (
f"https://twitter.com/intent/tweet?"
f"text=今日のキックはコレ🔊%0A"
f"{urllib.parse.quote(self.title)}%0D%0A%0D%0A"
f"&url={urllib.parse.quote(link)}%0A"
f"&hashtags=kick_preview"
)
texts = [
"Keep it up!",
"Push it!",
"Keep pressing!",
"Just one more!",
"You’re on fire!",
"Don’t stop now!",
"Keep going!"
]
self.push_text = random.choice(texts)
@rx.page(
title="Kick Preview",
description="One tap, one second of music.",
image="https://home.quark-hardcore.com/images/kp1.png",
meta=meta,
)
def index() -> rx.Component:
# Welcome Page (Index)
return rx.container(
rx.html("""
<head>
<link rel="icon" href="/assets/favicon.ico" type="image/x-icon">
</head>
<div style="
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-image: url('/images/BACKGROUND_LOGO.jpg');
background-repeat: repeat; /* 繰り返し表示 */
background-position: center; /* 中央寄せ */
background-size: contain; /* 画像全体を維持 */
opacity: 0.2;
z-index: -1;">
</div>
"""),
rx.vstack(
rx.image(
src="images/kp1.png",
style={
"mix-blend-mode": "difference",
"max-height": "100%"
}
),
rx.box(style={"height": "4vh",}),
rx.skeleton(
rx.cond(
State.image,
rx.flex(
rx.text(
State.push_text,
color_scheme="yellow"
),
rx.button(
rx.image(
src=State.image,
),
type="submit",
on_click=State.get_contents,
color_scheme="yellow",
style={
"background-color": "transparent",
"justify-content": "center",
"align-items": "center",
"width": "90vw",
"max-width": "500px",
"height": "calc(90vw * 0.98)",
"max-height": "500px",
"margin": "0 auto",
"transition": "all 0.1s ease",
},
_active={
"transform": "scale(0.95)",
"position": "relative",
"top": "2px",
}
),
style={
"flexDirection": "column",
"justify-content": "center",
"align-items": "center",
"width": "90vw",
"max-width": "500px",
"height": "calc(90vw * 0.98)",
"max-height": "500px",
"margin": "0 auto",
}
),
rx.flex(
rx.button(
rx.image(
src="images/yellow_button.png"
),
type="submit",
on_click=State.get_contents,
color_scheme="yellow",
style={
"background-color": "transparent",
"justify-content": "center",
"align-items": "center",
"width": "90vw",
"max-width": "500px",
"height": "calc(90vw * 0.98)",
"max-height": "500px",
"margin": "0 auto",
"transition": "all 0.1s ease",
},
_active={
"transform": "scale(0.95)",
"position": "relative",
"top": "2px",
}
),
style={
"flexDirection": "column",
"justify-content": "center",
"align-items": "center",
"width": "90vw",
"max-width": "500px",
"height": "calc(90vw * 0.98)",
"max-height": "500px",
"margin": "0 auto",
}
),
),
loading=State.processing
),
rx.flex(
rx.vstack(
rx.heading(
State.title,
size="7",
align="center",
style={
"max-width": "100%",
"text-align":"center",
"margin": "0 auto"
}
),
rx.audio(
url=State.audio,
playing=True,
loop=False,
controls=False,
width="100px",
style={
"display": "none"
}
)
),
style={
"display": "flex",
"flex-direction": "column",
"justify-content": "center",
"align-items": "center",
"width": "100%",
"text-align": "center"
}
)
),
rx.flex(
rx.box(style={"height": "4vh",}),
rx.cond(
State.image,
rx.hstack(
rx.link(
rx.button(
rx.icon("twitter", size=80),
style={
"width":"50px",
"height":"50px"
}
),
href=State.twitter_url,
is_external=True,
),
rx.link(
rx.button(
rx.icon("music", size=80),
color_scheme="gray",
style={
"width":"50px",
"height":"50px"
}
),
href=State.track_link,
is_external=True
),
),
rx.text()
),
style={
"display": "flex",
"flex-direction": "column",
"justify-content": "center",
"align-items": "center",
"width": "100%",
"text-align": "center"
}
),
rx.logo(),
style={
"overflow-x": "hidden" # これ入れるとiphoneで見たときに右に変な余白無くなる
},
width="100%"
)
app = rx.App()
app.add_page(index)
フロント側はChatGPTに頼りながらPCとスマホで違和感が無いように作りました。めちゃくちゃツギハギだと思います。
プロキシ設定
Reflexを使うにあたり、Apache2に以下の設定が必要でした
※一部抜粋
# WebSocketの設定
ProxyPass /_event ws://localhost:8000/_event
ProxyPassReverse /_event ws://localhost:8000/_event
ProxyPass /_next/webpack-hmr ws://localhost:3000/_next/webpack-hmr
ProxyPassReverse /_next/webpack-hmr ws://localhost:3000/_next/webpack-hmr
# Reflex静的ファイルのプロキシ設定
ProxyPass /_next/static/ http://localhost:3000/_next/static/
ProxyPassReverse /_next/static/ http://localhost:3000/_next/static/
# Reflexアプリ (ポート3000) にプロキシ
ProxyPass /kick-preview/ http://localhost:3000/
ProxyPassReverse /kick-preview/ http://localhost:3000/
WebSocket通信の為の設定で、2つのディレクトリにProxyPass,ProxyPassReverseを設定しました。
追加、静的ファイルのプロキシ設定と、URL指定のパスからアクセスさせるためのプロキシです。
このあたりはLocalhostでアプリを開き、開発モードでリクエストを確認しながら設定しました。
DB
アプリはデータを取得する際、DBにAPIを介してアクセスしています。
DBはMariaDBを使いました。MySQLを使いたかったですが、arm版がコンテナイメージでの提供しかなかったからです。
Raspberry Pi自体がコンテナ的な心持ちで使えるので、コンテナサービスをその上に入れたくないというのが根本理由です。
インストールします。
$ sudo apt install mariadb-server
$ sudo mariadb
Welcome to the MariaDB monitor. Commands end with ; or \g.
Your MariaDB connection id is 37
Server version: 10.11.6-MariaDB-0+deb12u1 Debian 12
Copyright (c) 2000, 2018, Oracle, MariaDB Corporation Ab and others.
Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.
起動確認します。起動していない場合はsudo systemctl start mariadb
します。
$ sudo systemctl status mariadb
● mariadb.service - MariaDB 10.11.6 database server
テーブル作成
こんな感じのテーブルを1つ作ります
column name | type | 説明 |
---|---|---|
id | bigint | PK |
created_at | timestamp | |
updated_at | timestamp | |
title | string | 曲のタイトルを記載します |
audio_content_uri | string | 曲を保存しているAWS S3 uriを記載します |
image_content_uri | string | 曲のジャケットを保存しているAWS S3 uriを記載します |
link | string | 曲のURLを記載します |
CREATE TABLE IF NOT EXISTS tracks (
id INT AUTO_INCREMENT PRIMARY KEY,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
title VARCHAR(255) NOT NULL,
audio_content_uri VARCHAR(255) NOT NULL,
image_content_uri VARCHAR(255) NOT NULL,
link VARCHAR(255) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
MariaDB [(none)]> CREATE DATABASE kick_preview; # データベース作る
MariaDB [(none)]> exit # 一旦出る
$ mysql -u quark -p kick_preview < tracks_create_table.sql # tracks_create_tableを実行
$ mariadb -u quark -p # 入る
MariaDB [kick_preview]> show create table tracks; +--------+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| Table | Create Table |
+--------+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| tracks | CREATE TABLE `tracks` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`created_at` timestamp NULL DEFAULT current_timestamp(),
`updated_at` timestamp NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
`title` varchar(255) NOT NULL,
`audio_content_uri` varchar(255) NOT NULL,
`image_content_uri` varchar(255) NOT NULL,
`link` varchar(255) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=68 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci |
+--------+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
1 row in set (0.000 sec)
テーブルを作ることができました。
DB API作成
アプリを運用する上で
- ボタンを押したらDBに対してSELECTしてデータを取得する
- DBに新しい曲を入れる
の2つの機能が必要なので、Flaskでそれらの機能の為のAPIを作りました
@app.route('/api/get-content', methods=['GET'])
def get_content():
try:
conn = mysql.connector.connect(
host='localhost',
user=mariadb_user,
password=mariadb_pw,
database='kick_preview'
)
cursor = conn.cursor()
cursor.execute("SELECT title, audio_content_uri, image_content_uri, link FROM tracks ORDER BY RAND() LIMIT 1;")
result = cursor.fetchone()
cursor.close()
conn.close()
if result is not None:
title = result[0]
audio_uri = result[1]
image_uri = result[2]
link = result[3]
res = jsonify({
"title" : title,
"audio_uri" : audio_uri,
"image_uri" : image_uri,
"link": link
})
logger.info(res)
return res
else:
res = jsonify({"error": "Not found data. Please check kick_preview table."})
logger.error(res)
return res
except Exception as e:
logger.error(f"Exception occurred: {str(e)}", exc_info=True)
return jsonify({"error":f"{e}"}), 500
@app.route('/api/put-content', methods=['PUT'])
def put_content():
try:
data = request.get_json()
conn = mysql.connector.connect(
host='localhost',
user=mariadb_user,
password=mariadb_pw,
database='kick_preview'
)
cursor = conn.cursor()
conn.start_transaction()
insert_query = """
INSERT INTO tracks (title, audio_content_uri, image_content_uri, link)
VALUES (%s, %s, %s, %s)
"""
values = (
data.get("title"),
data.get("wave_file_uri"),
data.get("image_file_uri"),
data.get("link")
)
cursor.execute(insert_query, values)
conn.commit()
cursor.close()
conn.close()
return jsonify({"message": "Data inserted successfully"}), 200
except Exception as e:
logger.error(f"Exception occurred: {str(e)}", exc_info=True)
return jsonify({"error":f"{e}"}), 500
@app.route('/api/get-content', methods=['GET'])
アプリのボタンを押すと、"SELECT title, audio_content_uri, image_content_uri, link FROM tracks ORDER BY RAND() LIMIT 1;"
が実行され、情報が渡されます。
楽曲データの挿入
@app.route('/api/put-content', methods=['PUT'])
を実行するためのファイルです。こちらで適切なデータかチェックしているのですが、分けなくてよかったです。
- 音楽は.wavか
- 1秒未満か
- 画像は縦横どちらも500px以上か
- jpeg or pngか
- linkは存在するか
等を確認し、問題が無ければS3に楽曲と画像をアップロードしURI情報を受け取り、Flask APIのPUTを叩きます。
import json
import argparse
import os
import wave
from urllib.request import urlopen
from urllib.error import HTTPError, URLError
from PIL import Image
import boto3
from botocore.exceptions import NoCredentialsError
from dotenv import load_dotenv
import requests
from logger_setup import setup_logger
logger = setup_logger()
load_dotenv()
class ContentUploader:
def __init__(self):
self.aws_access_key = os.getenv('AWS_ACCESS_KEY')
self.aws_secret_key = os.getenv('AWS_SECRET_KEY')
self.s3_bucket_name = "quark-kick-preview-storage"
def get_arg(self) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Kick Preview Content Uploader.")
parser.add_argument('-wfp', '--wave-file-path', required=True, help='Wave Audio File Path. wave length must be under 1 second.')
parser.add_argument('-ifp', '--image-file-path', required=True, help='Image File Path. image file must be JPG or PNG and at least 500x500 pixels.')
parser.add_argument('-t', '--title', required=True, help='Audio Title. Example : Artist Name - Track name')
parser.add_argument('-l', '--link', required=True, help='Audio info link. link must at least start with "http".')
args = parser.parse_args()
if not args.wave_file_path or not args.image_file_path or not args.title or not args.link:
print("Error: Missing required arguments.")
parser.print_help()
sys.exit(1)
return args
def audio_check(self, wave_file_path : str) -> None:
if not wave_file_path.lower().endswith('.wav'):
raise ValueError('The Wave file path must end with ".wav"')
try:
with wave.open(wave_file_path, 'rb') as wave_file:
# チャンネル数、サンプル幅、サンプルレート、フレーム数を取得
channels = wave_file.getnchannels()
sample_width = wave_file.getsampwidth()
frame_rate = wave_file.getframerate()
frame_count = wave_file.getnframes()
# 再生時間を計算
duration = frame_count / float(frame_rate)
logger.info(f"File: {wave_file_path}")
logger.info(f"Channels: {channels}")
logger.info(f"Sample Width: {sample_width}")
logger.info(f"Frame Rate: {frame_rate}")
logger.info(f"Frame Count: {frame_count}")
logger.info(f"Duration: {duration:.2f} seconds")
# 1秒未満かどうかをチェック
if duration >= 1.0:
raise wave.Error(f"Error: {wave_file_path} is {duration:.2f} seconds, which is not under 1 second.")
else:
logger.info(f"Success: {wave_file_path} is under 1 second.")
print("OK")
except wave.Error as e:
raise e
except Exception as e:
raise e
def image_check(self, image_file_path: str) -> None:
if not (image_file_path.lower().endswith('.jpg') or image_file_path.lower().endswith('.png')):
raise ValueError('The image file must be in JPG or PNG format.')
try:
with Image.open(image_file_path) as img:
width, height = img.size # 画像の幅と高さを取得
logger.info(f"File: {image_file_path}")
logger.info(f"Image format: {img.format}")
logger.info(f"Width: {width}px, Height: {height}px")
# フォーマットの確認
if img.format not in ['JPEG', 'PNG']:
raise ValueError(f"Error: {image_file_path} is not a valid JPG or PNG file.")
# 500x500px以上かどうかをチェック
if width < 500 or height < 500:
raise ValueError(f"Error: {image_file_path} is {width}x{height}px, which is less than 500x500px.")
else:
logger.info(f"Success: {image_file_path} is at least 500x500px.")
except ValueError as e:
raise e
except Exception as e:
raise e
def link_check(self, link : str) -> bool:
if not (link.lower().startswith('http')):
raise ValueError('Link must start with "http"')
try:
response = urlopen(link)
if response.status == 200:
logger.info(f"link exists: {link}")
return None
except HTTPError as e:
raise Exception(f"HTTPError: {e.code} for link: {link}")
except URLError as e:
raise Exception(f"URLError: {e.reason} for link: {link}")
except Exception as e:
raise e
def to_s3(self, file_path, s3_key_name) -> str:
try:
s3 = boto3.client('s3', aws_access_key_id=self.aws_access_key, aws_secret_access_key=self.aws_secret_key, region_name="ap-northeast-1")
s3_uri = f"s3://{self.s3_bucket_name}/{s3_key_name}"
s3.upload_file(file_path, Bucket=self.s3_bucket_name, Key=s3_key_name)
logger.info(f"Upload successful: {s3_key_name}")
return s3_uri
except FileNotFoundError as e:
raise e
except NoCredentialsError as e:
raise e
except Exception as e:
raise e
def put_content(self, title, wave_file_uri, image_file_uri, link) -> None:
url = "http://localhost:5000/api/put-content"
headers = {
"Content-Type": "application/json"
}
data = {
"title": f"{title}",
"wave_file_uri": f"{wave_file_uri}",
"image_file_uri": f"{image_file_uri}",
"link": f"{link}",
}
result = requests.put(url, data=json.dumps(data), headers=headers)
print(result)
result.raise_for_status()
logger.info("Put request succeeded.")
return None
if __name__ == "__main__":
UP = ContentUploader()
args = UP.get_arg()
title = args.title; wave_file_path = args.wave_file_path; image_file_path = args.image_file_path; link = args.link
UP.audio_check(wave_file_path); UP.image_check(image_file_path); UP.link_check(link)
wave_file_uri = UP.to_s3(wave_file_path, f"audios/{wave_file_path.split('/')[-1]}")
image_file_uri = UP.to_s3(image_file_path, f"images/{image_file_path.split('/')[-1]}")
UP.put_content(
title,
wave_file_uri,
image_file_uri,
link
)
こんなかんじで実行すればデータを入れることができます。
$ rye run python content_uploader.py \
-wfp "/home/quark/Work/my-kick-preview-db/contents/audio/PRFREE023_Berzark_Can_You_Hear_Me.wav" \
-ifp /home/quark/Work/my-kick-preview-db/contents/image/PRFREE023.jpg \
-t "Berzärk - Can You Hear Me" \
-l "https://prototypesrecords.bandcamp.com/track/can-you-hear-me-prfree23"
一括挿入スクリプト
.csvに以下のようにデータを入れれば、一括で入れることができるスクリプトも作りました。
"Death - Sacrifice", "/home/quark/Work/my-kick-preview-db/contents/audio/PR110_Death_Sacrifice.wav", "/home/quark/Work/my-kick-preview-db/contents/image/PR110.jpg", "https://prototypesrecords.bandcamp.com/track/sacrifice"
"Frenesys - Bassdrum", "/home/quark/Work/my-kick-preview-db/contents/audio/PR086_Frenesys_Bassdrum.wav", "/home/quark/Work/my-kick-preview-db/contents/image/PR086.jpg", "https://prototypesrecords.bandcamp.com/track/bassdrum"
import csv
import subprocess
# コマンドのテンプレート
command_template = [
"rye", "run", "python", "content_uploader.py",
"-wfp", "{audio_path}",
"-ifp", "{image_path}",
"-t", '"{title}"',
"-l", "{link}"
]
csv_file = "import.csv"
with open(csv_file, newline='', encoding='utf-8') as f:
reader = csv.reader(f)
for row in reader:
# CSVのカラムに対応するデータを取り出す
title = row[0]
audio_path = row[1]
image_path = row[2]
link = row[3]
# コマンドを生成
command = [part.format(audio_path=audio_path, image_path=image_path, title=title, link=link)
if '{' in part else part for part in command_template]
subprocess.run(" ".join(command), shell=True)
完成
インターネット公開編との要素を合わせて
$ sudo systemctl start mariadb
$ nohup rye run python api.py > nohup.out 2>&1 &
$ nohup rye run reflex run > nohup.out 2>&1 &
で立ち上げて完成です!
さいごに
今後もこんな感じで面白そうだな~と思ったものをアプリ化しつつ、細かいところも徐々に勉強して行ければと思っています。
ここまで見ていただきありがとうございました。
何かご指摘やアドバイスなどいただけますと嬉しいです。