フラミナル

考え方や調べたことを書き殴ります。IT技術系記事多め

Netflixのクローンを作るチュートリアルをやってみる

f:id:lirlia:20210521184835p:plain

zenn.dev

面白そう!と思って取り掛かりましたが、そのままだと動かず都度修正していきながらゴールにたどり着いたので後学のためまとめておきます。

GitHubでソースコード共有しています。

手順を実施する上での変更点

02. API Keyの取得しpostmanからAPIを送る

  • TMDBの APIキーを発行するために一般利用で申請を上げる必要があった
  • Application Summaryを記載する必要があるのだが、日本語でいくら入力してもエラーがでて進めなかったので「英語」で入力したところすんなりを追った

03. Reactの環境構築とAPI取得の下準備

古いNode.jsだったのでバージョン変更のためにnをグローバルにインストールして、NodeのバージョンをあげつつYarnもいれる。

$ npm install -g n
$ npm install --global yarn
$ n v16.2.0
$ npm install sass axios

ちなみに条件にあわないnodeをつかうと以下のエラーで怒られた。

error postcss@8.2.6: The engine "node" is incompatible with this module. Expected version "^10 || ^12 || >=14". Got "13.9.0"
error Found incompatible module.

その後npxというnpmにバンドルされているコマンドを用いて、create-react-appというコマンドを叩きReact + TypeScriptの環境を構築する。

$ npx create-react-app clone-netflix --template typescript

※npxはnpmのようなパッケージマネージャーだがコマンド(ここでいうcreate-react-app)を叩く時はnpmよりもnpxの方が楽に叩けるらしい。またgistに登録されているものをインストールせずに叩けたりするとか

詳細: npmとnpx。なにが違う? - Qiita

その後サーバを起動してブラウザの http://localhost:3000 にアクセスできればOKとのこと。

$ cd clone-netflix
$ yarn start

所感としてyarn startでサーバ起動するの遅いですね。PythonやGoでWebサーバ立ち上げる時の方がだいぶ早いので、開発フェーズでこの辺り時間かかると嫌だなあ。


clone-netflix/src配下の以下を削除しました。

  • setupTests.tx
  • logo.svg
  • App.test.tsx

その後clone-netflix/src/App.tsxを作成しました。そのままだと全然動かなかったのでGitHubのコードを参考にしながら手を加えました。

import React from 'react';
import './App.css';
import { Row } from "./Row";
import { requests } from "./request";

function App() {
  return (
<div className="App">
    <Row
      title="NETFLIX ORIGUINALS"
      fetchUrl={requests.feachNetflixOriginals}
      isLargeRow
    />
    <Row title="Top Rated" fetchUrl={requests.feactTopRated} />
    <Row title="Action Movies" fetchUrl={requests.feactActionMovies} />
    <Row title="Comedy Movies" fetchUrl={requests.feactComedyMovies} />
    <Row title="Horror Movies" fetchUrl={requests.feactHorrorMovies} />
    <Row title="Romance Movies" fetchUrl={requests.feactRomanceMovies} />
    <Row title="DOcumentaries" fetchUrl={requests.feactDocumentMovies} />
</div>
  );
}

export default App;
  • clone-netflix/src/axios.jsを作成(手順通り)
  • clone-netflix/src/request.jsを作成(手順通り)

04 Rowコンポーネントの作成

エラーが出たのでここも手を加えています。(title, fetchUrl, isLargeRowとか)

import React, { useState, useEffect } from "react";
import "./Row.scss";
import axios from "./axios";

const base_url = "https://image.tmdb.org/t/p/original";

type Props = {
  title: string;
  fetchUrl: string;
  isLargeRow?: boolean;
};

type Movie = {
  id: string;
  name: string;
  title: string;
  original_name: string;
  poster_path: string;
  backdrop_path: string;
};


