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
|
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
@@ -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
@@ -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é)."""
|
||||||
|
|||||||
Reference in New Issue
Block a user