【Nuxt3 + Python】独学でYouTubeのクローンを作ってみたら意外とうまくいった件

# はじめに
どうもこんにちは、Yamu(@yamu_studio )です。

とある案件でNuxt3に触れる機会があり、主にVue2を扱ってきた自分をステップアップさせるために今回は、勉強として<strong>YouTube</strong>のクローンをつくってみました。

この記事では、
- Nuxt3でWebアプリの作り方
- ページ遷移やコンポなどの使い方
- PugとBulmaの使い方
- FastAPIの使い方
- APIでデータのあれこれ

を解説していますのでよかったらみていってください！

NuxtやTypeScript、フロントエンドエンジニアの方はぜひ読んでほしい
- 何かプログラミングで作ってみたいという人
- 勉強中の人
- 暇してるエンジニアさん
___

# できるやつの概要
![スクリーンショット 2023-12-05 22.34.39.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2515412/bcd85a66-4fe0-b248-bcf0-d9d339d12b51.png)

## できたもの
今回作ったページは、[^1]
- Home(上の写真、最初のページ)
- 動画ページ
- チャンネルページ
- アップロードのページ
[^1]: クローンなのであくまでフロント部分がメインです。実際のYouTube機能をフルで実装はしていません。

## 機能について
- 動画カードが表示される
- APIからデータ取得する
- 登録者や再生回数によって表示が変わる

## できるようになったこと
- 基本的なNuxt3
- FastAPIのあれこれ
- DB ↔︎ API ↔︎ フロント の基本的な流れ

詳しいソース・解説はこのブログで！

https://raimuproject.com/posts/itZhcEEKPbIZA7ijP2NYViTnoeM4MohD

# つくっていく
今回自分は、Mac(Mac Air)で開発しています。
また、自分のNuxt3とPythonの環境は以下になります。
- Nuxt3 : 3.8.2
- Python : 3.11.6
- Docker Compose : v2.23.3-desktop.2

## Home を作る
まずは構造を把握します。
![Hme.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2515412/99d0968b-4a80-58d2-e5ec-b426a332cbd5.png)

一般的にサイトは、
- ヘッダー
- サイドメニュー
- メイン
に分かれていますがこのページはモロそれで、単純に上のヘッダー部分、左のサイドメニュー部分、そしてメインのジャンルたちと動画カードを並べる、といった感じですね。

ヘッダーはよく見ると、左のロゴ、真ん中に検索関連、右にプロフィールなどのアイコンとなっていますので、各まとまりを「space-between」でやればいけそうですね。[^2]
ボタンの丸まりはBulmaの「is-rounded」を使っています。
※以降、HTML部分はPugを使用しています。

[^2]:.content-row-space-betweenなどは別途scssファイルで定義しています。
```js
header#mainHeader
  nav.navbar.content-row-space-between
    .content-row-space-left
      .navbar-item.navbar-burger.m-2(:class="{'is-active':bugerActive}" @click="bugerClick")
        span
        span
        span
      .navbar-item
        NuxtLink(:to="'/'")
          img.image(src="/logo_yp.png")
      
    .content-row-space-left
      .navbar-item.control.has-icons-right
        input.input#inputLeftRounded(placeholder="検索" v-model="searchWord")
        button.button#searchLightRounded(@click="doSearch")
          span.icon
            i.fas.fa-lg.fa-solid.fa-magnifying-glass
      .navbar-item
        button.button.is-light.is-rounded(@click="visibleModal(0)")
          span.icon
            i.fas.fa-lg.fa-solid.fa-microphone

    .content-row-space-left
      .navbar-item
        button.button.is-white.is-rounded(@click="visibleModal(1)")
          span.icon
            i.fas.fa-lg.fa-solid.fa-video
      .navbar-item
        button.button.is-white.is-rounded(@click="visibleModal(2)")
          span.icon
            i.fas.fa-lg.fa-bell(v-if="alertModalActive")
            i.far.fa-lg.fa-bell(v-else)
      .navbar-item
        button.button.is-primary.is-inverted.is-rounded(@click="visibleModal(3)")
          img(src="/channelImg.png")
```
ここでちょこっと「Bulma」を解説します！
Bulmaとは、デザインやレイアウトを効率的に、簡単に作成するためのCSSフレームワークです。
もっと簡単にいうと、すでに整えられたcssを"クラス"を指定するだけで使えるようになるやつ、です。
上の例で言うと、ボタンが
```button.button.is-primary.is-inverted.is-rounded(~```
となっていましたが、buttonにこのクラス「button」をつけることで「いい感じのボタンのデザイン」になり、ほかにも「is-primary」をつけると「黄緑っぽい色がつく」になります。
こんな感じで0から
```css
.button{
  padding: 8px;
  margin: 4px;
  background-color: #3cff80;
  ~~~
}
```
と書かずともいい感じのデザインに仕上げることができます。

