diff options
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(): |