using System.Globalization;
using System.Text.RegularExpressions;
namespace Flaro.Core.Library;
public sealed record MovieName(string Title, int? Year);
///
/// Ob der Serientitel vom Ordner kommt oder aus dem Dateinamen. Steht
/// nur im Protokoll — aber genau diese Angabe fehlte, als aus 95 Serien 737
/// wurden: niemand konnte sagen, welcher der beiden Wege sie angelegt hatte.
///
public sealed record EpisodeName(
string SeriesTitle, int Season, int Episode, string? EpisodeTitle, bool AusOrdner = false);
/// Macht aus einem Dateinamen wieder Titel, Jahr, Staffel und Folge.
///
///
///
/// Hier scheitern Medienserver in der Praxis. Was auf der Platte liegt,
/// heisst nicht „Der Fall", sondern
/// Notruf.Hafenkante.S01E01.German.1080p.WEB-DL.x264-GRP.mkv, und was
/// davon Titel ist und was Beiwerk, steht nirgends. Rät man falsch, sucht die
/// Metadatenquelle nach „Notruf Hafenkante German 1080p" und findet nichts —
/// der Titel bleibt dann für immer der Dateiname.
///
///
/// Das Verfahren: abschneiden, nicht herauslösen. Alles ab dem ersten
/// Beiwerk-Merkmal fliegt weg. Einzelne Tags zu entfernen und den Rest
/// zusammenzuschieben klingt gründlicher, lässt aber bei jedem unbekannten Tag
/// Bruchstücke im Titel stehen — und unbekannte Tags gibt es immer.
///
///
public static partial class FileNames
{
/// Merkmale, ab denen nur noch Technik kommt.
///
///
/// Nicht vollständig und kann es nicht sein. Es muss nur das erste treffen
/// — alles dahinter fällt ohnehin weg.
///
private static readonly HashSet Beiwerk = new(StringComparer.OrdinalIgnoreCase)
{
// Quelle
"bluray", "blu-ray", "bdrip", "brrip", "bdremux", "remux", "dvdrip", "dvd", "dvdr",
"webrip", "web-dl", "webdl", "web", "hdtv", "pdtv", "hdrip", "cam", "ts", "tc", "r5",
"uhdbd", "uhd", "hddvd",
// Bild
"x264", "x265", "h264", "h265", "hevc", "avc", "xvid", "divx", "av1", "mpeg2", "vc1",
"hdr", "hdr10", "hdr10plus", "dolbyvision", "dovi", "sdr", "10bit", "8bit",
// Ton
"aac", "ac3", "eac3", "dts", "dtshd", "dts-hd", "truehd", "atmos", "flac", "opus",
"mp3", "dd", "ddp", "dd5", "ddp5", "lpcm", "pcm",
// Fassung
"proper", "repack", "rerip", "extended", "unrated", "uncut", "remastered", "imax",
"theatrical", "limited", "internal", "complete", "retail", "subbed", "dubbed",
// Sprache und Herkunft, wie die Szene sie schreibt
"german", "englisch", "english", "multi", "dual", "dl", "ger", "eng", "vo", "vost",
"omu", "subs", "sub",
};
/// Auflösungen wie 1080p, 2160p, 720i — und 4K.
[GeneratedRegex(@"^\d{3,4}[pi]$|^[48]k$", RegexOptions.IgnoreCase)]
private static partial Regex Auflösung();
/// S01E02, s1e2, S01.E02, Serie_S02E18, S2024E04 — die erste Folge zählt.
///
///
///
/// Kein \b vor dem S, und das hat 3445 Dateien gekostet. Eine
/// Wortgrenze sitzt zwischen einem Wortzeichen und einem Nicht-Wortzeichen
/// — der Unterstrich ist aber selbst ein Wortzeichen. In
/// The Good Doctor_S02E18_Trampolin.mkv steht zwischen r und
/// S damit keine Grenze, und der ganze Bestand aus einem
/// Mediathek-Download fiel durch. Die Rückschau verlangt stattdessen
/// ausdrücklich: kein Buchstabe, keine Ziffer davor — ein Unterstrich
/// schon.
///
///
/// Vier Ziffern für die Staffel. Sendungen aus Mediatheken
/// nummerieren nach Jahr: Die Anstalt_S2024E04. Mit drei Ziffern
/// fiel jede davon durch.
///
///
[GeneratedRegex(@"(?\d{1,4})[\s._-]*E(?\d{1,4})",
RegexOptions.IgnoreCase)]
private static partial Regex StaffelUndFolge();
/// 1x02, 01x02 — die ältere Schreibweise.
[GeneratedRegex(@"(?\d{1,2})x(?\d{2,3})(?![\dx])", RegexOptions.IgnoreCase)]
private static partial Regex KreuzSchreibweise();
/// Ein Jahr in Klammern: (1999) oder [1999].
[GeneratedRegex(@"[\(\[](?(?:18|19|20)\d{2})[\)\]]")]
private static partial Regex JahrInKlammern();
[GeneratedRegex(@"\s+")]
private static partial Regex Leerraum();
/// Liest Titel und Jahr aus einem Filmdateinamen.
public static MovieName ParseMovie(string dateiname)
{
var (worte, jahrAusKlammern) = Zerlegen(dateiname);
var (titel, jahr) = TitelBis(worte, grenze: worte.Length);
return new MovieName(titel.Length > 0 ? titel : Rohname(dateiname), jahrAusKlammern ?? jahr);
}
/// Der Name der Fassung — oder null, wenn der Name keine nennt.
///
///
///
/// Eine Fassung ist kein eigener Film. Die mobile Fassung, die
/// 4K-Fassung und das Original gehören zu einem Eintrag mit drei Dateien.
/// wirft den Zusatz deshalb weg — hier wird er
/// gelesen, damit die Dateien in der Auswahl Namen tragen statt Pfade.
///
///
/// Drei Schreibweisen, in dieser Reihenfolge: die Plex-Form
/// {edition-Mobil}, ein Zusatz hinter dem letzten Bindestrich
/// (Film (2021) - Mobil.mp4), und ein Elternordner, der nur die
/// Fassung benennt (Mobil/Film (2021).mp4).
///
///
/// Findet sich nichts, bleibt es bei null — dann bildet der Aufrufer
/// den Namen aus der gemessenen Auflösung. Zu raten wäre hier schlecht:
/// „Directors Cut" ist eine Fassung, „GRP" ist eine Veröffentlichungsgruppe.
///
///
public static string? Fassung(string pfad)
{
var roh = Rohname(pfad);
var klammer = EditionInKlammern().Match(roh);
if (klammer.Success) return Saeubern(klammer.Groups["name"].Value);
var strich = roh.LastIndexOf(" - ", StringComparison.Ordinal);
if (strich > 0)
{
var hinten = Saeubern(roh[(strich + 3)..]);
if (hinten.Length > 0 && IstFassungsname(hinten)) return Vereinheitlichen(hinten);
}
// **Drei Ebenen hoch, nicht nur eine.** Plex legt seine zweite Fassung
// als `Plex Versions/Optimized for Mobile///…` ab —
// der Ordner, der die Fassung benennt, steht dort also weit über der
// Datei. Drei Ebenen decken das ab, ohne irgendwann die Mediathek
// selbst für eine Fassung zu halten.
var ordner = Path.GetDirectoryName(pfad);
for (var ebene = 0; ebene < 3 && !string.IsNullOrEmpty(ordner); ebene++)
{
var name = Path.GetFileName(ordner);
if (name.Length > 0 && IstFassungsname(name)) return Vereinheitlichen(Saeubern(name));
ordner = Path.GetDirectoryName(ordner);
}
return null;
}
/// Ordner, die nur Fassungen einsammeln und keine Serie benennen.
///
///
/// Plex Versions ist kein Titel, sondern eine Schublade. Ohne diese
/// Prüfung hiesse die Serie so, und darunter läge der halbe Bestand ein
/// zweites Mal.
///
private static bool IstFassungsordner(string name) =>
IstFassungsname(name) || Schlank(name).Equals("plexversions", StringComparison.OrdinalIgnoreCase);
/// Macht aus einer gemessenen Höhe einen Namen: 2160 wird „4K".
///
///
/// Der Rückfall, wenn der Dateiname nichts hergibt. Zwei Dateien ohne
/// Zusatz hiessen sonst beide gleich, und in der Auswahl stünde zweimal
/// dasselbe.
///
public static string? FassungAusHoehe(int? hoehe) => hoehe switch
{
null or <= 0 => null,
>= 2000 => "4K",
>= 1400 => "1440p",
>= 1000 => "1080p",
>= 700 => "720p",
>= 500 => "576p",
_ => "SD",
};
private static bool IstFassungsname(string wort)
{
var blank = wort.Trim();
return Auflösung().IsMatch(blank) || Fassungsnamen.ContainsKey(Schlank(blank));
}
/// Schreibweisen, die dasselbe meinen, bekommen denselben Namen.
private static string Vereinheitlichen(string wort) =>
Fassungsnamen.GetValueOrDefault(Schlank(wort), wort.Trim());
private static string Schlank(string wort) =>
wort.Trim().Replace(" ", "").Replace("'", "").Replace("-", "");
private static string Saeubern(string wort) =>
Leerraum().Replace(wort.Replace('_', ' ').Replace('.', ' '), " ")
.Trim(' ', '-', '–', ',', '[', ']', '(', ')', '{', '}');
/// Was als Fassung durchgeht — knapp gehalten, mit Absicht.
///
///
/// Eine geschlossene Liste. Nähme man jedes Wort hinter dem letzten
/// Bindestrich, hiesse eine Fassung irgendwann „YIFY" oder „GRP" — das
/// sind Veröffentlichungsgruppen, keine Fassungen. Lieber kein Name als
/// ein falscher: ohne Treffer greift .
///
private static readonly Dictionary Fassungsnamen =
new(StringComparer.OrdinalIgnoreCase)
{
["mobil"] = "Mobil", ["mobile"] = "Mobil",
["optimiert"] = "Mobil", ["optimized"] = "Mobil",
// Wie Plex seine zweite Fassung nennt. Nicht um Plex zu bedienen,
// sondern weil die Dateien nun einmal so auf der Platte liegen:
// ohne diese Zeilen steht jede Folge zweimal untereinander, mit
// zwei gleich aussehenden Knopfreihen und ohne Hinweis, warum.
["optimizedformobile"] = "Mobil",
["optimiertfürmobilgeräte"] = "Mobil",
["optimiertfuermobilgeraete"] = "Mobil",
["directorscut"] = "Director's Cut", ["directorcut"] = "Director's Cut",
["extended"] = "Extended", ["extendedcut"] = "Extended",
["theatrical"] = "Kinofassung", ["kinofassung"] = "Kinofassung",
["uncut"] = "Uncut", ["remastered"] = "Remastered", ["imax"] = "IMAX",
["4k"] = "4K", ["8k"] = "8K", ["sd"] = "SD",
};
[GeneratedRegex(@"\{edition-(?[^}]+)\}", RegexOptions.IgnoreCase)]
private static partial Regex EditionInKlammern();
/// Liest Serie, Staffel, Folge und Folgentitel — oder null.
///
///
/// null heisst: hier steht keine Folgennummer. Dann ist es entweder
/// ein Film oder etwas, das niemand benennen kann — in beiden Fällen wäre
/// eine geratene Staffel 1 schlimmer als gar keine.
///
///
/// Der Wurzelpfad der Mediathek. Ist er angegeben, bestimmt der erste
/// Ordner darunter die Serie — und zwar allein.
///
public static EpisodeName? ParseEpisode(string dateiname, string? wurzel = null)
{
var roh = Rohname(dateiname);
var treffer = StaffelUndFolge().Match(roh);
if (!treffer.Success) treffer = KreuzSchreibweise().Match(roh);
int staffel, folge;
if (treffer.Success)
{
if (!int.TryParse(treffer.Groups["staffel"].Value, out staffel) ||
!int.TryParse(treffer.Groups["folge"].Value, out folge)) return null;
}
else
{
// **„Folge 12" ist eine Folgennummer, nur auf Deutsch.**
//
// Mediatheken und Mitschnitte benennen so:
// „Weihnachtsmann & Co. KG # Folge 12 # Strafe muss sein".
// Eine Staffel steht dort nie — die kommt, wenn überhaupt, vom
// Ordner darüber. Fehlt auch der, gilt Staffel 1: das ist die
// Übereinkunft für eine Serie ohne Staffeln, nicht geraten.
var nurFolge = NurFolgennummer().Match(roh);
if (!nurFolge.Success || !int.TryParse(nurFolge.Groups["folge"].Value, out folge))
return null;
staffel = StaffelAusOrdner(dateiname) ?? 1;
treffer = nurFolge;
}
var davor = roh[..treffer.Index];
var danach = roh[(treffer.Index + treffer.Length)..];
var (worteDavor, _) = Zerlegen(davor, istDateiname: false);
var (serie, _) = TitelBis(worteDavor, worteDavor.Length);
var (worteDanach, _) = Zerlegen(danach, istDateiname: false);
var (folgenTitel, _) = TitelBis(worteDanach, worteDanach.Length);
// **Der Ordner entscheidet, nicht der Dateiname.**
//
// Was unter „Serien/Pingu/" liegt, gehört zu Pingu — ohne Rücksicht
// darauf, wie die Datei heisst. Vorher entschied der Dateiname, und
// das ging reihenweise schief: Dateien namens „S01E001_Aang.mkv"
// tragen vor der Folgennummer nichts, also wurde der ganze Dateiname
// zum Serientitel. Aus einer Serie mit zwanzig Folgen wurden zwanzig
// Serien.
//
// Umgekehrt ebenso wichtig: „Doctor Who (2005) - S12E04.mkv" im Ordner
// „Doctor Who" ergab zwei Serien, weil die Jahresangabe im Dateinamen
// stand und im Ordner nicht. Der Ordner ist die verlässlichere Angabe,
// weil ihn ein Mensch angelegt hat.
//
// So macht es Plex seit je, und aus demselben Grund.
// **Mit Wurzel entscheidet der Ordner, ohne Wurzel nur im Notfall.**
//
// Steht die Wurzel fest, weiss man, welcher Ordner die Serie benennt —
// der erste darunter, von einem Menschen angelegt. Fehlt sie, könnte
// über dem Staffelordner irgendetwas stehen; dann ist der Dateiname
// die bessere Angabe, und der Ordner greift nur, wenn er gar nichts
// hergibt.
var ausOrdner = wurzel is { Length: > 0 }
? SerieAusOrdner(dateiname, wurzel)
: serie.Length > 0 ? null : SerieAusOrdner(dateiname, null);
// **Der Rohname ist keine Serie.**
//
// Hier stand als letzter Rückfall `Rohname(dateiname)`. Das war der
// teuerste Fehler des Projekts: liegt eine Datei direkt in der Wurzel
// und trägt vor der Folgennummer nichts Brauchbares, wurde der
// komplette Dateiname — samt Folgentitel — zum Serientitel. Aus 95
// Serien wurden 737, jede mit genau einer Folge, und jede frisch bei
// jedem Lauf.
//
// Jetzt gilt: entweder der Ordner weiss es, oder der Teil vor der
// Folgennummer weiss es — oder niemand weiss es, und dann wird nichts
// erfunden. Was hier `null` ergibt, zählt im Scan als unerkannt und
// erscheint unter „Nicht zugeordnet"; eine Geisterserie mit einer
// Folge ist schlechter als ein ehrliches „weiss ich nicht".
var serientitel = ausOrdner ?? (serie.Length > 0 ? serie : null);
if (serientitel is null) return null;
return new EpisodeName(
SeriesTitle: serientitel,
Season: staffel,
Episode: folge,
EpisodeTitle: folgenTitel.Length > 0 ? folgenTitel : null,
AusOrdner: ausOrdner is not null);
}
/// Warum sich eine Datei keiner Serie zuordnen liess.
///
///
/// Zwei Gründe, und sie verlangen Verschiedenes. Fehlt die
/// Folgennummer, muss die Datei umbenannt werden. Fehlt der Serienname,
/// genügt ein Ordner darum herum. „Nicht zugeordnet" allein sagt beides
/// nicht — und bei 3559 Dateien ist der Unterschied die ganze Arbeit.
///
public static string WarumNicht(string dateiname, string? wurzel = null)
{
var roh = Rohname(dateiname);
var treffer = StaffelUndFolge().Match(roh);
if (!treffer.Success) treffer = KreuzSchreibweise().Match(roh);
if (!treffer.Success)
{
return "keine Folgennummer im Dateinamen (erwartet wird etwa S01E02 oder 1x02).";
}
return wurzel is { Length: > 0 } && SerieAusOrdner(dateiname, wurzel) is null
? "liegt ohne Ordner direkt in der Mediathek, und vor der Folgennummer "
+ "steht kein Serienname."
: "vor der Folgennummer steht kein Serienname.";
}
/// Der Serienname aus dem Pfad.
///
///
///
/// Mit Wurzel: der erste Ordner darunter. Alles, was in
/// Serien/Pingu/ liegt, gehört zu Pingu — egal wie tief und egal
/// wie die Datei heisst. Das ist die Regel, die ein Mensch beim Anlegen
/// der Ordner im Kopf hatte.
///
///
/// Ohne Wurzel: höchstens zwei Ebenen hoch, Staffelordner
/// übersprungen. Diesen Weg gehen nur die Tests, die mit relativen Pfaden
/// arbeiten.
///
///
private static string? SerieAusOrdner(string pfad, string? wurzel)
{
if (wurzel is { Length: > 0 })
{
var unterhalb = UnterWurzel(pfad, wurzel);
// Liegt die Datei direkt in der Wurzel, gibt es keinen Ordner, der
// sie benennt — dann bleibt nur der Dateiname.
if (unterhalb is null || !unterhalb.Contains('/')) return null;
// **Schubladen überspringen.** Unter `Plex Versions/Optimized for
// Mobile/` steht die Serie erst darunter; wer den ersten Ordner
// nimmt, legt eine Serie namens „Plex Versions" an.
var teile = unterhalb.Split('/');
foreach (var teil in teile[..^1])
{
if (IstFassungsordner(teil) || IstStaffelordner(teil)) continue;
return Saubermachen(teil);
}
return null;
}
var ordner = Path.GetDirectoryName(pfad);
for (var ebene = 0; ebene < 2 && !string.IsNullOrEmpty(ordner); ebene++)
{
var name = Path.GetFileName(ordner);
if (name.Length == 0) return null;
if (!IstStaffelordner(name)) return Saubermachen(name);
ordner = Path.GetDirectoryName(ordner);
}
return null;
}
/// Der Pfad unterhalb der Wurzel, mit „/" getrennt.
private static string? UnterWurzel(string pfad, string wurzel)
{
var p = pfad.Replace('\\', '/');
var w = wurzel.Replace('\\', '/').TrimEnd('/');
if (!p.StartsWith(w + "/", StringComparison.OrdinalIgnoreCase)) return null;
return p[(w.Length + 1)..];
}
private static string? Saubermachen(string ordnername)
{
var (worte, _) = Zerlegen(ordnername, istDateiname: false);
var (titel, _) = TitelBis(worte, worte.Length);
return titel.Length > 0 ? titel : null;
}
/// „Folge 12", „Episode 3", „Teil 7" — eine Nummer ohne Staffel.
///
///
/// Mit Wortgrenze dahinter, damit „Folge 1" nicht aus „Folge 12" wird, und
/// nur bis drei Ziffern: „Folge 2024" wäre eher ein Datum.
///
[GeneratedRegex(@"(?\d{1,3})(?!\d)",
RegexOptions.IgnoreCase)]
private static partial Regex NurFolgennummer();
/// Die Staffelnummer aus einem Ordner „Season 4" über der Datei.
///
///
/// Nur aus dem unmittelbaren Ordner. Steht dort keine Zahl, wird
/// keine erfunden — der Aufrufer entscheidet, was dann gilt.
///
private static int? StaffelAusOrdner(string pfad)
{
var ordner = Path.GetFileName(Path.GetDirectoryName(pfad.Replace('\\', '/')) ?? "");
var treffer = StaffelZahl().Match(ordner.Trim());
return treffer.Success && int.TryParse(treffer.Groups["nr"].Value, out var nr) ? nr : null;
}
[GeneratedRegex(@"^(?:staffel|season|series|s)[\s._-]*(?\d{1,3})$", RegexOptions.IgnoreCase)]
private static partial Regex StaffelZahl();
/// „Staffel 1", „Season 02", „S3" — Ordner, die keine Serie benennen.
private static bool IstStaffelordner(string name) =>
StaffelOrdnerName().IsMatch(name.Trim());
[GeneratedRegex(@"^(staffel|season|series|s)[\s._-]*\d{1,3}$|^(specials?|extras?)$",
RegexOptions.IgnoreCase)]
private static partial Regex StaffelOrdnerName();
/// Nimmt Worte, bis das erste Beiwerk oder eine Jahreszahl kommt.
private static (string Titel, int? Jahr) TitelBis(string[] worte, int grenze)
{
var genommen = new List();
int? jahr = null;
for (var i = 0; i < grenze; i++)
{
var wort = worte[i];
if (IstBeiwerk(wort)) break;
// Eine Jahreszahl beendet den Titel — aber nur, wenn schon etwas
// davorsteht. Sonst verlöre „2012" seinen ganzen Namen.
if (genommen.Count > 0 && IstPlausiblesJahr(wort, out var gelesen))
{
jahr = gelesen;
break;
}
genommen.Add(wort);
}
return (Säubern(string.Join(' ', genommen)), jahr);
}
private static bool IstBeiwerk(string wort)
{
var blank = wort.Trim('[', ']', '(', ')', '{', '}', '-', '_');
if (blank.Length == 0) return false;
// **Fassungsnamen beenden den Titel wie jedes andere Beiwerk.**
// Vorher tat das nur die Auflösung: aus
// „The Matrix (1999) - Mobil.mp4" wurde der Film „The Matrix - Mobil",
// also ein zweiter Eintrag neben „The Matrix" — und die mobile
// Fassung stand getrennt vom Original in der Mediathek.
if (Fassungsnamen.ContainsKey(Schlank(blank))) return true;
if (Beiwerk.Contains(blank) || Auflösung().IsMatch(blank)) return true;
// „x264-GRP" und „DD5.1" hängen am Beiwerk fest.
var vorStrich = blank.Split('-', '.')[0];
return vorStrich.Length > 1 && (Beiwerk.Contains(vorStrich) || Auflösung().IsMatch(vorStrich));
}
/// Vier Ziffern allein machen noch kein Jahr.
///
///
/// „Blade Runner 2049" ist der Fall, an dem sich das entscheidet.
/// Eine Obergrenze knapp über heute trennt eine Jahreszahl von einer Zahl
/// im Titel — alles, was weit in der Zukunft liegt, gehört zum Namen.
///
private static bool IstPlausiblesJahr(string wort, out int jahr)
{
jahr = 0;
var blank = wort.Trim('[', ']', '(', ')');
if (blank.Length != 4 || !int.TryParse(blank, NumberStyles.None, CultureInfo.InvariantCulture, out var zahl))
return false;
if (zahl < 1880 || zahl > DateTime.UtcNow.Year + 2) return false;
jahr = zahl;
return true;
}
/// Dateiname ohne Pfad und ohne Endung.
private static string Rohname(string dateiname) => Path.GetFileNameWithoutExtension(dateiname);
/// Zerlegt in Worte und holt ein eingeklammertes Jahr heraus.
///
///
/// Punkte werden nur dann zu Leerzeichen, wenn sonst keine da sind.
/// „The.Matrix.1999" braucht es; „S.W.A.T. (2003)" verlöre dadurch seinen
/// Namen. Dass im Namen überhaupt Leerzeichen stehen, heisst: hier hat
/// jemand von Hand benannt, und seine Punkte sind gewollt.
///
private static (string[] Worte, int? JahrInKlammern) Zerlegen(string eingabe, bool istDateiname = true)
{
var text = istDateiname ? Rohname(eingabe) : eingabe;
int? jahr = null;
var geklammert = JahrInKlammern().Match(text);
if (geklammert.Success && int.TryParse(geklammert.Groups["jahr"].Value, out var gelesen))
{
jahr = gelesen;
text = text.Remove(geklammert.Index, geklammert.Length);
}
text = text.Replace('_', ' ');
if (!text.Contains(' ')) text = text.Replace('.', ' ');
return (Leerraum().Split(text.Trim()).Where(w => w.Length > 0).ToArray(), jahr);
}
/// Räumt Trennzeichen weg, die am Rand übrig bleiben.
///
///
/// Der Punkt steht ausdrücklich nicht dabei. Aus „S.W.A.T." würde
/// sonst „S.W.A.T" — und ein Punkt am Titelende ist ohnehin selten, weil
/// Trennpunkte schon beim Zerlegen zu Leerzeichen werden.
///
private static string Säubern(string roh) =>
Leerraum().Replace(roh.Replace('_', ' '), " ").Trim(' ', '-', '–', ',', '[', ']', '(', ')');
}