jellyfin-server/Emby.Naming/Video/VideoListResolver.cs

305 lines
12 KiB
C#
Raw Normal View History

2018-09-12 17:26:21 +00:00
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
2019-01-13 19:17:29 +00:00
using Emby.Naming.Common;
2019-12-06 19:40:06 +00:00
using MediaBrowser.Model.Entities;
2019-01-13 19:17:29 +00:00
using MediaBrowser.Model.IO;
2018-09-12 17:26:21 +00:00
namespace Emby.Naming.Video
{
2020-11-10 18:23:10 +00:00
/// <summary>
/// Resolves alternative versions and extras from list of video files.
/// </summary>
2021-05-23 22:30:41 +00:00
public static class VideoListResolver
2018-09-12 17:26:21 +00:00
{
2020-11-10 18:23:10 +00:00
/// <summary>
/// Resolves alternative versions and extras from list of video files.
/// </summary>
/// <param name="files">List of related video files.</param>
2021-05-23 22:30:41 +00:00
/// <param name="namingOptions">The naming options.</param>
2020-11-10 18:23:10 +00:00
/// <param name="supportMultiVersion">Indication we should consider multi-versions of content.</param>
2020-11-18 13:23:45 +00:00
/// <returns>Returns enumerable of <see cref="VideoInfo"/> which groups files together when related.</returns>
2021-05-23 22:30:41 +00:00
public static IEnumerable<VideoInfo> Resolve(List<FileSystemMetadata> files, NamingOptions namingOptions, bool supportMultiVersion = true)
2018-09-12 17:26:21 +00:00
{
var videoInfos = files
2021-05-23 22:30:41 +00:00
.Select(i => VideoResolver.Resolve(i.FullName, i.IsDirectory, namingOptions))
2020-11-01 09:47:31 +00:00
.OfType<VideoFileInfo>()
2018-09-12 17:26:21 +00:00
.ToList();
// Filter out all extras, otherwise they could cause stacks to not be resolved
// See the unit test TestStackedWithTrailer
var nonExtras = videoInfos
2019-12-06 19:40:06 +00:00
.Where(i => i.ExtraType == null)
2020-03-25 16:53:03 +00:00
.Select(i => new FileSystemMetadata { FullName = i.Path, IsDirectory = i.IsDirectory });
2018-09-12 17:26:21 +00:00
2021-05-23 22:30:41 +00:00
var stackResult = new StackResolver(namingOptions)
2020-01-22 21:18:56 +00:00
.Resolve(nonExtras).ToList();
2018-09-12 17:26:21 +00:00
var remainingFiles = videoInfos
2020-11-01 09:47:31 +00:00
.Where(i => !stackResult.Any(s => i.Path != null && s.ContainsFile(i.Path, i.IsDirectory)))
2018-09-12 17:26:21 +00:00
.ToList();
var list = new List<VideoInfo>();
2020-01-22 21:18:56 +00:00
foreach (var stack in stackResult)
2018-09-12 17:26:21 +00:00
{
2020-01-22 21:18:56 +00:00
var info = new VideoInfo(stack.Name)
2018-09-12 17:26:21 +00:00
{
2021-05-23 22:30:41 +00:00
Files = stack.Files.Select(i => VideoResolver.Resolve(i, stack.IsDirectoryStack, namingOptions))
2020-11-01 09:47:31 +00:00
.OfType<VideoFileInfo>()
.ToList()
2018-09-12 17:26:21 +00:00
};
2019-05-10 18:37:42 +00:00
info.Year = info.Files[0].Year;
2018-09-12 17:26:21 +00:00
2021-05-23 22:30:41 +00:00
var extras = ExtractExtras(remainingFiles, stack.Name, Path.GetFileNameWithoutExtension(stack.Files[0].AsSpan()), namingOptions.VideoFlagDelimiters);
2018-09-12 17:26:21 +00:00
if (extras.Count > 0)
{
info.Extras = extras;
}
list.Add(info);
}
var standaloneMedia = remainingFiles
2019-12-06 19:40:06 +00:00
.Where(i => i.ExtraType == null)
2018-09-12 17:26:21 +00:00
.ToList();
foreach (var media in standaloneMedia)
{
2021-05-23 22:30:41 +00:00
var info = new VideoInfo(media.Name) { Files = new[] { media } };
2018-09-12 17:26:21 +00:00
2019-05-10 18:37:42 +00:00
info.Year = info.Files[0].Year;
2018-09-12 17:26:21 +00:00
2021-05-23 22:30:41 +00:00
remainingFiles.Remove(media);
var extras = ExtractExtras(remainingFiles, media.FileNameWithoutExtension, namingOptions.VideoFlagDelimiters);
2018-09-12 17:26:21 +00:00
info.Extras = extras;
list.Add(info);
}
if (supportMultiVersion)
{
2021-05-23 22:30:41 +00:00
list = GetVideosGroupedByVersion(list, namingOptions);
2018-09-12 17:26:21 +00:00
}
// If there's only one resolved video, use the folder name as well to find extras
if (list.Count == 1)
{
var info = list[0];
var videoPath = list[0].Files[0].Path;
2021-05-23 22:30:41 +00:00
var parentPath = Path.GetDirectoryName(videoPath.AsSpan());
2018-09-12 17:26:21 +00:00
2021-05-23 22:30:41 +00:00
if (!parentPath.IsEmpty)
2018-09-12 17:26:21 +00:00
{
2019-05-10 18:37:42 +00:00
var folderName = Path.GetFileName(parentPath);
2021-05-23 22:30:41 +00:00
if (!folderName.IsEmpty)
2018-09-12 17:26:21 +00:00
{
2021-05-23 22:30:41 +00:00
var extras = ExtractExtras(remainingFiles, folderName, namingOptions.VideoFlagDelimiters);
2020-01-22 21:18:56 +00:00
extras.AddRange(info.Extras);
info.Extras = extras;
2018-09-12 17:26:21 +00:00
}
}
// Add the extras that are just based on file name as well
var extrasByFileName = remainingFiles
.Where(i => i.ExtraRule != null && i.ExtraRule.RuleType == ExtraRuleType.Filename)
.ToList();
remainingFiles = remainingFiles
.Except(extrasByFileName)
.ToList();
2020-01-22 21:18:56 +00:00
extrasByFileName.AddRange(info.Extras);
info.Extras = extrasByFileName;
2018-09-12 17:26:21 +00:00
}
// If there's only one video, accept all trailers
// Be lenient because people use all kinds of mishmash conventions with trailers.
2018-09-12 17:26:21 +00:00
if (list.Count == 1)
{
var trailers = remainingFiles
2019-12-06 19:40:06 +00:00
.Where(i => i.ExtraType == ExtraType.Trailer)
2018-09-12 17:26:21 +00:00
.ToList();
2020-01-22 21:18:56 +00:00
trailers.AddRange(list[0].Extras);
list[0].Extras = trailers;
2018-09-12 17:26:21 +00:00
remainingFiles = remainingFiles
.Except(trailers)
.ToList();
}
// Whatever files are left, just add them
2020-01-22 21:18:56 +00:00
list.AddRange(remainingFiles.Select(i => new VideoInfo(i.Name)
2018-09-12 17:26:21 +00:00
{
2021-05-23 22:30:41 +00:00
Files = new[] { i },
2020-03-25 20:33:44 +00:00
Year = i.Year
2018-09-12 17:26:21 +00:00
}));
2020-01-22 21:18:56 +00:00
return list;
2018-09-12 17:26:21 +00:00
}
2021-05-23 22:30:41 +00:00
private static List<VideoInfo> GetVideosGroupedByVersion(List<VideoInfo> videos, NamingOptions namingOptions)
2018-09-12 17:26:21 +00:00
{
if (videos.Count == 0)
{
return videos;
}
2021-05-23 22:30:41 +00:00
var folderName = Path.GetFileName(Path.GetDirectoryName(videos[0].Files[0].Path.AsSpan()));
2021-05-23 22:30:41 +00:00
if (folderName.Length <= 1 || !HaveSameYear(videos))
2018-09-12 17:26:21 +00:00
{
2021-05-23 22:30:41 +00:00
return videos;
}
2021-05-23 22:30:41 +00:00
// Cannot use Span inside local functions and delegates thus we cannot use LINQ here nor merge with the above [if]
for (var i = 0; i < videos.Count; i++)
{
var video = videos[i];
if (!IsEligibleForMultiVersion(folderName, video.Files[0].Path, namingOptions))
2020-01-22 21:18:56 +00:00
{
2021-05-23 22:30:41 +00:00
return videos;
2020-01-22 21:18:56 +00:00
}
2021-05-23 22:30:41 +00:00
}
// The list is created and overwritten in the caller, so we are allowed to do in-place sorting
videos.Sort((x, y) => string.Compare(x.Name, y.Name, StringComparison.Ordinal));
2020-01-22 21:18:56 +00:00
2021-05-23 22:30:41 +00:00
var list = new List<VideoInfo>
{
videos[0]
};
2021-05-23 22:30:41 +00:00
var alternateVersionsLen = videos.Count - 1;
var alternateVersions = new VideoFileInfo[alternateVersionsLen];
var extras = new List<VideoFileInfo>(list[0].Extras);
for (int i = 0; i < alternateVersionsLen; i++)
{
var video = videos[i + 1];
alternateVersions[i] = video.Files[0];
extras.AddRange(video.Extras);
}
2021-05-23 22:30:41 +00:00
list[0].AlternateVersions = alternateVersions;
list[0].Name = folderName.ToString();
list[0].Extras = extras;
2021-05-23 22:30:41 +00:00
return list;
}
2021-05-23 22:30:41 +00:00
private static bool HaveSameYear(IReadOnlyList<VideoInfo> videos)
{
2021-05-23 22:30:41 +00:00
if (videos.Count == 1)
{
2021-05-23 22:30:41 +00:00
return true;
}
2020-11-22 21:39:34 +00:00
2021-05-23 22:30:41 +00:00
var firstYear = videos[0].Year ?? -1;
for (var i = 1; i < videos.Count; i++)
{
if ((videos[i].Year ?? -1) != firstYear)
{
2021-05-23 22:30:41 +00:00
return false;
}
2021-05-23 22:30:41 +00:00
}
2021-05-23 22:30:41 +00:00
return true;
}
private static bool IsEligibleForMultiVersion(ReadOnlySpan<char> folderName, string testFilePath, NamingOptions namingOptions)
{
var testFilename = Path.GetFileNameWithoutExtension(testFilePath.AsSpan());
if (!testFilename.StartsWith(folderName, StringComparison.OrdinalIgnoreCase))
{
return false;
}
2021-05-23 22:30:41 +00:00
// 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
var tmpTestFilename = testFilename.ToString();
if (CleanStringParser.TryClean(tmpTestFilename, namingOptions.CleanStringRegexes, out var cleanName))
{
tmpTestFilename = cleanName.Trim().ToString();
}
// The CleanStringParser should have removed common keywords etc.
return string.IsNullOrEmpty(tmpTestFilename)
|| testFilename[0] == '-'
|| Regex.IsMatch(tmpTestFilename, @"^\[([^]]*)\]", RegexOptions.Compiled);
}
private static ReadOnlySpan<char> TrimFilenameDelimiters(ReadOnlySpan<char> name, ReadOnlySpan<char> videoFlagDelimiters)
{
return name.IsEmpty ? name : name.TrimEnd().TrimEnd(videoFlagDelimiters).TrimEnd();
2018-09-12 17:26:21 +00:00
}
2021-05-23 22:30:41 +00:00
private static bool StartsWith(ReadOnlySpan<char> fileName, ReadOnlySpan<char> baseName, ReadOnlySpan<char> trimmedBaseName)
2018-09-12 17:26:21 +00:00
{
2021-05-23 22:30:41 +00:00
if (baseName.IsEmpty)
2018-09-12 17:26:21 +00:00
{
2021-05-23 22:30:41 +00:00
return false;
2018-09-12 17:26:21 +00:00
}
2021-05-23 22:30:41 +00:00
return fileName.StartsWith(baseName, StringComparison.OrdinalIgnoreCase)
|| (!trimmedBaseName.IsEmpty && fileName.StartsWith(trimmedBaseName, StringComparison.OrdinalIgnoreCase));
}
/// <summary>
/// Finds similar filenames to that of [baseName] and removes any matches from [remainingFiles].
/// </summary>
/// <param name="remainingFiles">The list of remaining filenames.</param>
/// <param name="baseName">The base name to use for the comparison.</param>
/// <param name="videoFlagDelimiters">The video flag delimiters.</param>
/// <returns>A list of video extras for [baseName].</returns>
private static List<VideoFileInfo> ExtractExtras(IList<VideoFileInfo> remainingFiles, ReadOnlySpan<char> baseName, ReadOnlySpan<char> videoFlagDelimiters)
{
return ExtractExtras(remainingFiles, baseName, ReadOnlySpan<char>.Empty, videoFlagDelimiters);
}
/// <summary>
/// Finds similar filenames to that of [firstBaseName] and [secondBaseName] and removes any matches from [remainingFiles].
/// </summary>
/// <param name="remainingFiles">The list of remaining filenames.</param>
/// <param name="firstBaseName">The first base name to use for the comparison.</param>
/// <param name="secondBaseName">The second base name to use for the comparison.</param>
/// <param name="videoFlagDelimiters">The video flag delimiters.</param>
/// <returns>A list of video extras for [firstBaseName] and [secondBaseName].</returns>
private static List<VideoFileInfo> ExtractExtras(IList<VideoFileInfo> remainingFiles, ReadOnlySpan<char> firstBaseName, ReadOnlySpan<char> secondBaseName, ReadOnlySpan<char> videoFlagDelimiters)
{
var trimmedFirstBaseName = TrimFilenameDelimiters(firstBaseName, videoFlagDelimiters);
var trimmedSecondBaseName = TrimFilenameDelimiters(secondBaseName, videoFlagDelimiters);
var result = new List<VideoFileInfo>();
for (var pos = remainingFiles.Count - 1; pos >= 0; pos--)
2021-05-23 22:30:41 +00:00
{
var file = remainingFiles[pos];
if (file.ExtraType == null)
{
continue;
}
var filename = file.FileNameWithoutExtension;
if (StartsWith(filename, firstBaseName, trimmedFirstBaseName)
|| StartsWith(filename, secondBaseName, trimmedSecondBaseName))
{
result.Add(file);
remainingFiles.RemoveAt(pos);
}
}
return result;
2018-09-12 17:26:21 +00:00
}
}
}