export const Row = ({ title, fetchUrl, isLargeRow }: Props) => {
  const [movies, setMovies] = useState<Movie[]>([]);

  useEffect(() => {
    async function fetchData() {
      const request = await axios.get(fetchUrl);
      setMovies(request.data.results);
      return request;
    }
    fetchData();
  }, [fetchUrl]);

  console.log(movies);

  return(
    <div className="Row">
      <h2>{title}</h2>
      <div className="Row-posters">
        {/* ポスターコンテンツ */}
        {movies.map((movie, i) => (
          <img
            key={movie.id}
            className={`Row-poster ${isLargeRow && "Row-poster-large"}`}
            src={`${base_url}${
              isLargeRow ? movie.poster_path : movie.backdrop_path
            }`}
            alt={movie.name}
          />
        ))}
      </div>
    </div>
  );
};
.Row {
  margin-left: 20px;
  color: #fff;

  &-posters {
    display: flex;
    overflow-y: hidden;
    overflow-x: scroll;
    padding: 20px;

    &::-webkit-scrollbar {
      display: none;
    }
  }

  &-poster {
    object-fit: contain;
    width: 100%;
    max-height: 100px;
    margin: 10px;
    transition: transform 450ms;

    &-large {
      max-height: 250px;

      &:hover {
        transform: scale(1.09);
      }
    }

    &:hover {
      transform: scale(1.08);
    }
  }
}

05 Bannerコンポーネントの作成

import React, { useState, useEffect } from "react";
import axios from "./axios";
import { requests } from "./request";
import "./Banner.scss";

type movieProps = {
  title?: string;
  name?: string;
  orignal_name?: string;
  backdrop_path?: string;
  overview?: string;
};

export const Banner = () => {
  const [movie, setMovie] = useState<movieProps>({});
  useEffect(() => {
    async function fetchData() {
      const request = await axios.get(requests.feachNetflixOriginals);
      console.log(request.data.result);

      //apiからランダムで値を取得している
      setMovie(
        request.data.results[
          Math.floor(Math.random() * request.data.results.length - 1)
        ]
      );
      return request;
    }
    fetchData();
  }, []);
  console.log(movie);

  // descriptionの切り捨てよう関数
  function truncate(str: any, n: number) {
    // undefinedを弾く
    if (str !== undefined) {
      return str.length > n ? str?.substr(0, n - 1) + "..." : str;
    }
  }

  return (
    <header
      className="Banner"
      style={{
        backgroundSize: "cover",
        backgroundImage: `url("https://image.tmdb.org/t/p/original${movie?.backdrop_path}")`,
        backgroundPosition: "center center",
      }}
    >
      <div className="Banner-contents">
        <h1 className="banner-title">
          {movie?.title || movie?.name || movie?.orignal_name}
        </h1>
        <div className="Banner-buttons">
          <button className="Banner-button">Play</button>
          <button className="Banner-button">My List</button>
        </div>

        <h1 className="Banner-description">{truncate(movie?.overview, 150)}</h1>
      </div>

      <div className="Banner-fadeBottom" />
    </header>
  );
};
.Banner {
  color: #fff;
  object-fit: contain;
  height: 448px;

  &-contents {
    margin-left: 30px;
    padding-top: 140px;
    height: 190px;
  }

  &-title {
    font-size: 3rem;
    font-weight: 800;
    padding-bottom: 0.3rem;
  }

  &-description {
    width: 45rem;
    line-height: 1.3;
    padding-top: 1rem;
    font-size: 0.8rem;
    max-width: 360px;
    height: 80px;
  }

  &-button {
    cursor: pointer;
    color: #fff;
    outline: none;
    border: none;
    font-weight: 700;
    border-radius: 0.2vw;
    padding-left: 2rem;
    padding-right: 2rem;
    margin-right: 1rem;
    padding-top: 0.5rem;
    background-color: rgba(51, 51, 51, 0.5);
    padding-bottom: 0.5rem;

    &:hover {
      color: #000;
      background-color: #e6e6e6;
      transition: all 0.2s;
    }
  }

  &-fadeBottom {
    height: 7.4rem;
    background-image: linear-gradient(
      180deg,
      transparent,
      rgba(37, 37, 37, 0.61),
      #111
    );
  }
}

このあとApp.tsxをいじります

import React from 'react';
import './App.css';
import { Row } from "./Row";
import { Banner } from "./Banner";  // ★<- 追加
import { requests } from "./request";

