View on GitHub

人気ベース推薦システム

Home

人気ベース推薦システム

バックエンド

モデル

src/backend/api/online/models.py

......
# ↓追加
class ReclistPopularity(models.Model):
    """
    人気ベース推薦システムによる推薦リストモデル

    Attributes
    ----------
    id : CharField
        推薦リストID
    target_genre : ForeignKey[Genre]
        対象ジャンル
    rank : IntegerField
        推薦順位
    movie : ForeignKey[Movie]
        推薦映画
    score : FloatField
        推薦スコア
    """
    id = models.CharField(primary_key=True, max_length=5)
    target_genre = models.ForeignKey(Genre, on_delete=models.CASCADE)
    rank = models.IntegerField(blank=False, null=False)
    movie = models.ForeignKey(Movie, on_delete=models.CASCADE)
    score = models.FloatField()

    class Meta:
        managed = True
        db_table = 'r_reclist_popularity'

    def __str__(self):
        return f'ReclistPopularity {self.id}: Genre {self.target_genre.genre_name} - RecMovie {self.rank}: {self.movie.title}'
# ↑追加

マイグレーション

(recsys_full) backend$ python manage.py makemigrations online
Migrations for 'online':
  api/online/migrations/0004_reclistpopularity.py
    + Create model ReclistPopularity

(recsys_full) backend$ python manage.py migrate
Operations to perform:
  Apply all migrations: accounts, admin, auth, contenttypes, online, sessions, token_blacklist
Running migrations:
  Applying online.0004_reclistpopularity... OK

データの登録

offline$
 psql recsys_full -U postgres -c "\copy r_reclist_popularity (id, target_genre_id, rank, movie_id, score) from 'data/reclist_popularity.csv' with delimiter E'\t' csv header encoding 'UTF8'"

データの確認

recsys_full=#
 SELECT * FROM r_reclist_popularity;

ビュー

src/backend/api/online/views.py

......
from .models import Movie, UserMovieRating
from .models import ReclistPopularity  # <- 追加
from .mappers import MovieMapper, RatingMapper
......
# ↓追加
class MoviesPopularityView(APIView):
    """人気ベース推薦システムによる映画リストビュー"""
    permission_classes = (AllowAny,)

    def get(self, request, format=None):
        """
        対象ジャンルの人気ベース推薦リストを取得する。

        Requests
        --------
        user : User
            ユーザ
        target_genre_id : int
            対象ジャンルID

        Responses
        ---------
        movies : json
            推薦映画リスト
        """
        # ユーザ認証
        user = _get_user(request)

        # リクエストパラメタの取得
        target_genre_id = request.GET.get('target_genre_id')

        # オブジェクトの取得
        movies = ReclistPopularity.objects\
            .select_related('movie')\
            .prefetch_related('movie__movie_genres__genre')\
            .filter(target_genre_id=target_genre_id)

        rating_map = {}
        if user:
            rating_map = {
                rating.movie_id: rating for rating in UserMovieRating.objects.filter(user=user)
            }

        # レスポンス
        movies_dict = [
            MovieMapper(
                reclist.movie,
                rating=rating_map.get(reclist.movie_id)
            ).as_dict() for reclist in movies
        ]
        data = {
            'movies': movies_dict,
        }
        return Response(data, status.HTTP_200_OK)
# ↑追加

URL マッピング

src/backend/api/online/urls.py

from django.urls import path
from . import views

urlpatterns = [
    path('movies/', views.MoviesView.as_view()),
    path('movies/<int:movie_id>/', views.MovieView.as_view()),
    path('movies/popularity/', views.MoviesPopularityView.as_view()),  # <- 追加
    path('ratings/', views.RatingView.as_view()),
]

実行確認

ブラウザで下記 URL にアクセスしてください。

target_genre_idを変えてアクセスすると、ジャンル別に推薦リストが表示されます。

サインインビューの作成の手順にしたがってサインインし、アクセストークンを取得してください。取得したトークンを用いて、バックエンドサーバが起動している状態で、下記コマンドを実行してください。

$
 curl -X GET http://localhost:8000/api/online/movies/popularity/?target_genre_id=1 \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer <ACCESS_TOKEN>"

ユーザによる評価値が付与された映画リストが取得できます。

フロントエンド

定数の定義

src/frontend/src/constants/styles.ts

/**
 * スタイル関連の定数
 */
export const STYLES = {
  ......
  // 本日のおすすめ映画リスト関連
  LIST_MOVIE_DAILY_PHRASE: '本日のおすすめ',

  // ↓追加
  // 人気ベース映画推薦リスト関連
  LIST_MOVIE_POPULARITY_PHRASE: '人気の映画',
  LIST_MOVIE_POPULARITY_COUNT: 3,
  // ↑追加
  ......
} as const;

src/frontend/src/constants/settings.ts

......
// ↓追加
/**
 * 映画ジャンルリスト
 */
