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:
@@ -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
|
||||
|
||||
+16
-5
@@ -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(" -–—:.")
|
||||
|
||||
+42
-9
@@ -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é)."""
|
||||
|
||||
Reference in New Issue
Block a user