動画サービスをFirestore+CloudFunctionsで勝手に設計してみる ~ NobodySurf編

こんにちは。[もぐめっと](https://mogmet.com)です。
![スクリーンショット 2021-07-15 8.38.47.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/42234/d5d9712d-36d5-57f1-c90f-ea530f64fa4b.png)
最近本当に自分の写真のネタがなさすぎるので昔の写真を引っ張り出したりしてます。普通に滑ってるように見えますが、実は浮いてます。目の錯覚ショットですね。

今回は、昨日apple storeをみたらTodayで紹介されていた[NobodySurf](https://nobodysurf.com/)という超イケてるサーフィン動画をたくさん見れる超イケてるアプリが掲載されていたので、こちらのアプリをもしfirestoreでシステム構築したらどんな構成になるのか？という題材で勝手に設計してみたので僕が考えた設計を紹介します。

## アプリ概要

アプリについて紹介します。
トップからは主にカテゴリ別の動画が見れ、動画詳細で動画をチェックし、気に入れば自分のプレイリストを作って保存することができます。

|TOP|動画詳細|マイページ|
|---|---|---|
| ![IMG_3092.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/42234/0fb2f295-ef86-dfc1-9cc3-86e519674d5f.png) | ![IMG_3083.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/42234/34bed112-d955-8558-3e8c-bf8e7df7077e.png) |![IMG_15579C35E1E0-1.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/42234/a1bab484-169f-ad15-a08f-2e70d4170af3.jpeg)|

検索はサーファー名、カテゴリ、フィン、波の大きさ、スタンス、性別などなどいろんな角度から検索をすることができます。

|検索TOP|検索結果|
|---|---|
|![IMG_972E49B2592C-1.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/42234/08c6afbd-b4a3-71d4-9966-e90a6abc68e7.jpeg)|![IMG_3086.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/42234/06c515d3-7c7b-b87d-279b-5db6dc616891.png)|

## Firestore設計

こんな構造を考えてみました

- movies: サーフィン動画
- surfers: サーファー一覧
- news: お知らせ
- users: アプリを使うユーザ情報
    - playlists: ユーザ保存したプレイリスト情報
        - movies: 実際に保存したmovieのコピー
    - billingTransactions: 購入したときのレシートを保管
    - subscriptions: ユーザのサブスク状況

### movies

movie情報です。
movieのidからストリーミングするURLは生成することとします。

```
/movies/{movieId}
movieId: 自動生成
```

|Field|Type|Description|関連CloudFunctions|
|---|---|---|---|
|createdAt|Timestamp|登録日|
|updatedAt|Timestamp|更新日|
|movieTitle|String|動画タイトル|
|surfers|Map[String: DocumentReference] |サーファー名とそのリファレンス||
|filmers|Map[String: DocumentReference] |撮影者名とそのリファレンス||
|editors|Map[String: DocumentReference] |編集者名とそのリファレンス||
|filmEditors|Map[String: DocumentReference] |撮影者兼編集者名とそのリファレンス||
|presenters|Map[String: DocumentReference] |プレゼンター名とそのリファレンス||
|producers|Map[String: DocumentReference] |プロデューサ名とそのリファレンス||
|supporters|Map[String: DocumentReference] |サポーター名とそのリファレンス||
|narrators|Map[String: DocumentReference] |ナレーション名とそのリファレンス||
|writers|Map[String: DocumentReference] |ライター名とそのリファレンス||
|musics|Array[String]|使われてる音楽名|
|droneFootager|[String] |ドローン映像名||
|countries|[String]|撮影された国||
|locations|[String]|撮影された場所||
|series|String|シリーズ名|
|runTime|Int|動画時間|
|years|Array[Int]|撮影年代|
|surfboards|Array[String]|enum: shortboard, longboard, midLength, fish, softTop, gun||
|fins|Array[String]|enum: finless, singleFin, twinFin, triFin, Bonzer||
|waveSizes|Array[String]|enum: small, chestHead, overhead, big, huge|
|stances|Array[String]|enum: regular, goofy|
|genders|Array[String]|enum: man, woman, unknown|
|otherCategories|Array[String]|enum: youth, legend|
|relatedLinks|Map[String: String]|リンク名とURL|

### surfers

サーファー情報の管理です。

```
/surfers/{surferId}
movieId: 自動生成
```

|Field|Type|Description|関連CloudFunctions|
|---|---|---|---|
|createdAt|Timestamp|登録日|
|updatedAt|Timestamp|更新日|
|surferName|String|名前|
|surferTypes|Array[String]|サーファーだったり、撮影者だったり様々な肩書があるので配列で持ちます。enum: surfer, filmer, editor, fimEditor, presenter, producer, supporter, narrator, writer|
|surfboards|Array[String]|enum: shortboard, longboard, midLength, fish, softTop, gun||
|fins|Array[String]|enum: finless, singleFin, twinFin, triFin, Bonzer||
|waveSizes|Array[String]|enum: small, chestHead, overhead, big, huge|
|stances|Array[String]|enum: regular, goofy|
|genders|Array[String]|enum: man, woman, unknown|
|otherCategories|Array[String]|enum: youth, legend|
|relatedLinks|Map[String: String]|リンク名とURL|

### news

新着情報の表示用

```
/news/{newsId}
newsId: 自動生成
```

|Field|Type|Description|関連CloudFunctions|
|---|---|---|---|
|createdAt|Timestamp|登録日|
|updatedAt|Timestamp|更新日|
|deletedAt|Timestamp|nullの場合は表示しない|
|newsTitle|String|お知らせのタイトル|
|movieTitle|String?|動画タイトル|
|playlistTitle|String?|プレイリストタイトル|
|playlistReference|DocumentReference?|プレイリストのリファレンス|
|message|String?|bannerの場合に表示するメッセージ|
|url|String?|bannerの場合に遷移するURL|
|viewType|String?|enum: card, banner どんな形式でお知らせを見せるか|


newsの取得は deletedAt != nullで表示するものだけ取得します。

https://qiita.com/_mogaming/items/3b94d123b6774842f18a

### users

アプリを使うユーザ管理
下記はそれぞれFirebase Authenticationで解決できるのでここでは管理しません。
認証方法: Firebase Authenticationで管理
ユーザロール: Firebase Authenticationのcustom claimsで管理

Firestore側でなるべく個人情報などのsecureな情報は持たないように設計します。

```
/users/{userId}
userId: firebase authenticationで認証したuid
```

|Field|Type|Description|関連CloudFunctions|
|---|---|---|---|
|createdAt|Timestamp|登録日|
|updatedAt|Timestamp|更新日|
|needsNewsNotification|Boolean|お知らせ通知を送るかどうか|
|favoriteSurfStyle|String|好みのサーフスタイル enum: shortboard, longboard, alternative|
|fcmTokens|Array[String]|通知用のfcmToken配列。これを使ってpush通知を行う|

fcmTokenは今回はユーザ同士の読み取りがないためusersにもたせていますが、本来であればsecureな情報にあたるので、交流するようなSNS的なサービスであれば別途collectionを定義してやったほうがいいです。

### users/playlists

ユーザが保存したプレイリスト

```
/users/{userId}/playlists/{playlistId}
playlistId: 自動生成
```

|Field|Type|Description|関連CloudFunctions|
|---|---|---|---|
|createdAt|Timestamp|登録日|
|updatedAt|Timestamp|更新日|
|playlistTitle|String|プレイリスト名|
|movieCount|Number|保存した動画数|onCreatePlaylistMovie, onDeletePlaylistMovie|


### users/playlists/movies

保存した動画一覧。内容は[movies](#movies)と一緒

```
/users/{userId}/playlists/{playlistId}/movies/{movieId}
movieId: 元のmovieのidと同等
```

### users/billingTransactions

ユーザが課金処理をしてwebhookからレシートを発行されたら保管する。
処理に失敗しても辿れるように必ず最初に保存だけしておく。

```
/users/{userId}/billingTransactions/{billingTransactionId}
- billingTransactionId: Apple: originalTransactionId, Google: orderId
```

|Field|Type|Description|関連CloudFunctions|
|---|---|---|---|
|createdAt|Timestamp|登録日|onRequestSubscriptionApple,onRequestSubscriptionGoogle|
|updatedAt|Timestamp|更新日|onRequestSubscriptionApple,onRequestSubscriptionGoogle|
|purchasedAt|Timestamp|購入日|onRequestSubscriptionApple,onRequestSubscriptionGoogle|
|platform|String|購入したプラットフォームenum: apple,  google|onRequestSubscriptionApple,onRequestSubscriptionGoogle|
|receipt|String| Apple: レシート, Google: json|onRequestSubscriptionApple,onRequestSubscriptionGoogle|
|productId|String|商品ID|onRequestSubscriptionApple,onRequestSubscriptionGoogle|
|userReference|DocumentReference|コレクショングループで調べるよう|onRequestSubscriptionApple,onRequestSubscriptionGoogle|
|validatedData|Map[String: any]|レシートの検証結果の情報をいれておく|onRequestSubscriptionApple,onRequestSubscriptionGoogle|

### users/subscriptions

```
/users/{userId}/subscriptions/{subscriptionId}
- subscriptionId: Apple: originalTransactionId, Google: orderId
```


platformからのwebhookを受け取ってサブスクリプション情報を更新する。
stripeの[この辺を参考](https://github.com/stripe/stripe-firebase-extensions/blob/master/firestore-stripe-subscriptions/functions/src/interfaces.ts)にしてだいたい一緒だろうというのりで作った。

|Field|Type|Description|関連CloudFunctions|
|---|---|---|---|
|createdAt|Timestamp|登録日|onRequestSubscriptionApple,onRequestSubscriptionGoogle|
|updatedAt|Timestamp|更新日|onRequestSubscriptionApple,onRequestSubscriptionGoogle|
|cancelAt|Timestamp?|キャンセル日|onRequestSubscriptionApple,onRequestSubscriptionGoogle|
|canceledAt|Timestamp?|キャンセルした日|onRequestSubscriptionApple,onRequestSubscriptionGoogle|
|currentPeriodStart|Timestamp|サブスク開始日|onRequestSubscriptionApple,onRequestSubscriptionGoogle|
|currentPeriodEnd|Timestamp|サブスク終了日|onRequestSubscriptionApple,onRequestSubscriptionGoogle|
|endedAt|Timestamp?|サブスクを終了した日|onRequestSubscriptionApple,onRequestSubscriptionGoogle|
|trialStart|Timestamp?|お試し開始日|onRequestSubscriptionApple,onRequestSubscriptionGoogle|
|trialEnd|Timestamp?|お試し終了日|onRequestSubscriptionApple,onRequestSubscriptionGoogle|
|price|Number|金額|onRequestSubscriptionApple,onRequestSubscriptionGoogle|
|productId|String|商品ID|onRequestSubscriptionApple,onRequestSubscriptionGoogle|
|status|String|enum: active, canceled, incomplete, incompleteExpired, pastDue, trialing, unpaid|onRequestSubscriptionApple,onRequestSubscriptionGoogle|

ただ、status周りはストアごとにもあったりするのでもう少し精査の必要性あり

## CloudFunctions設計

### onRequestSubscriptionApple / onRequestSubscriptionGoogle

Requestで実装。
Apple/Googleからsubscriptionの更新があった時に発火されるwebhook先のURL。
受け取ったレシートを検証し、subscriptionsやbillingTransactionsレコードを管理する
また、userのclamsもサブスクの状態を見て更新します。

### onCreatePlaylistMovie, onDeletePlaylistMovie

ユーザがプレイリストを追加したり、削除した時に発火。プレイリスト数を増減させる。

## ユースケースを考える

### 動画のストリーミングについて

動画はcloud storageに保管すればストリーミング再生ができそうな雰囲気は醸し出してます。

https://cloud.google.com/storage/docs/streaming?hl=ja

https://stackoverflow.com/questions/37865482/firebase-storage-video-streaming

### 検索について

検索条件がたくさんあるので全部algoriaにお任せです。
ただ、検索キーとして、movieのsurfersやfilmersなどで使ってる名前を別途配列として定義してしまえばalgoriaを使わなくても検索をすることは可能です。（フィールドは検索用にたくさん増えてはしまいますが、algoria使わなくていいので実装コストは下がる）

ただ、最近extensionも出たので、algoriaの実装難易度も大分下がりました。

https://qiita.com/mogmet/items/943c0450957298f007ac

## まとめ

ユーザ同士の交流といった機能はないので、実装コストは大きく削れそうです。
そう考えるとユーザ交流させるサービスのコストって結構高いんだなぁと改めて感じました。

私はスノボが好きなのですが、いずれNobody Snowみたいな雪山版のイケてるサービスも作れたらいいなと密かに思ったりしています。

最後に、[ワンナイト人狼オンライン](https://online.1nite-jinro.com/link/blog)というゲームを作ってます！よかったら遊んでね！

https://online.1nite-jinro.com/link/blog

他にも[Camecon](https://camecon.me/)や[Offcha](https://offcha.com/)といったサービスも作ってるのでよかったら使ってね！

https://camecon.me/

https://offcha.com/

また、チームビルディングや技術顧問、Firebaseの設計やアドバイスといったお話も受け付けてますので御用の方は弊社までお問い合わせください。

https://virapture.com/