function App() {
  return (
<div className="App">
    <Banner /> // ★<- 追加
    <Row
      title="NETFLIX ORIGUINALS"
      fetchUrl={requests.feachNetflixOriginals}
      isLargeRow
    />
    <Row title="Top Rated" fetchUrl={requests.feactTopRated} />
    <Row title="Action Movies" fetchUrl={requests.feactActionMovies} />
    <Row title="Comedy Movies" fetchUrl={requests.feactComedyMovies} />
    <Row title="Horror Movies" fetchUrl={requests.feactHorrorMovies} />
    <Row title="Romance Movies" fetchUrl={requests.feactRomanceMovies} />
    <Row title="DOcumentaries" fetchUrl={requests.feactDocumentMovies} />
</div>
  );
}

export default App;

06 Navコンポーネントの追加

import React, { useState, useEffect } from "react";
import "./Nav.scss";

type Props = {
  className?: string;
};

export const Nav = (props: Props) => {
  const [show, setShow] = useState(false);
  useEffect(() => {
    const handleShow = () => {
      if (window.scrollY > 100) {
        setShow(true);
      } else {
        setShow(false);
      }
    };

    window.addEventListener("scroll", handleShow);
    return () => {
      window.removeEventListener("scroll", handleShow);
    };
  }, []);
  return (
    <div className={`Nav ${show && "Nav-black"}`}>
      <img
        className="Nav-logo"
        src="https://upload.wikimedia.org/wikipedia/commons/thumb/0/08/Netflix_2015_logo.svg/1920px-Netflix_2015_logo.svg.png"
        alt="Netflix Logo"
      />
      <img
        className="Nav-avater"
        src="https://i.pinimg.com/originals/0d/dc/ca/0ddccae723d85a703b798a5e682c23c1.png"
        alt="Avatar"
      />
    </div>
  );
};
.Nav {
  position: fixed;
  top: 0;
  width: 100%;
  height: 30px;
  padding: 20px;
  z-index: 1;
  display: flex;
  justify-content: space-between;

  /* Animations */
  transition-timing-function: ease-in;
  transition: all 0.5s;

  &-black {
    background-color: #111;
  }

  &-logo {
    position: fixed;
    left: 20px;
    width: 80px;
    object-fit: contain;
  }

  &-avater {
    position: fixed;
    right: 20px;
    width: 30px;
    object-fit: contain;
  }
}

このあとApp.tsxをいじります

import React from 'react';
import './App.css';
import { Row } from "./Row";
import { Banner } from "./Banner";
import { Nav } from "./Nav"; // ★<- 追加
import { requests } from "./request";

function App() {
  return (
<div className="App">
    <Nav />  // ★<- 追加
    <Banner />
    <Row
      title="NETFLIX ORIGUINALS"
      fetchUrl={requests.feachNetflixOriginals}
      isLargeRow
    />
    <Row title="Top Rated" fetchUrl={requests.feactTopRated} />
    <Row title="Action Movies" fetchUrl={requests.feactActionMovies} />
    <Row title="Comedy Movies" fetchUrl={requests.feactComedyMovies} />
    <Row title="Horror Movies" fetchUrl={requests.feactHorrorMovies} />
    <Row title="Romance Movies" fetchUrl={requests.feactRomanceMovies} />
    <Row title="DOcumentaries" fetchUrl={requests.feactDocumentMovies} />
</div>
  );
}

export default App;

07 映画の画像クリックでトレイラーを表示する

モジュールをインストールする

$ npm install movie-trailer
$ npm install react-youtube

外部からAPI_KEYを呼べるようにする

// const -> export const にする
export const API_KEY = "xxxx";

export const requests ={
    feachTrending:`/trending/all/week?api_key=${API_KEY}&language=en-us`,
    feachNetflixOriginals:`/discover/tv?api_key=${API_KEY}&with_networks=213`,
    feactTopRated:`/discover/tv?api_key=${API_KEY}&languager=en-us`,
    feactActionMovies:`/discover/tv?api_key=${API_KEY}&with_genres=28`,
    feactComedyMovies:`/discover/tv?api_key=${API_KEY}&with_genres=35`,
    feactHorrorMovies:`/discover/tv?api_key=${API_KEY}&with_genres=27`,
    feactRomanceMovies:`/discover/tv?api_key=${API_KEY}&with_genres=10749`,
    feactDocumentMovies:`/discover/tv?api_key=${API_KEY}&with_genres=99`,
}

