Rails ✕ Reactで動画投稿機能を作ってみた

## 概要
おはこんばんにちわ。お疲れ様です。
備忘録ついでに初投稿。
RailsとReactを使って動画投稿機能を作った + "shrine"ってものに触れたので、そっと置いておこうと思います。
つたない部分もあるかと思いますがよろしくお願いします。(ご指摘待ってますね)

## 環境
- Rails 6
- React 17
- gem shrine 3.0
- aws-sdk-s3 1.113.0

## shrineとは
簡単にいうとファイルアップロードのための便利なツールキットです。
詳しくという方は、いろんな記事があるので読んでいただけたらと思います。

ここでは、雑に公式のリンクだけ貼っときますね。
[Shrine公式](https://shrinerb.com/)

# 始める前に
今回はUserの中のカラムに入れたいと思います。
動画はAmazon S3に置きます。
ローカルではminioを使います。
環境構築は各々でお願いします。

## テーブル作成   
```schema.rb
    create_table "users", force: :cascade do |t|
      ~ 省略 ~
      t.text "video_data"
      t.datetime "created_at", null: false
      t.datetime "updated_at", null: false      
    end
```
動画にかかわるカラムは"video_data"の部分になります。
(カラム名)_dataにしてください。(画像だったら"image_data"など)

また、保存されるときになが～い文字列が送られるので、string型だと文字数制限に引っ掛かります。
よって、ここはtext型が適切です。

## 設定ファイル作成
```config/initializers/shrine.rb
require "shrine"
require "shrine/storage/file_system"
require 'shrine/storage/s3'

~ 省略 ~

#ファイルのURLの設定
Shrine.plugin :url_options, video_cache: url_options, video_store: url_options

#アップロードするディレクトリの指定
Shrine.storages = {
  video_cache: Shrine::Storage::S3.new(prefix: 'movies/cache/video', **s3_options),
  video_store: Shrine::Storage::S3.new(prefix: 'movies/store/video', **s3_options),
}

#プラグインの設定
Shrine.plugin :activerecord           
Shrine.plugin :cached_attachment_data 
Shrine.plugin :restore_cached_data    
Shrine.plugin :determine_mime_type, analyzer: :marcel
Shrine.plugin :validation_helpers
```
今回、自分は前述の通りS3とminioを使用しています。
"s3_options"と"url_options"は環境によって変更するようにしていますが詳しい設定などは省きます。

5つプラグインを設定しましたが簡単に説明します。
- Shrine.plugin :activerecord
Shrineにアクティブレコードとの統合を有効にするプラグイン。これにより、Shrineはアクティブレコードモデルと連携してファイルアップロードを管理できるようになります。

- Shrine.plugin :cached_attachment_data
アップロードされたファイルのメタデータや一時的なキャッシュをデータベースに保存するプラグイン。これにより、ファイルの情報を簡単に取得できるようになります。

- Shrine.plugin :restore_cached_data
ファイルがアップロードされた後に一時的なキャッシュに保存されたデータを元に戻すプラグイン。ファイルが失われた場合でも、元の情報を復元できるようになります。

- Shrine.plugin :determine_mime_type, analyzer: :marcel
ファイルのMIMEタイプを決定するためのプラグイン。:marcelというアナライザーを使用して、ファイルの内容からMIMEタイプを自動的に判定します。これにより、正確なMIMEタイプを取得できます。

- Shrine.plugin :validation_helpers
ファイルのバリデーションヘルパーを有効にするプラグイン。ファイルのバリデーションを行うことで、不正なファイルのアップロードを防止できます。

それでは、Shrineの設定も終わりましたので次にuploaderファイルを作成します。

```app/uploaders/video_uploader.rb
class VideoUploader < Shrine
  #プラグインの設定
  plugin :add_metadata
  #cacheとstoreの部分はshrine.rbで設定した名前を設定する
  plugin :default_storage, cache: :video_cache, store: :video_store

  #バリデーションの設定
  Attacher.validate do
    validate_max_size 500.megabytes, message: "500MB超えてるよ。小さくしてね。"
    validate_mime_type_inclusion %w(video/mp4), message: "MP4にしてね。"
  end
end
```
ここにもプラグインを設定しましたので、軽く説明します。

- plugin :add_metadata
ファイルメタデータを追加するためのプラグイン。ファイルがアップロードされた際に、そのファイルに関する追加のメタデータを取得し保持できるようになります。これにより、アップロードされたファイルに関する情報を容易に抽出できます。

- plugin :default_storage, cache: :video_cache, store: :video_store
デフォルトのストレージを設定するためのプラグイン。cacheとstoreの両方のストレージを設定しています。cacheはファイルの一時的な保存に使用され、storeはファイルの永続的な保存に使用されます。

バリデーションの'message'の部分は、実際にバリデーションが発生したときにビューに出力できる内容です。


## Model設定
```app/models/user.rb
class User < ApplicationRecord
  #カラム'video_data'に対してUploaderを設定
  include VideoUploader::Attachment(:video) # (:video_data)にしない！
end
```
'(:video)'の部分を'(:video_data)'にしないのはShrineの仕様上とでも認識しといてください。

また、今回はshrineを使用して保存する動画が1つだけですが
仮に複数該当するカラム(video1_data, video2_dataなど)がある場合は、1つのuploader.rbを使いまわすことができます。

その場合の例を添えておきます。
```app/models/user.rb
class User < ApplicationRecord
  # VideoUploaderを使いまわす場合
  include VideoUploader::Attachment(:video1)
  include VideoUploader::Attachment(:video2)
end
```

## Reactでフォームを作成
controllerの部分は省きます。
さて、ようやくフォームの部分にやってきました。もう少しで完成です。

```app/views/new.html.haml
// @user_videoの部分には該当するuserのvideo_dataのurlを含めています。
// video_dataに保存されていれば、@user.video.urlなどで取得できるので
// video_url: @user.video.urlのような形で入れるといいかも
= react_component('Form', user_video: @user_video)
```

```app/javascript/components/Form.tsx
import React, { useState, useCallback, useRef } from 'react';
import { useDropzone } from 'react-dropzone';
import { csrfToken } from '@rails/ujs';

type Video = {
  video_data: any,
  video_url: string,  
};

type Form = {  
  user_video: Video,  
};

type VideoForm = {
  onVideoDelete: Function,
  video: Video, 
};

// Formのコンポーネント
const Form = (props: Form) => {
  const { user_video } = props;

  //データがない場合の初期値
  const initialMovie = {
    video_data: '',
    video_url: '',    
  };
    
  const [video, setVideo] = useState<Video>(user_video ? user_video : initialVideo);
  const onVideoDelete = () => setVideo(initialMovie);
  
  return (
    <div>
      //動画ファイルを扱うため'encType="multipart/form-data"'を設定しておく
      //設定しないとファイルの名前だけ送られて保存できない
      <form action="./" method="POST" encType="multipart/form-data">        
        <input
          type={'hidden'}
          name={'authenticity_token'}
          defaultValue={csrfToken()}
        />
        
        <div>              
          <label>動画</label>
          <VideoForm
            onVideoDelete = {() =>onVideoDelete()}
            video = {video}                    
          />
        </div>
        
        <div>
          <input
            type={'submit'}
            name={'commit'}
            value={'保存'}          
          />
        </div>
      </form>
    </div>
  )
};

// 動画のコンポーネント
const VideoForm = (props: VideoForm) => {
  const {
    onVideoDelete,
    video,    
  } = props;

  const [videoUrl, setVideoUrl] = useState(video.video_url);
  const inputElement = useRef(null);
  const [error, setError] = useState<string[]>();
  const [isLoading, setIsLoading] = useState(false);

  const onChangeInputFile = (e:any, setState) => {
    //ファイルが選択、またはドラッグ＆ドロップされてきたとき
    if (e.target.files && e.target.files[0]) {
      const file = e.target.files[0]
      const reader = new FileReader()
      reader.onload = (e: any) => {
        setState(e.target.result)
      }
      reader.readAsDataURL(file)
    } else {
      // '削除'がクリックされたとき
      if (!value) return
      onVideoDelete();
      setError()
      setVideoUrl()
      let videoInputForm = document.getElementById("videoFile");
      videoInputForm.value = "";
    }
  };

  const setInputFiles = (acceptedFiles, fileInputElement) => {
    const dataTransfer = new DataTransfer()
    dataTransfer.items.add(acceptedFiles[0])
    fileInputElement.current.files = dataTransfer.files
  };

  const setFileInputAndPreview = (acceptedFiles, fileInputElement, setState) => {
    if (!acceptedFiles[0].type.startsWith('video/')) {
      return
    }
    if (acceptedFiles[0].size > 500 * 1024 * 1024) {
      setError({
        message: "500MBを超えてるよ。",
      })
      setIsLoading(false)
      return
    } else {
      setIsLoading(true)
      setError()
    }
    setInputFiles(acceptedFiles, fileInputElement)
    //ブラウザより動画ファイルの容量が大きい場合に読み込まれないときがあるので
    //動画ファイルのURLを生成してセットする
    let blobURL = URL.createObjectURL(acceptedFiles[0]);
    setState(blobURL)
    setIsLoading(false)
  };

  const onDrop = useCallback(acceptedFiles => {
    setFileInputAndPreview(acceptedFiles, inputElement, setValue)
  }, []
  );

  const { getRootProps, getInputProps } = useDropzone({
    onDrop,
    multiple: false,
    accept: '.mp4',
  });

  return (
    <>
      <div>
        <div onClick={ onChangeInputFile }>削除</div>
      </div>

      <div {...getRootProps()}>
        <div>
          <div>
            ここに動画をドロップ
            <span>または</span>
          </div>
          <div>
            <label>
              <input
                type={"file"}
                accept={'.mp4'}
                name={"user[video]"}
                src={videoUrl}
                onChange={(e) => onChangeInputFile(e, setVideoUrl)}
                {...getInputProps()}
                ref={inputElement}
                id={'videoFile'}
              />

              <div>クリックで動画を選択</div>

              {isLoading ? (
                <div>
                  <span>読み込み中...</span>
                </div>
              ) : ('')
              }

              {errorMessage && (
                <div>{error.message}</div>
              )}
            </label>
          </div>

          <div>
            {videoUrl && <video src={videoUrl} controls width='100%'/>}
          </div>
        </div>
      </div>
    </>
  )
};

export default Form;
```

## 個人的にハマった箇所
- encType="multipart/form-data"
Ralisのform_withでいうところだと'multipart: true'にあたるところです。
始めは入れていなかったのでパラメータでファイル名の文字列だけが送られてきて発狂してました。

- 動画ファイルをフォームに挿入するときにURLを生成
400MB以上のファイルを選択したときに、無言でエラーもないが動画が読み込まれない現象が発生して泡吹いてました。
簡単に言うと、最初は動画の生データをフォームにそのまま置いていましたが、ブラウザ側の制御で大きすぎるデータはクラッシュの原因になるからセーフティがかかっているみたいですね。
副産物として、URLで扱うことでフォームに挿入したときの動画の読み込みも早くなりました。:)
ちなみに画像データだとURLを生成した場合、逆に読み込みが遅かったりしました。:(

- フォームの動画情報の削除
React側の話ですが、始めは'setVideoUrl'だけを空にしていたら、削除をクリックしても操作によっては反応せず、尚且つエラーもでない現象が発生して空を見上げていました。
これは、'videoInputForm.value = "";'で該当するフォームの情報を削除するようにしたら解決することができました。
どうやら'setVideoUrl'だけを空にしただけでは中途半端にデータが残るだか何だかで変な挙動となるようです。

## 最後に
省略しているところもありますが、大体こんな感じで自分は実装できました。
足りない部分などは皆さんの環境やコードに合わせてカスタマイズしていただければと思います。

TEXTなどと違って多少ややこしいですね。
ですが、いい経験をすることができました。

長くなりましたが、ここまで読んでいただきありがとうございました。
それではまた次の記事で会いましょう

## 参考文献
偉大なる先人たちの知恵だったり、参考にさせていただいたものを載せておきます。

https://react-dropzone.js.org/

https://shrinerb.com/docs/storage/s3

https://qiita.com/fuku_tech/items/abbce671e7d882ec2f14

https://qiita.com/Dai_Kentaro/items/243a2c890f390e539af9

https://qiita.com/reflet/items/3e0f07bc9d64314515c1