diff --git a/server/src/Flaro.Metadata/Suchvarianten.cs b/server/src/Flaro.Metadata/Suchvarianten.cs new file mode 100644 index 0000000..187071d --- /dev/null +++ b/server/src/Flaro.Metadata/Suchvarianten.cs @@ -0,0 +1,132 @@ +using Flaro.Core.Library; + +namespace Flaro.Metadata; + +/// Was man noch versuchen kann, wenn eine Titelsuche nichts findet. +/// +/// +/// +/// Drei Fälle aus dem echten Bestand, alle an TMDB nachgemessen. +/// +/// +/// highschool musical 3 — null Treffer. Mit Leerzeichen +/// (high school musical 3) zwei, der richtige zuerst. Ein +/// zusammengeschriebenes Wort bringt die Suche vollständig zum Schweigen. +/// Harry Potter im Orden des Phoenix — null Treffer. Der +/// Dateiname trägt „im" statt „und der". Gekürzt auf Harry Potter +/// kommen zwanzig Treffer, und darunter ist der richtige. +/// dr dolittle — dreizehn Treffer, der richtige zuerst. Der +/// fehlende Punkt stört TMDB nicht; diese Vermutung war falsch und ist +/// hier festgehalten, damit sie nicht wiederkommt. +/// +/// +/// Die Reihenfolge ist Absicht. Erst der Titel, wie er dasteht — das +/// ist fast immer richtig und kostet eine Anfrage. Die Varianten laufen nur, +/// wenn gar nichts kam; dann ist eine Handvoll zusätzlicher Anfragen billiger +/// als ein Titel, der für immer unzugeordnet bleibt. +/// +/// +public static class Suchvarianten +{ + /// Mehr als das wird nicht probiert — sonst wird aus einer Suche ein Sturm. + private const int HöchstensVersuche = 10; + + /// Die Suchbegriffe, der Reihe nach — der erste ist der Titel selbst. + public static IEnumerable Fuer(string titel) + { + var sauber = titel.Trim(); + if (sauber.Length == 0) yield break; + + yield return sauber; + + var gesehen = new HashSet(StringComparer.OrdinalIgnoreCase) { sauber }; + var versuche = 1; + + foreach (var variante in Getrennt(sauber).Concat(Gekuerzt(sauber))) + { + if (versuche >= HöchstensVersuche) yield break; + if (!gesehen.Add(variante)) continue; + versuche++; + yield return variante; + } + } + + /// Trennt das längste Wort an jeder Stelle — von der Mitte nach aussen. + /// + /// + /// Ohne Wörterbuch. Eines für Deutsch und Englisch mitzuschleppen, + /// nur um „highschool" zu trennen, steht in keinem Verhältnis — und es + /// kennt die Namen nicht, um die es hier meistens geht. Von der Mitte aus + /// zu probieren trifft die üblichen Zusammenschreibungen in zwei bis fünf + /// Versuchen; gemessen: „highschool" nach 2, „jurassicpark" nach 5. + /// + private static IEnumerable Getrennt(string titel) + { + var worte = titel.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (worte.Length == 0) yield break; + + var stelle = 0; + for (var i = 1; i < worte.Length; i++) + if (worte[i].Length > worte[stelle].Length) stelle = i; + + var wort = worte[stelle]; + if (wort.Length < 8 || !wort.All(char.IsLetter)) yield break; + + var mitte = wort.Length / 2; + foreach (var schnitt in Enumerable.Range(3, wort.Length - 5).OrderBy(p => Math.Abs(p - mitte))) + { + var neu = worte.ToList(); + neu[stelle] = wort[..schnitt]; + neu.Insert(stelle + 1, wort[schnitt..]); + yield return string.Join(' ', neu); + } + } + + /// Lässt hintere Worte weg — zwei bleiben immer stehen. + /// + /// + /// Für Titel, in denen ein Wort schlicht falsch ist. Weniger als zwei + /// Worte zu suchen bringt nichts Brauchbares mehr: „Harry" allein liefert + /// alles Mögliche, und die Rangfolge hätte nichts mehr zu tun. + /// + private static IEnumerable Gekuerzt(string titel) + { + var worte = titel.Split(' ', StringSplitOptions.RemoveEmptyEntries); + for (var n = worte.Length - 1; n >= 2; n--) + yield return string.Join(' ', worte[..n]); + } + + /// Wie ähnlich zwei Titel einander sind — zwischen 0 und 1. + /// + /// + /// + /// Über gemeinsame Worte, nicht über Zeichen. Gesucht wird nach + /// einem gekürzten Titel, zurück kommen zwanzig Treffer, und der richtige + /// ist der mit den meisten gemeinsamen Worten — nicht der beliebteste. + /// Bei „Harry Potter" trennt genau das „Harry Potter und der Orden des + /// Phönix" von „Harry Potter: Fireplace". + /// + /// + /// Ohne Akzente. zerlegt in NFD und + /// wirft die Zeichen weg — damit findet „Phoenix" das „Phönix", und + /// „Tuerme" die „Türme" wenigstens zur Hälfte. + /// + /// + public static double Aehnlichkeit(string gesucht, params string?[] kandidaten) + { + var a = Worte(gesucht); + if (a.Count == 0) return 0; + + var b = new HashSet(StringComparer.Ordinal); + foreach (var k in kandidaten) b.UnionWith(Worte(k)); + if (b.Count == 0) return 0; + + var gemeinsam = a.Count(b.Contains); + return (double)gemeinsam / (a.Count + b.Count - gemeinsam); + } + + private static HashSet Worte(string? text) => + string.IsNullOrWhiteSpace(text) + ? [] + : [.. Titelschluessel.Von(text).Split(' ', StringSplitOptions.RemoveEmptyEntries)]; +} diff --git a/server/src/Flaro.Metadata/TmdbClient.cs b/server/src/Flaro.Metadata/TmdbClient.cs index ca421ef..a4125ac 100644 --- a/server/src/Flaro.Metadata/TmdbClient.cs +++ b/server/src/Flaro.Metadata/TmdbClient.cs @@ -83,9 +83,23 @@ public sealed class TmdbClient( public async Task> SearchMoviesAsync( string title, int? year, CancellationToken ct = default) { - var alle = await AlleAsync("search/movie", title, - year is { } j ? ("year", j.ToString()) : null, ct); - return alle.Select(m => Als(m, title)).ToList(); + foreach (var begriff in Suchvarianten.Fuer(title)) + { + var alle = await AlleAsync("search/movie", begriff, + year is { } j ? ("year", j.ToString()) : null, ct); + if (alle.Count == 0) continue; + + // **Nach Ähnlichkeit zum gesuchten Titel, nicht nach Beliebtheit.** + // Gefunden wird unter Umständen über einen gekürzten Begriff; + // dann liefert TMDB zwanzig Treffer, und der richtige ist der mit + // den meisten gemeinsamen Worten. + return alle + .OrderByDescending(m => Suchvarianten.Aehnlichkeit(title, m.Title, m.OriginalTitle)) + .Select(m => Als(m, title)) + .ToList(); + } + + return []; } public Task GetMovieAsync(int providerId, CancellationToken ct = default) => @@ -152,9 +166,19 @@ public sealed class TmdbClient( public async Task> SearchSeriesAsync( string title, int? year, CancellationToken ct = default) { - var alle = await AlleAsync("search/tv", title, - year is { } j ? ("first_air_date_year", j.ToString()) : null, ct); - return alle.Select(s => Als(s, title)).ToList(); + foreach (var begriff in Suchvarianten.Fuer(title)) + { + var alle = await AlleAsync("search/tv", begriff, + year is { } j ? ("first_air_date_year", j.ToString()) : null, ct); + if (alle.Count == 0) continue; + + return alle + .OrderByDescending(s => Suchvarianten.Aehnlichkeit(title, s.Name, s.OriginalName)) + .Select(s => Als(s, title)) + .ToList(); + } + + return []; } public Task GetSeriesAsync(int providerId, CancellationToken ct = default) => diff --git a/server/tests/Flaro.Metadata.Tests/TmdbClientTests.cs b/server/tests/Flaro.Metadata.Tests/TmdbClientTests.cs index 253d082..ad6de17 100644 --- a/server/tests/Flaro.Metadata.Tests/TmdbClientTests.cs +++ b/server/tests/Flaro.Metadata.Tests/TmdbClientTests.cs @@ -222,3 +222,75 @@ public class TrefferwahlTests Assert.That(film!.ProviderId, Is.EqualTo(1)); } } + +/// Was versucht wird, wenn der Titel so nicht gefunden wird. +/// +/// +/// Die Fälle stammen aus dem echten Bestand und sind an TMDB nachgemessen. +/// „highschool musical 3" liefert dort null Treffer, „high school musical 3" +/// zwei. „Harry Potter im Orden des Phoenix" null, „Harry Potter" zwanzig. +/// Und „dr dolittle" dreizehn — der fehlende Punkt war nie das Problem. +/// +public class SuchvariantenTests +{ + [Test] + public void Der_Titel_selbst_kommt_zuerst() + { + Assert.That(Suchvarianten.Fuer("Dr. Dolittle").First(), Is.EqualTo("Dr. Dolittle")); + } + + [Test] + public void Ein_zusammengeschriebenes_Wort_wird_getrennt() + { + Assert.That(Suchvarianten.Fuer("highschool musical 3"), + Does.Contain("high school musical 3")); + } + + /// Von der Mitte nach aussen — der richtige Schnitt kommt früh. + [Test] + public void Der_richtige_Schnitt_kommt_in_den_ersten_Versuchen() + { + var alle = Suchvarianten.Fuer("highschool musical 3").ToList(); + Assert.That(alle.IndexOf("high school musical 3"), Is.LessThanOrEqualTo(3)); + } + + [Test] + public void Hintere_Worte_fallen_weg() + { + Assert.That(Suchvarianten.Fuer("Harry Potter im Orden des Phoenix"), + Does.Contain("Harry Potter")); + } + + /// Unter zwei Worten hört es auf — „Harry" allein taugt nicht. + [Test] + public void Ein_einzelnes_Wort_wird_nicht_gesucht() + { + Assert.That(Suchvarianten.Fuer("Harry Potter im Orden des Phoenix"), + Does.Not.Contain("Harry")); + } + + [Test] + public void Es_wird_nicht_endlos_probiert() + { + Assert.That(Suchvarianten.Fuer("Ein ziemlich langer Titel mit vielen Worten darin"), + Has.Exactly(10).Items); + } + + /// Phoenix findet Phönix: die Akzente fallen weg. + [Test] + public void Aehnlichkeit_sieht_ueber_Akzente_hinweg() + { + var richtig = Suchvarianten.Aehnlichkeit( + "Harry Potter im Orden des Phoenix", "Harry Potter und der Orden des Phönix"); + var falsch = Suchvarianten.Aehnlichkeit( + "Harry Potter im Orden des Phoenix", "Harry Potter: Fireplace"); + + Assert.That(richtig, Is.GreaterThan(falsch)); + } + + [Test] + public void Nichts_Gemeinsames_ergibt_null() + { + Assert.That(Suchvarianten.Aehnlichkeit("Dune", "Alles steht Kopf"), Is.Zero); + } +}