export const GENRES = [
  'Action',
  'Adventure',
  'Animation',
  'Children',
  'Comedy',
  'Crime',
  'Documentary',
  'Drama',
  'Fantasy',
  'Film-Noir',
  'Horror',
  'Musical',
  'Mystery',
  'Romance',
  'Sci-Fi',
  'Thriller',
  'War',
  'Western',
  'IMAX',
];
// ↑追加

API

src/frontend/src/api/movies/getMoviesPopularity.ts

import { ApiContext, Movie } from "@/types/data";
import { fetcher } from "@/utils";

const context: ApiContext = {
  apiRootUrl: process.env.NEXT_PUBLIC_API_BASE_URL,
};

/**
 * 人気ベース推薦システムによる推薦映画リスト取得API
 * @param targetGenreId - 対象ジャンルID
 * @returns movies - 推薦映画リスト
 */
const getMoviesPopularity = async (
  targetGenreId: number,
): Promise<{ movies: Movie[] }> => {
  const access =
    typeof window !== "undefined" ? localStorage.getItem("access") : null;

  const url = `${context.apiRootUrl?.replace(/\/$/g, "")}/online/movies/popularity/?target_genre_id=${targetGenreId}`;
  const headers: Record<string, string> = {
    Accept: "application/json",
    "Content-Type": "application/json",
    ...(access ? { Authorization: `Bearer ${access}` } : {}),
  };

  return await fetcher(url, {
    method: "GET",
    headers: headers,
    cache: "no-store",
  });
};

export default getMoviesPopularity;

コンポーネント

src/frontend/src/app/components/ui/list/ListMoviePopularity.tsx

"use client";

import { useEffect, useState } from "react";

import { Movie, User } from "@/types/data";
import { STYLES, ERROR_MESSAGES, GENRES } from "@/constants";
import getMoviesPopularity from "@/api/movies/getMoviesPopularity";
import getMyAccount from "@/api/auth/getMyAccount";

import ListMovie from "./ListMovie";
import Loading from "../Loading";

interface Props {
  targetGenreId: number;
}

/**
 * 人気ベース推薦システムによる推薦映画リストコンポーネント
 */
const ListMoviePopularity = (props: Props) => {
  const [loading, setLoading] = useState(true);
  const [loadingUser, setLoadingUser] = useState(true);
  const [user, setUser] = useState<User | null>(null);
  const [movies, setMovies] = useState<Movie[]>([]);

  const phrase = `${GENRES[props.targetGenreId - 1]}${STYLES.LIST_MOVIE_POPULARITY_PHRASE}`;

  useEffect(() => {
    const load = async () => {
      try {
        const user_ = await getMyAccount();
        setUser(user_);
      } catch (e) {
        setUser(null);
      } finally {
        setLoadingUser(false);
      }

      try {
        const { movies: movies_ } = await getMoviesPopularity(
          props.targetGenreId,
        );
        setMovies(movies_);
      } catch (e) {
        console.error(ERROR_MESSAGES.MOVIE_GET_FAILED, e);
        setMovies([]);
      } finally {
        setLoading(false);
      }
    };
    load();
  }, [props.targetGenreId]);

  if (loading) {
    return (
      <div className={`${STYLES.LOADING_INLINE}`}>
        <Loading />
      </div>
    );
  }

  return (
    <>
      <ListMovie
        phrase={phrase}
        user={loadingUser ? null : user}
        movies={movies}
      />
    </>
  );
};

export default ListMoviePopularity;

src/frontend/src/app/components/ui/list/ListMoviePopularitySection.tsx

"use client";

import { useEffect, useState } from "react";

import { STYLES, GENRES } from "@/constants";

import ListMoviePopularity from "./ListMoviePopularity";

const ListMoviePopularitySection = () => {
  const [genreIds, setGenreIds] = useState<number[]>([]);

  useEffect(() => {
    const ids = [...Array(GENRES.length)].map((_, i) => i + 1);
    ids.sort(() => 0.5 - Math.random());
    setGenreIds(ids.slice(0, STYLES.LIST_MOVIE_POPULARITY_COUNT));
  }, []);

  return (
    <>
      {genreIds.map((gid) => (
        <ListMoviePopularity targetGenreId={gid} key={gid} />
      ))}
    </>
  );
};

export default ListMoviePopularitySection;

ページ

src/frontend/src/app/components/Index.tsx

......
import ListMovieDaily from './ui/list/ListMovieDaily';
import ListMoviePopularitySection from './ui/list/ListMoviePopularitySection';  // <- 追加

/**
 * インデックスコンポーネント
 *
 * JWT の有効期限を監視し、タイムアウト時にメイン画面をログアウト状態へ切り替える。
 */
const Index = () => {
  ......
  return (
    <>
      ...(略)...
        <>
          <div>ようこそ {user?.user_email} さん!</div>
          <ListMovieDaily />
          <ListMoviePopularitySection /> {/* <- 追加 */}
        </>
      )}
    </>
  );
};

export default Index;

実行確認

ブラウザで下記 URL にアクセスしてください。

ジャンル別推薦リストが 3 件提示されます。ブラウザを更新する度に、ジャンルがランダムに切り替わります。