Pour toi : recommandations diverses et déjà-possédé réellement exclu

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é
This commit is contained in:
Roman
2026-09-25 15:23:29 +00:00
parent d5fa546dd7
commit fed61adc56
3 changed files with 86 additions and 23 deletions
+28 -9
View File
@@ -303,15 +303,34 @@ class DiscoverService:
return result return result
top_genres = sorted(genre_counts, key=genre_counts.get, reverse=True)[:_MAX_GENRES] top_genres = sorted(genre_counts, key=genre_counts.get, reverse=True)[:_MAX_GENRES]
slugs = [category_slug(genre) for genre in top_genres] # Une requête par genre (Kitsu cumule les catégories en ET : quatre genres
items = await self._kitsu_anime( # ensemble ne laissent que quelques titres — parfois le déjà-possédé !).
{ # Fusion entrelacée : chaque genre contribue, doublons retirés.
"filter[categories]": ",".join(slugs), pools = [
"sort": "-userCount", list(pool)
"page[limit]": limit, # le déjà-possédé est filtré après for pool in await asyncio.gather(*(
} self._kitsu_anime(
) {"filter[categories]": category_slug(g), "sort": "-userCount", "page[limit]": limit}
kept = [item for item in items if item["title"] and item["title"].casefold() not in owned] )
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} result = {"based_on": top_genres, "items": kept}
self._cache.set(cache_key, result, _FOR_YOU_TTL_SECONDS) self._cache.set(cache_key, result, _FOR_YOU_TTL_SECONDS)
return result return result
+16 -5
View File
@@ -21,15 +21,26 @@ logger = logging.getLogger(__name__)
_NOISE_WORDS_RE = re.compile( _NOISE_WORDS_RE = re.compile(
r"\b(?:VOSTFR\d*|VOST|VF[IV]?|TRUEFRENCH|FRENCH|MULTI|SUBFR?)\b", re.IGNORECASE 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_SEASON_RE = re.compile(r"[\s\-–—:.]*\b(?:saison|season)\s*\d+\s*$", re.IGNORECASE)
_TRAILING_CODE_RE = re.compile(r"[\s\-–—:.]*\s*S\d+(?:E\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: 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 = _NOISE_WORDS_RE.sub(" ", title)
cleaned = _TRAILING_SEASON_RE.sub("", cleaned) previous = None
cleaned = _TRAILING_CODE_RE.sub("", cleaned) 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*[-–—_]+\s*", " ", cleaned)
cleaned = re.sub(r"\s+", " ", cleaned) cleaned = re.sub(r"\s+", " ", cleaned)
return cleaned.strip(" -–—:.") return cleaned.strip(" -–—:.")
+42 -9
View File
@@ -297,16 +297,15 @@ async def history(monkeypatch):
return ["Fantasy", "Adventure"] if anime_id == "46474" else [] return ["Fantasy", "Adventure"] if anime_id == "46474" else []
async def fake_kitsu_anime(params: dict) -> list[dict]: async def fake_kitsu_anime(params: dict) -> list[dict]:
slugs = frozenset(params["filter[categories]"].split(","))
assert params["sort"] == "-userCount" assert params["sort"] == "-userCount"
catalog = { # Une catégorie par requête (le cumul Kitsu est un ET trop restrictif)
frozenset(["fantasy", "adventure", "action", "comedy"]): [ assert "," not in params["filter[categories]"]
{"title": "Helck", "kitsu_id": "999"}, return [
{"title": "Sousou no Frieren", "kitsu_id": "46474"}, {"title": "Helck", "kitsu_id": "999"},
{"title": "Konosuba", "kitsu_id": "1"}, {"title": "Sousou no Frieren", "kitsu_id": "46474"},
], {"title": "Konosuba", "kitsu_id": "1"},
} ]
return catalog.get(slugs, [])
service = DiscoverService() service = DiscoverService()
service.test_user_id = user_id # pour les assertions du test 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 "Sousou no Frieren" in titles
assert "Konosuba" 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): async def test_for_you_cold_start_without_history(monkeypatch):
"""Aucun téléchargement/favori/Sonarr : amorçage signalé (non caché).""" """Aucun téléchargement/favori/Sonarr : amorçage signalé (non caché)."""