diff --git a/server/src/Flaro.Api/Endpoints/Contracts.cs b/server/src/Flaro.Api/Endpoints/Contracts.cs index c8bff95..1f348a0 100644 --- a/server/src/Flaro.Api/Endpoints/Contracts.cs +++ b/server/src/Flaro.Api/Endpoints/Contracts.cs @@ -41,9 +41,12 @@ public sealed record OhneZuordnung( public sealed record Mehrfachbericht( int Gesamt, IReadOnlyList Titel); -/// Die Namen der Dateien, die an diesem einen Titel hängen. +/// Die Dateien, die an diesem einen Titel hängen. public sealed record Mehrfachtitel( - Guid Id, string Art, string Titel, int? Jahr, IReadOnlyList Dateien); + Guid Id, string Art, string Titel, int? Jahr, IReadOnlyList Dateien); + +/// Eine Datei an einem Titel — mit Kennung, damit sie sich herauslösen lässt. +public sealed record Fassungsdatei(Guid Id, string Name); public sealed record UnzugeordneteDatei(Guid Id, string Pfad, string Grund); diff --git a/server/src/Flaro.Api/Endpoints/ManagementEndpoints.cs b/server/src/Flaro.Api/Endpoints/ManagementEndpoints.cs index 4281adf..56ec182 100644 --- a/server/src/Flaro.Api/Endpoints/ManagementEndpoints.cs +++ b/server/src/Flaro.Api/Endpoints/ManagementEndpoints.cs @@ -299,15 +299,15 @@ public static class ManagementEndpoints .Select(m => new { m.Id, m.Title, Jahr = m.ParsedYear ?? m.Year, - Pfade = db.MediaFiles.Where(f => f.MovieId == m.Id) - .Select(f => f.Path).ToList(), + Dateien = db.MediaFiles.Where(f => f.MovieId == m.Id) + .Select(f => new { f.Id, f.Path }).ToList(), }) .ToListAsync(); var liste = mehrfach .Select(m => new Mehrfachtitel( m.Id, "movie", m.Title, m.Jahr, - m.Pfade.Select(p => Dateiname(p) ?? p).ToList())) + m.Dateien.Select(f => new Fassungsdatei(f.Id, Dateiname(f.Path) ?? f.Path)).ToList())) .ToList(); return Results.Ok(new Mehrfachbericht(liste.Count, liste)); diff --git a/server/src/Flaro.Api/Endpoints/MatchEndpoints.cs b/server/src/Flaro.Api/Endpoints/MatchEndpoints.cs index 1e2924b..324becf 100644 --- a/server/src/Flaro.Api/Endpoints/MatchEndpoints.cs +++ b/server/src/Flaro.Api/Endpoints/MatchEndpoints.cs @@ -151,6 +151,63 @@ public static class MatchEndpoints return betroffen > 0 ? Results.NoContent() : Results.NotFound(); }).RequireAuthorization(FlaroPolicies.Administrator); + // **Eine Datei aus ihrem Titel herauslösen.** + // + // Zwei Dateien hängen an einem Eintrag, gehören aber zu zwei Filmen: + // „Hanni und Nanni" und „Hanni und Nanni 3" liefen bei der + // Metadatenquelle auf denselben Treffer, und der zweite Film war + // damit nicht mehr zu sehen. Die Riegel dagegen greifen erst beim + // nächsten Lauf — was schon zusammengelegt ist, bleibt es. + // + // Der neue Eintrag bekommt den Titel aus dem Dateinamen und keine + // Kennung: er steht danach unter „Ohne Zuordnung" und lässt sich dort + // in zwei Klicks richtigstellen. Eine Kennung zu raten hiesse, denselben + // Fehler noch einmal zu machen. + app.MapPost("/api/v1/files/{id:guid}/split", async ( + Guid id, FlaroContext db, TimeProvider clock) => + { + var datei = await db.MediaFiles.FirstOrDefaultAsync(f => f.Id == id); + if (datei is null) return Results.NotFound(); + + if (datei.MovieId is not { } alt) + { + return Results.Problem( + "Nur eine Filmdatei lässt sich herauslösen. Bei Serien entscheidet " + + "die Folgennummer, zu welcher Folge eine Datei gehört.", + statusCode: StatusCodes.Status400BadRequest); + } + + var geschwister = await db.MediaFiles.CountAsync(f => f.MovieId == alt); + if (geschwister < 2) + { + return Results.Problem( + "An diesem Titel hängt nur diese eine Datei — da gibt es nichts zu trennen.", + statusCode: StatusCodes.Status409Conflict); + } + + var name = FileNames.ParseMovie(datei.Path); + var bisher = await db.Movies.AsNoTracking().FirstAsync(m => m.Id == alt); + + var neu = new Movie + { + LibraryId = datei.LibraryId, + ParsedTitle = name.Title, + ParsedYear = name.Year, + Title = name.Title, + SortTitleValue = SortTitle.For(name.Title), + Year = name.Year, + // **Sofort sichtbar.** Wer etwas herauslöst, will es sehen — + // nicht erst freigeben müssen. + PublishAt = bisher.PublishAt ?? clock.GetUtcNow(), + }; + db.Movies.Add(neu); + + datei.MovieId = neu.Id; + await db.SaveChangesAsync(); + + return Results.Ok(new MatchState(null, null, false, neu.Title, neu.Year, Id: neu.Id)); + }).RequireAuthorization(FlaroPolicies.Administrator); + // **Woher dieser Titel kommt.** // // Steht im Dialog über der Suche. Wenn eine Zuordnung völlig daneben diff --git a/server/src/Flaro.Core/Library/Titelschluessel.cs b/server/src/Flaro.Core/Library/Titelschluessel.cs index 7e4fe1b..60c89e6 100644 --- a/server/src/Flaro.Core/Library/Titelschluessel.cs +++ b/server/src/Flaro.Core/Library/Titelschluessel.cs @@ -1,4 +1,5 @@ using System.Globalization; +using System.Linq; using System.Text; namespace Flaro.Core.Library; @@ -61,7 +62,41 @@ public static class Titelschluessel } } - return bau.ToString().TrimEnd(); + return OhneBindewort(bau.ToString().TrimEnd()); + } + + /// Die Wörter, die nur zwei Namen verbinden. + /// + /// + /// Weil „&" verschwindet und „und" stehenbleibt. TMDB führt + /// den Film als „Hanni & Nanni", auf der Platte heisst er „Hanni und + /// Nanni" — das Kaufmanns-Und fällt als Sonderzeichen heraus, das Wort + /// nicht, und die beiden Schlüssel gingen auseinander. Die Namensgleichheit + /// griff deshalb nicht, und gewählt wurde stattdessen der erstbeste + /// Treffer: „Hanni & Nanni 3". Gemessen am echten Dienst — der richtige + /// Film stand auf Platz zwei. + /// + /// Gestrichen statt umgeschrieben. „&" nach „und" zu übersetzen + /// hiesse, sich für eine Sprache zu entscheiden; „Batman and Robin" bliebe + /// dann aussen vor. Ohne das Bindewort passt beides. + /// + private static readonly string[] Bindewoerter = ["und", "and", "et", "e", "y"]; + + private static string OhneBindewort(string schluessel) + { + if (schluessel.Length == 0) return schluessel; + + var worte = schluessel.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (worte.Length < 3) return schluessel; // „Ich und" ist kein Bindewort-Fall + + // **Nur zwischen zwei Wörtern, nie am Rand.** „Und dann kam Polly" + // fängt mit einem Bindewort an, und das gehört zum Titel. + var behalten = worte + .Where((w, i) => i == 0 || i == worte.Length - 1 + || !Bindewoerter.Contains(w, StringComparer.Ordinal)) + .ToArray(); + + return string.Join(' ', behalten); } /// Ob zwei Fundstücke derselbe Titel sind. diff --git a/server/src/Flaro.Metadata/TmdbClient.cs b/server/src/Flaro.Metadata/TmdbClient.cs index a89e100..463f546 100644 --- a/server/src/Flaro.Metadata/TmdbClient.cs +++ b/server/src/Flaro.Metadata/TmdbClient.cs @@ -4,6 +4,8 @@ using System.Text.Json.Serialization; using Flaro.Core.Metadata; using Microsoft.Extensions.Logging; +using Flaro.Core.Library; + namespace Flaro.Metadata; /// Fragt TMDB nach Titeln, Beschreibungen und Bildern. @@ -289,10 +291,19 @@ public sealed class TmdbClient( // Steht ein Treffer mit genau diesem Namen in der Liste, ist er // gemeint. // - // Nur bei Gleichheit, nicht bei Ähnlichkeit: eine eigene Bewertung zu - // bauen hiesse, TMDBs Rangfolge nachzubauen — mit weniger Daten. - var genau = treffer.FirstOrDefault(e => - string.Equals(titelVon(e)?.Trim(), titel.Trim(), StringComparison.OrdinalIgnoreCase)); + // **Verglichen wird über den Titelschlüssel, nicht Zeichen für + // Zeichen.** Auf der Platte heisst der Film „Hanni und Nanni", bei + // TMDB „Hanni & Nanni" — zeichengenau passt das nie. Gewählt wurde + // deshalb der erstbeste Treffer, und das war „Hanni & Nanni 3". + // Gemessen: der richtige Film stand auf Platz zwei, und alle vier + // Treffer hatten dieselbe Beliebtheit. Die Reihenfolge war also + // Zufall, und auf Zufall soll sich hier nichts verlassen. + // + // Weiterhin nur bei Gleichheit des Schlüssels, nicht bei Ähnlichkeit: + // eine eigene Bewertung zu bauen hiesse, TMDBs Rangfolge nachzubauen + // — mit weniger Daten. + var gesucht = Titelschluessel.Von(titel); + var genau = treffer.FirstOrDefault(e => Titelschluessel.Von(titelVon(e)) == gesucht); return genau ?? treffer[0]; } diff --git a/server/tests/Flaro.Api.Tests/MetadataTests.cs b/server/tests/Flaro.Api.Tests/MetadataTests.cs index 4d861f4..4724c06 100644 --- a/server/tests/Flaro.Api.Tests/MetadataTests.cs +++ b/server/tests/Flaro.Api.Tests/MetadataTests.cs @@ -512,6 +512,76 @@ public class MetadataTests }); } + /// Eine Datei lässt sich aus ihrem Titel herauslösen. + /// + /// + /// Weil die Riegel nur Neues verhindern. Was schon zusammengelegt + /// ist, bleibt es — „Hanni und Nanni" und „Hanni und Nanni 3" hängen dann + /// an einem Eintrag, und einer der beiden Filme ist nicht mehr zu sehen. + /// Der neue Eintrag bekommt den Titel aus dem Dateinamen und keine + /// Kennung; eine zu raten hiesse, denselben Fehler zu wiederholen. + /// + [Test] + public async Task Eine_Datei_laesst_sich_aus_ihrem_Titel_herausloesen() + { + Datei("Hanni und Nanni/Hanni und Nanni.mp4"); + Datei("Hanni und Nanni/Hanni und Nanni 3.mp4"); + + // Die Quelle liefert für beides denselben Film — der Fall aus dem Betrieb. + var anbieter = new VorgegebenerAnbieter + { + Film = new MovieMetadata(225313, "Hanni & Nanni 3", 2013, null, null, null), + }; + + using var server = MitAnbieter(anbieter); + var (client, bibliothek) = await Einrichten(server, "Movies"); + await Scannen(client, bibliothek); + + var mehrfach = await client.GetFromJsonAsync( + $"/api/v1/libraries/{bibliothek}/versions"); + + Assert.That(mehrfach!.Gesamt, Is.EqualTo(1), "zwei Dateien an einem Titel"); + var dateien = mehrfach.Titel[0].Dateien; + Assert.That(dateien, Has.Count.EqualTo(2)); + + var raus = dateien.First(f => f.Name.Contains(" 3")); + var antwort = await client.PostAsync($"/api/v1/files/{raus.Id}/split", null); + antwort.EnsureSuccessStatusCode(); + var neu = (await antwort.Content.ReadFromJsonAsync())!; + + var filme = await client.GetFromJsonAsync>("/api/v1/movies"); + + Assert.Multiple(() => + { + Assert.That(filme!.Items, Has.Count.EqualTo(2), "jetzt zwei Filme"); + Assert.That(neu.Title, Is.EqualTo("Hanni und Nanni 3"), "der Titel kommt aus dem Dateinamen"); + Assert.That(neu.TmdbId, Is.Null, "und ohne Kennung — geraten wird nicht"); + Assert.That(filme.Items.Select(m => m.Files.Count), Is.All.EqualTo(1)); + }); + } + + /// Eine einzelne Datei lässt sich nicht herauslösen. + [Test] + public async Task Eine_einzelne_Datei_laesst_sich_nicht_herausloesen() + { + Datei("Matrix (1999)/Matrix (1999).mkv"); + + var anbieter = new VorgegebenerAnbieter + { + Film = new MovieMetadata(603, "Matrix", 1999, null, null, null), + }; + + using var server = MitAnbieter(anbieter); + var (client, bibliothek) = await Einrichten(server, "Movies"); + await Scannen(client, bibliothek); + + var filme = await client.GetFromJsonAsync>("/api/v1/movies"); + var datei = filme!.Items[0].Files.Single(); + + var antwort = await client.PostAsync($"/api/v1/files/{datei.Id}/split", null); + Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.Conflict)); + } + /// Eine falsche Zuordnung darf ihre eigene Korrektur nicht verhindern. /// /// diff --git a/server/tests/Flaro.Core.Tests/TitelschluesselTests.cs b/server/tests/Flaro.Core.Tests/TitelschluesselTests.cs index c139380..9b0883f 100644 --- a/server/tests/Flaro.Core.Tests/TitelschluesselTests.cs +++ b/server/tests/Flaro.Core.Tests/TitelschluesselTests.cs @@ -84,4 +84,58 @@ public class FassungTests Assert.That(FileNames.ParseMovie("The Matrix (1999) - Mobil.mp4").Title, Is.EqualTo("The Matrix")); Assert.That(FileNames.ParseMovie("The Matrix (1999) - 2160p.mkv").Title, Is.EqualTo("The Matrix")); } + + /* ---------- Bindewörter ---------- */ + + /// „Hanni und Nanni" und „Hanni & Nanni" sind derselbe Titel. + /// + /// + /// Der Fall, an dem ein ganzer Film verschwand. Auf der Platte + /// heisst er „Hanni und Nanni", bei TMDB „Hanni & Nanni" — das + /// Kaufmanns-Und fiel als Sonderzeichen heraus, das Wort „und" blieb + /// stehen. Die Namensgleichheit griff deshalb nie, und gewählt wurde der + /// erstbeste Treffer: „Hanni & Nanni 3". Beide Dateien landeten auf + /// derselben Kennung, und Teil 1 war nicht mehr zu sehen. + /// + [TestCase("Hanni und Nanni", "Hanni & Nanni")] + [TestCase("Batman and Robin", "Batman & Robin")] + [TestCase("Tom und Jerry", "Tom & Jerry")] + public void Bindewoerter_machen_keinen_Unterschied(string eines, string anderes) + { + Assert.That(Titelschluessel.Von(eines), Is.EqualTo(Titelschluessel.Von(anderes))); + } + + /// Eine Folgennummer bleibt ein Unterschied. + [Test] + public void Eine_Nummer_bleibt_ein_Unterschied() + { + Assert.That(Titelschluessel.Von("Hanni und Nanni"), + Is.Not.EqualTo(Titelschluessel.Von("Hanni & Nanni 3"))); + } + + /// Am Anfang gehört das Wort zum Titel. + /// + /// + /// „Und dann kam Polly" fängt mit einem Bindewort an — es wegzulassen + /// machte daraus „dann kam Polly", und das ist ein anderer Film. + /// + [TestCase("Und dann kam Polly")] + [TestCase("And Then There Were None")] + public void Am_Anfang_bleibt_das_Wort_stehen(string titel) + { + Assert.That(Titelschluessel.Von(titel), Does.StartWith( + titel.Split(' ')[0].ToLowerInvariant())); + } + + /// Zwei Wörter bleiben zwei Wörter. + /// + /// + /// Bei „Ich und" bliebe sonst nur „Ich" übrig — und das passt dann auf + /// jeden Film, der so anfängt. + /// + [Test] + public void Aus_zwei_Woertern_wird_nichts_gestrichen() + { + Assert.That(Titelschluessel.Von("Krieg und"), Is.EqualTo("krieg und")); + } } diff --git a/web/app/src/api.ts b/web/app/src/api.ts index 449de7c..465d3d7 100644 --- a/web/app/src/api.ts +++ b/web/app/src/api.ts @@ -815,10 +815,11 @@ export type OhneZuordnung = { export const ohneZuordnung = (bibliothek: string, anzahl = 300) => anfrage(`/libraries/${bibliothek}/unmatched?take=${anzahl}`); +export type Fassungsdatei = { id: string; name: string }; export type Mehrfachtitel = { id: string; art: 'movie' | 'series'; titel: string; jahr: number | null; - dateien: string[]; + dateien: Fassungsdatei[]; }; export type Mehrfachbericht = { gesamt: number; titel: Mehrfachtitel[] }; @@ -826,6 +827,15 @@ export type Mehrfachbericht = { gesamt: number; titel: Mehrfachtitel[] }; export const mehrfachfassungen = (bibliothek: string, anzahl = 300) => anfrage(`/libraries/${bibliothek}/versions?take=${anzahl}`); +/** Löst eine Datei aus ihrem Titel heraus — sie wird ein eigener Film. + * + * **Für falsch Zusammengelegtes.** Zwei Dateien an einem Eintrag sind meist + * zwei Fassungen; sind es zwei Filme, war einer davon nicht mehr zu sehen. + * Der neue Eintrag bekommt den Titel aus dem Dateinamen und keine Kennung — + * er steht danach unter „Ohne Zuordnung" und lässt sich dort richtigstellen. */ +export const dateiHerausloesen = (datei: string) => + anfrage(`/files/${datei}/split`, { method: 'POST' }); + export type VermissteDatei = { fileId: string; path: string; versionLabel: string | null; missingSince: string; libraryName: string; diff --git a/web/app/src/style.css b/web/app/src/style.css index 6c3b8ee..df430e5 100644 --- a/web/app/src/style.css +++ b/web/app/src/style.css @@ -1609,3 +1609,12 @@ body:has(.auswahlleiste:not(.hidden)) .hinweise { bottom: 104px; } font-size: 12px; color: var(--text-muted); flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +/* Jede Datei eines mehrfach belegten Titels einzeln, mit einem Weg heraus. */ +.fassungstitel { padding: 4px 0; } +.fassungsdatei { + display: flex; align-items: center; gap: var(--s-snug); + padding: 3px var(--s-snug) 3px 24px; +} +.fassungsdatei .oz-dazu { flex: 1 1 auto; } +.btn.klein { padding: 4px 10px; font-size: 12px; flex: 0 0 auto; } diff --git a/web/app/src/views/admin.ts b/web/app/src/views/admin.ts index b67d488..250e3d7 100644 --- a/web/app/src/views/admin.ts +++ b/web/app/src/views/admin.ts @@ -10,10 +10,11 @@ import { servernameSetzen, systemauskunft, laufendeWiedergaben, serverzustand, bildAdresse, speicherorte, bildspeicherLeeren, unzugeordnet, ohneZuordnung, mehrfachfassungen, + dateiHerausloesen, type Mehrfachtitel, sprachverteilung, abweichendeAuffrischen, type Bibliothek, type Wiedergabe, ApiFehler, } from '../api'; -import { esc, dauer, groesse, knopf, fehlerzeile, leer, meldung } from '../util'; +import { esc, dauer, groesse, knopf, fehlerzeile, leer, meldung, hinweisZeigen } from '../util'; import { dialog, nachfragen } from '../dialog'; import { icon, icons } from '../icons'; import { kontextmenue, menueAnZiel, type Menuepunkt } from '../menue'; @@ -473,16 +474,12 @@ function ohneZuordnungBereich(): HTMLElement { } if (mehrfach.gesamt > 0) { - liste.appendChild(gruppe( + liste.appendChild(fassungsgruppe( `${b.name} — ${anzahl(mehrfach.gesamt, 'Titel')} mit mehreren Dateien`, - 'Meist zwei Fassungen desselben Films. Stehen hier aber zwei ' - + 'verschiedene Filme, lief einer von beiden auf den falschen Treffer — ' - + 'dann hilft „Zuordnung korrigieren".', - mehrfach.titel.map(t => ({ - titel: t.titel + (t.jahr ? ` (${t.jahr})` : ''), - dazu: t.dateien.join(' · '), - tun: () => bearbeiten(t.art === 'series' ? 'series' : 'movies', t.id, () => void laden()), - })))); + 'Meist zwei Fassungen desselben Films — dann ist alles in Ordnung. ' + + 'Stehen hier zwei verschiedene Filme, war einer davon nicht mehr zu ' + + 'sehen: „Eigener Film" löst ihn wieder heraus.', + mehrfach.titel)); } }); } @@ -491,6 +488,67 @@ function ohneZuordnungBereich(): HTMLElement { return `${n} ${n === 1 ? wort : (wort === 'Datei' ? 'Dateien' : wort)}`; } + /** Dieselbe Gruppe, aber jede Datei einzeln mit einem Weg heraus. */ + function fassungsgruppe( + ueberschrift: string, hinweis: string, titel: Mehrfachtitel[], + ): HTMLElement { + const d = document.createElement('div'); + d.className = 'einstellung'; + + const kopf = document.createElement('div'); + kopf.className = 'zeile'; + kopf.innerHTML = `${esc(ueberschrift)}` + + `${esc(hinweis)}`; + d.appendChild(kopf); + + const innen = document.createElement('div'); + innen.className = 'ohnezuordnung'; + + titel.forEach(t => { + const zeile = document.createElement('div'); + zeile.className = 'fassungstitel'; + + const name = document.createElement('button'); + name.className = 'ohnezuordnung-zeile'; + name.innerHTML = `${esc(t.titel + (t.jahr ? ` (${t.jahr})` : ''))}`; + name.onclick = () => bearbeiten( + t.art === 'series' ? 'series' : 'movies', t.id, () => void laden()); + zeile.appendChild(name); + + t.dateien.forEach(f => { + const d2 = document.createElement('div'); + d2.className = 'fassungsdatei'; + + const n = document.createElement('span'); + n.className = 'oz-dazu'; + n.textContent = f.name; + + // **Nur bei mehr als einer Datei sinnvoll** — und mehr als eine ist + // hier die Bedingung, überhaupt in der Liste zu stehen. + const raus = knopf('Eigener Film', 'btn klein'); + raus.onclick = async () => { + raus.disabled = true; + try { + const neu = await dateiHerausloesen(f.id); + hinweisZeigen(`„${neu.title}" ist jetzt ein eigener Film — ohne Zuordnung.`); + await laden(); + } catch (fehler) { + hinweisZeigen(meldung(fehler), 'fehler'); + raus.disabled = false; + } + }; + + d2.append(n, raus); + zeile.appendChild(d2); + }); + + innen.appendChild(zeile); + }); + + d.appendChild(innen); + return d; + } + function gruppe( ueberschrift: string, hinweis: string, zeilen: { titel: string; dazu: string; tun: () => void }[],