Датасет: полное покрытие data/raw (71 339 строк из 30 401 релиза), правки парсеров и аудит
docker / build (push) Successful in 1m20s
docker / build (push) Successful in 1m20s
Переразбор всех страниц data/raw: legacy-страницы импорта, форматы префиксов ([*]Перевод (1):, Озвучка:, №2, диапазоны), дорожки только в строках «Аудио» (в т.ч. «(Русский, X)», «| Rus | Звук: …»), «Язык озвучки», несколько студий в одной строке, студия без типа. Кодек-строки: «Аудио #N Русский:», «Аудио pic RUS:», «Звук N:», «Аудиодорожка 01:», кириллические АVO/МVO. «Авторский» без числа голосов -> авт. одноголосый, «двугоголосый». Чистка студий: языки, кодеки, «по заказу», «в составе контейнера», отказы от озвучки. Итог: страниц с метками 30 260 (85.5 %), страниц с дорожкой без меток 1 259 -> 223; data/pages 30 444; после prepare_data 60 944 строки (train 54 686 / test 6 258). Новый tools/coverage_audit.py + reports/coverage_audit.md, docs/dataset-coverage.md. Переобучен models/best (base): на общем тесте type acc 0.976 против 0.949 у прежней, span 0.674 против 0.643; e2e kinozal 0.805/0.867, rt 0.771/0.740, nnm 0.752/0.693.
This commit is contained in:
1 parent
d2cf488b9c
commit
0f359837c5
21 files changed
+38789
-22512
No files matched your search
+408
-102
@@ -996,6 +996,46 @@ def harvest_nnm(forums: list[str], topics: list[str], pages: int, limit: int,
|
||||
CYR_TO_LAT = str.maketrans({"А": "A", "В": "B", "С": "C", "Е": "E", "Н": "H",
|
||||
"К": "K", "М": "M", "О": "O", "Р": "P", "Т": "T", "Х": "X"})
|
||||
|
||||
# Декоративный префикс строки дорожки: «[*]Перевод (1):», «►Перевод:», «• Озвучка»,
|
||||
# «- Перевод:», «№2:». Снимается до сопоставления с ключевым словом, сама строка
|
||||
# (`raw`) остаётся в датасете как на странице.
|
||||
RE_LEAD_DECOR = re.compile(r"^\s*(?:\[\s*\*?\s*\]|[*•·►→▸▪◦]+|[-–—]\s*|№\s*(?=\d))+\s*")
|
||||
# номер дорожки: «(1)», «1-4», «№2», «#3», «1» — один шаблон для «Перевод»/«Аудио»
|
||||
RE_TRACK_NUM = (r"(?:\(\s*\d+(?:\s*[-–]\s*\d+)?\s*\)|№\s*\d+(?:\s*[-–]\s*\d+)?"
|
||||
r"|#\s*\d+(?:\s*[-–]\s*\d+)?|\d+(?:\s*[-–]\s*\d+)?)")
|
||||
# язык в начале имени студии: «Русский, - AlexFilm», «Украинский, AMC»
|
||||
RE_LANG_PREFIX = re.compile(
|
||||
r"(?i)^(?:русск\w*|украин\w*|russian|ukrainian|english|англ\w*|японск\w*|китайск\w*"
|
||||
r"|корейск\w*|немецк\w*|французск\w*|итальянск\w*|испанск\w*|казахск\w*)\s*[,:]\s*[-–—]?\s*")
|
||||
# «X по заказу Y» — студия X, заказчик в метке не нужен
|
||||
RE_ON_ORDER = re.compile(r"(?i)^(.+?)\s+по\s+заказу\s+.+$")
|
||||
# «Перевод: Отсутствует», «Без слов», «Немой», «Не требуется» — дорожки озвучки нет
|
||||
RE_NO_DUB = re.compile(
|
||||
r"(?i)\b(?:отсутств\w*|отсутсву\w*|не\s*требу\w*|немо[йе]\b|без\s*слов|без\s*перевода"
|
||||
r"|нет\s*(?:перевода|озвучки)|оригинал\w*\s*(?:дорожк\w*|звук\w*|аудио)\b"
|
||||
r"|без\s*диалог\w*|нет\s*диалог\w*)\b")
|
||||
# то же, но только если строка НАЧИНАЕТСЯ с «нет перевода»: «Профессиональный …,
|
||||
# оставлена оригинальная дорожка» — это дорожка с пометкой, а не отказ от озвучки
|
||||
RE_NO_DUB_LINE = re.compile(
|
||||
r"(?i)^\W*(?:отсутств\w*|отсутсву\w*|не\s*требу\w*|немо[йе]\b|без\s*слов|без\s*перевода"
|
||||
r"|нет\s*(?:перевода|озвучки)|без\s*диалог\w*|нет\s*диалог\w*)\b")
|
||||
# пост nnm/rutracker: «Перевод:» может склеиться со следующим полем
|
||||
RE_FIELD_CUT = re.compile(
|
||||
r"\s+(?:Описание|Жанр|Год(?:\s*выпуска)?|Страна|Режиссер|Режиссёр|Актеры|Актёры"
|
||||
r"|Продолжительность|Субтитры|Размер|Качество|Видео|Аудио|Формат)\s*:")
|
||||
|
||||
|
||||
def trim_field(body: str) -> str:
|
||||
"""Обрезать значение поля на следующем поле поста («… || AniDB», «… Описание:»)."""
|
||||
return RE_FIELD_CUT.split((body or "").split("||")[0], maxsplit=1)[0].strip()
|
||||
|
||||
|
||||
|
||||
def strip_lead(line: str) -> str:
|
||||
"""Снять декоративный префикс («[*]», «►», «•»): ключевое слово встаёт в начало."""
|
||||
return RE_LEAD_DECOR.sub("", line or "", count=1)
|
||||
|
||||
|
||||
STUDIO_ALIASES = {
|
||||
"hdrezka": "HDrezka", "hd rezka": "HDrezka", "hdrezka studio": "HDrezka",
|
||||
"red head sound": "Red Head Sound", "read head sound": "Red Head Sound",
|
||||
@@ -1033,7 +1073,7 @@ STUDIO_NOISE = re.compile(
|
||||
# «дубляж» как тип, а не часть имени студии («Дубляжная», «Продубляж», «Дубляж-Студия»)
|
||||
RE_DUB_TYPE = r"дублир|(?<![а-яё])дубляж(?![а-яё])"
|
||||
ROLE_PATTERNS = [(RE_DUB_TYPE, "дубляж"), (r"многоголос", "многоголосый"),
|
||||
(r"двухголос", "двухголосый"), (r"одноголос", "одноголосый"),
|
||||
(r"дву[хг]?о?голос", "двухголосый"), (r"одноголос", "одноголосый"),
|
||||
(r"субтитр", "субтитры"), (r"оригинал", "оригинал")]
|
||||
|
||||
# 18+ у kinozal/rutracker/nnm стоит в строке дорожки; отдельная строка
|
||||
@@ -1063,6 +1103,11 @@ def mark_18(note: str, age: bool) -> str:
|
||||
def clean_studio(raw: str) -> tuple[str, str]:
|
||||
note = ""
|
||||
s = (raw or "").strip().strip("|").strip()
|
||||
s = RE_LANG_PREFIX.sub("", s) # «Русский, - AlexFilm» -> «AlexFilm»
|
||||
s = re.sub(r"(?i)^(?:озвучил\w*|озвучено|озвучивание|перевёл|перевел)\s*[-–—:]?\s*", "", s).strip()
|
||||
m = RE_ON_ORDER.match(s) # «X по заказу Y» -> «X»
|
||||
if m:
|
||||
s = m.group(1)
|
||||
s = re.sub(r"\[[^\]]*\]", " ", s)
|
||||
s = re.sub(r"\([^)]*\)", " ", s)
|
||||
if re.search(r"\b18\s*\+", s):
|
||||
@@ -1100,6 +1145,8 @@ def translation_type(text: str) -> tuple[str, str]:
|
||||
if re.search(r"любительск|псевдо", t): # любительский дубляж — отдельный класс, не «проф.»
|
||||
return "люб. дубляж", "люб."
|
||||
return "проф. дубляж", "проф."
|
||||
if kind == "неизвестно" and "авторск" in t:
|
||||
kind = "одноголосый" # «Авторский (Павел Санаев)» — один голос
|
||||
if "профессиональн" in t:
|
||||
pref = "проф."
|
||||
elif "любительск" in t:
|
||||
@@ -1115,7 +1162,8 @@ def translation_type(text: str) -> tuple[str, str]:
|
||||
|
||||
# ---- kinozal -------------------------------------------------------
|
||||
KZ_RE_TRANSLATION = re.compile(
|
||||
r"^\s*(?:Перевод|Озвучиван\w*|Озвучк\w*)\s*(?:#\s*\d+|\d+)?\s*:\s*(?P<body>.+)$", re.I)
|
||||
r"^\s*(?:Перевод|Озвучиван\w*|Озвучк\w*|Дубляж)\s*" + RE_TRACK_NUM +
|
||||
r"?\s*:\s*(?P<body>.+)$", re.I)
|
||||
KZ_RE_AUDIO_X = re.compile(
|
||||
r"^\s*[АA]удио(?:\s*#?\s*\d+)?\s*x\s*(?P<lang>[A-ZА-Я/]\w*)\s*:\s*(?P<body>.+)$", re.I)
|
||||
KZ_RE_AUDIO = re.compile(r"^\s*[АA]удио\s*:\s*(?P<body>.+)$", re.I)
|
||||
@@ -1172,7 +1220,7 @@ def _kz_parse_core(rec: dict) -> tuple[list[dict], dict]:
|
||||
tech_text = (rec.get("tech_tab") or "").strip()
|
||||
|
||||
def has_transl(t: str) -> bool:
|
||||
return any(KZ_RE_TRANSLATION.match(l) for l in t.splitlines())
|
||||
return any(KZ_RE_TRANSLATION.match(strip_lead(l)) for l in t.splitlines())
|
||||
|
||||
if has_transl(release_text):
|
||||
source, studio_source = release_text, "release"
|
||||
@@ -1182,39 +1230,45 @@ def _kz_parse_core(rec: dict) -> tuple[list[dict], dict]:
|
||||
translations: list[dict] = []
|
||||
audios: list[dict] = []
|
||||
subs = ""
|
||||
# переводы берём только из приоритетной вкладки, иначе «Техданные»
|
||||
# добавляют вторую, обезличенную строку «Перевод: Дублированный»
|
||||
for line in source.splitlines():
|
||||
line = line.strip()
|
||||
m = KZ_RE_TRANSLATION.match(line)
|
||||
if m:
|
||||
body = m.group("body").strip()
|
||||
|
||||
def parse_tab(text: str, src: str) -> None:
|
||||
"""«Перевод:» из вкладки: одна строка может называть несколько студий."""
|
||||
for raw in text.splitlines():
|
||||
line = strip_lead(raw.strip())
|
||||
m = KZ_RE_TRANSLATION.match(line)
|
||||
if not m:
|
||||
continue
|
||||
body = trim_field(m.group("body"))
|
||||
if not body or RE_NO_DUB_LINE.match(body):
|
||||
continue
|
||||
actors = ""
|
||||
am = KZ_RE_ACTORS.search(body)
|
||||
if am:
|
||||
actors = am.group("names").strip(" .")
|
||||
role, candidates = "", []
|
||||
role = ""
|
||||
pm = re.search(r"\(([^)]*)\)", body)
|
||||
if pm:
|
||||
role = pm.group(1)
|
||||
candidates = [body[pm.end():].strip(" |.-–—"), role,
|
||||
body[:pm.start()].strip(" |.-–—")]
|
||||
else:
|
||||
candidates = [body]
|
||||
studio, note = "", ""
|
||||
for cand in candidates:
|
||||
s1, n1 = clean_studio(cand)
|
||||
s1, n2 = canonical_studio(s1)
|
||||
if s1:
|
||||
studio, note = s1, " ".join(x for x in (n1, n2) if x)
|
||||
break
|
||||
kind, pro = translation_type(body)
|
||||
if re.search(r"не требуется", body, re.I):
|
||||
kind = "оригинал"
|
||||
translations.append({"studio": studio, "type": kind, "pro": pro,
|
||||
"role": role, "actors": actors, "note": note,
|
||||
"line": line})
|
||||
for name in part_studios(body) or [""]:
|
||||
translations.append({"studio": name, "type": kind, "pro": pro,
|
||||
"role": role, "actors": actors, "note": "",
|
||||
"line": raw.strip(), "src": src})
|
||||
|
||||
# переводы из обеих вкладок: «Техданные» дают тип, «Релиз» — студии
|
||||
parse_tab(release_text, "release")
|
||||
parse_tab(tech_text, "tech")
|
||||
named_types = {t["type"] for t in translations if t["studio"]}
|
||||
seen_t, uniq_t = set(), []
|
||||
for t in translations:
|
||||
if not t["studio"] and t["type"] in named_types:
|
||||
continue # обезличенный дубль строки со студией
|
||||
key = (t["type"], t["studio"].casefold(), t["line"])
|
||||
if key in seen_t:
|
||||
continue
|
||||
seen_t.add(key)
|
||||
uniq_t.append(t)
|
||||
translations = uniq_t
|
||||
|
||||
# аудиодорожки и субтитры могут быть в любой из вкладок
|
||||
for line in (source + "\n" + tech_text).splitlines():
|
||||
@@ -1274,7 +1328,7 @@ def _kz_parse_core(rec: dict) -> tuple[list[dict], dict]:
|
||||
"note": mark_18(note, age_18), "age_18": int(age_18),
|
||||
"codec": a.get("codec", ""), "channels": a.get("channels", ""),
|
||||
"khz": a.get("khz", ""), "kbps": a.get("kbps", ""),
|
||||
"subtitles": subs, "studio_source": studio_source, "raw": t["line"],
|
||||
"subtitles": subs, "studio_source": t["src"], "raw": t["line"],
|
||||
"lang": (a.get("lang") or "RUS") if match else "",
|
||||
})
|
||||
rel = {"source": "kinozal", "release_id": rec["id"], "ru_title": info["ru_title"],
|
||||
@@ -1287,9 +1341,11 @@ def _kz_parse_core(rec: dict) -> tuple[list[dict], dict]:
|
||||
|
||||
# ---- rutracker -----------------------------------------------------
|
||||
RT_RE_FIELD = re.compile(
|
||||
r"^\s*(?:Перевод|Озвучка|Озвучивание|Озвучено|Звук)\s*(?:#\s*\d+|\d+)?\s*:\s*(?P<body>.+)$", re.I)
|
||||
r"^\s*(?:Перевод|Озвучка|Озвучивание|Озвучено|Звук|Дубляж)\s*" + RE_TRACK_NUM +
|
||||
r"?\s*[:.]\s*(?P<body>.+)$", re.I)
|
||||
RT_RE_BARE = re.compile(
|
||||
r"^\s*(?:Перевод|Озвучка|Озвучивание|Озвучено|Звук)\s*(?:#\s*\d+|\d+)?\s*:\s*$", re.I)
|
||||
r"^\s*(?:Перевод|Озвучка|Озвучивание|Озвучено|Звук|Дубляж)\s*" + RE_TRACK_NUM +
|
||||
r"?\s*:\s*$", re.I)
|
||||
RT_RE_TYPE_START = re.compile(
|
||||
r"^\s*(?:Профессиональн|Любительск|Авторск|Дублирован|Многоголос|Двухголос|Одноголос)", re.I)
|
||||
RT_RE_SUBS = re.compile(r"^\s*Субтитры\s*:\s*(?P<body>.+)$", re.I)
|
||||
@@ -1397,19 +1453,26 @@ def _rt_parse_core(rec: dict) -> tuple[list[dict], dict]:
|
||||
for i, line in enumerate(lines):
|
||||
if i in taken:
|
||||
continue
|
||||
m = RT_RE_FIELD.match(line)
|
||||
bodies = [m.group("body")] if m else (rt_bare_list(lines, i, taken) if RT_RE_BARE.match(line) else [])
|
||||
head = strip_lead(line)
|
||||
m = RT_RE_FIELD.match(head)
|
||||
bodies = [trim_field(m.group("body"))] if m else (
|
||||
rt_bare_list(lines, i, taken) if RT_RE_BARE.match(head) else [])
|
||||
if bodies:
|
||||
for body in bodies:
|
||||
for part in rt_split_entries(body):
|
||||
if RE_NO_DUB_LINE.match(part):
|
||||
continue
|
||||
e = rt_parse_entry(part)
|
||||
if e["type"] in ("оригинал", "субтитры"):
|
||||
continue
|
||||
if not e["studio"] and e["type"] == "неизвестно":
|
||||
# несколько студий в одной части: «(многоголосый) — Jaskier, TVShows, LostFilm»
|
||||
names = part_studios(part) or ([e["studio"]] if e["studio"] else [])
|
||||
if e["type"] == "неизвестно" and not names:
|
||||
continue
|
||||
entries.append(e)
|
||||
for name in names or [""]:
|
||||
entries.append({**e, "studio": name})
|
||||
continue
|
||||
m = RT_RE_SUBS.match(line)
|
||||
m = RT_RE_SUBS.match(head)
|
||||
if m and not subs:
|
||||
subs = m.group("body").strip()
|
||||
rows = [{
|
||||
@@ -1430,9 +1493,14 @@ def _rt_parse_core(rec: dict) -> tuple[list[dict], dict]:
|
||||
|
||||
|
||||
# ---- nnmclub -------------------------------------------------------
|
||||
NNM_RE_AUDIO = re.compile(r"^\s*Аудио\s*(?:#?\s*\d+)?\s*:\s*(?P<body>.+)$", re.I)
|
||||
NNM_RE_PEREVOD = re.compile(r"^\s*Перевод\s*:\s*(?P<body>.+)$", re.I)
|
||||
NNM_RE_AUDIO = re.compile(
|
||||
r"^\s*(?:(?:Аудио|Audio)\s*[^:\n]{0,12}?|Звук\s*(?:[#№]\s*\d+|\d+)?)\s*[:.]\s*(?P<body>.+)$", re.I)
|
||||
NNM_RE_PEREVOD = re.compile(
|
||||
r"^\s*(?:Перевод|Озвучка|Озвучивание|Озвучено|Дубляж)\s*" + RE_TRACK_NUM +
|
||||
r"?\s*:\s*(?P<body>.+)$", re.I)
|
||||
NNM_LANG_RE = re.compile(r"(Русский|Английский|Украинский)", re.I)
|
||||
# «Язык озвучки: Русский (Berofu, Takera, Euji [AniMaunt])» — имена без строки «Аудио»
|
||||
NNM_RE_VOICE_LANG = re.compile(r"(?i)^\s*Язык\s*озвучк\w*\s*[:.]\s*(?P<body>.+)$")
|
||||
NNM_LANG_MAP = {"русский": "RUS", "английский": "ENG", "украинский": "UKR"}
|
||||
NNM_TAG_RE = re.compile(r"\b(DUB|MVO|DVO|AVO|VO|ДБ|ПМ|ПД|АП|ЛМ|ЛД|ЛО)\b", re.I)
|
||||
NNM_RE_CODEC = re.compile(r"\b(Opus|AC-?3|E-?AC-?3|AAC|DTS-?HD|DTS|FLAC|MP3|TrueHD)\b", re.I)
|
||||
@@ -1511,6 +1579,153 @@ def nnm_part_studio(part: str) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- студии из перечислений
|
||||
# корни «голосности»: по ним решается, режется ли перечисление через запятую
|
||||
RE_TYPE_ROOT = re.compile(r"(?i)\b(дубл|многоголос|дву[хг]?голос|одноголос)")
|
||||
# «Звук: X», «Озвучка: двухголосая (X)» — хвост с типом/именем в кодек-строке
|
||||
RE_DESC_TAIL = re.compile(r"(?i)\b(?:звук|озвучк\w*|озвучиван\w*|перевод|дубляж)\s*[:.]\s*(.+)$")
|
||||
# язык в начале скобки: «(Русский, Anilibria)» -> «Anilibria»
|
||||
RE_PAREN_LANG = re.compile(
|
||||
r"(?i)^\s*(?:русск\w*|russian|rus|ru|украин\w*|ukrainian|ukr|англ\w*|english|eng"
|
||||
r"|японск\w*|japanese|jap|китайск\w*|корейск\w*|немецк\w*|французск\w*|итальянск\w*"
|
||||
r"|испанск\w*|казахск\w*|оригинал\w*|original)\s*[,;:]?\s*")
|
||||
NOT_STUDIO_WORD = re.compile(
|
||||
r"(?i)^(?:по умолчанию|внешн\w*|встроенн\w*|в контейнере|в составе|оригинал\w*|комментар\w*"
|
||||
r"|сезон\w*|сери\w*|эпизод\w*|фильм\w*|часть\w*|диск\w*|дорожк\w*|звук\w*|перевод\w*)$")
|
||||
STUDIO_SEP = re.compile(r"\s*[,;|/]\s*")
|
||||
SOURCE_RE = re.compile(r"(?i)^(?:blu-?ray|bd\b|dvd|web-?dl|webrip|hdtv|uhd|itunes|netflix|amazon|r5)")
|
||||
RE_CODEC_PARAM_ANY = re.compile(
|
||||
r"(?i)\b(?:e-?ac-?3|ac-?3|ас3|dts|aac|truehd|flac|mp3|opus|l?pcm|mp2|dolby|ddp?)\b"
|
||||
r"|\d+(?:[.,]\d+)?\s*(?:kbps|кбит|kb/s|khz|кгц|гц|hz|ch|канал\w*)\b")
|
||||
|
||||
|
||||
def is_studio_junk(s: str) -> bool:
|
||||
"""Не имя студии: источник, тип, язык, техпараметры, «по умолчанию», число."""
|
||||
low = (s or "").strip().lower()
|
||||
return (len(low) < 3 or low in SOURCE_LIKE or low in STUDIO_PLACEHOLDERS
|
||||
or bool(NOT_STUDIO_WORD.match(low)) or bool(CODEC_NOT_STUDIO.match(low))
|
||||
or bool(SOURCE_RE.match(low)) or bool(RE_NO_DUB.search(low))
|
||||
or bool(RE_CODEC_PARAM_ANY.match(low))
|
||||
or bool(re.search(r"(?i)anidb|\bmal\b|shikimori|world\s*art|описан|список|эпизод|поверх"
|
||||
r"|\bсери[ияй]\b|\bсезон\w*\b|неизвестн\w*", low))
|
||||
or bool(re.fullmatch(r"[\d\s.,:/\-]+", low)))
|
||||
|
||||
|
||||
def split_studios(text: str) -> list[str]:
|
||||
"""Перечисление студий -> имена: «Jaskier, TVShows, HDrezka Studio, LostFilm»."""
|
||||
out: list[str] = []
|
||||
for part in STUDIO_SEP.split((text or "").strip(" |.-–—")):
|
||||
st, _ = clean_studio(part.strip(" .|"))
|
||||
st, _ = canonical_studio(st)
|
||||
if st and not is_studio_junk(st) and st.casefold() not in {x.casefold() for x in out}:
|
||||
out.append(st)
|
||||
return out
|
||||
|
||||
|
||||
def split_translation_parts(body: str) -> list[str]:
|
||||
"""Значение «Перевод:» -> части-дорожки, не разрезая скобки.
|
||||
|
||||
«Профессиональный (многоголосый, закадровый), Авторский» — две дорожки;
|
||||
«Авторский, одноголосый» — одна (второе слово уточняет первую);
|
||||
«Профессиональное (многоголосое) / Любительское (одноголосое, Живов)» — две.
|
||||
"""
|
||||
text = (body or "").strip()
|
||||
if not text:
|
||||
return []
|
||||
parts, depth, cur = [], 0, ""
|
||||
for ch in text:
|
||||
if ch in "([{":
|
||||
depth += 1
|
||||
elif ch in ")]}":
|
||||
depth = max(0, depth - 1)
|
||||
if ch in ",/" and depth == 0:
|
||||
parts.append(cur)
|
||||
cur = ""
|
||||
else:
|
||||
cur += ch
|
||||
parts.append(cur)
|
||||
parts = [p.strip() for p in parts if p.strip()]
|
||||
roots = sum(bool(RE_TYPE_ROOT.search(p)) for p in parts)
|
||||
starts = sum(bool(RT_RE_TYPE_START.match(p)) for p in parts)
|
||||
return parts if len(parts) > 1 and (roots >= 2 or starts >= 2) else [text]
|
||||
|
||||
|
||||
def part_studios(part: str) -> list[str]:
|
||||
"""Студии из части «Перевод:»: хвост после тире, скобки, остаток без слов типа."""
|
||||
names: list[str] = []
|
||||
|
||||
def add(chunk: str) -> None:
|
||||
for n in split_studios(chunk):
|
||||
if n.casefold() not in {x.casefold() for x in names}:
|
||||
names.append(n)
|
||||
|
||||
m = re.search(r"\s[-–—:]\s*(.+)$", part)
|
||||
if m:
|
||||
add(m.group(1))
|
||||
for pm in re.finditer(r"\(([^()]*)\)", part):
|
||||
chunk = RE_PAREN_LANG.sub("", pm.group(1))
|
||||
if chunk and not NNM_PART_TYPEISH.search(chunk):
|
||||
add(chunk)
|
||||
rest = re.sub(r"\([^()]*\)", " ", part)
|
||||
rest = re.sub(r"(?i)\b(?:профессиональн\w*|любительск\w*|авторск\w*|дублированн\w*|многоголос\w*"
|
||||
r"|дву[хг]?голос\w*|одноголос\w*|закадров\w*|перевод\w*|озвучк\w*|х?\d+\s*[xх])\b",
|
||||
" ", rest)
|
||||
add(rest)
|
||||
return names[:8]
|
||||
|
||||
|
||||
RE_LANG_TOKEN = re.compile(
|
||||
r"(?i)(?<![a-zа-яё])(русск\w*|russian|rus|ru|украин\w*|ukrainian|ukr"
|
||||
r"|англ\w*|english|eng|японск\w*|japanese|jap|китайск\w*|корейск\w*|немецк\w*"
|
||||
r"|французск\w*|итальянск\w*|испанск\w*|казахск\w*|оригинал\w*|original)(?![a-zа-яё])")
|
||||
|
||||
|
||||
def lang_tokens(text: str) -> list[str]:
|
||||
"""Коды языков, названных в строке: «| Rus |», «(Русский, Anilibria)», «RUS(int)»."""
|
||||
out: list[str] = []
|
||||
for m in RE_LANG_TOKEN.finditer(text or ""):
|
||||
w = m.group(1).lower()
|
||||
code = ("RUS" if w.startswith(("русск", "russ", "rus")) or w == "ru"
|
||||
else "UKR" if w.startswith(("укра", "ukra", "ukr")) or w == "uk"
|
||||
else "ENG" if w.startswith(("англ", "engl", "eng")) or w == "en"
|
||||
else "JPN" if w.startswith(("япон", "japan", "jap"))
|
||||
else "OTHER")
|
||||
if code not in out:
|
||||
out.append(code)
|
||||
return out
|
||||
|
||||
|
||||
def audio_names(body: str) -> list[str]:
|
||||
"""Имена студий из строки «Аудио …».
|
||||
|
||||
«Аудио 4: AC3 … (внешние) (Русский, Anilibria)» -> Anilibria;
|
||||
«Аудио: AAC … | Rus | Звук: Zendos & Eladiel» -> Zendos & Eladiel;
|
||||
«Аудио 2: … (MVO, LostFilm)» -> LostFilm.
|
||||
"""
|
||||
names: list[str] = []
|
||||
|
||||
def add(chunk: str) -> None:
|
||||
chunk = (chunk or "").strip()
|
||||
if not chunk or RE_CODEC_PARAM_ANY.search(chunk) and not RE_TYPE_ROOT.search(chunk):
|
||||
return
|
||||
for n in split_studios(chunk):
|
||||
if n.casefold() not in {x.casefold() for x in names}:
|
||||
names.append(n)
|
||||
|
||||
for seg in re.split(r"\|", body):
|
||||
seg = seg.strip()
|
||||
if not seg:
|
||||
continue
|
||||
dm = RE_DESC_TAIL.search(seg)
|
||||
add(dm.group(1) if dm else seg)
|
||||
for pm in re.finditer(r"\(([^()]*)\)", body):
|
||||
chunk = RE_PAREN_LANG.sub("", pm.group(1))
|
||||
if chunk and not NNM_PART_TYPEISH.search(chunk):
|
||||
add(chunk)
|
||||
return names[:8]
|
||||
|
||||
|
||||
|
||||
def _nnm_parse_core(rec: dict) -> tuple[list[dict], dict]:
|
||||
post = rec.get("post") or ""
|
||||
title = rec.get("page_title") or rec.get("title") or ""
|
||||
@@ -1526,37 +1741,36 @@ def _nnm_parse_core(rec: dict) -> tuple[list[dict], dict]:
|
||||
|
||||
for line in post.splitlines():
|
||||
line = line.strip()
|
||||
m = NNM_RE_AUDIO.match(line)
|
||||
if m:
|
||||
body = m.group("body").strip()
|
||||
lm = NNM_LANG_RE.search(body)
|
||||
if not lm or NNM_LANG_MAP.get(lm.group(1).lower()) != "RUS":
|
||||
continue # английскую/украинскую дорожку пропускаем
|
||||
rest = body[lm.end():]
|
||||
tm = NNM_TAG_RE.search(rest)
|
||||
m = NNM_RE_AUDIO.match(strip_lead(line))
|
||||
if m and not re.match(r"(?i)^\s*аудио\s*кодек", strip_lead(line)):
|
||||
body = trim_field(m.group("body"))
|
||||
if RE_NO_DUB_LINE.match(body):
|
||||
continue
|
||||
langs = lang_tokens(body)
|
||||
if not langs or not ({"RUS", "UKR"} & set(langs)):
|
||||
continue # английская/японская дорожка
|
||||
lang = "RUS" if "RUS" in langs else "UKR"
|
||||
flat = body.translate(CYR_TO_LAT)
|
||||
tm = NNM_TAG_RE.search(flat)
|
||||
tag = tm.group(1).upper() if tm else ""
|
||||
low = body.lower()
|
||||
if not tag: # формат «... - дубляж» без аббревиатуры
|
||||
low = rest.lower()
|
||||
if "дубляж" in low:
|
||||
tag = "DUB"
|
||||
elif "многоголос" in low:
|
||||
tag = "MVO"
|
||||
elif "двухголос" in low:
|
||||
elif re.search(r"дву[хг]?голос", low):
|
||||
tag = "DVO"
|
||||
elif "авторск" in low:
|
||||
tag = "AVO"
|
||||
elif "одноголос" in low:
|
||||
tag = "VO"
|
||||
name_raw = ""
|
||||
pm = re.search(r"\((.*)\)", rest)
|
||||
if pm:
|
||||
name_raw = pm.group(1).strip()
|
||||
studio, note = nnm_studio(name_raw)
|
||||
age_18 = has_18(rest)
|
||||
note = mark_18(note, age_18)
|
||||
ttype, pro = nnm_tag_type(tag)
|
||||
if not ttype:
|
||||
ttype, pro = "неизвестно", ""
|
||||
names = audio_names(body)
|
||||
if not names and ttype == "неизвестно":
|
||||
continue # ни типа, ни имени — строке нечего дать датасету
|
||||
codec_m = NNM_RE_CODEC.search(body)
|
||||
kb_m = NNM_RE_KBP.search(body)
|
||||
ch_m = NNM_RE_CH.search(body)
|
||||
@@ -1565,46 +1779,78 @@ def _nnm_parse_core(rec: dict) -> tuple[list[dict], dict]:
|
||||
lay = NNM_RE_LAYOUT.search(body)
|
||||
if lay:
|
||||
channels = str(int(lay.group(1)) + (1 if lay.group(2) != "0" else 0))
|
||||
rows.append({
|
||||
"source": "nnm", "release_id": rec["id"], "ru_title": ru_title,
|
||||
"year": year, "quality": quality, "is_series": is_series,
|
||||
"studio": studio or "(не указана)", "translation_type": ttype,
|
||||
"professional": pro, "role_raw": tag, "actors": name_raw,
|
||||
"note": note,
|
||||
"age_18": int(age_18),
|
||||
"codec": (codec_m.group(1).upper() if codec_m else ""),
|
||||
"channels": channels,
|
||||
"khz": "",
|
||||
"kbps": (kb_m.group(1) if kb_m else ""),
|
||||
"subtitles": "", "studio_source": "release", "raw": line,
|
||||
})
|
||||
continue
|
||||
|
||||
m = NNM_RE_PEREVOD.match(line)
|
||||
if m:
|
||||
for part in re.split(r"[,/]", m.group("body")):
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
ttype, pro = translation_type(part)
|
||||
if ttype in ("неизвестно", "оригинал", "субтитры"):
|
||||
continue
|
||||
perevod_types.append(part)
|
||||
# студия — если названа в части («Дублированный (Невафильм)»); без неё тип всё равно ценен
|
||||
age_18 = has_18(body)
|
||||
for name in names or [""]:
|
||||
rows.append({
|
||||
"source": "nnm", "release_id": rec["id"], "ru_title": ru_title,
|
||||
"year": year, "quality": quality, "is_series": is_series,
|
||||
"studio": nnm_part_studio(part) or "(не указана)", "translation_type": ttype,
|
||||
"professional": pro, "role_raw": part, "actors": "",
|
||||
"note": mark_18("", has_18(line)), "age_18": int(has_18(line)),
|
||||
"codec": "", "channels": "", "khz": "", "kbps": "",
|
||||
"subtitles": "", "studio_source": "tech", "raw": line,
|
||||
"studio": name or "(не указана)", "translation_type": ttype,
|
||||
"professional": pro, "role_raw": tag, "actors": name,
|
||||
"note": mark_18("", age_18),
|
||||
"age_18": int(age_18),
|
||||
"codec": (codec_m.group(1).upper() if codec_m else ""),
|
||||
"channels": channels,
|
||||
"khz": "",
|
||||
"kbps": (kb_m.group(1) if kb_m else ""),
|
||||
"subtitles": "", "studio_source": "release", "raw": line, "lang": lang,
|
||||
})
|
||||
continue
|
||||
|
||||
m = NNM_RE_PEREVOD.match(strip_lead(line))
|
||||
if m:
|
||||
body = trim_field(m.group("body"))
|
||||
for part in split_translation_parts(body):
|
||||
if RE_NO_DUB_LINE.match(part):
|
||||
continue
|
||||
ttype, pro = translation_type(part)
|
||||
if ttype in ("оригинал", "субтитры"):
|
||||
continue
|
||||
names = part_studios(part)
|
||||
if ttype == "неизвестно" and not names:
|
||||
continue
|
||||
# студия — если названа в части («Дублированный (Невафильм)»); без неё тип всё равно ценен
|
||||
for name in names or [""]:
|
||||
rows.append({
|
||||
"source": "nnm", "release_id": rec["id"], "ru_title": ru_title,
|
||||
"year": year, "quality": quality, "is_series": is_series,
|
||||
"studio": name or "(не указана)", "translation_type": ttype,
|
||||
"professional": pro, "role_raw": part, "actors": name,
|
||||
"note": mark_18("", has_18(line)), "age_18": int(has_18(line)),
|
||||
"codec": "", "channels": "", "khz": "", "kbps": "",
|
||||
"subtitles": "", "studio_source": "tech", "raw": line,
|
||||
})
|
||||
|
||||
# страницы, где дорожка описана только полем «Язык озвучки: Русский (Актеры [Студия])»
|
||||
if not rows:
|
||||
for line in post.splitlines():
|
||||
m = NNM_RE_VOICE_LANG.match(strip_lead(line.strip()))
|
||||
if not m:
|
||||
continue
|
||||
body = trim_field(m.group("body"))
|
||||
if "RUS" not in lang_tokens(body):
|
||||
continue
|
||||
names = audio_names(body)
|
||||
for team in re.findall(r"\[([^\[\]]{2,60})\]", body):
|
||||
for n in split_studios(team):
|
||||
if n.casefold() not in {x.casefold() for x in names}:
|
||||
names.append(n)
|
||||
for name in names[:8]:
|
||||
rows.append({
|
||||
"source": "nnm", "release_id": rec["id"], "ru_title": ru_title,
|
||||
"year": year, "quality": quality, "is_series": is_series,
|
||||
"studio": name, "translation_type": "неизвестно", "professional": "",
|
||||
"role_raw": "", "actors": "", "note": mark_18("", has_18(line)),
|
||||
"age_18": int(has_18(line)), "codec": "", "channels": "", "khz": "", "kbps": "",
|
||||
"subtitles": "", "studio_source": "release", "raw": line.strip(), "lang": "RUS",
|
||||
})
|
||||
|
||||
# есть построчные «Аудио N» с именами — строки «Перевод:» (tech) не нужны; иначе
|
||||
# оставляем tech-строки: типы и студии, названные в «Перевод:»
|
||||
# есть построчные «Аудио N» с именами — строки «Перевод:» (tech) того же типа
|
||||
# не нужны; типы, которых в «Аудио» нет, остаются (авторская дорожка и т. п.)
|
||||
if any(r["studio"] != "(не указана)" and r["studio_source"] == "release" for r in rows):
|
||||
rows = [r for r in rows if r["studio_source"] == "release" and r["studio"] != "(не указана)"]
|
||||
rel_types = {r["translation_type"] for r in rows
|
||||
if r["studio_source"] == "release" and r["studio"] != "(не указана)"}
|
||||
rows = [r for r in rows
|
||||
if r["studio_source"] == "release" or r["translation_type"] not in rel_types]
|
||||
|
||||
# дорожка без имени и без типа («- Русский») ничего не даёт датасету
|
||||
rows = [r for r in rows
|
||||
@@ -1815,7 +2061,8 @@ def kz_title_rows(rec: dict, text: str, rows: list[dict]) -> list[dict]:
|
||||
# кодек-строка одной дорожки: «Аудио 2: …», «Аудио Rus: …», «Аудио pic RUS: …», «Звук #1: …»,
|
||||
# «• №3: Русский (АС3 …)», «Russian (Dolby AC3, 6 ch) …»
|
||||
RE_CODEC_LINE = re.compile(
|
||||
r"(?i)^\s*[•#№*\-\s]*(?:(?:аудио\w*|audio|звук\w*)\b[^:\n]{0,24}:|№\s*\d+\s*:|(?:russian|русск\w*|украин\w*)\b)")
|
||||
r"(?i)^\s*[•#№*\-\s]*(?:(?:аудио\w*|audio|звук\w*|track|битрейт\s*аудио|title|заголовок)\b[^:\n]{0,24}:"
|
||||
r"|№\s*\d+(?:\s*[-–]\s*\d+)?\s*:|#\s*\d+\s*:|(?:russian|русск\w*|украин\w*)\b)")
|
||||
RE_CODEC_PARAM = re.compile(r"(?i)\b(?:e-?ac-?3|ac-?3|ас3|dts|aac|truehd|flac|mp3|opus|l?pcm|mp2)\b|\d\s*(?:kbps|кбит|kb/s)")
|
||||
RE_LANG_WORD = re.compile(
|
||||
r"(?i)русск|russian|\brus\b|украин|ukrain|\bukr\b|україн|англ|english|\beng\b|япон|japan|казах|kazakh"
|
||||
@@ -1824,7 +2071,8 @@ RE_LANG_WORD = re.compile(
|
||||
CODEC_NOT_STUDIO = re.compile(
|
||||
r"(?i)^(?:rus|ukr|eng|ru|ua|int|ext|pic|dub|mvo|dvo|avo|vo|лицензи\w*|ненормативн\w*.*|.*лексик\w*"
|
||||
r"|присутствует.*|внешн\w*|в составе.*|серии.*|эп\w*\.?\s*\d.*|[\d\s.,:/\-]+|.*\b(?:kbps|ch|khz|кбит)\b.*"
|
||||
r"|.*(?:русск|английск|украинск|russian|english|ukrainian|оригинал|original)\w*.*"
|
||||
r"|.*(?:русск|английск|украинск|russian|english|ukrainian|оригинал|original|японск|китайск"
|
||||
r"|корейск|немецк|французск|итальянск|испанск|казахск)\w*.*"
|
||||
r"|.*(?:голос|дублир|закадр|перевод).*|.*(?<![а-яё])дубляж(?![а-яё]).*|звук.*"
|
||||
r"|.*(?:blu-?ray|itunes|netflix|amazon|\bbd\b|\bdvd\b|web-?dl).*)$")
|
||||
|
||||
@@ -1866,15 +2114,46 @@ def _known_studios() -> re.Pattern | None:
|
||||
_KNOWN_RX: re.Pattern | None = None
|
||||
|
||||
|
||||
def _rec_base(rec: dict) -> dict:
|
||||
"""Метаданные релиза, когда размеченных строк ещё нет (для строк из кодек-линий)."""
|
||||
src = rec.get("source") or ""
|
||||
title = rec.get("page_title") or rec.get("title") or ""
|
||||
if src == "kinozal":
|
||||
info = kz_split_title(title.split(" :: ")[0])
|
||||
return {"source": src, "ru_title": info["ru_title"], "year": info["year"], "quality": "",
|
||||
"is_series": bool(re.search(r"сезон|серии", title, re.I))}
|
||||
info = rt_parse_title(title)
|
||||
return {"source": src, "ru_title": info["ru_title"], "year": info["year"],
|
||||
"quality": info["quality"], "is_series": info["is_series"]}
|
||||
|
||||
|
||||
def _line_names(line: str, info: dict, known: re.Pattern) -> list[tuple[str, bool]]:
|
||||
"""Кандидаты в студии кодек-строки: словарь, `|…|`, `<…>`, `[…]`, `(…)`."""
|
||||
cands: list[tuple[str, bool]] = [(m.group(1), True) for m in known.finditer(line)]
|
||||
cands += [(n, False) for n in info["names"]]
|
||||
for rx in (r"\|([^|]{2,60})\|", r"<([^<>]{2,60})>", r"\[([^\[\]]{2,60})\]", r"\(([^()]{2,80})\)"):
|
||||
cands += [(m.group(1), False) for m in re.finditer(rx, line)]
|
||||
seen, out = set(), []
|
||||
for name, voc in cands:
|
||||
k = name.strip().casefold()
|
||||
if k and k not in seen:
|
||||
seen.add(k)
|
||||
out.append((name, voc))
|
||||
return out
|
||||
|
||||
|
||||
def codec_line_rows(rec: dict, text: str, rows: list[dict], known: re.Pattern | None = None) -> list[dict]:
|
||||
"""Студии из кодек-строк русских/украинских дорожек, которых нет среди строк разметки.
|
||||
"""Студии и типы из кодек-строк русских/украинских дорожек.
|
||||
|
||||
kinozal: «Перевод: Профессиональный (многоголосый закадровый)» без студий + «Аудио 2: AC3 …
|
||||
(MVO, LostFilm)», «Аудио Rus: … | TVShows»; nnm/rutracker — те же кодек-строки рядом с
|
||||
«Перевод N:» со списком, где этой студии нет. Строка-вход — сегмент кодек-строки, как его
|
||||
«Перевод N:» со списком, где этой студии нет, и строки вида «Аудио 1: язык: Русский,
|
||||
перевод: многоголосый закадровый, кодек: AC3». Строка-вход — сегмент кодек-строки, как его
|
||||
режет сервис (`src.release._entries`); тип — из самой строки (MVO, «дубляж»), иначе
|
||||
единственный тип релиза, иначе «неизвестно». Сводка из нескольких языков
|
||||
(«Аудио: Русский (AC3…), украинский (AC3…)»), оригинал и комментарии — пропускаются.
|
||||
единственный тип релиза, иначе «неизвестно». Если у релиза ещё нет ни одной строки, из
|
||||
кодек-строки берётся и дорожка без имени (тип без студии) — так размечиваются страницы,
|
||||
где дорожки описаны только строками «Аудио». Сводки из нескольких языков, оригинал и
|
||||
комментарии пропускаются.
|
||||
"""
|
||||
from src.release import _entries, same_studio
|
||||
from src.trackinfo import parse_audio_line
|
||||
@@ -1882,18 +2161,21 @@ def codec_line_rows(rec: dict, text: str, rows: list[dict], known: re.Pattern |
|
||||
known = known if known is not None else _known_studios()
|
||||
have = [r["studio"] for r in rows if r.get("studio") and r["studio"] != "(не указана)"]
|
||||
types = {r["translation_type"] for r in rows} - {"неизвестно", ""}
|
||||
base = rows[0] if rows else {}
|
||||
base = rows[0] if rows else _rec_base(rec)
|
||||
out: list[dict] = []
|
||||
for line in (text or "").splitlines():
|
||||
line = re.sub(r"\s+", " ", line).strip()
|
||||
if len(line) > 300 or not RE_CODEC_LINE.match(line) or not RE_CODEC_PARAM.search(line):
|
||||
continue
|
||||
if len({_lang_key(m.group(0)) for m in RE_LANG_WORD.finditer(line)}) > 1:
|
||||
if RE_NO_DUB_LINE.match(line):
|
||||
continue
|
||||
langs = set(lang_tokens(line))
|
||||
if len(langs & {"RUS", "UKR", "ENG", "JPN", "OTHER"}) > 1:
|
||||
continue
|
||||
info = parse_audio_line(re.sub(r"(?i)^\s*[^:]{0,30}:\s*", "", line, count=1))
|
||||
if info["original"] or info["commentary"] or info["lang"] not in ("RUS", "UKR", ""):
|
||||
continue
|
||||
if not info["lang"] and not re.search(r"(?i)\brus\b|\bukr\b", line):
|
||||
if not info["lang"] and not (langs & {"RUS", "UKR"}) and not re.search(r"(?i)\brus\b|\bukr\b", line):
|
||||
continue
|
||||
tag = NNM_TAG_RE.search(line) or NNM_TAG_RE.search(line.translate(CYR_TO_LAT)) # «АVO» кириллицей
|
||||
kind, pro = nnm_tag_type(tag.group(1)) if tag else ("", "")
|
||||
@@ -1904,11 +2186,11 @@ def codec_line_rows(rec: dict, text: str, rows: list[dict], known: re.Pattern |
|
||||
if not kind:
|
||||
kind = next(iter(types)) if len(types) == 1 else "неизвестно"
|
||||
pro = kind.split()[0] if kind.split()[0] in ("проф.", "люб.", "авт.") else ""
|
||||
cands = [(m.group(1), True) for m in known.finditer(line)] + [(n, False) for n in info["names"]]
|
||||
for name, in_vocab in cands:
|
||||
names = _line_names(line, info, known)
|
||||
for name, in_vocab in names:
|
||||
studio, note = clean_studio(name)
|
||||
studio, extra = canonical_studio(studio)
|
||||
if len(studio) < 3 or studio.lower() in SOURCE_LIKE or CODEC_NOT_STUDIO.match(studio):
|
||||
if len(studio) < 3 or is_studio_junk(studio):
|
||||
continue
|
||||
if CODEC_NAME_NOISE.search(studio) or not in_vocab and re.search(r"\d", studio):
|
||||
continue
|
||||
@@ -1928,7 +2210,31 @@ def codec_line_rows(rec: dict, text: str, rows: list[dict], known: re.Pattern |
|
||||
"raw": seg, "lang": info["lang"] or ("UKR" if re.search(r"(?i)\bukr\b", line) else "RUS"),
|
||||
"audio_raw": line})
|
||||
have.append(studio)
|
||||
return out
|
||||
if not rows and not names and kind and kind != "неизвестно":
|
||||
# дорожка без имени: датасету ценен хотя бы тип; только для релизов без строк вовсе
|
||||
entries = _entries(line)
|
||||
out.append({
|
||||
"source": base.get("source") or rec.get("source", ""), "release_id": rec.get("id", ""),
|
||||
"ru_title": base.get("ru_title", ""), "year": base.get("year", ""),
|
||||
"quality": base.get("quality", ""), "is_series": base.get("is_series", False),
|
||||
"studio": "(не указана)", "translation_type": kind, "professional": pro,
|
||||
"role_raw": "", "actors": "",
|
||||
"note": mark_18("", info["adult"]),
|
||||
"age_18": int(info["adult"]), "codec": info["codec"], "channels": info["channels"],
|
||||
"khz": "", "kbps": info["kbps"], "subtitles": "", "studio_source": "codec",
|
||||
"raw": entries[0] if entries else line,
|
||||
"lang": info["lang"] or ("UKR" if re.search(r"(?i)\bukr\b", line) else "RUS"),
|
||||
"audio_raw": line})
|
||||
# один и тот же тип/студия из соседних строк-дублей
|
||||
seen, uniq = set(), []
|
||||
for r in out:
|
||||
key = (r["studio"].casefold(), r["translation_type"],
|
||||
re.sub(r"\s+", " ", r["raw"]).strip().casefold())
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
uniq.append(r)
|
||||
return uniq
|
||||
|
||||
|
||||
def _with_audio(core, texts):
|
||||
@@ -1943,8 +2249,8 @@ def _with_audio(core, texts):
|
||||
rows = kz_audio_only_rows(rec, text)
|
||||
if core is _kz_parse_core:
|
||||
rows += kz_title_rows(rec, text, rows)
|
||||
rows += codec_line_rows(rec, text, rows)
|
||||
if rows:
|
||||
rows += codec_line_rows(rec, text, rows)
|
||||
rel["n_translations"], rel["ok"] = len(rows), True
|
||||
rel["studios"] = " | ".join(sorted({r["studio"] for r in rows}))
|
||||
return rows, rel
|
||||
|
||||
Reference in new issue
Block a user