YouTubeの紹介動画を引っ張れるようにする。サンプルコードのままだと動画と映画がリンクしていなかったので、GitHubのコードを引っ張ってきて対応した。(ちなみに動画がない映画がほとんどなので、色々なサムネイルをクリックしてみないとちゃんと動くかわからない)

import React, { useState, useEffect } from "react";
import YouTube from "react-youtube"; // add
import "./Row.scss";
import axios from "./axios";
import { API_KEY } from "./request" // add
const movieTrailer = require("movie-trailer"); // add
const base_url = "https://image.tmdb.org/t/p/original";

type Props = {
  title: string;
  fetchUrl: string;
  isLargeRow?: boolean;
};

type Movie = {
  id: string;
  name: string;
  title: string;
  original_name: string;
  poster_path: string;
  backdrop_path: string;
};

// add
type Options = {
  height: string;
  width: string;
  playerVars: {
    autoplay: 0 | 1 | undefined;
  };
};

export const Row = ({ title, fetchUrl, isLargeRow }: Props) => {
  const [movies, setMovies] = useState<Movie[]>([]);
  // add
  const [trailerUrl, setTrailerUrl] = useState<string | null>("");

  useEffect(() => {
    async function fetchData() {
      const request = await axios.get(fetchUrl);
      setMovies(request.data.results);
      return request;
    }
    fetchData();
  }, [fetchUrl]);

  // add
  const opts: Options = {
    height: "390",
    width: "640",
    playerVars: {
      // https://developers.google.com/youtube/player_parameters
      autoplay: 1,
    },
  };

  // add
  const handleClick = async (movie: Movie) => {
    if (trailerUrl) {
      setTrailerUrl("");
    } else {
      let trailerurl = await axios.get(
        `/movie/${movie.id}/videos?api_key=` + API_KEY
      );
      setTrailerUrl(trailerurl.data.results[0]?.key);
    }
    movieTrailer(movie?.name || movie?.title || movie?.original_name || "")
        .then((url: string) => {
           const urlParams = new URLSearchParams(new URL(url).search);
           setTrailerUrl(urlParams.get("v"));
         })
         .catch((error: any) => console.log(error.message));
    
  };

  return(
    <div className="Row">
      <h2>{title}</h2>
      <div className="Row-posters">
        {/* ポスターコンテンツ */}
        {movies.map((movie, i) => (
          <img
            key={movie.id}
            className={`Row-poster ${isLargeRow && "Row-poster-large"}`}
            src={`${base_url}${
              isLargeRow ? movie.poster_path : movie.backdrop_path
            }`}
            alt={movie.name}
            onClick={() => handleClick(movie)}
          />
        ))}
      </div>
      {/* add */}
      {trailerUrl && <YouTube videoId={trailerUrl} opts={opts} />}
    </div>
  );
};

08 firebaseでデプロイ

firehose initをしたところ以前使っていた時の認証が中途半端に残っているエラーが発生

[debug] [2021-05-21T09:25:31.828Z] <<< HTTP RESPONSE 400 {"pragma":"no-cache","date":"Fri, 21 May 2021 09:25:31 GMT","cache-control":"no-cache, no-store, max-age=0, must-revalidate","expires":"Mon, 01 Jan 1990 00:00:00 GMT","content-type":"application/json; charset=utf-8","vary":"X-Origin, Referer, Origin,Accept-Encoding","server":"scaffolding on HTTPServer2","x-xss-protection":"0","x-frame-options":"SAMEORIGIN","x-content-type-options":"nosniff","alt-svc":"h3-29=\":443\"; ma=2592000,h3-T051=\":443\"; ma=2592000,h3-Q050=\":443\"; ma=2592000,h3-Q046=\":443\"; ma=2592000,h3-Q043=\":443\"; ma=2592000,quic=\":443\"; ma=2592000; v=\"46,43\"","accept-ranges":"none","transfer-encoding":"chunked"}
[debug] [2021-05-21T09:25:31.830Z] >>> HTTP REQUEST POST https://cloudresourcemanager.googleapis.com/v1/projects  
 {"projectId":"netflix-clone","name":"netflix-clone"}