細かい部分はブログのソースをご覧ください。[^blog]
[^blog]:詳細はこちらのブログから！ https://raimuproject.com/posts/itZhcEEKPbIZA7ijP2NYViTnoeM4MohD

サイドメニューはシンプルに上から並べるだけで良さそうですね。
通常、リンクは```<a>```を使いますが、Nuxt3では```<NuxtLink>```を使います。
バーガー(三本線のやつ)クリック時には今回モーダルで対応しました。上に画面がもう一個出てくるやつで、「あなたは18歳以上ですか？」で出てくるやつの同じようなものです。
これもBulmaの「.modal~」で対応しています。
また基礎的な部分でいうと、登録チャンネルの部分は```v-for```を使って要素を繰り返しています。
```js
    ul.menu-list.border-bottom-light.m-3
      li.pl-3.pr-3
        NuxtLink.p-2
          .content-row-space-left 
            span.icon
              i.fas.fa-lg.fa-regular.fa-house
            p.subtitle.is-size-7.m-0.pl-5 ホーム

      li.pl-3.pr-3
        NuxtLink.p-2
          .content-row-space-left 
            span.icon
              i.fas.fa-lg.fa-regular.fa-circle-play
            p.subtitle.is-size-7.m-0.pl-5 ショート

      li.pl-3.pr-3
        NuxtLink.p-2
          .content-row-space-left 
            span.icon
              i.fas.fa-lg.fa-tv
            p.subtitle.is-size-7.m-0.pl-5 登録チャンネル
        
    ul.menu-list.border-bottom-light.m-3
      li.pl-3.pr-3
        NuxtLink.p-2
          .content-row-space-left 
            p.subtitle.is-size-7.m-0 マイページ 
            span.icon
              i.fas.fa-lg.fa-solid.fa-angle-right

      li.pl-3.pr-3
        NuxtLink.p-2
          .content-row-space-left 
            span.icon
              i.fas.fa-lg.fa-regular.fa-address-card
            p.subtitle.is-size-7.m-0.pl-4 登録チャンネル

    p.title.is-size-7.pt-3 登録チャンネル
    ul.m-3
      li.pl-3.pr-3(v-for="ch in subscedChannelList" :key="ch.channelID")
        NuxtLink
          ChannelCard(:ch="ch")
```

メインの部分ですがソースはめちゃくちゃスカスカです。
ジャンルのボタンは横並びにして、動画カードはコンポーネント(部品)として作りそれを```v-for```しています。


```js
#homeView
  .tabs.content-row-space-left.p-3.m-0.janruTab
    button.button.is-small.mr-3(v-for="janru in janruList" :key="janru.cd" :class="[janru.cd == nowJanru ? 'is-black':'is-light']") {{ janru.title }}
    
  ul.columns.is-multiline.p-4.pt-6
    li.column.is-one-third(v-for="mv in TopMovieList")
      MovieCard(:movie="mv")
```

動画のカードは、上全体にサムネイルを置いて、下は左からチャンネルのプロフ画像、タイトルとかの情報のまとまりで並べて、中身もそんな感じに並べていきます。
僕の基礎的な考え方ですが、「横並びのかたまり」を縦に並べて、、、と要素を並べるようにしています。

できたページはこんな感じです。
![スクリーンショット 2023-12-05 22.34.39.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2515412/b20b7431-6e66-23ce-27b1-391057c3325e.png)

## 動画ページを作る
![スクリーンショット 2023-12-14 9.19.12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2515412/16dd9af9-8cf9-15f9-3b35-61ac47ff5fcd.png)
構造を見てみると、
- ヘッダー(サイドメニューも)
- 動画の場所
- 動画詳細
- コメント欄
- 関連動画と広告
に分かれています。

