repo creation with initial code after cloning public repo

This commit is contained in:
2026-02-19 07:36:25 -05:00
commit 460884fea3
2860 changed files with 650825 additions and 0 deletions
+55
View File
@@ -0,0 +1,55 @@
using System.Collections.Generic;
using System.Globalization;
using System.Text.RegularExpressions;
namespace Emby.Naming.Video
{
/// <summary>
/// <see href="http://kodi.wiki/view/Advancedsettings.xml#video" />.
/// </summary>
public static class CleanDateTimeParser
{
/// <summary>
/// Attempts to clean the name.
/// </summary>
/// <param name="name">Name of video.</param>
/// <param name="cleanDateTimeRegexes">Optional list of regexes to clean the name.</param>
/// <returns>Returns <see cref="CleanDateTimeResult"/> object.</returns>
public static CleanDateTimeResult Clean(string name, IReadOnlyList<Regex> cleanDateTimeRegexes)
{
CleanDateTimeResult result = new CleanDateTimeResult(name);
if (string.IsNullOrEmpty(name))
{
return result;
}
var len = cleanDateTimeRegexes.Count;
for (int i = 0; i < len; i++)
{
if (TryClean(name, cleanDateTimeRegexes[i], ref result))
{
return result;
}
}
return result;
}
private static bool TryClean(string name, Regex expression, ref CleanDateTimeResult result)
{
var match = expression.Match(name);
if (match.Success
&& match.Groups.Count == 5
&& match.Groups[1].Success
&& match.Groups[2].Success
&& int.TryParse(match.Groups[2].ValueSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out var year))
{
result = new CleanDateTimeResult(match.Groups[1].Value.TrimEnd(), year);
return true;
}
return false;
}
}
}
+31
View File
@@ -0,0 +1,31 @@
namespace Emby.Naming.Video
{
/// <summary>
/// Holder structure for name and year.
/// </summary>
public readonly struct CleanDateTimeResult
{
/// <summary>
/// Initializes a new instance of the <see cref="CleanDateTimeResult"/> struct.
/// </summary>
/// <param name="name">Name of video.</param>
/// <param name="year">Year of release.</param>
public CleanDateTimeResult(string name, int? year = null)
{
Name = name;
Year = year;
}
/// <summary>
/// Gets the name.
/// </summary>
/// <value>The name.</value>
public string Name { get; }
/// <summary>
/// Gets the year.
/// </summary>
/// <value>The year.</value>
public int? Year { get; }
}
}
+55
View File
@@ -0,0 +1,55 @@
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Text.RegularExpressions;
namespace Emby.Naming.Video
{
/// <summary>
/// <see href="http://kodi.wiki/view/Advancedsettings.xml#video" />.
/// </summary>
public static class CleanStringParser
{
/// <summary>
/// Attempts to extract clean name with regular expressions.
/// </summary>
/// <param name="name">Name of file.</param>
/// <param name="expressions">List of regex to parse name and year from.</param>
/// <param name="newName">Parsing result string.</param>
/// <returns>True if parsing was successful.</returns>
public static bool TryClean([NotNullWhen(true)] string? name, IReadOnlyList<Regex> expressions, out string newName)
{
if (string.IsNullOrEmpty(name))
{
newName = string.Empty;
return false;
}
// Iteratively apply the regexps to clean the string.
bool cleaned = false;
for (int i = 0; i < expressions.Count; i++)
{
if (TryClean(name, expressions[i], out newName))
{
cleaned = true;
name = newName;
}
}
newName = cleaned ? name : string.Empty;
return cleaned;
}
private static bool TryClean(string name, Regex expression, out string newName)
{
var match = expression.Match(name);
if (match.Success && match.Groups.TryGetValue("cleaned", out var cleaned))
{
newName = cleaned.Value;
return true;
}
newName = string.Empty;
return false;
}
}
}
+22
View File
@@ -0,0 +1,22 @@
using MediaBrowser.Model.Entities;
namespace Emby.Naming.Video
{
/// <summary>
/// Holder object for passing results from ExtraResolver.
/// </summary>
public class ExtraResult
{
/// <summary>
/// Gets or sets the type of the extra.
/// </summary>
/// <value>The type of the extra.</value>
public ExtraType? ExtraType { get; set; }
/// <summary>
/// Gets or sets the rule.
/// </summary>
/// <value>The rule.</value>
public ExtraRule? Rule { get; set; }
}
}
+46
View File
@@ -0,0 +1,46 @@
using MediaBrowser.Model.Entities;
using MediaType = Emby.Naming.Common.MediaType;
namespace Emby.Naming.Video
{
/// <summary>
/// A rule used to match a file path with an <see cref="MediaBrowser.Model.Entities.ExtraType"/>.
/// </summary>
public class ExtraRule
{
/// <summary>
/// Initializes a new instance of the <see cref="ExtraRule"/> class.
/// </summary>
/// <param name="extraType">Type of extra.</param>
/// <param name="ruleType">Type of rule.</param>
/// <param name="token">Token.</param>
/// <param name="mediaType">Media type.</param>
public ExtraRule(ExtraType extraType, ExtraRuleType ruleType, string token, MediaType mediaType)
{
Token = token;
ExtraType = extraType;
RuleType = ruleType;
MediaType = mediaType;
}
/// <summary>
/// Gets or sets the token to use for matching against the file path.
/// </summary>
public string Token { get; set; }
/// <summary>
/// Gets or sets the type of the extra to return when matched.
/// </summary>
public ExtraType ExtraType { get; set; }
/// <summary>
/// Gets or sets the type of the rule.
/// </summary>
public ExtraRuleType RuleType { get; set; }
/// <summary>
/// Gets or sets the type of the media to return when matched.
/// </summary>
public MediaType MediaType { get; set; }
}
}
+69
View File
@@ -0,0 +1,69 @@
using System;
using System.IO;
using System.Text.RegularExpressions;
using Emby.Naming.Audio;
using Emby.Naming.Common;
namespace Emby.Naming.Video
{
/// <summary>
/// Resolve if file is extra for video.
/// </summary>
public static class ExtraRuleResolver
{
private static readonly char[] _digits = { '0', '1', '2', '3', '4', '5', '6', '7', '8', '9' };
/// <summary>
/// Attempts to resolve if file is extra.
/// </summary>
/// <param name="path">Path to file.</param>
/// <param name="namingOptions">The naming options.</param>
/// <param name="libraryRoot">Top-level folder for the containing library.</param>
/// <returns>Returns <see cref="ExtraResult"/> object.</returns>
public static ExtraResult GetExtraInfo(string path, NamingOptions namingOptions, string? libraryRoot = "")
{
ExtraResult result = new ExtraResult();
bool isAudioFile = AudioFileParser.IsAudioFile(path, namingOptions);
bool isVideoFile = VideoResolver.IsVideoFile(path, namingOptions);
ReadOnlySpan<char> pathSpan = path.AsSpan();
ReadOnlySpan<char> fileName = Path.GetFileName(pathSpan);
ReadOnlySpan<char> fileNameWithoutExtension = Path.GetFileNameWithoutExtension(pathSpan);
// Trim the digits from the end of the filename so we can recognize things like -trailer2
ReadOnlySpan<char> trimmedFileNameWithoutExtension = fileNameWithoutExtension.TrimEnd(_digits);
ReadOnlySpan<char> directoryName = Path.GetFileName(Path.GetDirectoryName(pathSpan));
string fullDirectory = Path.GetDirectoryName(pathSpan).ToString();
foreach (ExtraRule rule in namingOptions.VideoExtraRules)
{
if ((rule.MediaType == MediaType.Audio && !isAudioFile)
|| (rule.MediaType == MediaType.Video && !isVideoFile))
{
continue;
}
bool isMatch = rule.RuleType switch
{
ExtraRuleType.Filename => fileNameWithoutExtension.Equals(rule.Token, StringComparison.OrdinalIgnoreCase),
ExtraRuleType.Suffix => trimmedFileNameWithoutExtension.EndsWith(rule.Token, StringComparison.OrdinalIgnoreCase),
ExtraRuleType.Regex => Regex.IsMatch(fileName, rule.Token, RegexOptions.IgnoreCase | RegexOptions.Compiled),
ExtraRuleType.DirectoryName => directoryName.Equals(rule.Token, StringComparison.OrdinalIgnoreCase)
&& !string.Equals(fullDirectory, libraryRoot, StringComparison.OrdinalIgnoreCase),
_ => false,
};
if (!isMatch)
{
continue;
}
result.ExtraType = rule.ExtraType;
result.Rule = rule;
return result;
}
return result;
}
}
}
+28
View File
@@ -0,0 +1,28 @@
namespace Emby.Naming.Video
{
/// <summary>
/// Extra rules type to determine against what <see cref="ExtraRule.Token"/> should be matched.
/// </summary>
public enum ExtraRuleType
{
/// <summary>
/// Match <see cref="ExtraRule.Token"/> against a suffix in the file name.
/// </summary>
Suffix = 0,
/// <summary>
/// Match <see cref="ExtraRule.Token"/> against the file name, excluding the file extension.
/// </summary>
Filename = 1,
/// <summary>
/// Match <see cref="ExtraRule.Token"/> against the file name, including the file extension.
/// </summary>
Regex = 2,
/// <summary>
/// Match <see cref="ExtraRule.Token"/> against the name of the directory containing the file.
/// </summary>
DirectoryName = 3
}
}
+56
View File
@@ -0,0 +1,56 @@
using System;
using System.Collections.Generic;
using Jellyfin.Extensions;
namespace Emby.Naming.Video
{
/// <summary>
/// Object holding list of files paths with additional information.
/// </summary>
public class FileStack
{
/// <summary>
/// Initializes a new instance of the <see cref="FileStack"/> class.
/// </summary>
/// <param name="name">The stack name.</param>
/// <param name="isDirectory">Whether the stack files are directories.</param>
/// <param name="files">The stack files.</param>
public FileStack(string name, bool isDirectory, IReadOnlyList<string> files)
{
Name = name;
IsDirectoryStack = isDirectory;
Files = files;
}
/// <summary>
/// Gets the name of file stack.
/// </summary>
public string Name { get; }
/// <summary>
/// Gets the list of paths in stack.
/// </summary>
public IReadOnlyList<string> Files { get; }
/// <summary>
/// Gets a value indicating whether stack is directory stack.
/// </summary>
public bool IsDirectoryStack { get; }
/// <summary>
/// Helper function to determine if path is in the stack.
/// </summary>
/// <param name="file">Path of desired file.</param>
/// <param name="isDirectory">Requested type of stack.</param>
/// <returns>True if file is in the stack.</returns>
public bool ContainsFile(string file, bool isDirectory)
{
if (string.IsNullOrEmpty(file))
{
return false;
}
return IsDirectoryStack == isDirectory && Files.Contains(file, StringComparison.OrdinalIgnoreCase);
}
}
}
+48
View File
@@ -0,0 +1,48 @@
using System.Diagnostics.CodeAnalysis;
using System.Text.RegularExpressions;
namespace Emby.Naming.Video;
/// <summary>
/// Regex based rule for file stacking (eg. disc1, disc2).
/// </summary>
public class FileStackRule
{
private readonly Regex _tokenRegex;
/// <summary>
/// Initializes a new instance of the <see cref="FileStackRule"/> class.
/// </summary>
/// <param name="token">Token.</param>
/// <param name="isNumerical">Whether the file stack rule uses numerical or alphabetical numbering.</param>
public FileStackRule(string token, bool isNumerical)
{
_tokenRegex = new Regex(token, RegexOptions.IgnoreCase | RegexOptions.Compiled);
IsNumerical = isNumerical;
}
/// <summary>
/// Gets a value indicating whether the rule uses numerical or alphabetical numbering.
/// </summary>
public bool IsNumerical { get; }
/// <summary>
/// Match the input against the rule regex.
/// </summary>
/// <param name="input">The input.</param>
/// <param name="result">The part type and number or <c>null</c>.</param>
/// <returns>A value indicating whether the input matched the rule.</returns>
public bool Match(string input, [NotNullWhen(true)] out (string StackName, string PartType, string PartNumber)? result)
{
result = null;
var match = _tokenRegex.Match(input);
if (!match.Success)
{
return false;
}
var partType = match.Groups["parttype"].Success ? match.Groups["parttype"].Value : "unknown";
result = (match.Groups["filename"].Value, partType, match.Groups["number"].Value);
return true;
}
}
+81
View File
@@ -0,0 +1,81 @@
using System;
using Emby.Naming.Common;
namespace Emby.Naming.Video
{
/// <summary>
/// Parse 3D format related flags.
/// </summary>
public static class Format3DParser
{
// Static default result to save on allocation costs.
private static readonly Format3DResult _defaultResult = new(false, null);
/// <summary>
/// Parse 3D format related flags.
/// </summary>
/// <param name="path">Path to file.</param>
/// <param name="namingOptions">The naming options.</param>
/// <returns>Returns <see cref="Format3DResult"/> object.</returns>
public static Format3DResult Parse(ReadOnlySpan<char> path, NamingOptions namingOptions)
{
int oldLen = namingOptions.VideoFlagDelimiters.Length;
Span<char> delimiters = stackalloc char[oldLen + 1];
namingOptions.VideoFlagDelimiters.AsSpan().CopyTo(delimiters);
delimiters[oldLen] = ' ';
return Parse(path, delimiters, namingOptions);
}
private static Format3DResult Parse(ReadOnlySpan<char> path, ReadOnlySpan<char> delimiters, NamingOptions namingOptions)
{
foreach (var rule in namingOptions.Format3DRules)
{
var result = Parse(path, rule, delimiters);
if (result.Is3D)
{
return result;
}
}
return _defaultResult;
}
private static Format3DResult Parse(ReadOnlySpan<char> path, Format3DRule rule, ReadOnlySpan<char> delimiters)
{
bool is3D = false;
string? format3D = null;
// If there's no preceding token we just consider it found
var foundPrefix = string.IsNullOrEmpty(rule.PrecedingToken);
while (path.Length > 0)
{
var index = path.IndexOfAny(delimiters);
if (index == -1)
{
index = path.Length - 1;
}
var currentSlice = path[..index];
path = path[(index + 1)..];
if (!foundPrefix)
{
foundPrefix = currentSlice.Equals(rule.PrecedingToken, StringComparison.OrdinalIgnoreCase);
continue;
}
is3D = foundPrefix && currentSlice.Equals(rule.Token, StringComparison.OrdinalIgnoreCase);
if (is3D)
{
format3D = rule.Token;
break;
}
}
return is3D ? new Format3DResult(true, format3D) : _defaultResult;
}
}
}
+31
View File
@@ -0,0 +1,31 @@
namespace Emby.Naming.Video
{
/// <summary>
/// Helper object to return data from <see cref="Format3DParser"/>.
/// </summary>
public class Format3DResult
{
/// <summary>
/// Initializes a new instance of the <see cref="Format3DResult"/> class.
/// </summary>
/// <param name="is3D">A value indicating whether the parsed string contains 3D tokens.</param>
/// <param name="format3D">The 3D format. Value might be null if [is3D] is <c>false</c>.</param>
public Format3DResult(bool is3D, string? format3D)
{
Is3D = is3D;
Format3D = format3D;
}
/// <summary>
/// Gets a value indicating whether [is3 d].
/// </summary>
/// <value><c>true</c> if [is3 d]; otherwise, <c>false</c>.</value>
public bool Is3D { get; }
/// <summary>
/// Gets the format3 d.
/// </summary>
/// <value>The format3 d.</value>
public string? Format3D { get; }
}
}
+31
View File
@@ -0,0 +1,31 @@
namespace Emby.Naming.Video
{
/// <summary>
/// Data holder class for 3D format rule.
/// </summary>
public class Format3DRule
{
/// <summary>
/// Initializes a new instance of the <see cref="Format3DRule"/> class.
/// </summary>
/// <param name="token">Token.</param>
/// <param name="precedingToken">Token present before current token.</param>
public Format3DRule(string token, string? precedingToken = null)
{
Token = token;
PrecedingToken = precedingToken;
}
/// <summary>
/// Gets or sets the token.
/// </summary>
/// <value>The token.</value>
public string Token { get; set; }
/// <summary>
/// Gets or sets the preceding token.
/// </summary>
/// <value>The preceding token.</value>
public string? PrecedingToken { get; set; }
}
}
+156
View File
@@ -0,0 +1,156 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Emby.Naming.AudioBook;
using Emby.Naming.Common;
using MediaBrowser.Model.IO;
namespace Emby.Naming.Video
{
/// <summary>
/// Resolve <see cref="FileStack"/> from list of paths.
/// </summary>
public static class StackResolver
{
/// <summary>
/// Resolves only directories from paths.
/// </summary>
/// <param name="files">List of paths.</param>
/// <param name="namingOptions">The naming options.</param>
/// <returns>Enumerable <see cref="FileStack"/> of directories.</returns>
public static IEnumerable<FileStack> ResolveDirectories(IEnumerable<string> files, NamingOptions namingOptions)
{
return Resolve(files.Select(i => new FileSystemMetadata { FullName = i, IsDirectory = true }), namingOptions);
}
/// <summary>
/// Resolves only files from paths.
/// </summary>
/// <param name="files">List of paths.</param>
/// <param name="namingOptions">The naming options.</param>
/// <returns>Enumerable <see cref="FileStack"/> of files.</returns>
public static IEnumerable<FileStack> ResolveFiles(IEnumerable<string> files, NamingOptions namingOptions)
{
return Resolve(files.Select(i => new FileSystemMetadata { FullName = i, IsDirectory = false }), namingOptions);
}
/// <summary>
/// Resolves audiobooks from paths.
/// </summary>
/// <param name="files">List of paths.</param>
/// <returns>Enumerable <see cref="FileStack"/> of directories.</returns>
public static IEnumerable<FileStack> ResolveAudioBooks(IEnumerable<AudioBookFileInfo> files)
{
var groupedDirectoryFiles = files.GroupBy(file => Path.GetDirectoryName(file.Path));
foreach (var directory in groupedDirectoryFiles)
{
if (string.IsNullOrEmpty(directory.Key))
{
foreach (var file in directory)
{
var stack = new FileStack(Path.GetFileNameWithoutExtension(file.Path), false, new[] { file.Path });
yield return stack;
}
}
else
{
var stack = new FileStack(Path.GetFileName(directory.Key), false, directory.Select(f => f.Path).ToArray());
yield return stack;
}
}
}
/// <summary>
/// Resolves videos from paths.
/// </summary>
/// <param name="files">List of paths.</param>
/// <param name="namingOptions">The naming options.</param>
/// <returns>Enumerable <see cref="FileStack"/> of videos.</returns>
public static IEnumerable<FileStack> Resolve(IEnumerable<FileSystemMetadata> files, NamingOptions namingOptions)
{
var potentialFiles = files
.Where(i => i.IsDirectory || VideoResolver.IsVideoFile(i.FullName, namingOptions) || VideoResolver.IsStubFile(i.FullName, namingOptions))
.OrderBy(i => i.FullName);
var potentialStacks = new Dictionary<string, StackMetadata>();
foreach (var file in potentialFiles)
{
var name = file.Name;
if (string.IsNullOrEmpty(name))
{
name = Path.GetFileName(file.FullName);
}
for (var i = 0; i < namingOptions.VideoFileStackingRules.Length; i++)
{
var rule = namingOptions.VideoFileStackingRules[i];
if (!rule.Match(name, out var stackParsingResult))
{
continue;
}
var stackName = stackParsingResult.Value.StackName;
var partNumber = stackParsingResult.Value.PartNumber;
var partType = stackParsingResult.Value.PartType;
if (!potentialStacks.TryGetValue(stackName, out var stackResult))
{
stackResult = new StackMetadata(file.IsDirectory, rule.IsNumerical, partType);
potentialStacks[stackName] = stackResult;
}
if (stackResult.Parts.Count > 0)
{
if (stackResult.IsDirectory != file.IsDirectory
|| !string.Equals(partType, stackResult.PartType, StringComparison.OrdinalIgnoreCase)
|| stackResult.ContainsPart(partNumber))
{
continue;
}
if (rule.IsNumerical != stackResult.IsNumerical)
{
break;
}
}
stackResult.Parts.Add(partNumber, file);
break;
}
}
foreach (var (fileName, stack) in potentialStacks)
{
if (stack.Parts.Count < 2)
{
continue;
}
yield return new FileStack(fileName, stack.IsDirectory, stack.Parts.Select(kv => kv.Value.FullName).ToArray());
}
}
private sealed class StackMetadata
{
public StackMetadata(bool isDirectory, bool isNumerical, string partType)
{
Parts = new Dictionary<string, FileSystemMetadata>(StringComparer.OrdinalIgnoreCase);
IsDirectory = isDirectory;
IsNumerical = isNumerical;
PartType = partType;
}
public Dictionary<string, FileSystemMetadata> Parts { get; }
public bool IsDirectory { get; }
public bool IsNumerical { get; }
public string PartType { get; }
public bool ContainsPart(string partNumber) => Parts.ContainsKey(partNumber);
}
}
}
+50
View File
@@ -0,0 +1,50 @@
using System;
using System.IO;
using Emby.Naming.Common;
using Jellyfin.Extensions;
namespace Emby.Naming.Video
{
/// <summary>
/// Resolve if file is stub (.disc).
/// </summary>
public static class StubResolver
{
/// <summary>
/// Tries to resolve if file is stub (.disc).
/// </summary>
/// <param name="path">Path to file.</param>
/// <param name="options">NamingOptions containing StubFileExtensions and StubTypes.</param>
/// <param name="stubType">Stub type.</param>
/// <returns>True if file is a stub.</returns>
public static bool TryResolveFile(string path, NamingOptions options, out string? stubType)
{
stubType = default;
if (string.IsNullOrEmpty(path))
{
return false;
}
var extension = Path.GetExtension(path.AsSpan());
if (!options.StubFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase))
{
return false;
}
var token = Path.GetExtension(Path.GetFileNameWithoutExtension(path.AsSpan())).TrimStart('.');
foreach (var rule in options.StubTypes)
{
if (token.Equals(rule.Token, StringComparison.OrdinalIgnoreCase))
{
stubType = rule.StubType;
return true;
}
}
return true;
}
}
}
+31
View File
@@ -0,0 +1,31 @@
namespace Emby.Naming.Video
{
/// <summary>
/// Data class holding information about Stub type rule.
/// </summary>
public class StubTypeRule
{
/// <summary>
/// Initializes a new instance of the <see cref="StubTypeRule"/> class.
/// </summary>
/// <param name="token">Token.</param>
/// <param name="stubType">Stub type.</param>
public StubTypeRule(string token, string stubType)
{
Token = token;
StubType = stubType;
}
/// <summary>
/// Gets or sets the token.
/// </summary>
/// <value>The token.</value>
public string Token { get; set; }
/// <summary>
/// Gets or sets the type of the stub.
/// </summary>
/// <value>The type of the stub.</value>
public string StubType { get; set; }
}
}
+120
View File
@@ -0,0 +1,120 @@
using System;
using MediaBrowser.Model.Entities;
namespace Emby.Naming.Video
{
/// <summary>
/// Represents a single video file.
/// </summary>
public class VideoFileInfo
{
/// <summary>
/// Initializes a new instance of the <see cref="VideoFileInfo"/> class.
/// </summary>
/// <param name="name">Name of file.</param>
/// <param name="path">Path to the file.</param>
/// <param name="container">Container type.</param>
/// <param name="year">Year of release.</param>
/// <param name="extraType">Extra type.</param>
/// <param name="extraRule">Extra rule.</param>
/// <param name="format3D">Format 3D.</param>
/// <param name="is3D">Is 3D.</param>
/// <param name="isStub">Is Stub.</param>
/// <param name="stubType">Stub type.</param>
/// <param name="isDirectory">Is directory.</param>
public VideoFileInfo(string name, string path, string? container, int? year = default, ExtraType? extraType = default, ExtraRule? extraRule = default, string? format3D = default, bool is3D = default, bool isStub = default, string? stubType = default, bool isDirectory = default)
{
Path = path;
Container = container;
Name = name;
Year = year;
ExtraType = extraType;
ExtraRule = extraRule;
Format3D = format3D;
Is3D = is3D;
IsStub = isStub;
StubType = stubType;
IsDirectory = isDirectory;
}
/// <summary>
/// Gets or sets the path.
/// </summary>
/// <value>The path.</value>
public string Path { get; set; }
/// <summary>
/// Gets or sets the container.
/// </summary>
/// <value>The container.</value>
public string? Container { get; set; }
/// <summary>
/// Gets or sets the name.
/// </summary>
/// <value>The name.</value>
public string Name { get; set; }
/// <summary>
/// Gets or sets the year.
/// </summary>
/// <value>The year.</value>
public int? Year { get; set; }
/// <summary>
/// Gets or sets the type of the extra, e.g. trailer, theme song, behind the scenes, etc.
/// </summary>
/// <value>The type of the extra.</value>
public ExtraType? ExtraType { get; set; }
/// <summary>
/// Gets or sets the extra rule.
/// </summary>
/// <value>The extra rule.</value>
public ExtraRule? ExtraRule { get; set; }
/// <summary>
/// Gets or sets the format3 d.
/// </summary>
/// <value>The format3 d.</value>
public string? Format3D { get; set; }
/// <summary>
/// Gets or sets a value indicating whether [is3 d].
/// </summary>
/// <value><c>true</c> if [is3 d]; otherwise, <c>false</c>.</value>
public bool Is3D { get; set; }
/// <summary>
/// Gets or sets a value indicating whether this instance is stub.
/// </summary>
/// <value><c>true</c> if this instance is stub; otherwise, <c>false</c>.</value>
public bool IsStub { get; set; }
/// <summary>
/// Gets or sets the type of the stub.
/// </summary>
/// <value>The type of the stub.</value>
public string? StubType { get; set; }
/// <summary>
/// Gets or sets a value indicating whether this instance is a directory.
/// </summary>
/// <value>The type.</value>
public bool IsDirectory { get; set; }
/// <summary>
/// Gets the file name without extension.
/// </summary>
/// <value>The file name without extension.</value>
public ReadOnlySpan<char> FileNameWithoutExtension => !IsDirectory
? System.IO.Path.GetFileNameWithoutExtension(Path.AsSpan())
: System.IO.Path.GetFileName(Path.AsSpan());
/// <inheritdoc />
public override string ToString()
{
return "VideoFileInfo(Name: '" + Name + "')";
}
}
}
+53
View File
@@ -0,0 +1,53 @@
using System;
using System.Collections.Generic;
using MediaBrowser.Model.Entities;
namespace Emby.Naming.Video
{
/// <summary>
/// Represents a complete video, including all parts and subtitles.
/// </summary>
public class VideoInfo
{
/// <summary>
/// Initializes a new instance of the <see cref="VideoInfo" /> class.
/// </summary>
/// <param name="name">The name.</param>
public VideoInfo(string? name)
{
Name = name;
Files = Array.Empty<VideoFileInfo>();
AlternateVersions = Array.Empty<VideoFileInfo>();
}
/// <summary>
/// Gets or sets the name.
/// </summary>
/// <value>The name.</value>
public string? Name { get; set; }
/// <summary>
/// Gets or sets the year.
/// </summary>
/// <value>The year.</value>
public int? Year { get; set; }
/// <summary>
/// Gets or sets the files.
/// </summary>
/// <value>The files.</value>
public IReadOnlyList<VideoFileInfo> Files { get; set; }
/// <summary>
/// Gets or sets the alternate versions.
/// </summary>
/// <value>The alternate versions.</value>
public IReadOnlyList<VideoFileInfo> AlternateVersions { get; set; }
/// <summary>
/// Gets or sets the extra type.
/// </summary>
public ExtraType? ExtraType { get; set; }
}
}
+223
View File
@@ -0,0 +1,223 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using Emby.Naming.Common;
using Jellyfin.Extensions;
using MediaBrowser.Model.IO;
namespace Emby.Naming.Video
{
/// <summary>
/// Resolves alternative versions and extras from list of video files.
/// </summary>
public static partial class VideoListResolver
{
[GeneratedRegex("[0-9]{2}[0-9]+[ip]", RegexOptions.IgnoreCase)]
private static partial Regex ResolutionRegex();
[GeneratedRegex(@"^\[([^]]*)\]")]
private static partial Regex CheckMultiVersionRegex();
/// <summary>
/// Resolves alternative versions and extras from list of video files.
/// </summary>
/// <param name="videoInfos">List of related video files.</param>
/// <param name="namingOptions">The naming options.</param>
/// <param name="supportMultiVersion">Indication we should consider multi-versions of content.</param>
/// <param name="parseName">Whether to parse the name or use the filename.</param>
/// <param name="libraryRoot">Top-level folder for the containing library.</param>
/// <returns>Returns enumerable of <see cref="VideoInfo"/> which groups files together when related.</returns>
public static IReadOnlyList<VideoInfo> Resolve(IReadOnlyList<VideoFileInfo> videoInfos, NamingOptions namingOptions, bool supportMultiVersion = true, bool parseName = true, string? libraryRoot = "")
{
// Filter out all extras, otherwise they could cause stacks to not be resolved
// See the unit test TestStackedWithTrailer
var nonExtras = videoInfos
.Where(i => i.ExtraType is null)
.Select(i => new FileSystemMetadata { FullName = i.Path, IsDirectory = i.IsDirectory });
var stackResult = StackResolver.Resolve(nonExtras, namingOptions).ToList();
var remainingFiles = new List<VideoFileInfo>();
var standaloneMedia = new List<VideoFileInfo>();
for (var i = 0; i < videoInfos.Count; i++)
{
var current = videoInfos[i];
if (stackResult.Any(s => s.ContainsFile(current.Path, current.IsDirectory)))
{
continue;
}
if (current.ExtraType is null)
{
standaloneMedia.Add(current);
}
else
{
remainingFiles.Add(current);
}
}
var list = new List<VideoInfo>();
foreach (var stack in stackResult)
{
var info = new VideoInfo(stack.Name)
{
Files = stack.Files.Select(i => VideoResolver.Resolve(i, stack.IsDirectoryStack, namingOptions, parseName, libraryRoot))
.OfType<VideoFileInfo>()
.ToList()
};
info.Year = info.Files[0].Year;
list.Add(info);
}
foreach (var media in standaloneMedia)
{
var info = new VideoInfo(media.Name) { Files = new[] { media } };
info.Year = info.Files[0].Year;
list.Add(info);
}
if (supportMultiVersion)
{
list = GetVideosGroupedByVersion(list, namingOptions);
}
// Whatever files are left, just add them
list.AddRange(remainingFiles.Select(i => new VideoInfo(i.Name)
{
Files = new[] { i },
Year = i.Year,
ExtraType = i.ExtraType
}));
return list;
}
private static List<VideoInfo> GetVideosGroupedByVersion(List<VideoInfo> videos, NamingOptions namingOptions)
{
if (videos.Count == 0)
{
return videos;
}
var folderName = Path.GetFileName(Path.GetDirectoryName(videos[0].Files[0].Path.AsSpan()));
if (folderName.Length <= 1 || !HaveSameYear(videos))
{
return videos;
}
// Cannot use Span inside local functions and delegates thus we cannot use LINQ here nor merge with the above [if]
VideoInfo? primary = null;
for (var i = 0; i < videos.Count; i++)
{
var video = videos[i];
if (video.ExtraType is not null)
{
continue;
}
if (!IsEligibleForMultiVersion(folderName, video.Files[0].FileNameWithoutExtension, namingOptions))
{
return videos;
}
if (folderName.Equals(video.Files[0].FileNameWithoutExtension, StringComparison.Ordinal))
{
primary = video;
}
}
if (videos.Count > 1)
{
var groups = videos
.Select(x => (filename: x.Files[0].FileNameWithoutExtension.ToString(), value: x))
.Select(x => (x.filename, resolutionMatch: ResolutionRegex().Match(x.filename), x.value))
.GroupBy(x => x.resolutionMatch.Success)
.ToList();
videos.Clear();
StringComparer comparer = StringComparer.Create(CultureInfo.InvariantCulture, CompareOptions.NumericOrdering);
foreach (var group in groups)
{
if (group.Key)
{
videos.InsertRange(0, group
.OrderByDescending(x => x.resolutionMatch.Value, comparer)
.ThenBy(x => x.filename, comparer)
.Select(x => x.value));
}
else
{
videos.AddRange(group.OrderBy(x => x.filename, comparer).Select(x => x.value));
}
}
}
primary ??= videos[0];
videos.Remove(primary);
var list = new List<VideoInfo>
{
primary
};
list[0].AlternateVersions = videos.Select(x => x.Files[0]).ToArray();
list[0].Name = folderName.ToString();
return list;
}
private static bool HaveSameYear(IReadOnlyList<VideoInfo> videos)
{
if (videos.Count == 1)
{
return true;
}
var firstYear = videos[0].Year ?? -1;
for (var i = 1; i < videos.Count; i++)
{
if ((videos[i].Year ?? -1) != firstYear)
{
return false;
}
}
return true;
}
private static bool IsEligibleForMultiVersion(ReadOnlySpan<char> folderName, ReadOnlySpan<char> testFilename, NamingOptions namingOptions)
{
if (!testFilename.StartsWith(folderName, StringComparison.OrdinalIgnoreCase))
{
return false;
}
// Remove the folder name before cleaning as we don't care about cleaning that part
if (folderName.Length <= testFilename.Length)
{
testFilename = testFilename[folderName.Length..].Trim();
}
// There are no span overloads for regex unfortunately
if (CleanStringParser.TryClean(testFilename.ToString(), namingOptions.CleanStringRegexes, out var cleanName))
{
testFilename = cleanName.AsSpan().Trim();
}
// The CleanStringParser should have removed common keywords etc.
return testFilename.IsEmpty
|| testFilename[0] == '-'
|| CheckMultiVersionRegex().IsMatch(testFilename);
}
}
}
+160
View File
@@ -0,0 +1,160 @@
using System;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using Emby.Naming.Common;
using Jellyfin.Extensions;
namespace Emby.Naming.Video
{
/// <summary>
/// Resolves <see cref="VideoFileInfo"/> from file path.
/// </summary>
public static class VideoResolver
{
/// <summary>
/// Resolves the directory.
/// </summary>
/// <param name="path">The path.</param>
/// <param name="namingOptions">The naming options.</param>
/// <param name="parseName">Whether to parse the name or use the filename.</param>
/// <param name="libraryRoot">Top-level folder for the containing library.</param>
/// <returns>VideoFileInfo.</returns>
public static VideoFileInfo? ResolveDirectory(string? path, NamingOptions namingOptions, bool parseName = true, string? libraryRoot = "")
{
return Resolve(path, true, namingOptions, parseName, libraryRoot);
}
/// <summary>
/// Resolves the file.
/// </summary>
/// <param name="path">The path.</param>
/// <param name="namingOptions">The naming options.</param>
/// <param name="libraryRoot">Top-level folder for the containing library.</param>
/// <returns>VideoFileInfo.</returns>
public static VideoFileInfo? ResolveFile(string? path, NamingOptions namingOptions, string? libraryRoot = "")
{
return Resolve(path, false, namingOptions, libraryRoot: libraryRoot);
}
/// <summary>
/// Resolves the specified path.
/// </summary>
/// <param name="path">The path.</param>
/// <param name="isDirectory">if set to <c>true</c> [is folder].</param>
/// <param name="namingOptions">The naming options.</param>
/// <param name="parseName">Whether or not the name should be parsed for info.</param>
/// <param name="libraryRoot">Top-level folder for the containing library.</param>
/// <returns>VideoFileInfo.</returns>
/// <exception cref="ArgumentNullException"><c>path</c> is <c>null</c>.</exception>
public static VideoFileInfo? Resolve(string? path, bool isDirectory, NamingOptions namingOptions, bool parseName = true, string? libraryRoot = "")
{
if (string.IsNullOrEmpty(path))
{
return null;
}
bool isStub = false;
ReadOnlySpan<char> container = ReadOnlySpan<char>.Empty;
string? stubType = null;
if (!isDirectory)
{
var extension = Path.GetExtension(path.AsSpan());
// Check supported extensions
if (!namingOptions.VideoFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase))
{
// It's not supported. Check stub extensions
if (!StubResolver.TryResolveFile(path, namingOptions, out stubType))
{
return null;
}
isStub = true;
}
container = extension.TrimStart('.');
}
var format3DResult = Format3DParser.Parse(path, namingOptions);
var extraResult = ExtraRuleResolver.GetExtraInfo(path, namingOptions, libraryRoot);
var name = Path.GetFileNameWithoutExtension(path);
int? year = null;
if (parseName)
{
var cleanDateTimeResult = CleanDateTime(name, namingOptions);
name = cleanDateTimeResult.Name;
year = cleanDateTimeResult.Year;
if (TryCleanString(name, namingOptions, out var newName))
{
name = newName;
}
}
return new VideoFileInfo(
path: path,
container: container.IsEmpty ? null : container.ToString(),
isStub: isStub,
name: name,
year: year,
stubType: stubType,
is3D: format3DResult.Is3D,
format3D: format3DResult.Format3D,
extraType: extraResult.ExtraType,
isDirectory: isDirectory,
extraRule: extraResult.Rule);
}
/// <summary>
/// Determines if path is video file based on extension.
/// </summary>
/// <param name="path">Path to file.</param>
/// <param name="namingOptions">The naming options.</param>
/// <returns>True if is video file.</returns>
public static bool IsVideoFile(string path, NamingOptions namingOptions)
{
var extension = Path.GetExtension(path.AsSpan());
return namingOptions.VideoFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Determines if path is video file stub based on extension.
/// </summary>
/// <param name="path">Path to file.</param>
/// <param name="namingOptions">The naming options.</param>
/// <returns>True if is video file stub.</returns>
public static bool IsStubFile(string path, NamingOptions namingOptions)
{
var extension = Path.GetExtension(path.AsSpan());
return namingOptions.StubFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Tries to clean name of clutter.
/// </summary>
/// <param name="name">Raw name.</param>
/// <param name="namingOptions">The naming options.</param>
/// <param name="newName">Clean name.</param>
/// <returns>True if cleaning of name was successful.</returns>
public static bool TryCleanString([NotNullWhen(true)] string? name, NamingOptions namingOptions, out string newName)
{
return CleanStringParser.TryClean(name, namingOptions.CleanStringRegexes, out newName);
}
/// <summary>
/// Tries to get name and year from raw name.
/// </summary>
/// <param name="name">Raw name.</param>
/// <param name="namingOptions">The naming options.</param>
/// <returns>Returns <see cref="CleanDateTimeResult"/> with name and optional year.</returns>
public static CleanDateTimeResult CleanDateTime(string name, NamingOptions namingOptions)
{
return CleanDateTimeParser.Clean(name, namingOptions.CleanDateTimeRegexes);
}
}
}