using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Emby.Naming.Common;
using Emby.Naming.ExternalFiles;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.MediaEncoding;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Dlna;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Globalization;
using MediaBrowser.Model.IO;
using MediaBrowser.Model.MediaInfo;
using Microsoft.Extensions.Logging;
namespace MediaBrowser.Providers.MediaInfo
{
///
/// Resolves external files for .
///
public abstract class MediaInfoResolver
{
///
/// The instance.
///
private readonly ExternalPathParser _externalPathParser;
///
/// The instance.
///
private readonly IMediaEncoder _mediaEncoder;
private readonly ILogger _logger;
private readonly IFileSystem _fileSystem;
///
/// The instance.
///
private readonly NamingOptions _namingOptions;
///
/// The of the files this resolver should resolve.
///
private readonly DlnaProfileType _type;
///
/// Initializes a new instance of the class.
///
/// The logger.
/// The localization manager.
/// The media encoder.
/// The file system.
/// The object containing FileExtensions, MediaDefaultFlags, MediaForcedFlags and MediaFlagDelimiters.
/// The of the parsed file.
protected MediaInfoResolver(
ILogger logger,
ILocalizationManager localizationManager,
IMediaEncoder mediaEncoder,
IFileSystem fileSystem,
NamingOptions namingOptions,
DlnaProfileType type)
{
_logger = logger;
_mediaEncoder = mediaEncoder;
_fileSystem = fileSystem;
_namingOptions = namingOptions;
_type = type;
_externalPathParser = new ExternalPathParser(namingOptions, localizationManager, _type);
}
///
/// Retrieves the external streams for the provided video.
///
/// The object to search external streams for.
/// The stream index to start adding external streams at.
/// The directory service to search for files.
/// True if the directory service cache should be cleared before searching.
/// The cancellation token.
/// The external streams located.
public async Task> GetExternalStreamsAsync(
Video video,
int startIndex,
IDirectoryService directoryService,
bool clearCache,
CancellationToken cancellationToken)
{
if (!video.IsFileProtocol)
{
return Array.Empty();
}
var pathInfos = GetExternalFiles(video, directoryService, clearCache);
if (!pathInfos.Any())
{
return Array.Empty();
}
var mediaStreams = new List();
foreach (var pathInfo in pathInfos)
{
if (!pathInfo.Path.AsSpan().EndsWith(".strm", StringComparison.OrdinalIgnoreCase))
{
try
{
var mediaInfo = await GetMediaInfo(pathInfo.Path, _type, cancellationToken).ConfigureAwait(false);
if (mediaInfo.MediaStreams.Count == 1)
{
MediaStream mediaStream = mediaInfo.MediaStreams[0];
if ((mediaStream.Type == MediaStreamType.Audio && _type == DlnaProfileType.Audio)
|| (mediaStream.Type == MediaStreamType.Subtitle && _type == DlnaProfileType.Subtitle))
{
mediaStream.Index = startIndex++;
mediaStream.IsDefault = pathInfo.IsDefault;
mediaStream.IsForced = pathInfo.IsForced || mediaStream.IsForced;
mediaStream.IsHearingImpaired = pathInfo.IsHearingImpaired || mediaStream.IsHearingImpaired;
mediaStreams.Add(MergeMetadata(mediaStream, pathInfo));
}
}
else
{
foreach (MediaStream mediaStream in mediaInfo.MediaStreams)
{
if ((mediaStream.Type == MediaStreamType.Audio && _type == DlnaProfileType.Audio)
|| (mediaStream.Type == MediaStreamType.Subtitle && _type == DlnaProfileType.Subtitle))
{
mediaStream.Index = startIndex++;
mediaStreams.Add(MergeMetadata(mediaStream, pathInfo));
}
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting external streams from {Path}", pathInfo.Path);
continue;
}
}
}
return mediaStreams;
}
///
/// Retrieves the external streams for the provided audio.
///
/// The object to search external streams for.
/// The stream index to start adding external streams at.
/// The directory service to search for files.
/// True if the directory service cache should be cleared before searching.
/// The external streams located.
public IReadOnlyList GetExternalStreams(
Audio audio,
int startIndex,
IDirectoryService directoryService,
bool clearCache)
{
if (!audio.IsFileProtocol)
{
return Array.Empty();
}
var pathInfos = GetExternalFiles(audio, directoryService, clearCache);
if (pathInfos.Count == 0)
{
return Array.Empty();
}
var mediaStreams = new MediaStream[pathInfos.Count];
for (var i = 0; i < pathInfos.Count; i++)
{
mediaStreams[i] = new MediaStream
{
Type = MediaStreamType.Lyric,
Path = pathInfos[i].Path,
Language = pathInfos[i].Language,
Index = startIndex++
};
}
return mediaStreams;
}
///
/// Returns the external file infos for the given video.
///
/// The object to search external files for.
/// The directory service to search for files.
/// True if the directory service cache should be cleared before searching.
/// The external file paths located.
public IReadOnlyList GetExternalFiles(
Video video,
IDirectoryService directoryService,
bool clearCache)
{
if (!video.IsFileProtocol)
{
return Array.Empty();
}
// Check if video folder exists
string folder = video.ContainingFolderPath;
if (!_fileSystem.DirectoryExists(folder))
{
return Array.Empty();
}
var files = directoryService.GetFilePaths(folder, clearCache).ToList();
files.Remove(video.Path);
var internalMetadataPath = video.GetInternalMetadataPath();
if (_fileSystem.DirectoryExists(internalMetadataPath))
{
files.AddRange(directoryService.GetFilePaths(internalMetadataPath, clearCache));
}
if (files.Count == 0)
{
return Array.Empty();
}
// VobSub .sub payloads only carry per-track language metadata when read via
// their paired .idx file, so probe the .idx instead and skip the .sub. Pairing
// requires the same directory (ffprobe can't resolve a split pair) and an
// ordinal comparison (ffprobe matches the .sub by exact case on case-sensitive
// filesystems, so a looser match could suppress a .sub with no working .idx).
// An .idx file with no paired .sub cannot be probed at all, so it is left out
// entirely rather than surfaced (which would otherwise fail every probe and,
// since the .idx would keep "existing" from Jellyfin's point of view, prevent
// stale subtitle stream metadata from ever being cleared once the .sub is gone).
HashSet? pairedVobSubKeys = _type == DlnaProfileType.Subtitle
? GetPairedVobSubKeys(files)
: null;
var externalPathInfos = new List();
ReadOnlySpan prefix = video.FileNameWithoutExtension;
foreach (var file in files)
{
if (IsSuppressedVobSubFile(file, pairedVobSubKeys))
{
continue;
}
var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(file.AsSpan());
if (fileNameWithoutExtension.Length >= prefix.Length
&& prefix.Equals(fileNameWithoutExtension[..prefix.Length], StringComparison.OrdinalIgnoreCase)
&& (fileNameWithoutExtension.Length == prefix.Length || _namingOptions.MediaFlagDelimiters.Contains(fileNameWithoutExtension[prefix.Length])))
{
var externalPathInfo = _externalPathParser.ParseFile(file, fileNameWithoutExtension[prefix.Length..].ToString());
if (externalPathInfo is not null)
{
externalPathInfos.Add(externalPathInfo);
}
}
}
return externalPathInfos;
}
///
/// Returns the external file infos for the given audio.
///
/// The object to search external files for.
/// The directory service to search for files.
/// True if the directory service cache should be cleared before searching.
/// The external file paths located.
public IReadOnlyList GetExternalFiles(
Audio audio,
IDirectoryService directoryService,
bool clearCache)
{
if (!audio.IsFileProtocol)
{
return Array.Empty();
}
string folder = audio.ContainingFolderPath;
var files = directoryService.GetFilePaths(folder, clearCache).ToList();
files.Remove(audio.Path);
var internalMetadataPath = audio.GetInternalMetadataPath();
if (_fileSystem.DirectoryExists(internalMetadataPath))
{
files.AddRange(directoryService.GetFilePaths(internalMetadataPath, clearCache));
}
if (files.Count == 0)
{
return Array.Empty();
}
var externalPathInfos = new List();
ReadOnlySpan prefix = audio.FileNameWithoutExtension;
foreach (var file in files)
{
var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(file.AsSpan());
if (fileNameWithoutExtension.Length >= prefix.Length
&& prefix.Equals(fileNameWithoutExtension[..prefix.Length], StringComparison.OrdinalIgnoreCase)
&& (fileNameWithoutExtension.Length == prefix.Length || _namingOptions.MediaFlagDelimiters.Contains(fileNameWithoutExtension[prefix.Length])))
{
var externalPathInfo = _externalPathParser.ParseFile(file, fileNameWithoutExtension[prefix.Length..].ToString());
if (externalPathInfo is not null)
{
externalPathInfos.Add(externalPathInfo);
}
}
}
return externalPathInfos;
}
///
/// Determines whether a candidate file is part of a VobSub .idx/.sub pair that
/// should be resolved to only its .idx file, or an .idx file with no paired .sub
/// that cannot be probed at all.
///
/// The full path to the candidate file.
/// The set of pairing keys with both an .idx and .sub present, or null if not applicable.
/// true if the file should be suppressed; otherwise, false.
private static bool IsSuppressedVobSubFile(string file, HashSet? pairedVobSubKeys)
{
if (pairedVobSubKeys is null)
{
return false;
}
var extension = Path.GetExtension(file.AsSpan());
if (extension.Equals(".sub", StringComparison.OrdinalIgnoreCase))
{
// A paired .idx exists; probe it instead of the .sub payload.
return pairedVobSubKeys.Contains(GetVobSubPairingKey(file));
}
if (extension.Equals(".idx", StringComparison.OrdinalIgnoreCase))
{
// Without its .sub payload, the .idx cannot be probed for any data.
return !pairedVobSubKeys.Contains(GetVobSubPairingKey(file));
}
return false;
}
///
/// Builds the set of directory+basename keys that have both an .idx and a .sub
/// file present, in a single pass over the candidate files.
///
/// The candidate files to search.
/// The set of pairing keys with both an .idx and .sub present.
private static HashSet GetPairedVobSubKeys(IEnumerable files)
{
var idxKeys = new HashSet(StringComparer.Ordinal);
var subKeys = new HashSet(StringComparer.Ordinal);
foreach (var file in files)
{
var extension = Path.GetExtension(file.AsSpan());
if (extension.Equals(".idx", StringComparison.OrdinalIgnoreCase))
{
idxKeys.Add(GetVobSubPairingKey(file));
}
else if (extension.Equals(".sub", StringComparison.OrdinalIgnoreCase))
{
subKeys.Add(GetVobSubPairingKey(file));
}
}
idxKeys.IntersectWith(subKeys);
return idxKeys;
}
///
/// Builds a directory+basename key used to pair a VobSub .idx file with its .sub
/// payload only when both live in the same directory.
///
/// The full path to the file.
/// A key combining the containing directory and file name without extension.
private static string GetVobSubPairingKey(string file)
{
var directory = Path.GetDirectoryName(file) ?? string.Empty;
var baseName = Path.GetFileNameWithoutExtension(file);
return Path.Combine(directory, baseName);
}
///
/// Returns the media info of the given file.
///
/// The path to the file.
/// The .
/// The cancellation token to cancel operation.
/// The media info for the given file.
private Task GetMediaInfo(string path, DlnaProfileType type, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
return _mediaEncoder.GetMediaInfo(
new MediaInfoRequest
{
MediaType = type,
MediaSource = new MediaSourceInfo
{
Path = path,
Protocol = MediaProtocol.File
}
},
cancellationToken);
}
///
/// Merges path metadata into stream metadata.
///
/// The object.
/// The object.
/// The modified mediaStream.
private MediaStream MergeMetadata(MediaStream mediaStream, ExternalPathParserResult pathInfo)
{
mediaStream.Path = pathInfo.Path;
mediaStream.IsExternal = true;
mediaStream.Title = string.IsNullOrEmpty(mediaStream.Title) ? (string.IsNullOrEmpty(pathInfo.Title) ? null : pathInfo.Title) : mediaStream.Title;
mediaStream.Language = string.IsNullOrEmpty(mediaStream.Language) ? (string.IsNullOrEmpty(pathInfo.Language) ? null : pathInfo.Language) : mediaStream.Language;
return mediaStream;
}
}
}