aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--docs/mediacenter.md16
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/files-app.js19
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/icon.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/search-page.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css9
-rw-r--r--packages/meshbay-node/src/meshbay_node/indexer/enrich.py13
-rw-r--r--packages/meshbay-node/src/meshbay_node/indexer/title_parse.py20
-rw-r--r--packages/meshbay-node/tests/test_enrich.py41
-rw-r--r--packages/meshbay-node/tests/test_title_parse.py22
19 files changed, 149 insertions, 15 deletions
diff --git a/docs/mediacenter.md b/docs/mediacenter.md
index 5c27939..45f57d9 100644
--- a/docs/mediacenter.md
+++ b/docs/mediacenter.md
@@ -770,6 +770,22 @@ TMDB id, and an unmatched poster gets a badge.
| ~~V12~~ | Movies not merged in the poster grid | `mergedMovies` groups by resolved `tmdb_id`, mirroring `mergedShows`; the detail modal lists the versions (resolution · duration · size), each a Play button. New `video.versions` key ×10. Edge: "Fix match" on a merged movie corrects only the representative file; the other version un-merges and can be corrected on its own |
| ~~V13~~ | Per-card "re-match this one file" button | `OP_TMDB_REMATCH` / `MNP.TMDB_REMATCH` (MNP 0.13, additive) → `media_cache.drop_tmdb_match` (forgets the match *and* the override marker). Signed like `tmdb_override`. Button next to "Fix match" in the detail modal; `transport.rematchTmdbMatch`; `video.rematch_one` key ×10 |
+### 10.2 A movie mis-shelved as a series (2026-08-29)
+
+`Some.Film.2017.MULTI.108.grp.mkv` — a movie whose `1080p` tag was truncated to `108` —
+made guessit invent `S01E08`, so `enrich.py`'s flat-library branch filed a standalone film
+as a nonexistent series, and "Fix match" (TV-only for a "show") could only offer other
+series. **Fixed** (`fix/movie-misclassified-as-show`): that branch now requires
+`ep.season` *and* `ep.episode`, plus either an explicit marker
+(`title_parse.has_episode_marker` — `SxxExx` / `1x08` / `Episode N` / `Season N`) or the
+absence of a `(2019)`-style year; otherwise it falls through to the movie branch. A real
+flat-dumped episode always carries a marker, so nothing regresses.
+
+**Still open:** there is no operator control over the movie/show classification itself —
+"Fix match" only changes the TMDB id, not the kind. A genuinely ambiguous name
+(`Show.2019.308.mkv`) that guessit slots wrongly has no in-UI remedy short of renaming the
+file. A "this is a movie / a show" toggle would close that.
+
## 11. Acceptance before shipping
1. Re-run the §3 validation (real TMDB calls, same corpus, same script
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/files-app.js b/packages/meshbay-hub/src/meshbay_hub/static/files-app.js
index 3d51e67..d2f0727 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/files-app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/files-app.js
@@ -27,7 +27,7 @@ function FilesPanel({
groupId, transportRef, gekRef, status,
entries, nodeDirs, nodeRoots, setEntries, setNodeDirs, setNodeRoots, applyIndex,
isNodeAdmin, operatorPaired, mayUpload, userId, setError, onPreview,
- showGroup, readOnly, getTransport,
+ showGroup, readOnly, getTransport, onRefreshIndex,
}) {
const [selecting, setSelecting] = useState(false);
const [selected, setSelected] = useState(() => new Set());
@@ -35,6 +35,16 @@ function FilesPanel({
const [sortAsc, setSortAsc] = useState(true);
const [filter, setFilter] = useState('');
const [currentPath, setCurrentPath] = useState('');
+ const [refreshing, setRefreshing] = useState(false);
+
+ // Force a re-fetch of the group index from the node — the client's file
+ // list is a view over a cached copy, and there is no other way to pull a
+ // fresh one on demand.
+ const doRefresh = useCallback(async () => {
+ if (refreshing || !onRefreshIndex) return;
+ setRefreshing(true);
+ try { await onRefreshIndex(); } finally { setRefreshing(false); }
+ }, [refreshing, onRefreshIndex]);
// A directory from the group just left rarely exists in the one just
// entered (e.g. "outputs" in one group, absent in another) — Files would
@@ -329,6 +339,13 @@ function FilesPanel({
</div>
<div class="breadcrumbs">
+ ${onRefreshIndex && html`
+ <button class="crumb crumb-refresh ${refreshing ? 'spinning' : ''}"
+ onClick=${doRefresh} disabled=${refreshing}
+ title=${t('group.refresh_index')}>
+ <${Icon} name="refresh" />
+ </button>
+ `}
<a class="crumb" onClick=${() => setCurrentPath('')}>
<${Icon} name="home" />
</a>
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/icon.js b/packages/meshbay-hub/src/meshbay_hub/static/icon.js
index 029c669..cc28110 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/icon.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/icon.js
@@ -52,6 +52,8 @@ const ICON_PATHS = {
'folder-plus': ['M3.5 6.6a1 1 0 0 1 1-1h4.2l2 2.4h7.8a1 1 0 0 1 1 1v9.4a1 1 0 0 1-1 1h-14a1 1 0 0 1-1-1z',
'M12 11.4v5', 'M9.5 13.9h5'],
plus: ['M12 5v14', 'M5 12h14'],
+ refresh: ['M20 11a8 8 0 0 0-15.3-2', 'M4 5v4h4',
+ 'M4 13a8 8 0 0 0 15.3 2', 'M20 19v-4h-4'],
clip: ['M20.5 11.8l-8.4 8.4a5.4 5.4 0 0 1-7.6-7.6l8.8-8.8a3.6 3.6 0 0 1 5.1 5.1l-8.8 8.8a1.8 1.8 0 0 1-2.5-2.5l8.1-8.1'],
pencil: ['M4 20h4l10.5-10.5a2.1 2.1 0 0 0-3-3L5 17v3',
'M14.5 6.5l3 3'],
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
index 07f6e19..d1cbf2b 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -94,6 +94,7 @@ export default {
'members.apps_need_one': "Mindestens eine Anwendung muss aktiviert bleiben.",
'members.danger_delete_hint': "Die Gruppe verschwindet für alle Mitglieder. Das lässt sich nicht rückgängig machen.",
'group.filter': 'Dateien filtern …',
+ 'group.refresh_index': 'Liste aktualisieren',
'group.col_name': 'Name',
'group.col_size': 'Größe',
'group.col_type': 'Typ',
@@ -524,7 +525,6 @@ export default {
// Status
// Search
- 'search.title': 'Dateien suchen',
'search.placeholder': 'In allen Gruppen suchen …',
'search.no_results': 'Keine Datei entspricht Ihrer Suche.',
'search.col_group': 'Gruppe',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
index 2e5bee3..6ddfa91 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -95,6 +95,7 @@ export default {
'members.apps_need_one': "At least one application must stay enabled.",
'members.danger_delete_hint': "This removes the group for every member. It cannot be undone.",
'group.filter': 'Filter files...',
+ 'group.refresh_index': 'Refresh file list',
'group.col_name': 'Name',
'group.col_size': 'Size',
'group.col_type': 'Type',
@@ -601,7 +602,6 @@ export default {
// Status
// Search
- 'search.title': 'Search Files',
'search.placeholder': 'Search across all groups...',
'search.no_results': 'No files match your search.',
'search.col_group': 'Group',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
index 321ac95..c942869 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -92,6 +92,7 @@ export default {
'members.apps_need_one': "Al menos una aplicación debe permanecer activada.",
'members.danger_delete_hint': "El grupo desaparece para todos sus miembros. No se puede deshacer.",
'group.filter': 'Filtrar archivos...',
+ 'group.refresh_index': 'Actualizar la lista',
'group.col_name': 'Nombre',
'group.col_size': 'Tamaño',
'group.col_type': 'Tipo',
@@ -520,7 +521,6 @@ export default {
// Status
// Search
- 'search.title': 'Buscar archivos',
'search.placeholder': 'Buscar en todos los grupos...',
'search.no_results': 'Ningún archivo coincide con su búsqueda.',
'search.col_group': 'Grupo',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
index 51360a5..8eefb29 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -93,6 +93,7 @@ export default {
'members.apps_need_one': "Au moins une application doit rester activée.",
'members.danger_delete_hint': "Le groupe disparaît pour tous ses membres. C’est irréversible.",
'group.filter': 'Filtrer les fichiers...',
+ 'group.refresh_index': 'Actualiser la liste',
'group.col_name': 'Nom',
'group.col_size': 'Taille',
'group.col_type': 'Type',
@@ -524,7 +525,6 @@ export default {
// Status
// Search
- 'search.title': 'Rechercher des fichiers',
'search.placeholder': 'Rechercher dans tous les groupes...',
'search.no_results': 'Aucun fichier ne correspond à votre recherche.',
'search.col_group': 'Groupe',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
index 8e53b5a..4ee261c 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -93,6 +93,7 @@ export default {
'members.apps_need_one': "Almeno un'applicazione deve restare attiva.",
'members.danger_delete_hint': "Il gruppo scompare per tutti i membri. Non è reversibile.",
'group.filter': 'Filtra i file...',
+ 'group.refresh_index': 'Aggiorna l\'elenco',
'group.col_name': 'Nome',
'group.col_size': 'Dimensione',
'group.col_type': 'Tipo',
@@ -522,7 +523,6 @@ export default {
// Status
// Search
- 'search.title': 'Cerca file',
'search.placeholder': 'Cerca in tutti i gruppi...',
'search.no_results': 'Nessun file corrisponde alla sua ricerca.',
'search.col_group': 'Gruppo',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
index b6dc85d..e30f5bf 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -91,6 +91,7 @@ export default {
'members.apps_need_one': "少なくとも1つのアプリケーションを有効にしておく必要があります。",
'members.danger_delete_hint': "グループはすべてのメンバーから消えます。元に戻せません。",
'group.filter': 'ファイルを絞り込み…',
+ 'group.refresh_index': '一覧を更新',
'group.col_name': '名前',
'group.col_size': 'サイズ',
'group.col_type': '種類',
@@ -511,7 +512,6 @@ export default {
// Status
// Search
- 'search.title': 'ファイルを検索',
'search.placeholder': 'すべてのグループから検索…',
'search.no_results': '検索条件に一致するファイルはありません。',
'search.col_group': 'グループ',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
index ad086a8..2f66e00 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -94,6 +94,7 @@ export default {
'members.apps_need_one': "Er moet minstens één toepassing ingeschakeld blijven.",
'members.danger_delete_hint': "De groep verdwijnt voor alle leden. Dit kan niet ongedaan worden gemaakt.",
'group.filter': 'Bestanden filteren...',
+ 'group.refresh_index': 'Lijst vernieuwen',
'group.col_name': 'Naam',
'group.col_size': 'Grootte',
'group.col_type': 'Type',
@@ -524,7 +525,6 @@ export default {
// Status
// Search
- 'search.title': 'Bestanden zoeken',
'search.placeholder': 'In alle groepen zoeken...',
'search.no_results': 'Geen bestand voldoet aan uw zoekopdracht.',
'search.col_group': 'Groep',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
index 29d95aa..f809182 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -98,6 +98,7 @@ export default {
'members.apps_need_one': "Co najmniej jedna aplikacja musi pozostać włączona.",
'members.danger_delete_hint': "Grupa zniknie dla wszystkich członków. Tego nie można cofnąć.",
'group.filter': 'Filtruj pliki...',
+ 'group.refresh_index': 'Odśwież listę',
'group.col_name': 'Nazwa',
'group.col_size': 'Rozmiar',
'group.col_type': 'Typ',
@@ -541,7 +542,6 @@ export default {
// Status
// Search
- 'search.title': 'Wyszukiwanie plików',
'search.placeholder': 'Szukaj we wszystkich grupach...',
'search.no_results': 'Żaden plik nie pasuje do zapytania.',
'search.col_group': 'Grupa',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
index 93f7198..2e1a9f5 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
@@ -94,6 +94,7 @@ export default {
'members.apps_need_one': "Pelo menos um aplicativo deve permanecer ativado.",
'members.danger_delete_hint': "O grupo desaparece para todos os membros. Não há como desfazer.",
'group.filter': 'Filtrar arquivos...',
+ 'group.refresh_index': 'Atualizar a lista',
'group.col_name': 'Nome',
'group.col_size': 'Tamanho',
'group.col_type': 'Tipo',
@@ -521,7 +522,6 @@ export default {
// Status
// Search
- 'search.title': 'Pesquisar arquivos',
'search.placeholder': 'Pesquisar em todos os grupos...',
'search.no_results': 'Nenhum arquivo corresponde à sua pesquisa.',
'search.col_group': 'Grupo',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
index b8ae04f..1ec23eb 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
@@ -91,6 +91,7 @@ export default {
'members.apps_need_one': "至少需要保留一个已启用的应用。",
'members.danger_delete_hint': "该群组将对所有成员消失,且无法恢复。",
'group.filter': '筛选文件…',
+ 'group.refresh_index': '刷新列表',
'group.col_name': '名称',
'group.col_size': '大小',
'group.col_type': '类型',
@@ -497,7 +498,6 @@ export default {
// Status
// Search
- 'search.title': '搜索文件',
'search.placeholder': '在所有群组中搜索…',
'search.no_results': '没有文件符合您的搜索。',
'search.col_group': '群组',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/search-page.js b/packages/meshbay-hub/src/meshbay_hub/static/search-page.js
index e61dcd8..e36532f 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/search-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/search-page.js
@@ -480,8 +480,6 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs })
return html`
<div>
- <h2>${t('search.title')}</h2>
-
<div class="search-bar">
<${Icon} name="search" />
<input type="text"
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css
index 1ead2d0..739f9c6 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/style.css
+++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css
@@ -886,6 +886,15 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
}
.crumb:hover { background: var(--bg-raised); text-decoration: none; }
.crumb-sep { color: var(--text-dim); }
+.crumb-refresh {
+ background: none;
+ border: 0;
+ font: inherit;
+ color: var(--text-secondary);
+}
+.crumb-refresh:hover:not(:disabled) { color: var(--text); }
+.crumb-refresh:disabled { cursor: default; }
+.crumb-refresh.spinning .icon { animation: spin 0.8s linear infinite; }
/* ── File table ──────────────────────────────────────────────────────────── */
diff --git a/packages/meshbay-node/src/meshbay_node/indexer/enrich.py b/packages/meshbay-node/src/meshbay_node/indexer/enrich.py
index 07f0be7..8b2eca5 100644
--- a/packages/meshbay-node/src/meshbay_node/indexer/enrich.py
+++ b/packages/meshbay-node/src/meshbay_node/indexer/enrich.py
@@ -283,9 +283,18 @@ class Enricher:
fields["display_title"] = show_folder.name
fields["season"] = season
fields["episode"] = episode
- elif ep.episode is not None:
+ elif (ep.season is not None and ep.episode is not None
+ and (title_parse.has_episode_marker(entry.name)
+ or title_parse.year_in(entry.name) is None)):
# No season-like ancestor at all (a flat library) but the
- # filename itself carries season+episode (§3.4).
+ # filename itself carries season+episode (§3.4) — *and* it
+ # is a real marker, not guessit reading a bare number as
+ # SxxExx. A movie whose "1080p" tag was truncated to "108",
+ # or "1280" left in the name, otherwise parses to S01E08 /
+ # S12E80 and gets shelved as a nonexistent series
+ # (found live 2026-08-29). A genuine flat-dumped episode
+ # has an explicit SxxExx/1x08/"Episode N" marker; a movie
+ # has a "(2019)"-style year and no such marker.
title = ep.display_title or await asyncio.to_thread(
_title_from_siblings, file_path)
fields["display_title"] = title or title_parse.naive_title(entry.name)
diff --git a/packages/meshbay-node/src/meshbay_node/indexer/title_parse.py b/packages/meshbay-node/src/meshbay_node/indexer/title_parse.py
index 018746b..def947f 100644
--- a/packages/meshbay-node/src/meshbay_node/indexer/title_parse.py
+++ b/packages/meshbay-node/src/meshbay_node/indexer/title_parse.py
@@ -288,6 +288,26 @@ def strip_track_prefix(text: str) -> str:
return re.sub(r"^[\s-]+", "", text[m.end():]).strip() if m else text
+# An *explicit* season/episode marker: SxxExx, 1x08, "Episode 8", "Ep 8",
+# "Season 1"/"Saison 1". guessit will also invent a season+episode from a
+# bare 3-4 digit run ("1080p" truncated to "108" -> S01E08; "1280" ->
+# S12E80), which is how a plain movie ends up shelved as a series
+# (§10.1/V14). The indexer uses this to tell a real flat-library episode
+# from that hallucination.
+_EPISODE_MARKER_RE = re.compile(
+ r"s\d{1,2}[\s._-]*e\d{1,3}"
+ r"|\b\d{1,2}x\d{1,3}\b"
+ r"|\bepisode[\s._-]*\d{1,3}\b"
+ r"|\bep[\s._-]*\d{1,3}\b"
+ r"|\b(?:season|saison)[\s._-]*\d{1,2}\b",
+ re.IGNORECASE,
+)
+
+
+def has_episode_marker(filename: str) -> bool:
+ return bool(_EPISODE_MARKER_RE.search(filename))
+
+
def parse_episode_filename(filename: str) -> ParsedName:
"""
Parse an episode filename. `display_title` may come back None (e.g.
diff --git a/packages/meshbay-node/tests/test_enrich.py b/packages/meshbay-node/tests/test_enrich.py
index 4b957cd..a35c713 100644
--- a/packages/meshbay-node/tests/test_enrich.py
+++ b/packages/meshbay-node/tests/test_enrich.py
@@ -373,3 +373,44 @@ async def test_enricher_reads_a_three_digit_episode_number_correctly(tmp_path, m
assert fields["season"] == 6
assert fields["episode"] == 100
+
+
+@pytestmark_ffmpeg
+@pytest.mark.asyncio
+async def test_a_movie_with_a_mangled_quality_tag_is_not_shelved_as_a_series(
+ tmp_path, media_cache):
+ """
+ Found live 2026-08-29: a standalone film whose "1080p" tag was
+ truncated to "108" in the filename makes guessit invent S01E08, so
+ enrich (flat library, no season ancestor) filed it as a nonexistent
+ series. A real flat-dumped episode carries an explicit SxxExx / 1x08 /
+ "Episode N" marker; a movie has a "(2017)"-style year and none.
+ """
+ clip = tmp_path / "Some.Film.2017.MULTI.108.grp.mkv"
+ _make_clip(clip)
+ entry = IndexEntry(id="fid-trunc", name=clip.name, path=clip.name,
+ size=clip.stat().st_size, type="video", added_at=0)
+
+ enricher = Enricher(media_cache)
+ _, fields = await _run(enricher, entry, clip)
+
+ assert fields.get("season") is None and fields.get("episode") is None, (
+ "a movie with a mangled quality tag must not become a series")
+ assert fields["display_title"]
+
+
+@pytestmark_ffmpeg
+@pytest.mark.asyncio
+async def test_a_flat_episode_with_a_real_marker_stays_a_show_even_with_a_year(
+ tmp_path, media_cache):
+ """The guard must not misfire: a genuine flat-dumped episode that also
+ carries a year has an explicit SxxExx marker and stays a show."""
+ clip = tmp_path / "Some.Show.2022.S01E02.1080p.WEB.mkv"
+ _make_clip(clip)
+ entry = IndexEntry(id="fid-marker", name=clip.name, path=clip.name,
+ size=clip.stat().st_size, type="video", added_at=0)
+
+ enricher = Enricher(media_cache)
+ _, fields = await _run(enricher, entry, clip)
+
+ assert fields["season"] == 1 and fields["episode"] == 2
diff --git a/packages/meshbay-node/tests/test_title_parse.py b/packages/meshbay-node/tests/test_title_parse.py
index ac4d434..045635f 100644
--- a/packages/meshbay-node/tests/test_title_parse.py
+++ b/packages/meshbay-node/tests/test_title_parse.py
@@ -7,6 +7,7 @@ is a manual acceptance step (§11), not something this repo's corpus holds.
from meshbay_node.indexer.title_parse import (
ParsedName,
clean_query,
+ has_episode_marker,
leading_episode_number,
naive_title,
parse_episode_filename,
@@ -113,6 +114,27 @@ def test_clean_query_despaces_a_folder_name_without_eating_the_last_word():
assert naive_title("Some.Show.Name") != "Some Show Name" # the trap it avoids
+# ── §10.1/V14: telling a real episode marker from a mangled number ──────────
+
+def test_has_episode_marker_accepts_real_markers():
+ for name in ["Some.Show.S01E08.mkv", "some.show.s1.e8.mkv",
+ "Some Show 1x08.mkv", "Some Show 01x08.mkv",
+ "Some Show Episode 8.mkv", "Some Show ep08.mkv",
+ "Some Show ep.8.mkv", "Some Show Season 1.mkv",
+ "Une Serie Saison 3.mkv"]:
+ assert has_episode_marker(name), name
+
+
+def test_has_episode_marker_rejects_bare_numbers_and_ordinary_words():
+ # "1080p" truncated to "108", "1280" left in, a year, plain words that
+ # merely contain "ep" — none of these are episode markers.
+ for name in ["Some.Film.2017.MULTI.108.grp.mkv",
+ "Some.Flick.2013.1280.x264-grp.mkv",
+ "Some Movie 2017.mkv", "The Dark Knight.mkv",
+ "Sleep 8.mkv", "Deep 8 mm.mkv", "Ocean's 11.mkv"]:
+ assert not has_episode_marker(name), name
+
+
# ── bug 2026-08-29: guessit peels "Volume N" off the title ─────────────────
def test_movie_volume_number_is_folded_back_into_the_title():