[debug] [2021-05-21T09:25:32.679Z] <<< HTTP RESPONSE 401 {"www-authenticate":"Bearer realm=\"https://accounts.google.com/\", error=\"invalid_token\"","vary":"X-Origin, Referer, Origin,Accept-Encoding","content-type":"application/json; charset=UTF-8","date":"Fri, 21 May 2021 09:25:32 GMT","server":"ESF","cache-control":"private","x-xss-protection":"0","x-frame-options":"SAMEORIGIN","x-content-type-options":"nosniff","server-timing":"gfet4t7; dur=653","alt-svc":"h3-29=\":443\"; ma=2592000,h3-T051=\":443\"; ma=2592000,h3-Q050=\":443\"; ma=2592000,h3-Q046=\":443\"; ma=2592000,h3-Q043=\":443\"; ma=2592000,quic=\":443\"; ma=2592000; v=\"46,43\"","accept-ranges":"none","transfer-encoding":"chunked"}
[debug] [2021-05-21T09:25:32.680Z] <<< HTTP RESPONSE BODY {"error":{"code":401,"message":"Request had invalid authentication credentials. Expected OAuth 2 access token, login cookie or other valid authentication credential. See https://developers.google.com/identity/sign-in/web/devconsole-project.","status":"UNAUTHENTICATED"}}
[debug] [2021-05-21T09:25:32.807Z] FirebaseError: HTTP Error: 401, Request had invalid authentication credentials. Expected OAuth 2 access token, login cookie or other valid authentication credential. See https://developers.google.com/identity/sign-in/web/devconsole-project.
    at module.exports (/usr/local/lib/node_modules/firebase-tools/lib/responseToError.js:38:12)
    at Request._callback (/usr/local/lib/node_modules/firebase-tools/lib/api.js:41:35)
    at Request.self.callback (/usr/local/lib/node_modules/firebase-tools/node_modules/request/request.js:185:22)
    at Request.emit (node:events:365:28)
    at Request.emit (node:domain:470:12)
    at Request.<anonymous> (/usr/local/lib/node_modules/firebase-tools/node_modules/request/request.js:1154:10)
    at Request.emit (node:events:365:28)
    at Request.emit (node:domain:470:12)
    at IncomingMessage.<anonymous> (/usr/local/lib/node_modules/firebase-tools/node_modules/request/request.js:1076:12)
    at Object.onceWrapper (node:events:471:28)
    at IncomingMessage.emit (node:events:377:35)
    at IncomingMessage.emit (node:domain:470:12)
    at endReadableNT (node:internal/streams/readable:1312:12)
    at processTicksAndRejections (node:internal/process/task_queues:83:21)
[error] 
[error] Error: Failed to create project. See firebase-debug.log for more info.

そのため firebase initしたらError: HTTP Error: 401が出た - haayaaa’s diary を参考にして以下のコマンドを実行し解決。

$ npm install -g firebase-tools
$ firebase login --reauth --no-localhost

その後、initする

$ firehose init
  • Hostingを選択しスペースキーを押下し、Enterを押下
  • Create New Projectを選択(適当な名前)
Project information:
   - Project ID: netflix-clone-lirlia
   - Project Name: netflix-clone-lirlia

Firebase console is available at
https://console.firebase.google.com/project/netflix-clone-lirlia/overview
i  Using project netflix-clone-lirlia (netflix-clone-lirlia)

=== Hosting Setup

Your public directory is the folder (relative to your project directory) that
will contain Hosting assets to be uploaded with firebase deploy. If you
have a build process for your assets, use your build's output directory.

? What do you want to use as your public directory? build
? Configure as a single-page app (rewrite all urls to /index.html)? No
? Set up automatic builds and deploys with GitHub? No

i  Writing configuration info to firebase.json...
i  Writing project information to .firebaserc...
i  Writing gitignore file to .gitignore...

✔  Firebase initialization complete!
$ firebase deploy

↓デプロイ成功しました。

https://netflix-clone-lirlia.web.app/index.html