そして、ページは「動画についての要素」左の大部分で、右側に「関連動画など」になっています。これはBulmaの「tiles」で対応しています。複雑な２次元レイアウトを構築するときによく使います。
今回は左の大部分：関連動画たちを8:4で実装しました。

```js
#channelView
  .tile.is-ancestor.p-3(v-if="!isLoading")
    .tile.is-parent.is-vertical.is-8
      .is-child
        .content
          video.contentRounded(
            id="movieBox"
            controls
            v-if="movieData.movie != null && movieData.movie != ''"
            v-on:loadedmetadata="loadingMovie()"
            v-on:play="onPlay()"
            v-on:ended="onEnded()"
            controlsList="nodownload"
            oncontextmenu="return false;"
            muted
            playsinline)
            source(
              :src="movieData.movie"
              type='video/mp4; codecs="avc1.42E01E, mp4a.40.2"')

      .is-child.has-text-left.p-3
      
      〜〜〜〜

    .tile.is-parent.is-vertical.p-2.pl-0
```

また、本家のページはURLの「v=●●●」の部分
```https://www.youtube.com/watch?v=●●●```
で動画が特定されます。なのでこの●の部分(動画ID)からデータを引き出し、その内容を表示してます。そのため、このページのソースは、```pages/watch.vue```に書いていきます。

まず、ヘッダー系はさっき使ったものを再利用できますね。同じように関連動画の部分もさっきの動画カードを再利用できます。このように再利用できそうなもの(部品として何回も使われそうなもの)はコンポーネントとして作ると、色んな場面で簡単に利用できるようになります。
```components```フォルダーにその部品のvueファイルを作ればコンポーネントは(ほぼ)完成です。[^blog]

動画の部分は、動画の機能(字幕や5秒スキップ等)は一旦飛ばして単純に動画をボンっと配置しました。
下のタイトルはそのまま、チャンネルやいいねのところはよく見ると
- チャンネル情報
- 高評価などのボタン
のまとまりが左右に分かれているので```justify-content: space-between```でできます。
```js
.content-row-space-between
  .content-row-space-left 
    figure.image.is-32x32.m-1
      img.is-rounded(:src="movieData.channel.thumbnail")
    .has-text-left
      p.title.is-size-7.cutMaxLength#movieChannelName {{ movieData.channel.name }}
      p.subtitle.is-size-7 チャンネル登録者数 {{ $common.millBillUnit(movieData.channel.subscribers) }}人
    button.button.is-black.is-rounded.p-2 チャンネル登録

  .content-row-space-right 
    button.button.leftRounded
      span.icon
        i.fa-lg.fa-regular.fa-thumbs-up
      span {{ $common.millBillUnit(movieData.goods) }}
    button.button.rightRounded
      span.icon
        i.fa-lg.fa-regular.fa-thumbs-down
    button.button.is-rounded.m-1
      span.icon
        i.fa-lg.fas.fa-share
      span 共有
    button.button.is-rounded.m-1
      span.icon
        i.fas.fa-lg.fa-solid.fa-ellipsis
```
コメント部分も同じようにできますね！[^blog]

こんなページになりました。
![スクリーンショット 2023-12-05 22.34.52.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2515412/2d12c61c-b5b1-ff34-d2ee-3733e5f5770c.png)
![スクリーンショット 2023-12-05 22.34.59.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2515412/29ab957b-01de-85ad-49c4-ca5c3d47c894.png)

## チャンネルページを作る
![スクリーンショット 2023-12-14 9.17.45.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2515412/172acf8f-eac9-142a-8b66-890517608d75.png)

実はやっていることほぼ動画詳細と同じです。
まずは構造理解してみると、
- ヘッダーとサイドバー
- でかい画像
- チャンネル情報
- 「ホーム」とかの切り替えタブ
- タブごとの中身
になっています。

新しく出てきた要素だとまず「タブ」部分ですが、こちらもBulmaの「tabs」を使用しています。これを使えば簡単にタブを構築することができます。親要素に「tabs」クラスをつけ、その中にul、liとすればOKです。選択中の要素には「is-active」クラスをつければOKです。
```js
.tabs
  ul
    li(:class="{'is-active':tabNo == 0}")
      NuxtLink(@click="moveTag(0)") ホーム
    li(:class="{'is-active':tabNo == 1}")
      NuxtLink(@click="moveTag(1)") 動画

    ~~~
```

