Test Failed
Push — develop ( 7c3d28...e1debf )
by BENARD
12:55 queued 06:18
created

VideoRecommendationService::getRelatedVideos()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 27
Code Lines 17

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 2
eloc 17
c 1
b 0
f 0
nc 2
nop 2
dl 0
loc 27
rs 9.7
1
<?php
2
3
declare(strict_types=1);
4
5
namespace VideoGamesRecords\CoreBundle\Service;
6
7
use Doctrine\ORM\EntityManagerInterface;
8
use Psr\Cache\CacheItemPoolInterface;
9
use Psr\Log\LoggerInterface;
10
use VideoGamesRecords\CoreBundle\Entity\Video;
11
use VideoGamesRecords\CoreBundle\Repository\VideoRepository;
12
use VideoGamesRecords\CoreBundle\Service\VideoRelevanceScorer;
13
14
class VideoRecommendationService
15
{
16
    private const CACHE_TTL = 3600; // 1 hour
17
    private const CACHE_PREFIX = 'video_recommendations_';
18
19
    public function __construct(
20
        private readonly EntityManagerInterface $entityManager,
21
        private readonly CacheItemPoolInterface $cache,
22
        private readonly LoggerInterface $logger,
23
        private readonly VideoRelevanceScorer $relevanceScorer
24
    ) {
25
    }
26
27
    public function getRelatedVideos(Video $video, int $limit = 10): array
28
    {
29
        $cacheKey = self::CACHE_PREFIX . $video->getId();
30
        $cacheItem = $this->cache->getItem($cacheKey);
31
32
        if ($cacheItem->isHit()) {
33
            $this->logger->info('Video recommendations served from cache', [
34
                'videoId' => $video->getId(),
35
                'cacheKey' => $cacheKey
36
            ]);
37
            return $cacheItem->get();
38
        }
39
40
        $recommendations = $this->generateRecommendations($video, $limit);
41
42
        $cacheItem->set($recommendations);
43
        $cacheItem->expiresAfter(self::CACHE_TTL);
44
        $this->cache->save($cacheItem);
45
46
        $this->logger->info('Video recommendations generated and cached', [
47
            'videoId' => $video->getId(),
48
            'recommendationsCount' => count($recommendations),
49
            'cacheKey' => $cacheKey,
50
            'ttl' => self::CACHE_TTL
51
        ]);
52
53
        return $recommendations;
54
    }
55
56
    private function generateRecommendations(Video $video, int $limit): array
57
    {
58
        $recommendations = [];
59
        $usedVideoIds = [$video->getId()];
60
61
        // Strategy 1: Same game videos (25% - 2-3 videos)
62
        $sameGameCount = max(1, (int) ceil($limit * 0.25));
63
        $sameGameVideos = $this->getVideosBySameGame($video, $sameGameCount, $usedVideoIds);
64
        $recommendations = array_merge($recommendations, $sameGameVideos);
65
        $usedVideoIds = array_merge($usedVideoIds, $this->extractVideoIds($sameGameVideos));
66
67
        // Strategy 2: Same series videos (25% - 2-3 videos)
68
        $sameSeriesCount = max(1, (int) ceil($limit * 0.25));
69
        $sameSeriesVideos = $this->getVideosBySameSeries($video, $sameSeriesCount, $usedVideoIds);
70
        $recommendations = array_merge($recommendations, $sameSeriesVideos);
71
        $usedVideoIds = array_merge($usedVideoIds, $this->extractVideoIds($sameSeriesVideos));
72
73
        // Strategy 3: Same genre videos via IGDB (40% - 3-4 videos)
74
        $sameGenreCount = max(1, (int) ceil($limit * 0.40));
75
        $sameGenreVideos = $this->getVideosBySameGenres($video, $sameGenreCount, $usedVideoIds);
76
        $recommendations = array_merge($recommendations, $sameGenreVideos);
77
        $usedVideoIds = array_merge($usedVideoIds, $this->extractVideoIds($sameGenreVideos));
78
79
        // Strategy 4: Popular/Random videos to fill remaining slots (10%)
80
        $remainingCount = $limit - count($recommendations);
81
        if ($remainingCount > 0) {
82
            $randomVideos = $this->getPopularRandomVideos($remainingCount, $usedVideoIds);
83
            $recommendations = array_merge($recommendations, $randomVideos);
84
        }
85
86
        // Nouvelle approche : utiliser le scoring de pertinence au lieu du shuffle
87
        return $this->applyRelevanceScoring($video, $recommendations, $limit);
88
    }
89
90
    private function getVideosBySameGame(Video $video, int $limit, array $excludeIds): array
91
    {
92
        if (!$video->getGame()) {
93
            return [];
94
        }
95
96
        /** @var VideoRepository $repository */
97
        $repository = $this->entityManager->getRepository(Video::class);
98
99
        $qb = $repository->createQueryBuilder('v')
100
            ->where('v.game = :game')
101
            ->andWhere('v.id NOT IN (:excludeIds)')
102
            ->andWhere('v.isActive = true')
103
            ->setParameter('game', $video->getGame())
104
            ->setParameter('excludeIds', $excludeIds)
105
            ->orderBy('v.viewCount', 'DESC')
106
            ->addOrderBy('v.createdAt', 'DESC')
107
            ->setMaxResults($limit);
108
109
        return $qb->getQuery()->getResult();
110
    }
111
112
    private function getVideosBySameSeries(Video $video, int $limit, array $excludeIds): array
113
    {
114
        if (!$video->getGame()?->getSerie()) {
115
            return [];
116
        }
117
118
        /** @var VideoRepository $repository */
119
        $repository = $this->entityManager->getRepository(Video::class);
120
121
        $qb = $repository->createQueryBuilder('v')
122
            ->join('v.game', 'g')
123
            ->where('g.serie = :serie')
124
            ->andWhere('v.id NOT IN (:excludeIds)')
125
            ->andWhere('v.isActive = true')
126
            ->setParameter('serie', $video->getGame()->getSerie())
127
            ->setParameter('excludeIds', $excludeIds)
128
            ->orderBy('v.viewCount', 'DESC')
129
            ->addOrderBy('v.createdAt', 'DESC')
130
            ->setMaxResults($limit);
131
132
        return $qb->getQuery()->getResult();
133
    }
134
135
    private function getVideosBySameGenres(Video $video, int $limit, array $excludeIds): array
136
    {
137
        $game = $video->getGame();
138
        if (!$game?->getIgdbGame()) {
139
            return [];
140
        }
141
142
        $genres = $game->getIgdbGame()->getGenres();
143
        if ($genres->isEmpty()) {
144
            return [];
145
        }
146
147
        $genreIds = [];
148
        foreach ($genres as $genre) {
149
            $genreIds[] = $genre->getId();
150
        }
151
152
        /** @var VideoRepository $repository */
153
        $repository = $this->entityManager->getRepository(Video::class);
154
155
        $qb = $repository->createQueryBuilder('v')
156
            ->join('v.game', 'g')
157
            ->join('g.igdbGame', 'ig')
158
            ->join('ig.genres', 'genre')
159
            ->where('genre.id IN (:genreIds)')
160
            ->andWhere('v.id NOT IN (:excludeIds)')
161
            ->andWhere('v.isActive = true')
162
            ->andWhere('g.id != :currentGameId')
163
            ->setParameter('genreIds', $genreIds)
164
            ->setParameter('excludeIds', $excludeIds)
165
            ->setParameter('currentGameId', $game->getId())
166
            ->groupBy('v.id')
167
            ->orderBy('COUNT(genre.id)', 'DESC') // Games with more matching genres first
168
            ->addOrderBy('v.viewCount', 'DESC')
169
            ->addOrderBy('v.createdAt', 'DESC')
170
            ->setMaxResults($limit);
171
172
        return $qb->getQuery()->getResult();
173
    }
174
175
    private function getPopularRandomVideos(int $limit, array $excludeIds): array
176
    {
177
        /** @var VideoRepository $repository */
178
        $repository = $this->entityManager->getRepository(Video::class);
179
180
        // Get popular videos from the last 30 days
181
        $qb = $repository->createQueryBuilder('v')
182
            ->where('v.id NOT IN (:excludeIds)')
183
            ->andWhere('v.isActive = true')
184
            ->andWhere('v.createdAt >= :thirtyDaysAgo')
185
            ->setParameter('excludeIds', $excludeIds)
186
            ->setParameter('thirtyDaysAgo', new \DateTime('-30 days'))
187
            ->orderBy('v.viewCount', 'DESC')
188
            ->addOrderBy('v.likeCount', 'DESC')
189
            ->setMaxResults($limit * 3); // Get more to randomize
190
191
        $videos = $qb->getQuery()->getResult();
192
193
        // Randomize and limit
194
        shuffle($videos);
195
        $videos = array_slice($videos, 0, $limit);
196
197
        return $videos;
198
    }
199
200
    private function extractVideoIds(array $videos): array
201
    {
202
        return array_map(fn(Video $video) => $video->getId(), $videos);
203
    }
204
205
    private function applyRelevanceScoring(Video $sourceVideo, array $candidateVideos, int $limit): array
206
    {
207
        // Utiliser le scorer pour trier par pertinence
208
        $rankedVideos = $this->relevanceScorer->rankVideos($sourceVideo, $candidateVideos);
209
210
        // Appliquer la diversification pour éviter la surreprésentation
211
        return $this->diversifyRecommendations($rankedVideos, $limit);
212
    }
213
214
    /**
215
     * Diversifie les recommandations pour éviter trop de vidéos du même jeu/joueur
216
     */
217
    private function diversifyRecommendations(array $rankedVideos, int $limit): array
218
    {
219
        $result = [];
220
        $gameCount = [];
221
        $playerCount = [];
222
        $seriesCount = [];
223
224
        // Règles de diversification (configurables)
225
        $maxPerGame = max(1, (int) ceil($limit * 0.4)); // Max 40% du même jeu
226
        $maxPerPlayer = max(1, (int) ceil($limit * 0.3)); // Max 30% du même joueur
227
        $maxPerSeries = max(1, (int) ceil($limit * 0.6)); // Max 60% de la même série
228
229
        foreach ($rankedVideos as $video) {
230
            if (count($result) >= $limit) {
231
                break;
232
            }
233
234
            $gameId = $video->getGame()?->getId() ?? 'no_game';
235
            $playerId = $video->getPlayer()?->getId() ?? 'no_player';
236
            $seriesId = $video->getGame()?->getSerie()?->getId() ?? 'no_series';
237
238
            $currentGameCount = $gameCount[$gameId] ?? 0;
239
            $currentPlayerCount = $playerCount[$playerId] ?? 0;
240
            $currentSeriesCount = $seriesCount[$seriesId] ?? 0;
241
242
            // Vérifier les limites de diversification
243
            if (
244
                $currentGameCount < $maxPerGame &&
245
                $currentPlayerCount < $maxPerPlayer &&
246
                $currentSeriesCount < $maxPerSeries
247
            ) {
248
                $result[] = $video;
249
                $gameCount[$gameId] = $currentGameCount + 1;
250
                $playerCount[$playerId] = $currentPlayerCount + 1;
251
                $seriesCount[$seriesId] = $currentSeriesCount + 1;
252
            }
253
        }
254
255
        // Si on n'a pas assez de vidéos à cause des contraintes,
256
        // ajouter les meilleures restantes sans contraintes
257
        if (count($result) < $limit) {
258
            $remaining = array_diff($rankedVideos, $result);
259
            $needed = $limit - count($result);
260
            $result = array_merge($result, array_slice($remaining, 0, $needed));
261
        }
262
263
        return $result;
264
    }
265
266
    /**
267
     * Méthode pour débugger les scores de recommandation
268
     */
269
    public function getRecommendationsWithScores(Video $video, int $limit = 10): array
270
    {
271
        $recommendations = [];
272
        $usedVideoIds = [$video->getId()];
273
274
        // Récupérer les candidats de la même façon
275
        $sameGameCount = max(1, (int) ceil($limit * 0.25));
276
        $sameGameVideos = $this->getVideosBySameGame($video, $sameGameCount, $usedVideoIds);
277
        $recommendations = array_merge($recommendations, $sameGameVideos);
278
        $usedVideoIds = array_merge($usedVideoIds, $this->extractVideoIds($sameGameVideos));
279
280
        $sameSeriesCount = max(1, (int) ceil($limit * 0.25));
281
        $sameSeriesVideos = $this->getVideosBySameSeries($video, $sameSeriesCount, $usedVideoIds);
282
        $recommendations = array_merge($recommendations, $sameSeriesVideos);
283
        $usedVideoIds = array_merge($usedVideoIds, $this->extractVideoIds($sameSeriesVideos));
284
285
        $sameGenreCount = max(1, (int) ceil($limit * 0.40));
286
        $sameGenreVideos = $this->getVideosBySameGenres($video, $sameGenreCount, $usedVideoIds);
287
        $recommendations = array_merge($recommendations, $sameGenreVideos);
288
        $usedVideoIds = array_merge($usedVideoIds, $this->extractVideoIds($sameGenreVideos));
289
290
        $remainingCount = $limit - count($recommendations);
291
        if ($remainingCount > 0) {
292
            $randomVideos = $this->getPopularRandomVideos($remainingCount, $usedVideoIds);
293
            $recommendations = array_merge($recommendations, $randomVideos);
294
        }
295
296
        // Retourner avec les scores pour debug
297
        return $this->relevanceScorer->rankVideosWithScores($video, $recommendations);
298
    }
299
300
    public function clearVideoRecommendationsCache(Video $video): bool
301
    {
302
        $cacheKey = self::CACHE_PREFIX . $video->getId();
303
        return $this->cache->deleteItem($cacheKey);
304
    }
305
306
    public function getRecommendationStats(Video $video): array
307
    {
308
        $game = $video->getGame();
309
        $stats = [
310
            'video_id' => $video->getId(),
311
            'has_game' => $game !== null,
312
            'has_series' => $game?->getSerie() !== null,
313
            'has_igdb_game' => $game?->getIgdbGame() !== null,
314
            'genres_count' => 0,
315
            'cache_status' => 'miss'
316
        ];
317
318
        if ($game?->getIgdbGame()) {
319
            $stats['genres_count'] = $game->getIgdbGame()->getGenres()->count();
320
        }
321
322
        $cacheKey = self::CACHE_PREFIX . $video->getId();
323
        $cacheItem = $this->cache->getItem($cacheKey);
324
        if ($cacheItem->isHit()) {
325
            $stats['cache_status'] = 'hit';
326
        }
327
328
        return $stats;
329
    }
330
}
331