自宅のサーバーをインターネットに公開してWebアプリを作ってみた（アプリ編）

## はじめに

普段は主にクラウドサービスを使っているのですが、「ローカル環境のマシンをインターネットに公開する場合どうするんだろう」と思ったので勉強を兼ねてやってみました。

長くなったので

- インターネット公開編
- アプリ編

の2つに分けて書きました。これは**アプリ編**です。
インターネット公開編は[こちら](https://qiita.com/nijigen_plot/items/0add0092d7b24fe4e4e6)です。

## アプリ紹介

https://home.quark-hardcore.com/kick-preview/


<iframe width="500" height="700" src="https://www.youtube.com/embed/lsmtIWlziEE" title="ガバキックが沢山聴けるアプリを作りました #shorts" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>

ボタンを押すと曲（のキック）がランダムに1秒流れるアプリです。


## 全体構成

![system_architecture_2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/635079/b94227c2-97b1-4ca1-4b43-b894990dd733.png)


全体の構成は上記のような形です。
今記事では主に黄色枠部分について記載します。

## 環境


- サーバー : [Raspberry Pi 5 8GB](https://raspberry-pi.ksyic.com/main/index/pdp.id/1015,1016,1062,1063,1064,1056,1057,1058/pdp.open/1016/)
    - OS : Debian 12
- ルーター : [TP-Link Archer AX55](https://www.tp-link.com/jp/home-networking/wifi-router/archer-ax55/)
- フロント&バックエンドライブラリ : Reflex
- DB : MariaDB
- CRUD API : Flask
- Webサーバーアプリ : Apache2
- クラウドストレージ : Amazon S3
- SSL証明書 : Let's Encrypt
- DNS : Lightsail DNS

## アプリの作成

### フロント&バックエンド部

Reflexというライブラリを使います

https://reflex.dev/

PythonだけでWebアプリケーションのいフロントエンドとバックエンドを実装できるライブラリです。
「ボタンを押したらランダムに曲が流れる」というのがコンセプトだったので、そんな感じで作りました（雑


https://reflex.dev/docs/getting-started/basics/#displaying-data-that-changes-over-time

Reflexでは`rx.State`サブクラスを使ってクラスを定義することで、何かしらのアクションからクラス内の変数の値を変えることができます。

私のアプリでは

1. ボタンを押す
2. get_contentsメソッドが実行
3. Flask APIを介してDBにアクセス。メタデータを取得
4. メタデータをもとに音楽はS3からデータの署名付きURLを発行
    a. [ReflexのAudio](https://reflex.dev/docs/library/media/audio/)がURLでの対応な為
5. 画像はS3からデータをダウンロードして描画
6. アプリに反映
    a. ジャケット
    b. 楽曲
    c. タイトル
    d. 楽曲リンク(下部のシェアボタンに影響)

という形でStateを使っています。


```python
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に以下の設定が必要でした

※一部抜粋
```conf:/etc/apache2/sites-available/000-default-le-ssl.conf

        # 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自体がコンテナ的な心持ちで使えるので、コンテナサービスをその上に入れたくないというのが根本理由です。

https://mariadb.org/


インストールします。
```bash
$ 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`します。

```bash
$ 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を記載します |

```sql:tracks_create_table.sql
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;
```

```bash
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を作りました

https://flask.palletsprojects.com/en/3.0.x/


```python
@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を叩きます。

```python:content_uploader.py
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
    )
    
```

こんなかんじで実行すればデータを入れることができます。

```bash
$ 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に以下のようにデータを入れれば、一括で入れることができるスクリプトも作りました。

```csv:import.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"
```

```python:batch_uploader.py
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)
```

## 完成

[インターネット公開編](https://qiita.com/nijigen_plot/items/0add0092d7b24fe4e4e6)との要素を合わせて

```bash
$ sudo systemctl start mariadb
$ nohup rye run python api.py > nohup.out 2>&1 &
$ nohup rye run reflex run > nohup.out 2>&1 &
```
で立ち上げて完成です！

### さいごに

今後もこんな感じで面白そうだな~と思ったものをアプリ化しつつ、細かいところも徐々に勉強して行ければと思っています。
ここまで見ていただきありがとうございました。
何かご指摘やアドバイスなどいただけますと嬉しいです。