From fed61adc569274d591a134e31bb57f314cb1e746 Mon Sep 17 00:00:00 2001 From: Roman Date: Fri, 25 Sep 2026 15:23:29 +0000 Subject: [PATCH] =?UTF-8?q?Pour=20toi=20:=20recommandations=20diverses=20e?= =?UTF-8?q?t=20d=C3=A9j=C3=A0-poss=C3=A9d=C3=A9=20r=C3=A9ellement=20exclu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deux défauts enchaînés : la requête Kitsu cumulait les genres en ET (quatre catégories → 1 seul titre, précisément le téléchargement à l'origine des genres), et l'exclusion échouait car « Titre - Saison 1 - E3 » ne normalisait jamais vers « Titre » canonique. - Une requête Kitsu par genre, fusion entrelacée (chaque genre contribue) - normalize_title retire saison/épisode en boucle avec frontières de mots (« - Saison 1 - E3 », « S1 E1 » → titre canonique ; « Série 9 » intact) - test de non-régession : le possédé avec marqueurs n'est plus recommandé --- app/services/discover.py | 37 ++++++++++++++++++++++------- app/services/kitsu.py | 21 +++++++++++++---- tests/test_discover.py | 51 +++++++++++++++++++++++++++++++++------- 3 files changed, 86 insertions(+), 23 deletions(-) diff --git a/app/services/discover.py b/app/services/discover.py index bada26a..8dc1304 100644 --- a/app/services/discover.py +++ b/app/services/discover.py @@ -303,15 +303,34 @@ class DiscoverService: return result top_genres = sorted(genre_counts, key=genre_counts.get, reverse=True)[:_MAX_GENRES] - slugs = [category_slug(genre) for genre in top_genres] - items = await self._kitsu_anime( - { - "filter[categories]": ",".join(slugs), - "sort": "-userCount", - "page[limit]": limit, # le déjà-possédé est filtré après - } - ) - kept = [item for item in items if item["title"] and item["title"].casefold() not in owned] + # Une requête par genre (Kitsu cumule les catégories en ET : quatre genres + # ensemble ne laissent que quelques titres — parfois le déjà-possédé !). + # Fusion entrelacée : chaque genre contribue, doublons retirés. + pools = [ + list(pool) + for pool in await asyncio.gather(*( + self._kitsu_anime( + {"filter[categories]": category_slug(g), "sort": "-userCount", "page[limit]": limit} + ) + for g in top_genres + )) + if pool + ] + candidates: list[dict] = [] + seen: set[str] = set() + while pools: + for pool in pools[:]: + item = pool.pop(0) + key = str(item.get("kitsu_id") or item.get("title", "").casefold()) + if key not in seen: + seen.add(key) + candidates.append(item) + if not pool: + pools.remove(pool) + kept = [ + item for item in candidates + if item["title"] and normalize_title(item["title"]).casefold() not in owned + ][:limit] result = {"based_on": top_genres, "items": kept} self._cache.set(cache_key, result, _FOR_YOU_TTL_SECONDS) return result diff --git a/app/services/kitsu.py b/app/services/kitsu.py index 15af465..0d51d05 100644 --- a/app/services/kitsu.py +++ b/app/services/kitsu.py @@ -21,15 +21,26 @@ logger = logging.getLogger(__name__) _NOISE_WORDS_RE = re.compile( r"\b(?:VOSTFR\d*|VOST|VF[IV]?|TRUEFRENCH|FRENCH|MULTI|SUBFR?)\b", re.IGNORECASE ) -_TRAILING_SEASON_RE = re.compile(r"[\s\-–—:.]*\s*(?:saison|season)\s*\d+\s*$", re.IGNORECASE) -_TRAILING_CODE_RE = re.compile(r"[\s\-–—:.]*\s*S\d+(?:E\d+)?\s*$", re.IGNORECASE) +_TRAILING_SEASON_RE = re.compile(r"[\s\-–—:.]*\b(?:saison|season)\s*\d+\s*$", re.IGNORECASE) +_TRAILING_CODE_RE = re.compile(r"[\s\-–—:.]*\bS\d+(?:\s*E\d+)?\s*$", re.IGNORECASE) +_TRAILING_EPISODE_RE = re.compile(r"[\s\-–—:.]*\b(?:e|ep|episode)\s*\d+\s*$", re.IGNORECASE) def normalize_title(title: str) -> str: - """Nettoie un titre de scraping avant recherche Kitsu (bruit, saison, tirets).""" + """Nettoie un titre de scraping avant comparaison/recherche Kitsu. + + Retire le bruit (VF/VOSTFR…), les marqueurs de saison/épisode en fin de titre + (« - Saison 1 - E3 », « S1 E1 ») — appliqués en boucle : un titre canonique + émergera identique d'un téléchargement (« Titre - Saison 1 - E3 ») ou d'une + fiche Kitsu (« Titre »), condition de l'exclusion du déjà-possédé. + """ cleaned = _NOISE_WORDS_RE.sub(" ", title) - cleaned = _TRAILING_SEASON_RE.sub("", cleaned) - cleaned = _TRAILING_CODE_RE.sub("", cleaned) + previous = None + while previous != cleaned: + previous = cleaned + cleaned = _TRAILING_SEASON_RE.sub("", cleaned) + cleaned = _TRAILING_CODE_RE.sub("", cleaned) + cleaned = _TRAILING_EPISODE_RE.sub("", cleaned) cleaned = re.sub(r"\s*[-–—_]+\s*", " ", cleaned) cleaned = re.sub(r"\s+", " ", cleaned) return cleaned.strip(" -–—:.") diff --git a/tests/test_discover.py b/tests/test_discover.py index 5223baf..1fe917b 100644 --- a/tests/test_discover.py +++ b/tests/test_discover.py @@ -297,16 +297,15 @@ async def history(monkeypatch): return ["Fantasy", "Adventure"] if anime_id == "46474" else [] async def fake_kitsu_anime(params: dict) -> list[dict]: - slugs = frozenset(params["filter[categories]"].split(",")) assert params["sort"] == "-userCount" - catalog = { - frozenset(["fantasy", "adventure", "action", "comedy"]): [ - {"title": "Helck", "kitsu_id": "999"}, - {"title": "Sousou no Frieren", "kitsu_id": "46474"}, - {"title": "Konosuba", "kitsu_id": "1"}, - ], - } - return catalog.get(slugs, []) + # Une catégorie par requête (le cumul Kitsu est un ET trop restrictif) + assert "," not in params["filter[categories]"] + return [ + {"title": "Helck", "kitsu_id": "999"}, + {"title": "Sousou no Frieren", "kitsu_id": "46474"}, + {"title": "Konosuba", "kitsu_id": "1"}, + ] + service = DiscoverService() service.test_user_id = user_id # pour les assertions du test @@ -325,6 +324,40 @@ async def test_for_you_aggregates_genres_and_excludes_owned(history): assert "Sousou no Frieren" in titles assert "Konosuba" in titles +async def test_for_you_excludes_owned_with_episode_markers(monkeypatch): + """Régression : « Titre - Saison 1 - E3 » possédé doit exclure le « Titre » + canonique recommandé (la normalisation retire saison ET épisode, en boucle).""" + cursor = await db.execute( + "INSERT INTO users (username, password_hash) VALUES (?, ?)", + ("reg-user", "x" * 64), + ) + user_id = cursor.lastrowid + await db.execute( + "INSERT INTO downloads (source_key, video_url, title, status) VALUES (?,?,?,?)", + ("kr", "https://v/r", "The Eminence in Shadow - Saison 1 - E3", "done"), + ) + await db.execute( + "INSERT INTO favorites (user_id, source, source_id, title, image_url, payload) " + "VALUES (?,?,?,?,?,?)", + (user_id, "vostfree", "r", "Favori R", None, '{"genres": ["Fantasy"]}'), + ) + + async def fake_match(title: str): + return None # genres apportés par le favori + + async def fake_kitsu_anime(params: dict) -> list[dict]: + return [ + {"title": "The Eminence in Shadow", "kitsu_id": "1"}, + {"title": "Autre Anime", "kitsu_id": "2"}, + ] + + service = DiscoverService() + monkeypatch.setattr(service, "_kitsu_match_for_title", fake_match) + monkeypatch.setattr(service, "_kitsu_anime", fake_kitsu_anime) + result = await service.for_you(user_id=user_id, limit=10) + titles = [item["title"] for item in result["items"]] + assert "The Eminence in Shadow" not in titles # possédé (« - Saison 1 - E3 ») + assert "Autre Anime" in titles async def test_for_you_cold_start_without_history(monkeypatch): """Aucun téléchargement/favori/Sonarr : amorçage signalé (non caché)."""