From bb884826747724455a9d69282982dfdebab815ea Mon Sep 17 00:00:00 2001 From: Michael Wieland Date: Sun, 21 Jun 2026 22:05:26 +0200 Subject: [PATCH 1/6] Optimize queries (WIP - some parts are broken) --- config/packages/twig.yaml | 4 ++ .../tracky/controller/HistoryController.php | 4 +- .../php/tracky/controller/HomeController.php | 47 +++++++++---- .../php/tracky/controller/MovieController.php | 68 +++++++++++-------- .../php/tracky/controller/ShowController.php | 26 +++---- .../php/tracky/controller/UserController.php | 4 +- src/main/php/tracky/model/Episode.php | 19 ++---- src/main/php/tracky/model/EpisodeView.php | 30 -------- src/main/php/tracky/model/Movie.php | 13 +--- src/main/php/tracky/model/MovieView.php | 30 -------- .../tracky/model/{ViewEntry.php => View.php} | 36 ++++++++-- src/main/php/tracky/orm/MovieRepository.php | 18 ----- src/main/php/tracky/orm/ShowRepository.php | 25 ------- src/main/php/tracky/orm/ViewRepository.php | 27 +++++++- .../php/tracky/watchstats/ItemWatchStats.php | 22 ++++++ .../watchstats/WatchStatsCollection.php | 35 ++++++++++ .../tracky/watchstats/WatchStatsProvider.php | 40 +++++++++++ .../components/history-entry-card-small.twig | 10 +-- templates/components/movie-card-small.twig | 10 +-- templates/components/view-last-watched.twig | 6 +- templates/movies.twig | 2 +- templates/user/show-progress.twig | 4 +- 22 files changed, 270 insertions(+), 210 deletions(-) delete mode 100644 src/main/php/tracky/model/EpisodeView.php delete mode 100644 src/main/php/tracky/model/MovieView.php rename src/main/php/tracky/model/{ViewEntry.php => View.php} (51%) create mode 100644 src/main/php/tracky/watchstats/ItemWatchStats.php create mode 100644 src/main/php/tracky/watchstats/WatchStatsCollection.php create mode 100644 src/main/php/tracky/watchstats/WatchStatsProvider.php diff --git a/config/packages/twig.yaml b/config/packages/twig.yaml index 5e2f446..4a13da8 100644 --- a/config/packages/twig.yaml +++ b/config/packages/twig.yaml @@ -1,3 +1,7 @@ +twig: + globals: + watchStatsProvider: '@tracky\watchstats\WatchStatsProvider' + when@test: twig: strict_variables: true diff --git a/src/main/php/tracky/controller/HistoryController.php b/src/main/php/tracky/controller/HistoryController.php index 07a0974..34fbfcc 100644 --- a/src/main/php/tracky/controller/HistoryController.php +++ b/src/main/php/tracky/controller/HistoryController.php @@ -10,7 +10,7 @@ use tracky\model\EpisodeView; use tracky\model\MovieView; use tracky\model\User; -use tracky\model\ViewEntry; +use tracky\model\View; use tracky\orm\ViewRepository; use tracky\Pagination; use tracky\ViewType; @@ -82,7 +82,7 @@ public function getPage(Request $request, User $user, EntityManagerInterface $en private function sorted(array $entries): array { - usort($entries, function (ViewEntry $entry1, ViewEntry $entry2) { + usort($entries, function (View $entry1, View $entry2) { if ($entry1->getDateTime() < $entry2->getDateTime()) { return 1; } elseif ($entry1->getDateTime() > $entry2->getDateTime()) { diff --git a/src/main/php/tracky/controller/HomeController.php b/src/main/php/tracky/controller/HomeController.php index 5125838..049be53 100644 --- a/src/main/php/tracky/controller/HomeController.php +++ b/src/main/php/tracky/controller/HomeController.php @@ -36,7 +36,7 @@ public function home(ShowRepository $showRepository, EpisodeRepository $episodeR $nowWatching = $scrobbler->getNowWatching($user); $latestWatchedEpisodes = $viewRepository->findBy(["user" => $user->getId()], ["dateTime" => "desc"], $this->maxEpisodes, type: ViewType::EPISODE); $latestWatchedMovies = $viewRepository->findBy(["user" => $user->getId()], ["dateTime" => "desc"], $this->maxMovies, type: ViewType::MOVIE); - $nextEpisodes = $this->getNextEpisodes($showRepository, $user); + $nextEpisodes = $this->getNextEpisodes($showRepository, $viewRepository, $user); } return $this->render("index.twig", [ @@ -49,22 +49,46 @@ public function home(ShowRepository $showRepository, EpisodeRepository $episodeR ]); } - private function getNextEpisodes(ShowRepository $showRepository, User $user) + private function getNextEpisodes(ShowRepository $showRepository, ViewRepository $viewRepository, User $user) { $latestEpisodes = []; $nextEpisodes = []; - foreach ($showRepository->findAllWithEpisodesAndViews($user->getId()) as $show) { - $latestWatchedEpisodes = $show->getLatestWatchedEpisodes($user, 1, true); - if (!empty($latestWatchedEpisodes)) { - $latestEpisodes[] = $latestWatchedEpisodes[0]; + $watchStats = $viewRepository->getEpisodeWatchStatsForUser($user); + + $latestEpisodes = []; + + foreach ($showRepository->findAllWithEpisodes() as $show) { + $mostRecentWatch = null; + $mostRecentWatchedEpisode = null; + + foreach ($show->getSeasons() as $season) { + foreach ($season->getEpisodes() as $episode) { + $episodeWatchStats = $watchStats[$episode->getId()] ?? null; + if ($episodeWatchStats === null) { + continue; + } + + if ($mostRecentWatch === null or $episodeWatchStats["lastWatched"] > $mostRecentWatch) { + $mostRecentWatch = $episodeWatchStats["lastWatched"]; + $mostRecentWatchedEpisode = $episode; + } + } } + + if ($mostRecentWatch === null) { + continue; + } + + $latestEpisodes[] = [ + "episode" => $mostRecentWatchedEpisode, + "lastWatched" => $mostRecentWatch + ]; } usort($latestEpisodes, function ($item1, $item2) { - list(, $item1Timestamp) = $item1; - list(, $item2Timestamp) = $item2; - + $item1Timestamp = $item1["lastWatched"]; + $item2Timestamp = $item2["lastWatched"]; if ($item1Timestamp === $item2Timestamp) { return 0; @@ -74,10 +98,7 @@ private function getNextEpisodes(ShowRepository $showRepository, User $user) }); foreach ($latestEpisodes as $item) { - /** - * @var Episode - */ - $episode = $item[0]; + $episode = $item["episode"]; $nextEpisode = $episode->getNextEpisode(); if ($nextEpisode !== null) { $nextEpisodes[] = $nextEpisode; diff --git a/src/main/php/tracky/controller/MovieController.php b/src/main/php/tracky/controller/MovieController.php index 7fde630..5fd8e35 100644 --- a/src/main/php/tracky/controller/MovieController.php +++ b/src/main/php/tracky/controller/MovieController.php @@ -16,6 +16,7 @@ use tracky\ImageFetcher; use tracky\model\Movie; use tracky\model\MovieView; +use tracky\model\View; use tracky\orm\MovieRepository; use tracky\orm\ViewRepository; use tracky\ViewType; @@ -55,31 +56,31 @@ public function getMoviesPage(Request $request): Response $sortDirection = self::SORT_ASC; } + if ($user !== null) { + $watchStats = $this->viewRepository->getWatchStatsForUser($user, ViewType::MOVIE); + } else { + $watchStats = null; + } + // Special sorting for playCount and lastPlayed if ($user !== null and ($sortField === "playCount" or $sortField === "lastPlayed")) { - $movies = $this->movieRepository->findAllWithViews($user->getId()); - usort($movies, function (Movie $movie1, Movie $movie2) use ($user, $sortField, $sortDirection) { - $views1 = $movie1->getViewsForUser($user); - $views2 = $movie2->getViewsForUser($user); + $movies = $this->movieRepository->findAll(); + + usort($movies, function (Movie $movie1, Movie $movie2) use ($sortField, $sortDirection, $watchStats) { + $item1 = $watchStats->getStatsForItem($movie1); + $item2 = $watchStats->getStatsForItem($movie2); + + $value1 = 0; + $value2 = 0; switch ($sortField) { case "playCount": - $value1 = count($views1); - $value2 = count($views2); + $value1 = $item1?->getCount() ?? 0; + $value2 = $item2?->getCount() ?? 0; break; case "lastPlayed": - $lastView1 = $views1->last(); - if ($lastView1 === false) { - $lastView1 = null; - } - - $lastView2 = $views2->last(); - if ($lastView2 === false) { - $lastView2 = null; - } - - $value1 = $lastView1?->getDateTime()?->getTimestamp() ?? 0; - $value2 = $lastView2?->getDateTime()?->getTimestamp() ?? 0; + $value1 = $item1?->getLastWatched()?->getTimestamp() ?? 0; + $value2 = $item2?->getLastWatched()?->getTimestamp() ?? 0; break; } @@ -101,7 +102,8 @@ public function getMoviesPage(Request $request): Response "field" => $sortField, "direction" => $sortDirection ], - "movies" => $movies + "movies" => $movies, + "watchStats" => $watchStats ]); } @@ -124,17 +126,23 @@ public function getMovieImage(Movie $movie, ImageFetcher $imageFetcher): Respons #[Route("/movies/{movie}", name: "movies_single_page", methods: ["GET"])] public function getMoviePage(Movie $movie): Response { + $user = $this->getUser(); + if ($user !== null) { + $itemWatchStats = $this->viewRepository->getWatchStatsForUser($user, ViewType::MOVIE)->getStatsForItem($movie); + } else { + $itemWatchStats = null; + } + return $this->render("movie.twig", [ - "movie" => $movie + "movie" => $movie, + "itemWatchStats" => $itemWatchStats ]); } #[Route("/movies/{movie}", name: "movies_remove_movie_action", methods: ["DELETE"])] #[IsGranted("IS_AUTHENTICATED")] - public function removeMovie(Movie $movie, EntityManagerInterface $entityManager): Response + public function removeMovie(Movie $movie, ViewRepository $viewRepository, EntityManagerInterface $entityManager): Response { - $viewRepository = $entityManager->getRepository(MovieView::class); - // Make sure no view exists for this movie if ($viewRepository->count(["item" => $movie->getId()], type: ViewType::MOVIE)) { return $this->json([ @@ -158,12 +166,12 @@ public function addView(Movie $movie, Request $request, EntityManagerInterface $ throw new BadRequestException("Invalid payload"); } - $movieView = new MovieView; - $movieView->setMovie($movie); - $movieView->setUser($this->getUser()); - $movieView->setDateTime($dateTime); + $view = new View; + $view->setItem($movie); + $view->setUser($this->getUser()); + $view->setDateTime($dateTime); - $entityManager->persist($movieView); + $entityManager->persist($view); $entityManager->flush(); return new Response("View added to database"); @@ -171,9 +179,9 @@ public function addView(Movie $movie, Request $request, EntityManagerInterface $ #[Route("/movies/{movie}/views/all", name: "movies_remove_views_action", methods: ["DELETE"])] #[IsGranted("IS_AUTHENTICATED")] - public function removeViewsByEpisode(Movie $movie, EntityManagerInterface $entityManager): Response + public function removeViewsByMovie(Movie $movie, ViewRepository $viewRepository, EntityManagerInterface $entityManager): Response { - $views = $movie->getViewsForUser($this->getUser()); + $views = $viewRepository->findBy(["item" => $movie->getId(), "user" => $this->getUser(), "type" => ViewType::EPISODE->value]); foreach ($views as $view) { $entityManager->remove($view); diff --git a/src/main/php/tracky/controller/ShowController.php b/src/main/php/tracky/controller/ShowController.php index 7469be6..9ed0bfd 100644 --- a/src/main/php/tracky/controller/ShowController.php +++ b/src/main/php/tracky/controller/ShowController.php @@ -15,9 +15,9 @@ use tracky\datetime\DateTime; use tracky\ImageFetcher; use tracky\model\Episode; -use tracky\model\EpisodeView; use tracky\model\Season; use tracky\model\Show; +use tracky\model\View; use tracky\orm\ShowRepository; use tracky\orm\ViewRepository; use tracky\ViewType; @@ -81,10 +81,8 @@ public function getShowOverviewPage(Show $show): Response #[Route("/shows/{show}", name: "shows_show_remove_action", methods: ["DELETE"])] #[IsGranted("IS_AUTHENTICATED")] - public function removeShow(Show $show, EntityManagerInterface $entityManager): Response + public function removeShow(Show $show, ViewRepository $viewRepository, EntityManagerInterface $entityManager): Response { - $viewRepository = $entityManager->getRepository(EpisodeView::class); - // Make sure no episode view exists for this show foreach ($show->getSeasons() as $season) { foreach ($season->getEpisodes() as $episode) { @@ -209,12 +207,12 @@ public function addView(Show $show, int $seasonNumber, int $episodeNumber, Reque throw new BadRequestException("Invalid payload"); } - $episodeView = new EpisodeView; - $episodeView->setEpisode($episode); - $episodeView->setUser($this->getUser()); - $episodeView->setDateTime($dateTime); + $view = new View; + $view->setItem($episode); + $view->setUser($this->getUser()); + $view->setDateTime($dateTime); - $entityManager->persist($episodeView); + $entityManager->persist($view); $entityManager->flush(); return new Response("View added to database"); @@ -222,7 +220,7 @@ public function addView(Show $show, int $seasonNumber, int $episodeNumber, Reque #[Route("/shows/{show}/seasons/{seasonNumber}/episodes/{episodeNumber}/views/all", name: "shows_remove_episode_view_action", methods: ["DELETE"])] #[IsGranted("IS_AUTHENTICATED")] - public function removeViewsByEpisode(Show $show, int $seasonNumber, int $episodeNumber, EntityManagerInterface $entityManager): Response + public function removeViewsByEpisode(Show $show, int $seasonNumber, int $episodeNumber, ViewRepository $viewRepository, EntityManagerInterface $entityManager): Response { $season = $show->getSeason($seasonNumber); if ($season === null) { @@ -234,7 +232,7 @@ public function removeViewsByEpisode(Show $show, int $seasonNumber, int $episode throw new NotFoundHttpException("Episode not found"); } - $views = $episode->getViewsForUser($this->getUser()); + $views = $viewRepository->findBy(["item" => $episode->getId(), "user" => $this->getUser(), "type" => ViewType::EPISODE->value]); foreach ($views as $view) { $entityManager->remove($view); @@ -259,11 +257,15 @@ public function removeViewById(Show $show, int $seasonNumber, int $episodeNumber throw new NotFoundHttpException("Episode not found"); } - $view = $this->viewRepository->findOneBy(["id" => $entryId, "user" => $this->getUser()], type: ViewType::EPISODE); + $view = $this->viewRepository->findOneBy(["id" => $entryId, "user" => $this->getUser(), "type" => ViewType::EPISODE->value]); if ($view === null) { throw new NotFoundHttpException("View not found"); } + if ($view->getItem() !== $episode->getId()) { + throw new NotFoundHttpException("View item does not match episode"); + } + $entityManager->remove($view); $entityManager->flush(); diff --git a/src/main/php/tracky/controller/UserController.php b/src/main/php/tracky/controller/UserController.php index 48b83d4..a874f71 100644 --- a/src/main/php/tracky/controller/UserController.php +++ b/src/main/php/tracky/controller/UserController.php @@ -119,11 +119,11 @@ public function getProfilePage(User $user, ViewRepository $viewRepository, Scrob } #[Route("/users/{username}/show-progress", name: "user_profile_show_progress_page")] - public function getShowProgressForUser(User $user, ShowRepository $showRepository): Response + public function getShowProgressForUser(User $user, ShowRepository $showRepository, ViewRepository $viewRepository): Response { return $this->render("user/show-progress.twig", [ "user" => $user, - "shows" => $showRepository->findAllWithEpisodesAndViews($user->getId()) + "shows" => $showRepository->findAllWithEpisodes() ]); } } diff --git a/src/main/php/tracky/model/Episode.php b/src/main/php/tracky/model/Episode.php index a6552ed..d672b20 100644 --- a/src/main/php/tracky/model/Episode.php +++ b/src/main/php/tracky/model/Episode.php @@ -1,13 +1,13 @@ "ASC"])] - private mixed $views; - public function getSeason(): Season { return $this->season; @@ -86,14 +82,6 @@ public function setFirstAired(?Date $firstAired): Episode return $this; } - public function getViewsForUser(User $user) - { - $criteria = Criteria::create(); - $criteria->where(Criteria::expr()->eq("user", $user)); - - return $this->views->matching($criteria); - } - public function getPreviousEpisode(): ?Episode { $season = $this->getSeason(); @@ -128,6 +116,11 @@ public function getNextEpisode(): ?Episode return null; } + public function getViewType(): ViewType + { + return ViewType::EPISODE; + } + public function __toString(): string { return sprintf("%dx%d %s", $this->getSeason()->getNumber(), $this->getNumber(), $this->getTitle()); diff --git a/src/main/php/tracky/model/EpisodeView.php b/src/main/php/tracky/model/EpisodeView.php deleted file mode 100644 index 3452515..0000000 --- a/src/main/php/tracky/model/EpisodeView.php +++ /dev/null @@ -1,30 +0,0 @@ -item; - } - - public function setEpisode(Episode $episode): EpisodeView - { - $this->item = $episode; - return $this; - } - - public function getType(): ViewType - { - return ViewType::EPISODE; - } -} diff --git a/src/main/php/tracky/model/Movie.php b/src/main/php/tracky/model/Movie.php index 3e07a12..7ff16e3 100644 --- a/src/main/php/tracky/model/Movie.php +++ b/src/main/php/tracky/model/Movie.php @@ -1,13 +1,13 @@ "ASC"])] - private mixed $views; - public function getTitle(): string { return $this->title; @@ -76,11 +72,8 @@ public function setYear(?int $year): Movie return $this; } - public function getViewsForUser(User $user) + public function getViewType(): ViewType { - $criteria = Criteria::create(); - $criteria->where(Criteria::expr()->eq("user", $user)); - - return $this->views->matching($criteria); + return ViewType::MOVIE; } } diff --git a/src/main/php/tracky/model/MovieView.php b/src/main/php/tracky/model/MovieView.php deleted file mode 100644 index 73557fb..0000000 --- a/src/main/php/tracky/model/MovieView.php +++ /dev/null @@ -1,30 +0,0 @@ -item; - } - - public function setMovie(Movie $movie): MovieView - { - $this->item = $movie; - return $this; - } - - public function getType(): ViewType - { - return ViewType::MOVIE; - } -} diff --git a/src/main/php/tracky/model/ViewEntry.php b/src/main/php/tracky/model/View.php similarity index 51% rename from src/main/php/tracky/model/ViewEntry.php rename to src/main/php/tracky/model/View.php index 0e92c20..80612cf 100644 --- a/src/main/php/tracky/model/ViewEntry.php +++ b/src/main/php/tracky/model/View.php @@ -3,14 +3,12 @@ use Doctrine\ORM\Mapping as ORM; use tracky\datetime\DateTime; +use tracky\orm\ViewRepository; use tracky\ViewType; -#[ORM\Entity] +#[ORM\Entity(repositoryClass: ViewRepository::class)] #[ORM\Table(name: "views")] -#[ORM\InheritanceType("SINGLE_TABLE")] -#[ORM\DiscriminatorColumn(name: "type", enumType: ViewType::class, type: "string", columnDefinition: "ENUM('episode', 'movie')")] -#[ORM\DiscriminatorMap([ViewType::EPISODE->value => EpisodeView::class, ViewType::MOVIE->value => MovieView::class])] -abstract class ViewEntry extends BaseEntity +class View extends BaseEntity { #[ORM\ManyToOne(targetEntity: User::class)] #[ORM\JoinColumn(name: "user", referencedColumnName: "id")] @@ -19,6 +17,12 @@ abstract class ViewEntry extends BaseEntity #[ORM\Column(name: "datetime", type: "datetime")] protected DateTime $dateTime; + #[ORM\Column(name: "item", type: "integer")] + protected int $item; + + #[ORM\Column(name: "type", enumType: ViewType::class, type: "string", columnDefinition: "ENUM('episode', 'movie')")] + protected ViewType $type; + public function getUser(): User { return $this->user; @@ -41,5 +45,25 @@ public function setDateTime(DateTime $dateTime): self return $this; } - abstract public function getType(): ViewType; + public function getItem(): int + { + return $this->item; + } + + public function setItem(Episode|Movie $item): self + { + $this->item = $item->getId(); + return $this; + } + + public function getType(): ViewType + { + return $this->type; + } + + public function setType(ViewType $type): self + { + $this->type = $type; + return $this; + } } diff --git a/src/main/php/tracky/orm/MovieRepository.php b/src/main/php/tracky/orm/MovieRepository.php index f99f1c9..3b5eb09 100644 --- a/src/main/php/tracky/orm/MovieRepository.php +++ b/src/main/php/tracky/orm/MovieRepository.php @@ -13,22 +13,4 @@ public function __construct(ManagerRegistry $registry) { parent::__construct($registry, Movie::class); } - - /** - * @return Movie[] - */ - public function findAllWithViews(int $userId): array - { - $query = $this->getEntityManager()->createQuery(" - SELECT movie, view - FROM tracky\model\Movie movie - LEFT JOIN movie.views view - LEFT JOIN view.user user - WHERE user.id IS NULL OR user.id = :userId - "); - - $query->setParameter("userId", $userId); - - return $query->getResult(); - } } diff --git a/src/main/php/tracky/orm/ShowRepository.php b/src/main/php/tracky/orm/ShowRepository.php index 8c2ce90..15e75e5 100644 --- a/src/main/php/tracky/orm/ShowRepository.php +++ b/src/main/php/tracky/orm/ShowRepository.php @@ -77,29 +77,4 @@ public function findByIdWithEpisodesAndViews(int $showId, int $userId): Show return $query->getOneOrNullResult(); } - - /** - * @return Show[] - */ - public function findAllWithEpisodesAndViews(int $userId): array - { - $query = $this->getEntityManager()->createQuery(" - SELECT show, season, episode, view - FROM tracky\model\Show show - LEFT JOIN show.seasons season - LEFT JOIN season.episodes episode - LEFT JOIN episode.views view - LEFT JOIN view.user user - WHERE user.id IS NULL OR user.id = :userId - ORDER BY show.title ASC - "); - - $query->setParameter("userId", $userId); - - $query->setFetchMode(Show::class, "seasons", ClassMetadataInfo::FETCH_EAGER); - $query->setFetchMode(Season::class, "episodes", ClassMetadataInfo::FETCH_EAGER); - $query->setFetchMode(Episode::class, "views", ClassMetadataInfo::FETCH_EAGER); - - return $query->getResult(); - } } diff --git a/src/main/php/tracky/orm/ViewRepository.php b/src/main/php/tracky/orm/ViewRepository.php index 3b80d2f..bcf2e44 100644 --- a/src/main/php/tracky/orm/ViewRepository.php +++ b/src/main/php/tracky/orm/ViewRepository.php @@ -5,14 +5,16 @@ use Doctrine\ORM\QueryBuilder; use Doctrine\Persistence\ManagerRegistry; use tracky\datetime\DateRange; -use tracky\model\ViewEntry; +use tracky\model\User; +use tracky\model\View; use tracky\ViewType; +use tracky\watchstats\WatchStatsCollection; class ViewRepository extends ServiceEntityRepository { - public function __construct(ManagerRegistry $registry, string $entityClass = ViewEntry::class) + public function __construct(ManagerRegistry $registry) { - parent::__construct($registry, $entityClass); + parent::__construct($registry, View::class); } private function getQueryBuilder(string $select, array $criteria, ?array $orderBy = null, $limit = null, $offset = null, ?ViewType $type = null, ?DateRange $dateRange = null): QueryBuilder @@ -90,4 +92,23 @@ public function getPaged(array $criteria, int $page, int $perPage, ?ViewType $ty return $this->findBy($criteria, ["dateTime" => "desc"], $perPage, $offset, $type, $dateRange); } + + public function getWatchStatsForUser(User $user, ViewType $viewType): WatchStatsCollection + { + $rows = $this->createQueryBuilder("view") + ->select(" + view.item, + COUNT(view.id) AS watchCount, + MAX(view.dateTime) AS lastWatched + ") + ->where("view.user = :user") + ->andWhere("view.type = :type") + ->groupBy("view.item") + ->setParameter("user", $user) + ->setParameter("type", $viewType->value) + ->getQuery() + ->getArrayResult(); + + return WatchStatsCollection::fromQueryRows($rows); + } } diff --git a/src/main/php/tracky/watchstats/ItemWatchStats.php b/src/main/php/tracky/watchstats/ItemWatchStats.php new file mode 100644 index 0000000..b7b8514 --- /dev/null +++ b/src/main/php/tracky/watchstats/ItemWatchStats.php @@ -0,0 +1,22 @@ +count; + } + + public function getLastWatched(): DateTime + { + return $this->lastWatched; + } +} diff --git a/src/main/php/tracky/watchstats/WatchStatsCollection.php b/src/main/php/tracky/watchstats/WatchStatsCollection.php new file mode 100644 index 0000000..94ae09b --- /dev/null +++ b/src/main/php/tracky/watchstats/WatchStatsCollection.php @@ -0,0 +1,35 @@ + + */ + private array $data; + + public function __construct(array $data = []) + { + $this->data = $data; + } + + public static function fromQueryRows(array $rows) + { + $data = []; + + foreach ($rows as $row) { + $data[(int) $row["item"]] = new ItemWatchStats(count: (int) $row["watchCount"], lastWatched: new DateTime($row["lastWatched"])); + } + + return new static($data); + } + + public function getStatsForItem(Episode|Movie $item): ?ItemWatchStats + { + return $this->data[$item->getId()] ?? null; + } +} diff --git a/src/main/php/tracky/watchstats/WatchStatsProvider.php b/src/main/php/tracky/watchstats/WatchStatsProvider.php new file mode 100644 index 0000000..4f9babb --- /dev/null +++ b/src/main/php/tracky/watchstats/WatchStatsProvider.php @@ -0,0 +1,40 @@ +, WatchStatsCollection> + */ + private array $collections; + + public function __construct( + private readonly ViewRepository $viewRepository, + private readonly Security $security + ) {} + + public function getStatsForType(ViewType $type): ?WatchStatsCollection + { + if ($this->collections[$type->value] ?? null !== null) { + return $this->collections[$type->value]; + } + + $user = $this->security->getUser(); + if ($user === null) { + return null; + } + + return $this->collections[$type->value] = $this->viewRepository->getWatchStatsForUser($user, $type); + } + + public function getItemStats(Episode|Movie $item): ?ItemWatchStats + { + return $this->getStatsForType($item->getViewType())?->getStatsForItem($item); + } +} diff --git a/templates/components/history-entry-card-small.twig b/templates/components/history-entry-card-small.twig index f65a084..5856c1a 100644 --- a/templates/components/history-entry-card-small.twig +++ b/templates/components/history-entry-card-small.twig @@ -6,14 +6,14 @@ {% set show = season.show %} {% set linkUrl = path("shows_season_page", {"show": show.id, "number": season.number}) ~ "#e" ~ episode.number %} {% set imageUrl = path("shows_episode_image", {"show": show.id, "seasonNumber": season.number, "episodeNumber": episode.number}) %} - {% set views = episode.viewsForUser(user) %} - {% set metadata = metadata|merge({"views": views|length, "show": show.id, "season": season.number, "episode": episode.number}) %} + {% set itemWatchStats = watchStatsProvider.getItemStats(episode) %} + {% set metadata = metadata|merge({"views": itemWatchStats.count, "show": show.id, "season": season.number, "episode": episode.number}) %} {% elseif entry.type.value == "movie" %} {% set movie = entry.movie %} {% set linkUrl = path("movies_single_page", {"movie": movie.id}) %} {% set imageUrl = path("movies_get_image", {"movie": movie.id}) %} - {% set views = movie.viewsForUser(user) %} - {% set metadata = metadata|merge({"views": views|length, "movie": movie.id}) %} + {% set itemWatchStats = watchStatsProvider.getItemStats(movie) %} + {% set metadata = metadata|merge({"views": itemWatchStats.count, "movie": movie.id}) %} {% endif %}
@@ -33,7 +33,7 @@ {% if user == app.user %} {% endif %} diff --git a/templates/components/movie-card-small.twig b/templates/components/movie-card-small.twig index 94cc635..fc96c67 100644 --- a/templates/components/movie-card-small.twig +++ b/templates/components/movie-card-small.twig @@ -14,11 +14,11 @@ {{ movie.year }} - {% if is_granted("IS_AUTHENTICATED") %} - {% set viewsForUser = movie.getViewsForUser(app.user) %} - {% if viewsForUser|length %} - {{ "views.views"|trans({"%count%": viewsForUser|length}) }} - {{ viewsForUser.last.dateTime|format_datetime }} + {% if watchStats %} + {% set itemWatchStats = watchStatsProvider.getItemStats(movie) %} + {% if itemWatchStats %} + {{ "views.views"|trans({"%count%": itemWatchStats.count}) }} + {{ itemWatchStats.lastWatched|format_datetime }} {% endif %} {% endif %} diff --git a/templates/components/view-last-watched.twig b/templates/components/view-last-watched.twig index e7e1a25..9bcb6f8 100644 --- a/templates/components/view-last-watched.twig +++ b/templates/components/view-last-watched.twig @@ -1,6 +1,6 @@ {% if is_granted("IS_AUTHENTICATED") %} - {% set viewsForUser = item.getViewsForUser(app.user) %} - {% if viewsForUser|length %} - {{ "views.last-watched"|trans }}: {{ viewsForUser.last.dateTime|format_datetime }} ({{ "views.views"|trans({"%count%": viewsForUser|length}) }}) + {% set itemWatchStats = watchStatsProvider.getItemStats(item) %} + {% if itemWatchStats %} + {{ "views.last-watched"|trans }}: {{ itemWatchStats.lastWatched|format_datetime }} ({{ "views.views"|trans({"%count%": itemWatchStats.count}) }}) {% endif %} {% endif %} diff --git a/templates/movies.twig b/templates/movies.twig index c4f261e..19e6d89 100644 --- a/templates/movies.twig +++ b/templates/movies.twig @@ -41,7 +41,7 @@ {% if movies %} - {% include "components/movie-list-small.twig" with {"movies": movies} %} + {% include "components/movie-list-small.twig" with {"movies": movies, "watchStats": watchStats} only %} {% else %}
{{ "movies.no-movies-available"|trans }}
{% endif %} diff --git a/templates/user/show-progress.twig b/templates/user/show-progress.twig index a374da2..175a4fd 100644 --- a/templates/user/show-progress.twig +++ b/templates/user/show-progress.twig @@ -31,7 +31,7 @@ {% for season in show.seasons %} {% set perEpisodePercentage = 100 / totalEpisodes %} {% for episode in season.episodes %} - {% set watched = episode.getViewsForUser(user)|length > 0 %} + {% set watched = watchStatsProvider.getItemStats(episode)?.count > 0 %} {% if watched %} {% set watchedEpisodes = watchedEpisodes + 1 %} {% endif %} @@ -59,7 +59,7 @@ {% set perEpisodePercentage = 100 / totalEpisodes %} {% for episode in season.episodes %} - {% set watched = episode.getViewsForUser(user)|length > 0 %} + {% set watched = watchStatsProvider.getItemStats(episode)?.count > 0 %} {% if watched %} {% set watchedEpisodes = watchedEpisodes + 1 %} {% endif %} From 1769f693bff095a19ee59b342231cb85d38e4262 Mon Sep 17 00:00:00 2001 From: Michael Wieland Date: Mon, 22 Jun 2026 21:25:28 +0200 Subject: [PATCH 2/6] Use WatchStatsProvider to get watched/unwatched episodes in Show --- .../php/tracky/controller/ShowController.php | 25 ++-- src/main/php/tracky/model/Show.php | 112 +++++++++--------- templates/shows/show.twig | 2 +- 3 files changed, 67 insertions(+), 72 deletions(-) diff --git a/src/main/php/tracky/controller/ShowController.php b/src/main/php/tracky/controller/ShowController.php index 9ed0bfd..766b368 100644 --- a/src/main/php/tracky/controller/ShowController.php +++ b/src/main/php/tracky/controller/ShowController.php @@ -21,6 +21,7 @@ use tracky\orm\ShowRepository; use tracky\orm\ViewRepository; use tracky\ViewType; +use tracky\watchstats\WatchStatsProvider; class ShowController extends AbstractController { @@ -122,53 +123,53 @@ public function getRandomEpisodesPage(int $show): Response #[Route("/shows/{show}/latest-watched", name: "shows_latest_watched_episodes_page")] #[IsGranted("IS_AUTHENTICATED")] - public function getLatestWatchedEpisodesPage(int $show): Response + public function getLatestWatchedEpisodesPage(int $show, WatchStatsProvider $watchStatsProvider): Response { - $show = $this->showRepository->findByIdWithEpisodesAndViews($show, $this->getUser()->getId()); + $show = $this->showRepository->findByIdWithEpisodes($show); return $this->render("shows/episodes.twig", [ "show" => $show, "title" => "shows.latest-watched-episodes", - "episodes" => $show->getLatestWatchedEpisodes($this->getUser(), $this->maxEpisodes) + "episodes" => $show->getLatestWatchedEpisodes($watchStatsProvider, $this->maxEpisodes) ]); } #[Route("/shows/{show}/most-watched", name: "shows_most_watched_episodes_page")] #[IsGranted("IS_AUTHENTICATED")] - public function getMostWatchedEpisodesPage(int $show): Response + public function getMostWatchedEpisodesPage(int $show, WatchStatsProvider $watchStatsProvider): Response { - $show = $this->showRepository->findByIdWithEpisodesAndViews($show, $this->getUser()->getId()); + $show = $this->showRepository->findByIdWithEpisodes($show); return $this->render("shows/episodes.twig", [ "show" => $show, "title" => "shows.most-watched-episodes", - "episodes" => $show->getMostOrLeastWatchedEpisodes($this->getUser(), $this->maxEpisodes) + "episodes" => $show->getMostOrLeastWatchedEpisodes($watchStatsProvider, $this->maxEpisodes) ]); } #[Route("/shows/{show}/least-watched", name: "shows_least_watched_episodes_page")] #[IsGranted("IS_AUTHENTICATED")] - public function getLeastWatchedEpisodesPage(int $show): Response + public function getLeastWatchedEpisodesPage(int $show, WatchStatsProvider $watchStatsProvider): Response { - $show = $this->showRepository->findByIdWithEpisodesAndViews($show, $this->getUser()->getId()); + $show = $this->showRepository->findByIdWithEpisodes($show); return $this->render("shows/episodes.twig", [ "show" => $show, "title" => "shows.least-watched-episodes", - "episodes" => $show->getMostOrLeastWatchedEpisodes($this->getUser(), $this->maxEpisodes, true) + "episodes" => $show->getMostOrLeastWatchedEpisodes($watchStatsProvider, $this->maxEpisodes, true) ]); } #[Route("/shows/{show}/unwatched", name: "shows_unwatched_episodes_page")] #[IsGranted("IS_AUTHENTICATED")] - public function getUnwatchedEpisodesPage(int $show): Response + public function getUnwatchedEpisodesPage(int $show, WatchStatsProvider $watchStatsProvider): Response { - $show = $this->showRepository->findByIdWithEpisodesAndViews($show, $this->getUser()->getId()); + $show = $this->showRepository->findByIdWithEpisodes($show); return $this->render("shows/episodes.twig", [ "show" => $show, "title" => "shows.unwatched-episodes", - "episodes" => $show->getUnwatchedEpisodes($this->getUser()) + "episodes" => $show->getUnwatchedEpisodes($watchStatsProvider) ]); } diff --git a/src/main/php/tracky/model/Show.php b/src/main/php/tracky/model/Show.php index c92f8a9..bd4d72d 100644 --- a/src/main/php/tracky/model/Show.php +++ b/src/main/php/tracky/model/Show.php @@ -8,6 +8,7 @@ use tracky\model\traits\PosterImage; use tracky\model\traits\DataProvider; use tracky\orm\ShowRepository; +use tracky\watchstats\WatchStatsProvider; #[ORM\Entity(repositoryClass: ShowRepository::class)] #[ORM\Table(name: "shows")] @@ -176,101 +177,94 @@ public function getRandomEpisodes(int $count): array return $randomEpisodes; } - public function getLatestWatchedEpisodes(User $user, int $count, bool $includeTimestamp = false): array + public function getLatestWatchedEpisodes(WatchStatsProvider $watchStatsProvider, int $count, bool $includeWatchStats = false): array { - $allEpisodes = []; - - foreach ($this->getSeasons() as $season) { - foreach ($season->getEpisodes() as $episode) { - $views = $episode->getViewsForUser($user); - $viewCount = count($views); - if (!$viewCount) { - continue; - } + $episodes = $this->getWatchedEpisodesSortedByLastWatched($watchStatsProvider); - $allEpisodes[] = [$episode, $views->last()->getDateTime()->getTimestamp()]; + if (!$includeWatchStats) { + foreach ($episodes as &$item) { + $item = $item[0]; } } - usort($allEpisodes, function ($item1, $item2) { - list(, $item1Timestamp) = $item1; - list(, $item2Timestamp) = $item2; + return array_slice($episodes, 0, $count); + } + + public function getMostOrLeastWatchedEpisodes(WatchStatsProvider $watchStatsProvider, int $count, bool $leastWatched = false): array + { + $episodes = $this->getWatchedEpisodesSortedByLastWatched($watchStatsProvider); + + foreach ($episodes as &$item) { + $item = $item[0]; + } + + if ($leastWatched) { + $episodes = array_reverse($episodes); + } + + return array_slice($episodes, 0, $count); + } + + /** + * @return list + */ + public function getWatchedEpisodesSortedByLastWatched(WatchStatsProvider $watchStatsProvider): array + { + $episodes = $this->getWatchedEpisodes($watchStatsProvider); + + usort($episodes, function ($item1, $item2) { + $item1WatchStats = $item1[1]; + $item2WatchStats = $item2[1]; + $item1LastWatched = $item1WatchStats->getLastWatched(); + $item2LastWatched = $item2WatchStats->getLastWatched(); - if ($item1Timestamp === $item2Timestamp) { + if ($item1LastWatched === $item2LastWatched) { return 0; } - return ($item1Timestamp > $item2Timestamp) ? -1 : 1; + return ($item1LastWatched > $item2LastWatched) ? -1 : 1; }); - if (!$includeTimestamp) { - foreach ($allEpisodes as &$item) { - $item = $item[0]; - } - } - - return array_slice($allEpisodes, 0, $count); + return $episodes; } - public function getMostOrLeastWatchedEpisodes(User $user, int $count, bool $leastWatched = false): array + /** + * @return list + */ + public function getWatchedEpisodes(WatchStatsProvider $watchStatsProvider): array { $allEpisodes = []; foreach ($this->getSeasons() as $season) { foreach ($season->getEpisodes() as $episode) { - $views = $episode->getViewsForUser($user); - $viewCount = count($views); - if (!$viewCount) { + $itemWatchStats = $watchStatsProvider->getItemStats($episode); + if ($itemWatchStats === null or !$itemWatchStats->getCount()) { continue; } - $allEpisodes[] = [$episode, $viewCount, $views->last()->getDateTime()->getTimestamp()]; - } - } - - usort($allEpisodes, function ($item1, $item2) { - list(, $item1Count, $item1Timestamp) = $item1; - list(, $item2Count, $item2Timestamp) = $item2; - - - if ($item1Count === $item2Count) { - if ($item1Timestamp === $item2Timestamp) { - return 0; - } - - return ($item1Timestamp > $item2Timestamp) ? -1 : 1; + $allEpisodes[] = [$episode, $itemWatchStats]; } - - return ($item1Count > $item2Count) ? -1 : 1; - }); - - foreach ($allEpisodes as &$item) { - $item = $item[0]; } - if ($leastWatched) { - $allEpisodes = array_reverse($allEpisodes); - } - - return array_slice($allEpisodes, 0, $count); + return $allEpisodes; } - public function getUnwatchedEpisodes(User $user): array + public function getUnwatchedEpisodes(WatchStatsProvider $watchStatsProvider): array { - $episodes = []; + $unwatchedEpisodes = []; foreach ($this->getSeasons() as $season) { foreach ($season->getEpisodes() as $episode) { - $views = $episode->getViewsForUser($user); - if (count($views)) { + $itemWatchStats = $watchStatsProvider->getItemStats($episode); + if ($itemWatchStats !== null and $itemWatchStats->getCount()) { continue; } - $episodes[] = $episode; + $unwatchedEpisodes[] = $episode; } } - return $episodes; + return $unwatchedEpisodes; } } diff --git a/templates/shows/show.twig b/templates/shows/show.twig index 74bf813..eb826a5 100644 --- a/templates/shows/show.twig +++ b/templates/shows/show.twig @@ -23,7 +23,7 @@ {{ _self.navItem("shows_latest_watched_episodes_page", {"show": show.id}, "shows.latest-watched-episodes"|trans) }} {{ _self.navItem("shows_most_watched_episodes_page", {"show": show.id}, "shows.most-watched-episodes"|trans) }} {{ _self.navItem("shows_least_watched_episodes_page", {"show": show.id}, "shows.least-watched-episodes"|trans) }} - {{ _self.navItem("shows_unwatched_episodes_page", {"show": show.id}, "shows.unwatched-episodes"|trans({"%count%": show.getUnwatchedEpisodes(app.user)|length})) }} + {{ _self.navItem("shows_unwatched_episodes_page", {"show": show.id}, "shows.unwatched-episodes"|trans({"%count%": show.getUnwatchedEpisodes(watchStatsProvider)|length})) }} {% endif %} From 6ed82cd456eabf409d7395a2b7d968a691cccfb6 Mon Sep 17 00:00:00 2001 From: Michael Wieland Date: Mon, 22 Jun 2026 21:58:10 +0200 Subject: [PATCH 3/6] Fixed always showing TV show progress for current user instead of username from URL --- .../php/tracky/controller/UserController.php | 6 ++-- .../tracky/watchstats/WatchStatsProvider.php | 34 +++++++++++++------ templates/user/show-progress.twig | 4 +-- 3 files changed, 29 insertions(+), 15 deletions(-) diff --git a/src/main/php/tracky/controller/UserController.php b/src/main/php/tracky/controller/UserController.php index a874f71..e4d7712 100644 --- a/src/main/php/tracky/controller/UserController.php +++ b/src/main/php/tracky/controller/UserController.php @@ -15,6 +15,7 @@ use tracky\orm\UserRepository; use tracky\scrobbler\Scrobbler; use tracky\ViewType; +use tracky\watchstats\WatchStatsProvider; class UserController extends AbstractController { @@ -119,11 +120,12 @@ public function getProfilePage(User $user, ViewRepository $viewRepository, Scrob } #[Route("/users/{username}/show-progress", name: "user_profile_show_progress_page")] - public function getShowProgressForUser(User $user, ShowRepository $showRepository, ViewRepository $viewRepository): Response + public function getShowProgressForUser(User $user, ShowRepository $showRepository, WatchStatsProvider $watchStatsProvider): Response { return $this->render("user/show-progress.twig", [ "user" => $user, - "shows" => $showRepository->findAllWithEpisodes() + "shows" => $showRepository->findAllWithEpisodes(), + "watchStatsCollection" => $watchStatsProvider->getStatsForType(ViewType::EPISODE, $user) ]); } } diff --git a/src/main/php/tracky/watchstats/WatchStatsProvider.php b/src/main/php/tracky/watchstats/WatchStatsProvider.php index 4f9babb..c47994c 100644 --- a/src/main/php/tracky/watchstats/WatchStatsProvider.php +++ b/src/main/php/tracky/watchstats/WatchStatsProvider.php @@ -4,37 +4,49 @@ use Symfony\Bundle\SecurityBundle\Security; use tracky\model\Episode; use tracky\model\Movie; +use tracky\model\User; use tracky\orm\ViewRepository; use tracky\ViewType; class WatchStatsProvider { /** - * @var array, WatchStatsCollection> + * @var array, WatchStatsCollection>> */ - private array $collections; + private array $perUserCollections; public function __construct( private readonly ViewRepository $viewRepository, private readonly Security $security ) {} - public function getStatsForType(ViewType $type): ?WatchStatsCollection + public function getStatsForType(ViewType $type, ?User $user = null): ?WatchStatsCollection { - if ($this->collections[$type->value] ?? null !== null) { - return $this->collections[$type->value]; + if ($user === null) { + /** + * @var User + */ + $user = $this->security->getUser(); + if ($user === null) { + return null; + } } - $user = $this->security->getUser(); - if ($user === null) { - return null; + $userId = $user->getId(); + + if (!isset($this->perUserCollections[$userId])) { + $this->perUserCollections[$userId] = []; + } + + if (isset($this->perUserCollections[$userId][$type->value])) { + return $this->perUserCollections[$userId][$type->value]; } - return $this->collections[$type->value] = $this->viewRepository->getWatchStatsForUser($user, $type); + return $this->perUserCollections[$userId][$type->value] = $this->viewRepository->getWatchStatsForUser($user, $type); } - public function getItemStats(Episode|Movie $item): ?ItemWatchStats + public function getItemStats(Episode|Movie $item, ?User $user = null): ?ItemWatchStats { - return $this->getStatsForType($item->getViewType())?->getStatsForItem($item); + return $this->getStatsForType($item->getViewType(), $user)?->getStatsForItem($item); } } diff --git a/templates/user/show-progress.twig b/templates/user/show-progress.twig index 175a4fd..bd2677d 100644 --- a/templates/user/show-progress.twig +++ b/templates/user/show-progress.twig @@ -31,7 +31,7 @@ {% for season in show.seasons %} {% set perEpisodePercentage = 100 / totalEpisodes %} {% for episode in season.episodes %} - {% set watched = watchStatsProvider.getItemStats(episode)?.count > 0 %} + {% set watched = watchStatsCollection.getStatsForItem(episode)?.count > 0 %} {% if watched %} {% set watchedEpisodes = watchedEpisodes + 1 %} {% endif %} @@ -59,7 +59,7 @@ {% set perEpisodePercentage = 100 / totalEpisodes %} {% for episode in season.episodes %} - {% set watched = watchStatsProvider.getItemStats(episode)?.count > 0 %} + {% set watched = watchStatsCollection.getStatsForItem(episode)?.count > 0 %} {% if watched %} {% set watchedEpisodes = watchedEpisodes + 1 %} {% endif %} From 19125a967265359c96b1ba519a2967bef768b120 Mon Sep 17 00:00:00 2001 From: Michael Wieland Date: Thu, 25 Jun 2026 22:09:15 +0200 Subject: [PATCH 4/6] Fix home page, fix most/least watched episodes --- .../php/tracky/controller/HomeController.php | 95 +++++++++---------- .../php/tracky/controller/ShowController.php | 6 +- src/main/php/tracky/model/Show.php | 37 ++++---- src/main/php/tracky/orm/EpisodeRepository.php | 16 ++++ .../php/tracky/orm/EpisodeViewRepository.php | 13 --- src/main/php/tracky/orm/MovieRepository.php | 16 ++++ .../php/tracky/orm/MovieViewRepository.php | 13 --- src/main/php/tracky/orm/ViewRepository.php | 23 ++++- .../components/history-entry-card-small.twig | 23 +++-- templates/components/movie-card-small.twig | 10 +- 10 files changed, 133 insertions(+), 119 deletions(-) delete mode 100644 src/main/php/tracky/orm/EpisodeViewRepository.php delete mode 100644 src/main/php/tracky/orm/MovieViewRepository.php diff --git a/src/main/php/tracky/controller/HomeController.php b/src/main/php/tracky/controller/HomeController.php index 049be53..972de13 100644 --- a/src/main/php/tracky/controller/HomeController.php +++ b/src/main/php/tracky/controller/HomeController.php @@ -5,13 +5,15 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; use tracky\model\Episode; -use tracky\model\User; +use tracky\model\Movie; +use tracky\model\View; use tracky\orm\EpisodeRepository; use tracky\orm\MovieRepository; use tracky\orm\ShowRepository; use tracky\orm\ViewRepository; use tracky\scrobbler\Scrobbler; use tracky\ViewType; +use tracky\watchstats\WatchStatsProvider; class HomeController extends AbstractController { @@ -24,7 +26,7 @@ public function __construct( } #[Route("/", name: "home_page")] - public function home(ShowRepository $showRepository, EpisodeRepository $episodeRepository, MovieRepository $movieRepository, ViewRepository $viewRepository, Scrobbler $scrobbler): Response + public function home(ShowRepository $showRepository, EpisodeRepository $episodeRepository, MovieRepository $movieRepository, ViewRepository $viewRepository, WatchStatsProvider $watchStatsProvider, Scrobbler $scrobbler): Response { $nowWatching = null; $latestWatchedEpisodes = null; @@ -34,9 +36,32 @@ public function home(ShowRepository $showRepository, EpisodeRepository $episodeR $user = $this->getUser(); if ($user !== null) { $nowWatching = $scrobbler->getNowWatching($user); - $latestWatchedEpisodes = $viewRepository->findBy(["user" => $user->getId()], ["dateTime" => "desc"], $this->maxEpisodes, type: ViewType::EPISODE); - $latestWatchedMovies = $viewRepository->findBy(["user" => $user->getId()], ["dateTime" => "desc"], $this->maxMovies, type: ViewType::MOVIE); - $nextEpisodes = $this->getNextEpisodes($showRepository, $viewRepository, $user); + + $latestWatchedEpisodes = []; + $episodeViews = $viewRepository->findBy(["user" => $user->getId()], ["dateTime" => "desc"], $this->maxEpisodes, type: ViewType::EPISODE); + $episodes = $episodeRepository->findByIds(array_map(fn(View $view) => $view->getItem(), $episodeViews)); + $episodes = array_combine(array_map(fn(Episode $episode) => $episode->getId(), $episodes), $episodes); + + foreach ($episodeViews as $view) { + $latestWatchedEpisodes[] = [ + "view" => $view, + "item" => $episodes[$view->getItem()] + ]; + } + + $latestWatchedMovies = []; + $movieViews = $viewRepository->findBy(["user" => $user->getId()], ["dateTime" => "desc"], $this->maxMovies, type: ViewType::MOVIE); + $movies = $movieRepository->findByIds(array_map(fn(View $view) => $view->getItem(), $movieViews)); + $movies = array_combine(array_map(fn(Movie $movie) => $movie->getId(), $movies), $movies); + + foreach ($movieViews as $view) { + $latestWatchedMovies[] = [ + "view" => $view, + "item" => $movies[$view->getItem()] + ]; + } + + $nextEpisodes = $this->getNextEpisodes($showRepository, $watchStatsProvider); } return $this->render("index.twig", [ @@ -49,62 +74,30 @@ public function home(ShowRepository $showRepository, EpisodeRepository $episodeR ]); } - private function getNextEpisodes(ShowRepository $showRepository, ViewRepository $viewRepository, User $user) + private function getNextEpisodes(ShowRepository $showRepository, WatchStatsProvider $watchStatsProvider) { - $latestEpisodes = []; - $nextEpisodes = []; - - $watchStats = $viewRepository->getEpisodeWatchStatsForUser($user); - - $latestEpisodes = []; + /** + * @var list + */ + $episodes = []; foreach ($showRepository->findAllWithEpisodes() as $show) { - $mostRecentWatch = null; - $mostRecentWatchedEpisode = null; - - foreach ($show->getSeasons() as $season) { - foreach ($season->getEpisodes() as $episode) { - $episodeWatchStats = $watchStats[$episode->getId()] ?? null; - if ($episodeWatchStats === null) { - continue; - } - - if ($mostRecentWatch === null or $episodeWatchStats["lastWatched"] > $mostRecentWatch) { - $mostRecentWatch = $episodeWatchStats["lastWatched"]; - $mostRecentWatchedEpisode = $episode; - } - } - } + $latestWatchedEpisode = $show->getLatestWatchedEpisodes($watchStatsProvider, 1)[0] ?? null; - if ($mostRecentWatch === null) { + if ($latestWatchedEpisode === null) { continue; } - $latestEpisodes[] = [ - "episode" => $mostRecentWatchedEpisode, - "lastWatched" => $mostRecentWatch - ]; - } - - usort($latestEpisodes, function ($item1, $item2) { - $item1Timestamp = $item1["lastWatched"]; - $item2Timestamp = $item2["lastWatched"]; - - if ($item1Timestamp === $item2Timestamp) { - return 0; + $nextEpisode = $latestWatchedEpisode[0]->getNextEpisode(); + if ($nextEpisode === null) { + continue; } - return ($item1Timestamp > $item2Timestamp) ? -1 : 1; - }); - - foreach ($latestEpisodes as $item) { - $episode = $item["episode"]; - $nextEpisode = $episode->getNextEpisode(); - if ($nextEpisode !== null) { - $nextEpisodes[] = $nextEpisode; - } + $episodes[] = [$nextEpisode, $latestWatchedEpisode[1]->getLastWatched()]; } - return array_slice($nextEpisodes, 0, $this->maxNextEpisodeShows); + usort($episodes, fn($item1, $item2) => $item2[1] <=> $item1[1]); + + return array_map(fn($item) => $item[0], array_slice($episodes, 0, $this->maxNextEpisodeShows)); } } diff --git a/src/main/php/tracky/controller/ShowController.php b/src/main/php/tracky/controller/ShowController.php index 766b368..2895687 100644 --- a/src/main/php/tracky/controller/ShowController.php +++ b/src/main/php/tracky/controller/ShowController.php @@ -130,7 +130,7 @@ public function getLatestWatchedEpisodesPage(int $show, WatchStatsProvider $watc return $this->render("shows/episodes.twig", [ "show" => $show, "title" => "shows.latest-watched-episodes", - "episodes" => $show->getLatestWatchedEpisodes($watchStatsProvider, $this->maxEpisodes) + "episodes" => array_map(fn($item) => $item[0], $show->getLatestWatchedEpisodes($watchStatsProvider, $this->maxEpisodes)) ]); } @@ -143,7 +143,7 @@ public function getMostWatchedEpisodesPage(int $show, WatchStatsProvider $watchS return $this->render("shows/episodes.twig", [ "show" => $show, "title" => "shows.most-watched-episodes", - "episodes" => $show->getMostOrLeastWatchedEpisodes($watchStatsProvider, $this->maxEpisodes) + "episodes" => array_map(fn($item) => $item[0], $show->getMostOrLeastWatchedEpisodes($watchStatsProvider, $this->maxEpisodes, false)) ]); } @@ -156,7 +156,7 @@ public function getLeastWatchedEpisodesPage(int $show, WatchStatsProvider $watch return $this->render("shows/episodes.twig", [ "show" => $show, "title" => "shows.least-watched-episodes", - "episodes" => $show->getMostOrLeastWatchedEpisodes($watchStatsProvider, $this->maxEpisodes, true) + "episodes" => array_map(fn($item) => $item[0], $show->getMostOrLeastWatchedEpisodes($watchStatsProvider, $this->maxEpisodes, true)) ]); } diff --git a/src/main/php/tracky/model/Show.php b/src/main/php/tracky/model/Show.php index bd4d72d..b6c888e 100644 --- a/src/main/php/tracky/model/Show.php +++ b/src/main/php/tracky/model/Show.php @@ -177,26 +177,29 @@ public function getRandomEpisodes(int $count): array return $randomEpisodes; } + /** + * @return list + */ public function getLatestWatchedEpisodes(WatchStatsProvider $watchStatsProvider, int $count, bool $includeWatchStats = false): array { $episodes = $this->getWatchedEpisodesSortedByLastWatched($watchStatsProvider); - if (!$includeWatchStats) { - foreach ($episodes as &$item) { - $item = $item[0]; - } - } - return array_slice($episodes, 0, $count); } - public function getMostOrLeastWatchedEpisodes(WatchStatsProvider $watchStatsProvider, int $count, bool $leastWatched = false): array + /** + * @return list + */ + public function getMostOrLeastWatchedEpisodes(WatchStatsProvider $watchStatsProvider, int $count, bool $leastWatched): array { - $episodes = $this->getWatchedEpisodesSortedByLastWatched($watchStatsProvider); + $episodes = $this->getWatchedEpisodes($watchStatsProvider); - foreach ($episodes as &$item) { - $item = $item[0]; - } + usort($episodes, function($item1, $item2) { + $item1WatchStats = $item1[1]; + $item2WatchStats = $item2[1]; + + return $item2WatchStats->getCount() <=> $item1WatchStats->getCount(); + }); if ($leastWatched) { $episodes = array_reverse($episodes); @@ -216,14 +219,7 @@ public function getWatchedEpisodesSortedByLastWatched(WatchStatsProvider $watchS $item1WatchStats = $item1[1]; $item2WatchStats = $item2[1]; - $item1LastWatched = $item1WatchStats->getLastWatched(); - $item2LastWatched = $item2WatchStats->getLastWatched(); - - if ($item1LastWatched === $item2LastWatched) { - return 0; - } - - return ($item1LastWatched > $item2LastWatched) ? -1 : 1; + return $item2WatchStats->getLastWatched() <=> $item1WatchStats->getLastWatched(); }); return $episodes; @@ -250,6 +246,9 @@ public function getWatchedEpisodes(WatchStatsProvider $watchStatsProvider): arra return $allEpisodes; } + /** + * @return Episode[] + */ public function getUnwatchedEpisodes(WatchStatsProvider $watchStatsProvider): array { $unwatchedEpisodes = []; diff --git a/src/main/php/tracky/orm/EpisodeRepository.php b/src/main/php/tracky/orm/EpisodeRepository.php index 8ebee70..a804c61 100644 --- a/src/main/php/tracky/orm/EpisodeRepository.php +++ b/src/main/php/tracky/orm/EpisodeRepository.php @@ -34,4 +34,20 @@ public function search(string $query, ?int $showId = null) ->getQuery() ->getResult(); } + + /** + * @return Episode[] + */ + public function findByIds(array $ids): array + { + if (empty($ids)) { + return []; + } + + $queryBuilder = $this->createQueryBuilder("episode"); + + $queryBuilder->where($queryBuilder->expr()->in("episode.id", $ids)); + + return $queryBuilder->getQuery()->getResult(); + } } diff --git a/src/main/php/tracky/orm/EpisodeViewRepository.php b/src/main/php/tracky/orm/EpisodeViewRepository.php deleted file mode 100644 index a0913f5..0000000 --- a/src/main/php/tracky/orm/EpisodeViewRepository.php +++ /dev/null @@ -1,13 +0,0 @@ -createQueryBuilder("movie"); + + $queryBuilder->where($queryBuilder->expr()->in("movie.id", $ids)); + + return $queryBuilder->getQuery()->getResult(); + } } diff --git a/src/main/php/tracky/orm/MovieViewRepository.php b/src/main/php/tracky/orm/MovieViewRepository.php deleted file mode 100644 index a9c5c71..0000000 --- a/src/main/php/tracky/orm/MovieViewRepository.php +++ /dev/null @@ -1,13 +0,0 @@ -andWhere("view INSTANCE OF :type") + ->andWhere("view.type = :type") ->setParameter(":type", $type->value); } @@ -61,6 +61,9 @@ private function getQueryBuilder(string $select, array $criteria, ?array $orderB return $queryBuilder; } + /** + * @return View[] + */ public function findBy(array $criteria, ?array $orderBy = null, $limit = null, $offset = null, ?ViewType $type = null, ?DateRange $dateRange = null): array { $queryBuilder = $this->getQueryBuilder("view", $criteria, $orderBy, $limit, $offset, $type, $dateRange); @@ -68,7 +71,7 @@ public function findBy(array $criteria, ?array $orderBy = null, $limit = null, $ return $queryBuilder->getQuery()->getResult(); } - public function findOneBy(array $criteria, ?array $orderBy = null, ?ViewType $type = null): ?object + public function findOneBy(array $criteria, ?array $orderBy = null, ?ViewType $type = null): ?View { $items = $this->findBy($criteria, $orderBy, 1, type: $type); @@ -79,6 +82,22 @@ public function findOneBy(array $criteria, ?array $orderBy = null, ?ViewType $ty return $items[0]; } + /** + * @return int[] + */ + public function getItemIdsBy(array $criteria, ?array $orderBy = null, $limit = null, $offset = null, ?ViewType $type = null, ?DateRange $dateRange = null): array + { + $queryBuilder = $this->getQueryBuilder("view", $criteria, $orderBy, $limit, $offset, $type, $dateRange); + + $ids = []; + + foreach ($queryBuilder->getQuery()->getResult() as $view) { + $ids[] = $view->getItem(); + } + + return $ids; + } + public function count(array $criteria = [], ?ViewType $type = null, ?DateRange $dateRange = null): int { $queryBuilder = $this->getQueryBuilder(select: "count(view.id)", criteria: $criteria, type: $type, dateRange: $dateRange); diff --git a/templates/components/history-entry-card-small.twig b/templates/components/history-entry-card-small.twig index 5856c1a..60d5744 100644 --- a/templates/components/history-entry-card-small.twig +++ b/templates/components/history-entry-card-small.twig @@ -1,19 +1,18 @@ -{% set metadata = {"entry-type": entry.type.value, "entry-id": entry.id} %} +{% set itemWatchStats = watchStatsProvider.getItemStats(entry.item) %} +{% set metadata = {"entry-type": entry.view.type.value, "entry-id": entry.view.id, "views": itemWatchStats.count} %} -{% if entry.type.value == "episode" %} - {% set episode = entry.episode %} +{% if entry.view.type.value == "episode" %} + {% set episode = entry.item %} {% set season = episode.season %} {% set show = season.show %} {% set linkUrl = path("shows_season_page", {"show": show.id, "number": season.number}) ~ "#e" ~ episode.number %} {% set imageUrl = path("shows_episode_image", {"show": show.id, "seasonNumber": season.number, "episodeNumber": episode.number}) %} - {% set itemWatchStats = watchStatsProvider.getItemStats(episode) %} - {% set metadata = metadata|merge({"views": itemWatchStats.count, "show": show.id, "season": season.number, "episode": episode.number}) %} -{% elseif entry.type.value == "movie" %} - {% set movie = entry.movie %} + {% set metadata = metadata|merge({"show": show.id, "season": season.number, "episode": episode.number}) %} +{% elseif entry.view.type.value == "movie" %} + {% set movie = entry.item %} {% set linkUrl = path("movies_single_page", {"movie": movie.id}) %} {% set imageUrl = path("movies_get_image", {"movie": movie.id}) %} - {% set itemWatchStats = watchStatsProvider.getItemStats(movie) %} - {% set metadata = metadata|merge({"views": itemWatchStats.count, "movie": movie.id}) %} + {% set metadata = metadata|merge({"movie": movie.id}) %} {% endif %}
@@ -23,13 +22,13 @@
- {% if entry.type.value == "episode" %} + {% if entry.view.type.value == "episode" %}
{{ season.number }}x{{ episode.number }} {{ episode.title|default("unknown-title"|trans) }}
- {% elseif entry.type.value == "movie" %} + {% elseif entry.view.type.value == "movie" %}
{{ movie.title }}
{% endif %} -
{{ entry.datetime|format_datetime }}
+
{{ entry.view.datetime|format_datetime }}
{% if user == app.user %} From be456a01e03fb154ab93070a06483344f0c2462d Mon Sep 17 00:00:00 2001 From: Michael Wieland Date: Sat, 27 Jun 2026 23:01:01 +0200 Subject: [PATCH 5/6] Show default image if image is missing, some style improvements like image hover effect and rounded corners --- .../assets/images/missing-image-poster.svg | 64 +++++++++++++++++++ .../assets/images/missing-image-wide.svg | 50 +++++++++++++++ src/main/resources/assets/script/main.ts | 31 +++++---- src/main/resources/assets/style/main.scss | 45 ++++++++++--- templates/components/episode-card-small.twig | 2 +- templates/components/episode-card-wide.twig | 4 +- .../components/history-entry-card-small.twig | 4 +- templates/components/movie-card-small.twig | 2 +- templates/components/movie-card-wide.twig | 4 +- .../components/now-watching-episode.twig | 4 +- templates/components/now-watching-movie.twig | 4 +- templates/components/season-card-small.twig | 2 +- templates/components/show-card-small.twig | 2 +- templates/library-management/add-items.twig | 2 +- templates/movie.twig | 2 +- templates/user/show-progress.twig | 4 +- 16 files changed, 190 insertions(+), 36 deletions(-) create mode 100644 src/main/resources/assets/images/missing-image-poster.svg create mode 100644 src/main/resources/assets/images/missing-image-wide.svg diff --git a/src/main/resources/assets/images/missing-image-poster.svg b/src/main/resources/assets/images/missing-image-poster.svg new file mode 100644 index 0000000..4433157 --- /dev/null +++ b/src/main/resources/assets/images/missing-image-poster.svg @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + NO IMAGE + + + + AVAILABLE + + + diff --git a/src/main/resources/assets/images/missing-image-wide.svg b/src/main/resources/assets/images/missing-image-wide.svg new file mode 100644 index 0000000..8697d91 --- /dev/null +++ b/src/main/resources/assets/images/missing-image-wide.svg @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + NO IMAGE + + + + AVAILABLE + + + diff --git a/src/main/resources/assets/script/main.ts b/src/main/resources/assets/script/main.ts index f510b26..6ac485d 100644 --- a/src/main/resources/assets/script/main.ts +++ b/src/main/resources/assets/script/main.ts @@ -3,21 +3,12 @@ import "./history"; import "./library-management"; import "./view"; -document.addEventListener("DOMContentLoaded", () => { - let lazyBackgroundObserver = new IntersectionObserver((entries) => { - entries.forEach(function (entry) { - if (entry.isIntersecting) { - let element = entry.target as HTMLElement; - element.style.backgroundImage = `url(${element.dataset.imageUrl})`; - lazyBackgroundObserver.unobserve(element); - } - }); - }); - - document.querySelectorAll(".lazy-background").forEach(function (lazyBackground) { - lazyBackgroundObserver.observe(lazyBackground); - }); +// @ts-ignore +import missingImagePoster from "../images/missing-image-poster.svg"; +// @ts-ignore +import missingImageWide from "../images/missing-image-wide.svg"; +document.addEventListener("DOMContentLoaded", () => { document.querySelectorAll(".season-dropdown").forEach((dropdown) => { dropdown.addEventListener("shown.bs.dropdown", () => { let menu = dropdown.querySelector(".dropdown-menu"); @@ -29,4 +20,16 @@ document.addEventListener("DOMContentLoaded", () => { }); }); }); + + document.querySelectorAll("img.image-poster").forEach((element) => { + element.addEventListener("error", () => { + (element as HTMLImageElement).src = missingImagePoster; + }); + }); + + document.querySelectorAll("img.image-wide").forEach((element) => { + element.addEventListener("error", () => { + (element as HTMLImageElement).src = missingImageWide; + }); + }); }); diff --git a/src/main/resources/assets/style/main.scss b/src/main/resources/assets/style/main.scss index 102335a..5461ceb 100644 --- a/src/main/resources/assets/style/main.scss +++ b/src/main/resources/assets/style/main.scss @@ -64,17 +64,13 @@ html { background: $navbar-bg; } -.scaled-image { - background-repeat: no-repeat; - background-position: center; - border-radius: var(--bs-border-radius) 0 0 var(--bs-border-radius); +.image-poster, .image-wide { + transition: all 0.3s ease; } -@media (max-width: 767px) { - .scaled-image { - height: 300px; - border-radius: var(--bs-border-radius) var(--bs-border-radius) 0 0; - } +.image-poster:hover, .image-wide:hover { + transform: scale(1.05); + border-radius: var(--bs-border-radius); } .app-layout { @@ -105,7 +101,36 @@ html { } .show-progress-image { - background-size: contain; + display: block; +} + +.show-progress-image img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; +} + +@media (min-width: 768px) { + .show-progress-image { + aspect-ratio: 2 / 3; + } + + .show-progress-image img { + border-top-left-radius: var(--bs-border-radius); + border-bottom-left-radius: var(--bs-border-radius); + } +} + +@media (max-width: 767px) { + .show-progress-image { + aspect-ratio: auto; + } + + .show-progress-image img { + border-top-left-radius: var(--bs-border-radius); + border-top-right-radius: var(--bs-border-radius); + } } .show-progress-seasons { diff --git a/templates/components/episode-card-small.twig b/templates/components/episode-card-small.twig index fd8e7f6..d91d4a0 100644 --- a/templates/components/episode-card-small.twig +++ b/templates/components/episode-card-small.twig @@ -1,7 +1,7 @@ {% set linkUrl = path("shows_season_page", {"show": episode.season.show.id, "number": episode.season.number}) ~ "#e" ~ episode.number %}
- +
diff --git a/templates/components/episode-card-wide.twig b/templates/components/episode-card-wide.twig index c7250b8..db650b4 100644 --- a/templates/components/episode-card-wide.twig +++ b/templates/components/episode-card-wide.twig @@ -1,6 +1,8 @@
-
+
+ +
diff --git a/templates/components/history-entry-card-small.twig b/templates/components/history-entry-card-small.twig index 60d5744..3e1f98e 100644 --- a/templates/components/history-entry-card-small.twig +++ b/templates/components/history-entry-card-small.twig @@ -7,11 +7,13 @@ {% set show = season.show %} {% set linkUrl = path("shows_season_page", {"show": show.id, "number": season.number}) ~ "#e" ~ episode.number %} {% set imageUrl = path("shows_episode_image", {"show": show.id, "seasonNumber": season.number, "episodeNumber": episode.number}) %} + {% set imageType = "wide" %} {% set metadata = metadata|merge({"show": show.id, "season": season.number, "episode": episode.number}) %} {% elseif entry.view.type.value == "movie" %} {% set movie = entry.item %} {% set linkUrl = path("movies_single_page", {"movie": movie.id}) %} {% set imageUrl = path("movies_get_image", {"movie": movie.id}) %} + {% set imageType = "poster" %} {% set metadata = metadata|merge({"movie": movie.id}) %} {% endif %} @@ -19,7 +21,7 @@
- +
{% if entry.view.type.value == "episode" %} diff --git a/templates/components/movie-card-small.twig b/templates/components/movie-card-small.twig index f5f9351..3e7c35f 100644 --- a/templates/components/movie-card-small.twig +++ b/templates/components/movie-card-small.twig @@ -1,7 +1,7 @@ {% set linkUrl = path("movies_single_page", {"movie": movie.id}) %}
- +
diff --git a/templates/components/movie-card-wide.twig b/templates/components/movie-card-wide.twig index 4bb9dcc..be62ba6 100644 --- a/templates/components/movie-card-wide.twig +++ b/templates/components/movie-card-wide.twig @@ -1,6 +1,8 @@
-
+
+ +
{{ movie.title }}
diff --git a/templates/components/now-watching-episode.twig b/templates/components/now-watching-episode.twig index 4988683..9b1a722 100644 --- a/templates/components/now-watching-episode.twig +++ b/templates/components/now-watching-episode.twig @@ -2,7 +2,9 @@
-
+
+ +
diff --git a/templates/components/now-watching-movie.twig b/templates/components/now-watching-movie.twig index 1c960aa..726d2c8 100644 --- a/templates/components/now-watching-movie.twig +++ b/templates/components/now-watching-movie.twig @@ -2,7 +2,9 @@
-
+
+ +
diff --git a/templates/components/season-card-small.twig b/templates/components/season-card-small.twig index e897ddd..ae57d21 100644 --- a/templates/components/season-card-small.twig +++ b/templates/components/season-card-small.twig @@ -1,7 +1,7 @@ {% set linkUrl = path("shows_season_page", {"show": season.show.id, "number": season.number}) %}
- +
diff --git a/templates/components/show-card-small.twig b/templates/components/show-card-small.twig index 296535b..0b77c1a 100644 --- a/templates/components/show-card-small.twig +++ b/templates/components/show-card-small.twig @@ -1,7 +1,7 @@ {% set linkUrl = path("shows_show_page", {"show": show.id}) %}
- +
diff --git a/templates/library-management/add-items.twig b/templates/library-management/add-items.twig index f58a5b0..e89141e 100644 --- a/templates/library-management/add-items.twig +++ b/templates/library-management/add-items.twig @@ -35,7 +35,7 @@ {% for result in results %}
- +
{{ result.title }}
{{ result.year }}
diff --git a/templates/movie.twig b/templates/movie.twig index 753f798..a026f44 100644 --- a/templates/movie.twig +++ b/templates/movie.twig @@ -5,7 +5,7 @@ {% block content %}
- +
diff --git a/templates/user/show-progress.twig b/templates/user/show-progress.twig index bd2677d..685ee3f 100644 --- a/templates/user/show-progress.twig +++ b/templates/user/show-progress.twig @@ -17,7 +17,9 @@
- + + +

{{ show.title }}

From 63d390d70dc6ae4611d09480cd7c68f83d1968c7 Mon Sep 17 00:00:00 2001 From: Michael Wieland Date: Sun, 28 Jun 2026 17:04:52 +0200 Subject: [PATCH 6/6] Fixed remaining PHP errors --- src/main/php/tracky/HistoryEntry.php | 76 +++++++++++++++++++ .../tracky/controller/HistoryController.php | 35 +++------ .../php/tracky/controller/HomeController.php | 26 +------ .../php/tracky/controller/MovieController.php | 1 - .../php/tracky/controller/UserController.php | 17 +++-- src/main/php/tracky/scrobbler/Scrobbler.php | 23 +++--- .../watchstats/WatchStatsCollection.php | 11 ++- .../tracky/watchstats/WatchStatsProvider.php | 49 ++++++------ .../components/history-entry-card-small.twig | 8 +- .../components/history-entry-list-small.twig | 2 +- .../components/now-watching-episode.twig | 38 ++++++---- templates/components/now-watching-movie.twig | 38 ++++++---- templates/index.twig | 18 +++-- 13 files changed, 207 insertions(+), 135 deletions(-) create mode 100644 src/main/php/tracky/HistoryEntry.php diff --git a/src/main/php/tracky/HistoryEntry.php b/src/main/php/tracky/HistoryEntry.php new file mode 100644 index 0000000..82642d3 --- /dev/null +++ b/src/main/php/tracky/HistoryEntry.php @@ -0,0 +1,76 @@ +view; + } + + public function getItem(): Episode|Movie + { + return $this->item; + } + + public function getViewCount(): int + { + return $this->watchStatsProvider->getItemStatsByView($this->view)->getCount(); + } + + /** + * @return list + */ + public static function getFromViews(array $views, EpisodeRepository $episodeRepository, MovieRepository $movieRepository, WatchStatsProvider $watchStatsProvider): array + { + $perTypeItems = []; + + // Split up list of views to list of items per type + foreach ($views as $view) { + $type = $view->getType()->value; + + if (!isset($perTypeItems[$type])) { + $perTypeItems[$type] = []; + } + + $perTypeItems[$type][] = $view->getItem(); + } + + // Fetch items per type + foreach ($perTypeItems as $type => $items) { + $items = array_unique($items); + + switch ($type) { + case ViewType::EPISODE->value: + $items = $episodeRepository->findByIds($items); + break; + case ViewType::MOVIE->value: + $items = $movieRepository->findByIds($items); + break; + } + + $perTypeItems[$type] = array_combine(array_map(fn(Episode|Movie $item) => $item->getId(), $items), $items); + } + + $historyEntries = []; + + foreach ($views as $view) { + $historyEntries[] = new HistoryEntry($view, $perTypeItems[$view->getType()->value][$view->getItem()], $watchStatsProvider); + } + + return $historyEntries; + } +} diff --git a/src/main/php/tracky/controller/HistoryController.php b/src/main/php/tracky/controller/HistoryController.php index 34fbfcc..4d73a90 100644 --- a/src/main/php/tracky/controller/HistoryController.php +++ b/src/main/php/tracky/controller/HistoryController.php @@ -1,19 +1,19 @@ query->getInt("page", 1); @@ -47,11 +47,9 @@ public function getPage(Request $request, User $user, EntityManagerInterface $en switch ($type) { case ViewType::EPISODE: $criteria["item"] = $item; - $viewRepository = $entityManager->getRepository(EpisodeView::class); break; case ViewType::MOVIE: $criteria["item"] = $item; - $viewRepository = $entityManager->getRepository(MovieView::class); break; } @@ -60,14 +58,16 @@ public function getPage(Request $request, User $user, EntityManagerInterface $en $pagination = new Pagination($page, $count, $this->itemsPerPage, $this->maxPreviousNextPages); if ($dateRange === null) { - $firstPage = $this->sorted($viewRepository->getPaged($criteria, 1, $this->itemsPerPage, $type, $dateRange)); - $lastPage = $this->sorted($viewRepository->getPaged($criteria, $pagination->getLastPage(), $this->itemsPerPage, $type, $dateRange)); + $firstPage = $viewRepository->getPaged($criteria, 1, $this->itemsPerPage, $type, $dateRange); + $lastPage = $viewRepository->getPaged($criteria, $pagination->getLastPage(), $this->itemsPerPage, $type, $dateRange); if (!empty($firstPage) and !empty($lastPage)) { $dateRange = new DateRange($lastPage[count($lastPage) - 1]->getDateTime()->toDate(), $firstPage[0]->getDateTime()->toDate()); } } + $views = $viewRepository->getPaged($criteria, $page, $this->itemsPerPage, $type, $dateRange); + return $this->render("user/history.twig", [ "user" => $user, "dateRange" => $dateRange, @@ -76,22 +76,7 @@ public function getPage(Request $request, User $user, EntityManagerInterface $en "item" => $item ], "pagination" => $pagination, - "entries" => $this->sorted($viewRepository->getPaged($criteria, $page, $this->itemsPerPage, $type, $dateRange)) + "entries" => HistoryEntry::getFromViews($views, $episodeRepository, $movieRepository, new WatchStatsProvider($viewRepository, $user)) ]); } - - private function sorted(array $entries): array - { - usort($entries, function (View $entry1, View $entry2) { - if ($entry1->getDateTime() < $entry2->getDateTime()) { - return 1; - } elseif ($entry1->getDateTime() > $entry2->getDateTime()) { - return -1; - } else { - return 0; - } - }); - - return $entries; - } } diff --git a/src/main/php/tracky/controller/HomeController.php b/src/main/php/tracky/controller/HomeController.php index 972de13..eb1639d 100644 --- a/src/main/php/tracky/controller/HomeController.php +++ b/src/main/php/tracky/controller/HomeController.php @@ -4,9 +4,7 @@ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; -use tracky\model\Episode; -use tracky\model\Movie; -use tracky\model\View; +use tracky\HistoryEntry; use tracky\orm\EpisodeRepository; use tracky\orm\MovieRepository; use tracky\orm\ShowRepository; @@ -37,29 +35,11 @@ public function home(ShowRepository $showRepository, EpisodeRepository $episodeR if ($user !== null) { $nowWatching = $scrobbler->getNowWatching($user); - $latestWatchedEpisodes = []; $episodeViews = $viewRepository->findBy(["user" => $user->getId()], ["dateTime" => "desc"], $this->maxEpisodes, type: ViewType::EPISODE); - $episodes = $episodeRepository->findByIds(array_map(fn(View $view) => $view->getItem(), $episodeViews)); - $episodes = array_combine(array_map(fn(Episode $episode) => $episode->getId(), $episodes), $episodes); - - foreach ($episodeViews as $view) { - $latestWatchedEpisodes[] = [ - "view" => $view, - "item" => $episodes[$view->getItem()] - ]; - } - - $latestWatchedMovies = []; $movieViews = $viewRepository->findBy(["user" => $user->getId()], ["dateTime" => "desc"], $this->maxMovies, type: ViewType::MOVIE); - $movies = $movieRepository->findByIds(array_map(fn(View $view) => $view->getItem(), $movieViews)); - $movies = array_combine(array_map(fn(Movie $movie) => $movie->getId(), $movies), $movies); - foreach ($movieViews as $view) { - $latestWatchedMovies[] = [ - "view" => $view, - "item" => $movies[$view->getItem()] - ]; - } + $latestWatchedEpisodes = HistoryEntry::getFromViews($episodeViews, $episodeRepository, $movieRepository, $watchStatsProvider); + $latestWatchedMovies = HistoryEntry::getFromViews($movieViews, $episodeRepository, $movieRepository, $watchStatsProvider); $nextEpisodes = $this->getNextEpisodes($showRepository, $watchStatsProvider); } diff --git a/src/main/php/tracky/controller/MovieController.php b/src/main/php/tracky/controller/MovieController.php index 5fd8e35..a7ff3fd 100644 --- a/src/main/php/tracky/controller/MovieController.php +++ b/src/main/php/tracky/controller/MovieController.php @@ -15,7 +15,6 @@ use tracky\datetime\DateTime; use tracky\ImageFetcher; use tracky\model\Movie; -use tracky\model\MovieView; use tracky\model\View; use tracky\orm\MovieRepository; use tracky\orm\ViewRepository; diff --git a/src/main/php/tracky/controller/UserController.php b/src/main/php/tracky/controller/UserController.php index e4d7712..67b8452 100644 --- a/src/main/php/tracky/controller/UserController.php +++ b/src/main/php/tracky/controller/UserController.php @@ -9,7 +9,10 @@ use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Security\Http\Authentication\AuthenticationUtils; +use tracky\HistoryEntry; use tracky\model\User; +use tracky\orm\EpisodeRepository; +use tracky\orm\MovieRepository; use tracky\orm\ShowRepository; use tracky\orm\ViewRepository; use tracky\orm\UserRepository; @@ -109,23 +112,27 @@ public function getRegisterPage(Request $request, UserPasswordHasherInterface $p } #[Route("/users/{username}", name: "user_profile_page")] - public function getProfilePage(User $user, ViewRepository $viewRepository, Scrobbler $scrobbler): Response + public function getProfilePage(User $user, ViewRepository $viewRepository, EpisodeRepository $episodeRepository, MovieRepository $movieRepository, Scrobbler $scrobbler): Response { + $watchStatsProvider = new WatchStatsProvider($viewRepository, $user); + $episodeViews = $viewRepository->findBy(["user" => $user->getId()], ["dateTime" => "desc"], 10, type: ViewType::EPISODE); + $movieViews = $viewRepository->findBy(["user" => $user->getId()], ["dateTime" => "desc"], 10, type: ViewType::MOVIE); + return $this->render("user/profile.twig", [ "user" => $user, "nowWatching" => $scrobbler->getNowWatching($user), - "latestWatchedEpisodes" => $viewRepository->findBy(["user" => $user->getId()], ["dateTime" => "desc"], 10, type: ViewType::EPISODE), - "latestWatchedMovies" => $viewRepository->findBy(["user" => $user->getId()], ["dateTime" => "desc"], 10, type: ViewType::MOVIE) + "latestWatchedEpisodes" => HistoryEntry::getFromViews($episodeViews, $episodeRepository, $movieRepository, $watchStatsProvider), + "latestWatchedMovies" => HistoryEntry::getFromViews($movieViews, $episodeRepository, $movieRepository, $watchStatsProvider), ]); } #[Route("/users/{username}/show-progress", name: "user_profile_show_progress_page")] - public function getShowProgressForUser(User $user, ShowRepository $showRepository, WatchStatsProvider $watchStatsProvider): Response + public function getShowProgressForUser(User $user, ShowRepository $showRepository, ViewRepository $viewRepository): Response { return $this->render("user/show-progress.twig", [ "user" => $user, "shows" => $showRepository->findAllWithEpisodes(), - "watchStatsCollection" => $watchStatsProvider->getStatsForType(ViewType::EPISODE, $user) + "watchStatsCollection" => (new WatchStatsProvider($viewRepository, $user))->getStatsForType(ViewType::EPISODE) ]); } } diff --git a/src/main/php/tracky/scrobbler/Scrobbler.php b/src/main/php/tracky/scrobbler/Scrobbler.php index 7303953..7fa80d4 100644 --- a/src/main/php/tracky/scrobbler/Scrobbler.php +++ b/src/main/php/tracky/scrobbler/Scrobbler.php @@ -5,12 +5,11 @@ use tracky\dataprovider\Helper; use tracky\datetime\DateTime; use tracky\model\Episode; -use tracky\model\EpisodeView; use tracky\model\Movie; -use tracky\model\MovieView; use tracky\model\ScrobbleQueueItem; use tracky\model\Show; use tracky\model\User; +use tracky\model\View; use tracky\orm\MovieRepository; use tracky\orm\ShowRepository; use UnexpectedValueException; @@ -132,12 +131,12 @@ private function addEpisodeView(array $json, DateTime $dateTime, User $user): vo { $episode = $this->getEpisode($json); - $episodeView = new EpisodeView; - $episodeView->setEpisode($episode); - $episodeView->setUser($user); - $episodeView->setDateTime($dateTime); + $view = new View; + $view->setItem($episode); + $view->setUser($user); + $view->setDateTime($dateTime); - $this->entityManager->persist($episodeView); + $this->entityManager->persist($view); $this->entityManager->flush(); } @@ -145,12 +144,12 @@ private function addMovieView(array $json, DateTime $dateTime, User $user): void { $movie = $this->getMovie($json); - $movieView = new MovieView; - $movieView->setMovie($movie); - $movieView->setUser($user); - $movieView->setDateTime($dateTime); + $view = new View; + $view->setItem($movie); + $view->setUser($user); + $view->setDateTime($dateTime); - $this->entityManager->persist($movieView); + $this->entityManager->persist($view); $this->entityManager->flush(); } diff --git a/src/main/php/tracky/watchstats/WatchStatsCollection.php b/src/main/php/tracky/watchstats/WatchStatsCollection.php index 94ae09b..315c387 100644 --- a/src/main/php/tracky/watchstats/WatchStatsCollection.php +++ b/src/main/php/tracky/watchstats/WatchStatsCollection.php @@ -2,6 +2,7 @@ namespace tracky\watchstats; use tracky\datetime\DateTime; +use tracky\model\BaseEntity; use tracky\model\Episode; use tracky\model\Movie; @@ -28,8 +29,14 @@ public static function fromQueryRows(array $rows) return new static($data); } - public function getStatsForItem(Episode|Movie $item): ?ItemWatchStats + public function getStatsForItem(Episode|Movie|int $item): ?ItemWatchStats { - return $this->data[$item->getId()] ?? null; + if ($item instanceof BaseEntity) { + $id = $item->getId(); + } else { + $id = $item; + } + + return $this->data[$id] ?? null; } } diff --git a/src/main/php/tracky/watchstats/WatchStatsProvider.php b/src/main/php/tracky/watchstats/WatchStatsProvider.php index c47994c..50b48f3 100644 --- a/src/main/php/tracky/watchstats/WatchStatsProvider.php +++ b/src/main/php/tracky/watchstats/WatchStatsProvider.php @@ -2,51 +2,56 @@ namespace tracky\watchstats; use Symfony\Bundle\SecurityBundle\Security; +use Symfony\Component\DependencyInjection\Attribute\Autowire; use tracky\model\Episode; use tracky\model\Movie; use tracky\model\User; +use tracky\model\View; use tracky\orm\ViewRepository; use tracky\ViewType; class WatchStatsProvider { + private ?User $user; /** - * @var array, WatchStatsCollection>> + * @var array, WatchStatsCollection> */ - private array $perUserCollections; + private array $perTypeCollection; public function __construct( private readonly ViewRepository $viewRepository, - private readonly Security $security - ) {} - public function getStatsForType(ViewType $type, ?User $user = null): ?WatchStatsCollection + #[Autowire(service: "Symfony\\Bundle\\SecurityBundle\\Security")] + User|Security $userOrSecurity + ) { - if ($user === null) { - /** - * @var User - */ - $user = $this->security->getUser(); - if ($user === null) { - return null; - } + if ($userOrSecurity instanceof Security) { + $this->user = $userOrSecurity->getUser(); + } else { + $this->user = $userOrSecurity; } + } - $userId = $user->getId(); - - if (!isset($this->perUserCollections[$userId])) { - $this->perUserCollections[$userId] = []; + public function getStatsForType(ViewType $type): ?WatchStatsCollection + { + if ($this->user === null) { + return null; } - if (isset($this->perUserCollections[$userId][$type->value])) { - return $this->perUserCollections[$userId][$type->value]; + if (!isset($this->perTypeCollection[$type->value])) { + $this->perTypeCollection[$type->value] = $this->viewRepository->getWatchStatsForUser($this->user, $type); } - return $this->perUserCollections[$userId][$type->value] = $this->viewRepository->getWatchStatsForUser($user, $type); + return $this->perTypeCollection[$type->value]; + } + + public function getItemStats(Episode|Movie|int $item): ?ItemWatchStats + { + return $this->getStatsForType($item->getViewType())?->getStatsForItem($item); } - public function getItemStats(Episode|Movie $item, ?User $user = null): ?ItemWatchStats + public function getItemStatsByView(View $view): ?ItemWatchStats { - return $this->getStatsForType($item->getViewType(), $user)?->getStatsForItem($item); + return $this->getStatsForType($view->getType())?->getStatsForItem($view->getItem()); } } diff --git a/templates/components/history-entry-card-small.twig b/templates/components/history-entry-card-small.twig index 3e1f98e..15154c5 100644 --- a/templates/components/history-entry-card-small.twig +++ b/templates/components/history-entry-card-small.twig @@ -1,5 +1,5 @@ -{% set itemWatchStats = watchStatsProvider.getItemStats(entry.item) %} -{% set metadata = {"entry-type": entry.view.type.value, "entry-id": entry.view.id, "views": itemWatchStats.count} %} +{# Note: entry is of type HistoryEntry #} +{% set metadata = {"entry-type": entry.view.type.value, "entry-id": entry.view.id, "views": entry.viewCount} %} {% if entry.view.type.value == "episode" %} {% set episode = entry.item %} @@ -32,9 +32,9 @@ {% endif %}
{{ entry.view.datetime|format_datetime }}
- {% if user == app.user %} + {% if entry.view.user == app.user %} {% endif %}
diff --git a/templates/components/history-entry-list-small.twig b/templates/components/history-entry-list-small.twig index f3274e9..0067cd8 100644 --- a/templates/components/history-entry-list-small.twig +++ b/templates/components/history-entry-list-small.twig @@ -1,7 +1,7 @@
{% for entry in entries %}
- {% include "components/history-entry-card-small.twig" with {"entry": entry} %} + {% include "components/history-entry-card-small.twig" with {"entry": entry} only %}
{% endfor %}
diff --git a/templates/components/now-watching-episode.twig b/templates/components/now-watching-episode.twig index 9b1a722..5d4fdcc 100644 --- a/templates/components/now-watching-episode.twig +++ b/templates/components/now-watching-episode.twig @@ -1,27 +1,33 @@ {% set linkUrl = path("shows_season_page", {"show": episode.season.show.id, "number": episode.season.number}) ~ "#e" ~ episode.number %}
- -
- -
+
+
-
- {{ episode.title|default("unknown-title"|trans) }} -
-
{{ "shows.episode"|trans({"%number%": episode.season.number ~ "x" ~ episode.number}) }}
-
- {% if episode.runtime is not null %} - {{ "runtime"|trans({"%runtime%": episode.runtime}) }} - {% endif %} - {% if episode.firstAired is not null %} - {{ episode.firstAired|format_date }} - {% endif %} - {% include "components/view.twig" with {"type": "episode", "item": episode} only %} +
+
+
{{ episode.title|default("unknown-title"|trans) }}
+ + {{ "shows.episode"|trans({"%number%": episode.number}) }} + {% if episode.firstAired is not null %} + • {{ episode.firstAired|format_date }} + {% endif %} + {% if episode.runtime is not null %} + • {{ "runtime"|trans({"%runtime%": episode.runtime}) }} + {% endif %} + +
+ + {% include "components/view-buttons.twig" with {"type": "episode", "item": episode} only %}
+
{{ episode.plot }}
+ +
+ {% include "components/view-last-watched.twig" with {"type": "episode", "item": episode} only %} +
diff --git a/templates/components/now-watching-movie.twig b/templates/components/now-watching-movie.twig index 726d2c8..2d9a073 100644 --- a/templates/components/now-watching-movie.twig +++ b/templates/components/now-watching-movie.twig @@ -1,29 +1,35 @@ {% set linkUrl = path("movies_single_page", {"movie": movie.id}) %}
- -
- -
+
+
-
- {{ movie.title }} -
-
- {% if movie.tagline is not null %} - {{ movie.tagline }} - {% endif %} +
+
+
{{ movie.title }}
+ {% if movie.tagline is not null %} + {{ movie.tagline }} + {% endif %} - {% if movie.runtime is not null %} - {{ "runtime"|trans({"%runtime%": movie.runtime}) }} - {% endif %} + + {% if movie.runtime is not null %} + {{ "runtime"|trans({"%runtime%": movie.runtime}) }} • + {% endif %} - {{ movie.year }} - {% include "components/view.twig" with {"type": "movie", "item": movie} only %} + {{ movie.year }} + +
+ + {% include "components/view-buttons.twig" with {"type": "movie", "item": movie} only %}
+
{{ movie.plot }}
+ +
+ {% include "components/view-last-watched.twig" with {"type": "movie", "item": movie} only %} +
diff --git a/templates/index.twig b/templates/index.twig index 37cc2a5..3af3037 100644 --- a/templates/index.twig +++ b/templates/index.twig @@ -5,20 +5,22 @@ {% block content %} {% if app.user %} {% if nowWatching %} -

{{ "now-watching"|trans }}

+
+

{{ "now-watching"|trans }}

- {% if nowWatching.entry.className == "Episode" %} - {% include "components/now-watching-episode.twig" with {"episode": nowWatching.entry, "duration": nowWatching.duration, "progress": nowWatching.progress} %} - {% elseif nowWatching.entry.className == "Movie" %} - {% include "components/now-watching-movie.twig" with {"movie": nowWatching.entry, "duration": nowWatching.duration, "progress": nowWatching.progress} %} - {% endif %} + {% if nowWatching.entry.className == "Episode" %} + {% include "components/now-watching-episode.twig" with {"episode": nowWatching.entry, "duration": nowWatching.duration, "progress": nowWatching.progress} %} + {% elseif nowWatching.entry.className == "Movie" %} + {% include "components/now-watching-movie.twig" with {"movie": nowWatching.entry, "duration": nowWatching.duration, "progress": nowWatching.progress} %} + {% endif %} +
{% endif %}

{{ "shows.latest-watched-episodes"|trans }}

{% if latestWatchedEpisodes %} - {% include "components/history-entry-list-small.twig" with {"entries": latestWatchedEpisodes, "user": app.user} %} + {% include "components/history-entry-list-small.twig" with {"entries": latestWatchedEpisodes} %} {% else %}
{{ "shows.no-episodes-available"|trans }}
{% endif %} @@ -28,7 +30,7 @@

{{ "movies.latest-watched-movies"|trans }}

{% if latestWatchedMovies %} - {% include "components/history-entry-list-small.twig" with {"entries": latestWatchedMovies, "user": app.user} %} + {% include "components/history-entry-list-small.twig" with {"entries": latestWatchedMovies} %} {% else %}
{{ "movies.no-movies-available"|trans }}
{% endif %}