そして、選択されたタブに対応した要素を下で```v-if```で出し分ければOKです。

できたページはこちらです。
![スクリーンショット 2023-12-14 12.08.08.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2515412/89df288e-cd0d-e238-5eb9-6a93e7743254.png)

## アップロードページを作る
ここはあくまで「FastAPIで動画をアップロード」を実装したくて作ってみました。
流れとしては、```input(type="file" ~)```で動画を選択したらAPIにその動画データをPOST(送信)し、そのデータをデータベースに保存しています。
どれもシンプルになっていて、inputで画像が選択されたらそのファイルをFormDataにいれ、API(今回はローカルで立ち上げたエンドポイント「http://127.0.0.1:8000/movie/upload」)にPOST送信しています。
```js
let file = event.target.files[0];
const formdata = new FormData();
formdata.append("file", file);

formdata.append(
    "movie",
    JSON.stringify({
      movie_id: "aawwa",
      channel_id: 2,
      title: "タイトル",
      description: "概要欄",
    })
);

const { data, error } = await useFetch(`http://127.0.0.1:8000/movie/upload`, {
    method: "POST",
    body: formdata,
});
```

そして受け取ったデータを元にFastAPI側でDBにInsertします。
```python
# エンドポイント
@router.post("/movie/upload", response_model=y_schemas.Movie)
async def upload_file(movie: Json = Form(), file: UploadFile = File(...), db: Session = Depends(get_db)):
    # 一旦読み込み必要
    movieFile = await file.read()
    
    res_movie = y_crud.create_movie(db, movie, movieFile)
    return res_movie

def create_movie(db: Session, movie, file):
    new_movie = y_model.Movie()
    new_movie.movie_id = movie["movie_id"]
    new_movie.title = movie["title"]
    new_movie.description = movie["description"]
    new_movie.channel_id = movie["channel_id"]
    new_movie.created_at = datetime.datetime.now()
    new_movie.updated_at = datetime.datetime.now()
    new_movie.src = file

    db.add(new_movie)
    db.commit()
    db.refresh(new_movie)
    return new_movie
```

スキーマやモデルは実際のソースはブログから飛べるので気になる方はみてみてください。[^blog]
DBは以下のようなデータが追加されます。

| id | movie_id | janru_id | title | description | thumbnail | src | created_at | updated_at | channel_id |
|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|
| 1 | aaaaa  | 1 | タイトル  | 概要欄  | null  |  (データ) |  日付(タイムスタンプ) |  日付(タイムスタンプ) | 1(チャンネルのkey id) |

※動画のインサイト(再生回数とか)は別テーブルで管理します。

これで動画のアップロード機能が実装できました。
また、動画情報を(動画ページで)取得する際は動画のIDを指定して、
```js
const { data: resMovData, error: resMovError } = await useFetch(
  `http://127.0.0.1:8000/movies/${movieID}`,
  {
    method: "GET",
    headers: {
      "content-type": "application/json",
    },
  }
);
```
とAPIを呼び出します。API側はその動画IDに該当するデータを返せばいいので、
```py
# エンドポイント
@router.get("/movies/{movie_id}", response_model=y_schemas.Movie)
def get_movie(movie_id: str, db: Session = Depends(get_db)):
    movie = y_crud.get_movie(db, movie_id=movie_id)
    return movie

def get_movie(db: Session, movie_id: str):
    return db.query(y_model.Movie).filter(y_model.Movie.movie_id == movie_id).first()
```
で取得できます。ほかにもAPIは設定しているので気になる方はソースをご覧ください！[^blog]

できたページ(モーダル)はこんな感じです。
![スクリーンショット 2024-01-11 16.48.43.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2515412/1d6ece2e-93ba-d9ec-5532-8a01d869395b.png)

# 感想・まとめ
どうだったでしょうか？YouTubeのよくみる３ページをクローンしてみましたが、基本的なUI部分はほぼBulmaを使ったので自分でcssを書く機会はめちゃくちゃ少なくなりました。
後半のAPIの作成やAPIのデータの受け取り・送信はちょっと難しいと感じる方も多いかと思います。
今後もこのようなクローンを作って解説したり、初心者に向けたNuxt3,Pythonなどの解説をしますのでよかったらフォローやお気に入りをお願いします！

同じような内容ですがブログもやっているのでほかの解説を見たい方はぜひいらしゃってください。

https://raimuproject.com/