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);
+ }
+}