„highschool musical 3" fand TMDB nicht, „high school musical 3" schon

Drei gemeldete Fälle, alle an TMDB nachgemessen statt vermutet:

  highschool musical 3              0 Treffer
  high school musical 3             2 Treffer, der richtige zuerst
  Harry Potter im Orden des Phoenix 0 Treffer
  Harry Potter                     20 Treffer, der richtige darunter
  dr dolittle                      13 Treffer, der richtige zuerst

Der fehlende Punkt bei Dr. Dolittle war nie das Problem — diese Vermutung
ist in Suchvarianten.cs festgehalten, damit sie nicht wiederkommt.

Die Suche versucht jetzt der Reihe nach: den Titel wie er dasteht, dann
das längste Wort an jeder Stelle getrennt (von der Mitte nach aussen,
ohne Wörterbuch — „highschool" trifft nach 2 Versuchen, „jurassicpark"
nach 5), dann von hinten gekürzt, bis zwei Worte übrig sind. Höchstens
zehn Anfragen, und nur dann, wenn die erste gar nichts brachte.

Und die Treffer werden nach Ähnlichkeit zum gesuchten Titel sortiert,
nicht nach Beliebtheit. Über gemeinsame Worte ohne Akzente: so trennt
„Harry Potter" den „Orden des Phönix" von „Harry Potter: Fireplace", und
„Phoenix" findet „Phönix".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-04 19:13:24 +02:00
co-authored by Claude Opus 5
parent 4b0323f955
commit aeaf6eb578
3 changed files with 234 additions and 6 deletions
+132
View File
@@ -0,0 +1,132 @@
using Flaro.Core.Library;
namespace Flaro.Metadata;
/// <summary>Was man noch versuchen kann, wenn eine Titelsuche nichts findet.</summary>
///
/// <remarks>
/// <para>
/// <b>Drei Fälle aus dem echten Bestand, alle an TMDB nachgemessen.</b>
/// </para>
/// <list type="bullet">
/// <item><c>highschool musical 3</c> — null Treffer. Mit Leerzeichen
/// (<c>high school musical 3</c>) zwei, der richtige zuerst. Ein
/// zusammengeschriebenes Wort bringt die Suche vollständig zum Schweigen.</item>
/// <item><c>Harry Potter im Orden des Phoenix</c> — null Treffer. Der
/// Dateiname trägt „im" statt „und der". Gekürzt auf <c>Harry Potter</c>
/// kommen zwanzig Treffer, und darunter ist der richtige.</item>
/// <item><c>dr dolittle</c> — dreizehn Treffer, der richtige zuerst. Der
/// fehlende Punkt stört TMDB <b>nicht</b>; diese Vermutung war falsch und ist
/// hier festgehalten, damit sie nicht wiederkommt.</item>
/// </list>
/// <para>
/// <b>Die Reihenfolge ist Absicht.</b> 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.
/// </para>
/// </remarks>
public static class Suchvarianten
{
/// <summary>Mehr als das wird nicht probiert — sonst wird aus einer Suche ein Sturm.</summary>
private const int HöchstensVersuche = 10;
/// <summary>Die Suchbegriffe, der Reihe nach — der erste ist der Titel selbst.</summary>
public static IEnumerable<string> Fuer(string titel)
{
var sauber = titel.Trim();
if (sauber.Length == 0) yield break;
yield return sauber;
var gesehen = new HashSet<string>(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;
}
}
/// <summary>Trennt das längste Wort an jeder Stelle — von der Mitte nach aussen.</summary>
///
/// <remarks>
/// <b>Ohne Wörterbuch.</b> 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.
/// </remarks>
private static IEnumerable<string> 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);
}
}
/// <summary>Lässt hintere Worte weg — zwei bleiben immer stehen.</summary>
///
/// <remarks>
/// 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.
/// </remarks>
private static IEnumerable<string> Gekuerzt(string titel)
{
var worte = titel.Split(' ', StringSplitOptions.RemoveEmptyEntries);
for (var n = worte.Length - 1; n >= 2; n--)
yield return string.Join(' ', worte[..n]);
}
/// <summary>Wie ähnlich zwei Titel einander sind — zwischen 0 und 1.</summary>
///
/// <remarks>
/// <para>
/// <b>Über gemeinsame Worte, nicht über Zeichen.</b> 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".
/// </para>
/// <para>
/// <b>Ohne Akzente.</b> <see cref="Titelschluessel"/> zerlegt in NFD und
/// wirft die Zeichen weg — damit findet „Phoenix" das „Phönix", und
/// „Tuerme" die „Türme" wenigstens zur Hälfte.
/// </para>
/// </remarks>
public static double Aehnlichkeit(string gesucht, params string?[] kandidaten)
{
var a = Worte(gesucht);
if (a.Count == 0) return 0;
var b = new HashSet<string>(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<string> Worte(string? text) =>
string.IsNullOrWhiteSpace(text)
? []
: [.. Titelschluessel.Von(text).Split(' ', StringSplitOptions.RemoveEmptyEntries)];
}
+30 -6
View File
@@ -83,9 +83,23 @@ public sealed class TmdbClient(
public async Task<IReadOnlyList<MovieMetadata>> SearchMoviesAsync(
string title, int? year, CancellationToken ct = default)
{
var alle = await AlleAsync<TmdbMovie>("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<TmdbMovie>("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<MovieMetadata?> GetMovieAsync(int providerId, CancellationToken ct = default) =>
@@ -152,9 +166,19 @@ public sealed class TmdbClient(
public async Task<IReadOnlyList<SeriesMetadata>> SearchSeriesAsync(
string title, int? year, CancellationToken ct = default)
{
var alle = await AlleAsync<TmdbSeries>("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<TmdbSeries>("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<SeriesMetadata?> GetSeriesAsync(int providerId, CancellationToken ct = default) =>
@@ -222,3 +222,75 @@ public class TrefferwahlTests
Assert.That(film!.ProviderId, Is.EqualTo(1));
}
}
/// <summary>Was versucht wird, wenn der Titel so nicht gefunden wird.</summary>
///
/// <remarks>
/// <b>Die Fälle stammen aus dem echten Bestand und sind an TMDB nachgemessen.</b>
/// „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.
/// </remarks>
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"));
}
/// <summary>Von der Mitte nach aussen — der richtige Schnitt kommt früh.</summary>
[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"));
}
/// <summary>Unter zwei Worten hört es auf — „Harry" allein taugt nicht.</summary>
[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);
}
/// <summary>Phoenix findet Phönix: die Akzente fallen weg.</summary>
[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);
}
}