mirror of
https://github.com/jellyfin/jellyfin.git
synced 2025-04-18 19:25:00 -04:00
Cleanup extracted files (#13760)
* Cleanup extracted files * Pagination and fixes * Add migration for attachments to MigrateLibraryDb * Unify attachment handling * Don't extract again if files were already extracted * Fix MKS attachment extraction * Always run full extraction on mks * Don't try to extract mjpeg streams as attachments * Fallback to check if attachments were extracted to cache folder * Fixup
This commit is contained in:
parent
0bde7bae05
commit
596b635511
17 changed files with 2397 additions and 416 deletions
|
@ -1,14 +1,14 @@
|
|||
#pragma warning disable CS1591
|
||||
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Database.Implementations;
|
||||
using MediaBrowser.Controller;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.IO;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Trickplay;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
|
@ -19,15 +19,18 @@ public class CleanDatabaseScheduledTask : ILibraryPostScanTask
|
|||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly ILogger<CleanDatabaseScheduledTask> _logger;
|
||||
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
|
||||
private readonly IPathManager _pathManager;
|
||||
|
||||
public CleanDatabaseScheduledTask(
|
||||
ILibraryManager libraryManager,
|
||||
ILogger<CleanDatabaseScheduledTask> logger,
|
||||
IDbContextFactory<JellyfinDbContext> dbProvider)
|
||||
IDbContextFactory<JellyfinDbContext> dbProvider,
|
||||
IPathManager pathManager)
|
||||
{
|
||||
_libraryManager = libraryManager;
|
||||
_logger = logger;
|
||||
_dbProvider = dbProvider;
|
||||
_pathManager = pathManager;
|
||||
}
|
||||
|
||||
public async Task Run(IProgress<double> progress, CancellationToken cancellationToken)
|
||||
|
@ -56,6 +59,38 @@ public class CleanDatabaseScheduledTask : ILibraryPostScanTask
|
|||
{
|
||||
_logger.LogInformation("Cleaning item {Item} type: {Type} path: {Path}", item.Name, item.GetType().Name, item.Path ?? string.Empty);
|
||||
|
||||
foreach (var mediaSource in item.GetMediaSources(false))
|
||||
{
|
||||
// Delete extracted subtitles
|
||||
try
|
||||
{
|
||||
var subtitleFolder = _pathManager.GetSubtitleFolderPath(mediaSource.Id);
|
||||
if (Directory.Exists(subtitleFolder))
|
||||
{
|
||||
Directory.Delete(subtitleFolder, true);
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.LogWarning("Failed to remove subtitle cache folder for {Item}: {Exception}", item.Id, e.Message);
|
||||
}
|
||||
|
||||
// Delete extracted attachments
|
||||
try
|
||||
{
|
||||
var attachmentFolder = _pathManager.GetAttachmentFolderPath(mediaSource.Id);
|
||||
if (Directory.Exists(attachmentFolder))
|
||||
{
|
||||
Directory.Delete(attachmentFolder, true);
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.LogWarning("Failed to remove attachment cache folder for {Item}: {Exception}", item.Id, e.Message);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete item
|
||||
_libraryManager.DeleteItem(item, new DeleteOptions
|
||||
{
|
||||
DeleteFileLocation = false
|
||||
|
|
|
@ -492,7 +492,24 @@ namespace Emby.Server.Implementations.Library
|
|||
|
||||
if (item is Video video)
|
||||
{
|
||||
// Trickplay
|
||||
list.Add(_pathManager.GetTrickplayDirectory(video));
|
||||
|
||||
// Subtitles and attachments
|
||||
foreach (var mediaSource in item.GetMediaSources(false))
|
||||
{
|
||||
var subtitleFolder = _pathManager.GetSubtitleFolderPath(mediaSource.Id);
|
||||
if (subtitleFolder is not null)
|
||||
{
|
||||
list.Add(subtitleFolder);
|
||||
}
|
||||
|
||||
var attachmentFolder = _pathManager.GetAttachmentFolderPath(mediaSource.Id);
|
||||
if (attachmentFolder is not null)
|
||||
{
|
||||
list.Add(attachmentFolder);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return list;
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
using System;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.IO;
|
||||
|
@ -12,22 +14,56 @@ namespace Emby.Server.Implementations.Library;
|
|||
public class PathManager : IPathManager
|
||||
{
|
||||
private readonly IServerConfigurationManager _config;
|
||||
private readonly IApplicationPaths _appPaths;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="PathManager"/> class.
|
||||
/// </summary>
|
||||
/// <param name="config">The server configuration manager.</param>
|
||||
/// <param name="appPaths">The application paths.</param>
|
||||
public PathManager(
|
||||
IServerConfigurationManager config)
|
||||
IServerConfigurationManager config,
|
||||
IApplicationPaths appPaths)
|
||||
{
|
||||
_config = config;
|
||||
_appPaths = appPaths;
|
||||
}
|
||||
|
||||
private string SubtitleCachePath => Path.Combine(_appPaths.DataPath, "subtitles");
|
||||
|
||||
private string AttachmentCachePath => Path.Combine(_appPaths.DataPath, "attachments");
|
||||
|
||||
/// <inheritdoc />
|
||||
public string GetAttachmentPath(string mediaSourceId, string fileName)
|
||||
{
|
||||
return Path.Join(GetAttachmentFolderPath(mediaSourceId), fileName);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string GetAttachmentFolderPath(string mediaSourceId)
|
||||
{
|
||||
var id = Guid.Parse(mediaSourceId);
|
||||
return Path.Join(AttachmentCachePath, id.ToString("D", CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string GetSubtitleFolderPath(string mediaSourceId)
|
||||
{
|
||||
var id = Guid.Parse(mediaSourceId);
|
||||
return Path.Join(SubtitleCachePath, id.ToString("D", CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string GetSubtitlePath(string mediaSourceId, int streamIndex, string extension)
|
||||
{
|
||||
return Path.Join(GetSubtitleFolderPath(mediaSourceId), streamIndex.ToString(CultureInfo.InvariantCulture) + extension);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string GetTrickplayDirectory(BaseItem item, bool saveWithMedia = false)
|
||||
{
|
||||
var basePath = _config.ApplicationPaths.TrickplayPath;
|
||||
var idString = item.Id.ToString("N", CultureInfo.InvariantCulture);
|
||||
var idString = item.Id.ToString("D", CultureInfo.InvariantCulture);
|
||||
|
||||
return saveWithMedia
|
||||
? Path.Combine(item.ContainingFolderPath, Path.ChangeExtension(item.Path, ".trickplay"))
|
||||
|
|
|
@ -54,6 +54,7 @@ namespace Jellyfin.Server.Migrations
|
|||
typeof(Routines.FixAudioData),
|
||||
typeof(Routines.RemoveDuplicatePlaylistChildren),
|
||||
typeof(Routines.MigrateLibraryDb),
|
||||
typeof(Routines.MoveExtractedFiles),
|
||||
typeof(Routines.MigrateRatingLevels),
|
||||
typeof(Routines.MoveTrickplayFiles),
|
||||
typeof(Routines.MigrateKeyframeData),
|
||||
|
|
|
@ -80,6 +80,7 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
|
|||
|
||||
using (var operation = GetPreparedDbContext("Cleanup database"))
|
||||
{
|
||||
operation.JellyfinDbContext.AttachmentStreamInfos.ExecuteDelete();
|
||||
operation.JellyfinDbContext.BaseItems.ExecuteDelete();
|
||||
operation.JellyfinDbContext.ItemValues.ExecuteDelete();
|
||||
operation.JellyfinDbContext.UserData.ExecuteDelete();
|
||||
|
@ -251,6 +252,29 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
|
|||
}
|
||||
}
|
||||
|
||||
using (var operation = GetPreparedDbContext("moving AttachmentStreamInfos"))
|
||||
{
|
||||
const string mediaAttachmentQuery =
|
||||
"""
|
||||
SELECT ItemId, AttachmentIndex, Codec, CodecTag, Comment, filename, MIMEType
|
||||
FROM mediaattachments
|
||||
WHERE EXISTS(SELECT 1 FROM TypedBaseItems WHERE TypedBaseItems.guid = mediaattachments.ItemId)
|
||||
""";
|
||||
|
||||
using (new TrackedMigrationStep("loading AttachmentStreamInfos", _logger))
|
||||
{
|
||||
foreach (SqliteDataReader dto in connection.Query(mediaAttachmentQuery))
|
||||
{
|
||||
operation.JellyfinDbContext.AttachmentStreamInfos.Add(GetMediaAttachment(dto));
|
||||
}
|
||||
}
|
||||
|
||||
using (new TrackedMigrationStep($"saving {operation.JellyfinDbContext.AttachmentStreamInfos.Local.Count} AttachmentStreamInfos entries", _logger))
|
||||
{
|
||||
operation.JellyfinDbContext.SaveChanges();
|
||||
}
|
||||
}
|
||||
|
||||
using (var operation = GetPreparedDbContext("moving People"))
|
||||
{
|
||||
const string personsQuery =
|
||||
|
@ -709,6 +733,48 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
|
|||
return item;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the attachment.
|
||||
/// </summary>
|
||||
/// <param name="reader">The reader.</param>
|
||||
/// <returns>MediaAttachment.</returns>
|
||||
private AttachmentStreamInfo GetMediaAttachment(SqliteDataReader reader)
|
||||
{
|
||||
var item = new AttachmentStreamInfo
|
||||
{
|
||||
Index = reader.GetInt32(1),
|
||||
Item = null!,
|
||||
ItemId = reader.GetGuid(0),
|
||||
};
|
||||
|
||||
if (reader.TryGetString(2, out var codec))
|
||||
{
|
||||
item.Codec = codec;
|
||||
}
|
||||
|
||||
if (reader.TryGetString(3, out var codecTag))
|
||||
{
|
||||
item.CodecTag = codecTag;
|
||||
}
|
||||
|
||||
if (reader.TryGetString(4, out var comment))
|
||||
{
|
||||
item.Comment = comment;
|
||||
}
|
||||
|
||||
if (reader.TryGetString(5, out var fileName))
|
||||
{
|
||||
item.Filename = fileName;
|
||||
}
|
||||
|
||||
if (reader.TryGetString(6, out var mimeType))
|
||||
{
|
||||
item.MimeType = mimeType;
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
private (BaseItemEntity BaseItem, string[] LegacyUserDataKey) GetItem(SqliteDataReader reader)
|
||||
{
|
||||
var entity = new BaseItemEntity()
|
||||
|
|
299
Jellyfin.Server/Migrations/Routines/MoveExtractedFiles.cs
Normal file
299
Jellyfin.Server/Migrations/Routines/MoveExtractedFiles.cs
Normal file
|
@ -0,0 +1,299 @@
|
|||
#pragma warning disable CA5351 // Do Not Use Broken Cryptographic Algorithms
|
||||
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Jellyfin.Data.Enums;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Common.Extensions;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.IO;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.MediaInfo;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Server.Migrations.Routines;
|
||||
|
||||
/// <summary>
|
||||
/// Migration to move extracted files to the new directories.
|
||||
/// </summary>
|
||||
public class MoveExtractedFiles : IDatabaseMigrationRoutine
|
||||
{
|
||||
private readonly IApplicationPaths _appPaths;
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly ILogger<MoveExtractedFiles> _logger;
|
||||
private readonly IMediaSourceManager _mediaSourceManager;
|
||||
private readonly IPathManager _pathManager;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="MoveExtractedFiles"/> class.
|
||||
/// </summary>
|
||||
/// <param name="appPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param>
|
||||
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
/// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param>
|
||||
/// <param name="pathManager">Instance of the <see cref="IPathManager"/> interface.</param>
|
||||
public MoveExtractedFiles(
|
||||
IApplicationPaths appPaths,
|
||||
ILibraryManager libraryManager,
|
||||
ILogger<MoveExtractedFiles> logger,
|
||||
IMediaSourceManager mediaSourceManager,
|
||||
IPathManager pathManager)
|
||||
{
|
||||
_appPaths = appPaths;
|
||||
_libraryManager = libraryManager;
|
||||
_logger = logger;
|
||||
_mediaSourceManager = mediaSourceManager;
|
||||
_pathManager = pathManager;
|
||||
}
|
||||
|
||||
private string SubtitleCachePath => Path.Combine(_appPaths.DataPath, "subtitles");
|
||||
|
||||
private string AttachmentCachePath => Path.Combine(_appPaths.DataPath, "attachments");
|
||||
|
||||
/// <inheritdoc />
|
||||
public Guid Id => new("9063b0Ef-CFF1-4EDC-9A13-74093681A89B");
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "MoveExtractedFiles";
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool PerformOnNewInstall => false;
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Perform()
|
||||
{
|
||||
const int Limit = 500;
|
||||
int itemCount = 0, offset = 0;
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
var itemsQuery = new InternalItemsQuery
|
||||
{
|
||||
MediaTypes = [MediaType.Video],
|
||||
SourceTypes = [SourceType.Library],
|
||||
IsVirtualItem = false,
|
||||
IsFolder = false,
|
||||
Limit = Limit,
|
||||
StartIndex = offset,
|
||||
EnableTotalRecordCount = true,
|
||||
};
|
||||
|
||||
var records = _libraryManager.GetItemsResult(itemsQuery).TotalRecordCount;
|
||||
_logger.LogInformation("Checking {Count} items for movable extracted files.", records);
|
||||
|
||||
// Make sure directories exist
|
||||
Directory.CreateDirectory(SubtitleCachePath);
|
||||
Directory.CreateDirectory(AttachmentCachePath);
|
||||
|
||||
itemsQuery.EnableTotalRecordCount = false;
|
||||
do
|
||||
{
|
||||
itemsQuery.StartIndex = offset;
|
||||
var result = _libraryManager.GetItemsResult(itemsQuery);
|
||||
|
||||
var items = result.Items;
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (MoveSubtitleAndAttachmentFiles(item))
|
||||
{
|
||||
itemCount++;
|
||||
}
|
||||
}
|
||||
|
||||
offset += Limit;
|
||||
if (offset % 5_000 == 0)
|
||||
{
|
||||
_logger.LogInformation("Checked extracted files for {Count} items in {Time}.", offset, sw.Elapsed);
|
||||
}
|
||||
} while (offset < records);
|
||||
|
||||
_logger.LogInformation("Checked {Checked} items - Moved files for {Items} items in {Time}.", records, itemCount, sw.Elapsed);
|
||||
|
||||
// Get all subdirectories with 1 character names (those are the legacy directories)
|
||||
var subdirectories = Directory.GetDirectories(SubtitleCachePath, "*", SearchOption.AllDirectories).Where(s => s.Length == SubtitleCachePath.Length + 2).ToList();
|
||||
subdirectories.AddRange(Directory.GetDirectories(AttachmentCachePath, "*", SearchOption.AllDirectories).Where(s => s.Length == AttachmentCachePath.Length + 2));
|
||||
|
||||
// Remove all legacy subdirectories
|
||||
foreach (var subdir in subdirectories)
|
||||
{
|
||||
Directory.Delete(subdir, true);
|
||||
}
|
||||
|
||||
// Remove old cache path
|
||||
var attachmentCachePath = Path.Join(_appPaths.CachePath, "attachments");
|
||||
if (Directory.Exists(attachmentCachePath))
|
||||
{
|
||||
Directory.Delete(attachmentCachePath, true);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Cleaned up left over subtitles and attachments.");
|
||||
}
|
||||
|
||||
private bool MoveSubtitleAndAttachmentFiles(BaseItem item)
|
||||
{
|
||||
var mediaStreams = item.GetMediaStreams().Where(s => s.Type == MediaStreamType.Subtitle && !s.IsExternal);
|
||||
var itemIdString = item.Id.ToString("N", CultureInfo.InvariantCulture);
|
||||
var modified = false;
|
||||
foreach (var mediaStream in mediaStreams)
|
||||
{
|
||||
if (mediaStream.Codec is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var mediaStreamIndex = mediaStream.Index;
|
||||
var extension = GetSubtitleExtension(mediaStream.Codec);
|
||||
var oldSubtitleCachePath = GetOldSubtitleCachePath(item.Path, mediaStream.Index, extension);
|
||||
if (string.IsNullOrEmpty(oldSubtitleCachePath) || !File.Exists(oldSubtitleCachePath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var newSubtitleCachePath = _pathManager.GetSubtitlePath(itemIdString, mediaStreamIndex, extension);
|
||||
if (File.Exists(newSubtitleCachePath))
|
||||
{
|
||||
File.Delete(oldSubtitleCachePath);
|
||||
}
|
||||
else
|
||||
{
|
||||
var newDirectory = Path.GetDirectoryName(newSubtitleCachePath);
|
||||
if (newDirectory is not null)
|
||||
{
|
||||
Directory.CreateDirectory(newDirectory);
|
||||
File.Move(oldSubtitleCachePath, newSubtitleCachePath, false);
|
||||
_logger.LogDebug("Moved subtitle {Index} for {Item} from {Source} to {Destination}", mediaStreamIndex, item.Id, oldSubtitleCachePath, newSubtitleCachePath);
|
||||
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var attachments = _mediaSourceManager.GetMediaAttachments(item.Id).Where(a => !string.Equals(a.Codec, "mjpeg", StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
var shouldExtractOneByOne = attachments.Any(a => !string.IsNullOrEmpty(a.FileName)
|
||||
&& (a.FileName.Contains('/', StringComparison.OrdinalIgnoreCase) || a.FileName.Contains('\\', StringComparison.OrdinalIgnoreCase)));
|
||||
foreach (var attachment in attachments)
|
||||
{
|
||||
var attachmentIndex = attachment.Index;
|
||||
var oldAttachmentPath = GetOldAttachmentDataPath(item.Path, attachmentIndex);
|
||||
if (string.IsNullOrEmpty(oldAttachmentPath) || !File.Exists(oldAttachmentPath))
|
||||
{
|
||||
oldAttachmentPath = GetOldAttachmentCachePath(itemIdString, attachment, shouldExtractOneByOne);
|
||||
if (string.IsNullOrEmpty(oldAttachmentPath) || !File.Exists(oldAttachmentPath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
var newAttachmentPath = _pathManager.GetAttachmentPath(itemIdString, attachment.FileName ?? attachmentIndex.ToString(CultureInfo.InvariantCulture));
|
||||
if (File.Exists(newAttachmentPath))
|
||||
{
|
||||
File.Delete(oldAttachmentPath);
|
||||
}
|
||||
else
|
||||
{
|
||||
var newDirectory = Path.GetDirectoryName(newAttachmentPath);
|
||||
if (newDirectory is not null)
|
||||
{
|
||||
Directory.CreateDirectory(newDirectory);
|
||||
File.Move(oldAttachmentPath, newAttachmentPath, false);
|
||||
_logger.LogDebug("Moved attachment {Index} for {Item} from {Source} to {Destination}", attachmentIndex, item.Id, oldAttachmentPath, newAttachmentPath);
|
||||
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return modified;
|
||||
}
|
||||
|
||||
private string? GetOldAttachmentDataPath(string? mediaPath, int attachmentStreamIndex)
|
||||
{
|
||||
if (mediaPath is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string filename;
|
||||
var protocol = _mediaSourceManager.GetPathProtocol(mediaPath);
|
||||
if (protocol == MediaProtocol.File)
|
||||
{
|
||||
DateTime? date;
|
||||
try
|
||||
{
|
||||
date = File.GetLastWriteTimeUtc(mediaPath);
|
||||
}
|
||||
catch (IOException e)
|
||||
{
|
||||
_logger.LogDebug("Skipping attachment at index {Index} for {Path}: {Exception}", attachmentStreamIndex, mediaPath, e.Message);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
filename = (mediaPath + attachmentStreamIndex.ToString(CultureInfo.InvariantCulture) + "_" + date.Value.Ticks.ToString(CultureInfo.InvariantCulture)).GetMD5().ToString("D", CultureInfo.InvariantCulture);
|
||||
}
|
||||
else
|
||||
{
|
||||
filename = (mediaPath + attachmentStreamIndex.ToString(CultureInfo.InvariantCulture)).GetMD5().ToString("D", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
return Path.Join(_appPaths.DataPath, "attachments", filename[..1], filename);
|
||||
}
|
||||
|
||||
private string? GetOldAttachmentCachePath(string mediaSourceId, MediaAttachment attachment, bool shouldExtractOneByOne)
|
||||
{
|
||||
var attachmentFolderPath = Path.Join(_appPaths.CachePath, "attachments", mediaSourceId);
|
||||
if (shouldExtractOneByOne)
|
||||
{
|
||||
return Path.Join(attachmentFolderPath, attachment.Index.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(attachment.FileName))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return Path.Join(attachmentFolderPath, attachment.FileName);
|
||||
}
|
||||
|
||||
private string? GetOldSubtitleCachePath(string path, int streamIndex, string outputSubtitleExtension)
|
||||
{
|
||||
DateTime? date;
|
||||
try
|
||||
{
|
||||
date = File.GetLastWriteTimeUtc(path);
|
||||
}
|
||||
catch (IOException e)
|
||||
{
|
||||
_logger.LogDebug("Skipping subtitle at index {Index} for {Path}: {Exception}", streamIndex, path, e.Message);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
var ticksParam = string.Empty;
|
||||
ReadOnlySpan<char> filename = new Guid(MD5.HashData(Encoding.Unicode.GetBytes(path + "_" + streamIndex.ToString(CultureInfo.InvariantCulture) + "_" + date.Value.Ticks.ToString(CultureInfo.InvariantCulture) + ticksParam))) + outputSubtitleExtension;
|
||||
|
||||
return Path.Join(SubtitleCachePath, filename[..1], filename);
|
||||
}
|
||||
|
||||
private static string GetSubtitleExtension(string codec)
|
||||
{
|
||||
if (codec.ToLower(CultureInfo.InvariantCulture).Equals("ass", StringComparison.OrdinalIgnoreCase)
|
||||
|| codec.ToLower(CultureInfo.InvariantCulture).Equals("ssa", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "." + codec;
|
||||
}
|
||||
else if (codec.Contains("pgs", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ".sup";
|
||||
}
|
||||
else
|
||||
{
|
||||
return ".srt";
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,4 +1,5 @@
|
|||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Model.Dto;
|
||||
|
||||
namespace MediaBrowser.Controller.IO;
|
||||
|
||||
|
@ -14,4 +15,35 @@ public interface IPathManager
|
|||
/// <param name="saveWithMedia">Whether or not the tile should be saved next to the media file.</param>
|
||||
/// <returns>The absolute path.</returns>
|
||||
public string GetTrickplayDirectory(BaseItem item, bool saveWithMedia = false);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the path to the subtitle file.
|
||||
/// </summary>
|
||||
/// <param name="mediaSourceId">The media source id.</param>
|
||||
/// <param name="streamIndex">The stream index.</param>
|
||||
/// <param name="extension">The subtitle file extension.</param>
|
||||
/// <returns>The absolute path.</returns>
|
||||
public string GetSubtitlePath(string mediaSourceId, int streamIndex, string extension);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the path to the subtitle file.
|
||||
/// </summary>
|
||||
/// <param name="mediaSourceId">The media source id.</param>
|
||||
/// <returns>The absolute path.</returns>
|
||||
public string GetSubtitleFolderPath(string mediaSourceId);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the path to the attachment file.
|
||||
/// </summary>
|
||||
/// <param name="mediaSourceId">The media source id.</param>
|
||||
/// <param name="fileName">The attachmentFileName index.</param>
|
||||
/// <returns>The absolute path.</returns>
|
||||
public string GetAttachmentPath(string mediaSourceId, string fileName);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the path to the attachment folder.
|
||||
/// </summary>
|
||||
/// <param name="mediaSourceId">The media source id.</param>
|
||||
/// <returns>The absolute path.</returns>
|
||||
public string GetAttachmentFolderPath(string mediaSourceId);
|
||||
}
|
||||
|
|
|
@ -19,6 +19,7 @@ using Jellyfin.Database.Implementations.Enums;
|
|||
using Jellyfin.Extensions;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Controller.Extensions;
|
||||
using MediaBrowser.Controller.IO;
|
||||
using MediaBrowser.Model.Configuration;
|
||||
using MediaBrowser.Model.Dlna;
|
||||
using MediaBrowser.Model.Dto;
|
||||
|
@ -55,6 +56,7 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||
private readonly ISubtitleEncoder _subtitleEncoder;
|
||||
private readonly IConfiguration _config;
|
||||
private readonly IConfigurationManager _configurationManager;
|
||||
private readonly IPathManager _pathManager;
|
||||
|
||||
// i915 hang was fixed by linux 6.2 (3f882f2)
|
||||
private readonly Version _minKerneli915Hang = new Version(5, 18);
|
||||
|
@ -153,13 +155,15 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||
IMediaEncoder mediaEncoder,
|
||||
ISubtitleEncoder subtitleEncoder,
|
||||
IConfiguration config,
|
||||
IConfigurationManager configurationManager)
|
||||
IConfigurationManager configurationManager,
|
||||
IPathManager pathManager)
|
||||
{
|
||||
_appPaths = appPaths;
|
||||
_mediaEncoder = mediaEncoder;
|
||||
_subtitleEncoder = subtitleEncoder;
|
||||
_config = config;
|
||||
_configurationManager = configurationManager;
|
||||
_pathManager = pathManager;
|
||||
}
|
||||
|
||||
private enum DynamicHdrMetadataRemovalPlan
|
||||
|
@ -1785,7 +1789,7 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||
var alphaParam = enableAlpha ? ":alpha=1" : string.Empty;
|
||||
var sub2videoParam = enableSub2video ? ":sub2video=1" : string.Empty;
|
||||
|
||||
var fontPath = Path.Combine(_appPaths.CachePath, "attachments", state.MediaSource.Id);
|
||||
var fontPath = _pathManager.GetAttachmentFolderPath(state.MediaSource.Id);
|
||||
var fontParam = string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
":fontsdir='{0}'",
|
||||
|
|
|
@ -9,26 +9,33 @@ using MediaBrowser.Controller.Entities;
|
|||
using MediaBrowser.Model.Dto;
|
||||
using MediaBrowser.Model.Entities;
|
||||
|
||||
namespace MediaBrowser.Controller.MediaEncoding
|
||||
namespace MediaBrowser.Controller.MediaEncoding;
|
||||
|
||||
public interface IAttachmentExtractor
|
||||
{
|
||||
public interface IAttachmentExtractor
|
||||
{
|
||||
Task<(MediaAttachment Attachment, Stream Stream)> GetAttachment(
|
||||
BaseItem item,
|
||||
string mediaSourceId,
|
||||
int attachmentStreamIndex,
|
||||
CancellationToken cancellationToken);
|
||||
/// <summary>
|
||||
/// Gets the path to the attachment file.
|
||||
/// </summary>
|
||||
/// <param name="item">The <see cref="BaseItem"/>.</param>
|
||||
/// <param name="mediaSourceId">The media source id.</param>
|
||||
/// <param name="attachmentStreamIndex">The attachment index.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>The async task.</returns>
|
||||
Task<(MediaAttachment Attachment, Stream Stream)> GetAttachment(
|
||||
BaseItem item,
|
||||
string mediaSourceId,
|
||||
int attachmentStreamIndex,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task ExtractAllAttachments(
|
||||
string inputFile,
|
||||
MediaSourceInfo mediaSource,
|
||||
string outputPath,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task ExtractAllAttachmentsExternal(
|
||||
string inputArgument,
|
||||
string id,
|
||||
string outputPath,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
/// <summary>
|
||||
/// Gets the path to the attachment file.
|
||||
/// </summary>
|
||||
/// <param name="inputFile">The input file path.</param>
|
||||
/// <param name="mediaSource">The <see cref="MediaSourceInfo" /> source id.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>The async task.</returns>
|
||||
Task ExtractAllAttachments(
|
||||
string inputFile,
|
||||
MediaSourceInfo mediaSource,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
|
|
@ -1,7 +1,4 @@
|
|||
#pragma warning disable CS1591
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
|
@ -9,28 +6,27 @@ using System.Linq;
|
|||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AsyncKeyedLock;
|
||||
using MediaBrowser.Common;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Common.Extensions;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.IO;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.MediaEncoding;
|
||||
using MediaBrowser.MediaEncoding.Encoder;
|
||||
using MediaBrowser.Model.Dto;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.IO;
|
||||
using MediaBrowser.Model.MediaInfo;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace MediaBrowser.MediaEncoding.Attachments
|
||||
{
|
||||
/// <inheritdoc cref="IAttachmentExtractor"/>
|
||||
public sealed class AttachmentExtractor : IAttachmentExtractor, IDisposable
|
||||
{
|
||||
private readonly ILogger<AttachmentExtractor> _logger;
|
||||
private readonly IApplicationPaths _appPaths;
|
||||
private readonly IFileSystem _fileSystem;
|
||||
private readonly IMediaEncoder _mediaEncoder;
|
||||
private readonly IMediaSourceManager _mediaSourceManager;
|
||||
private readonly IPathManager _pathManager;
|
||||
|
||||
private readonly AsyncKeyedLocker<string> _semaphoreLocks = new(o =>
|
||||
{
|
||||
|
@ -38,18 +34,26 @@ namespace MediaBrowser.MediaEncoding.Attachments
|
|||
o.PoolInitialFill = 1;
|
||||
});
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AttachmentExtractor"/> class.
|
||||
/// </summary>
|
||||
/// <param name="logger">The <see cref="ILogger{AttachmentExtractor}"/>.</param>
|
||||
/// <param name="fileSystem">The <see cref="IFileSystem"/>.</param>
|
||||
/// <param name="mediaEncoder">The <see cref="IMediaEncoder"/>.</param>
|
||||
/// <param name="mediaSourceManager">The <see cref="IMediaSourceManager"/>.</param>
|
||||
/// <param name="pathManager">The <see cref="IPathManager"/>.</param>
|
||||
public AttachmentExtractor(
|
||||
ILogger<AttachmentExtractor> logger,
|
||||
IApplicationPaths appPaths,
|
||||
IFileSystem fileSystem,
|
||||
IMediaEncoder mediaEncoder,
|
||||
IMediaSourceManager mediaSourceManager)
|
||||
IMediaSourceManager mediaSourceManager,
|
||||
IPathManager pathManager)
|
||||
{
|
||||
_logger = logger;
|
||||
_appPaths = appPaths;
|
||||
_fileSystem = fileSystem;
|
||||
_mediaEncoder = mediaEncoder;
|
||||
_mediaSourceManager = mediaSourceManager;
|
||||
_pathManager = pathManager;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
@ -77,157 +81,151 @@ namespace MediaBrowser.MediaEncoding.Attachments
|
|||
throw new ResourceNotFoundException($"MediaSource {mediaSourceId} has no attachment with stream index {attachmentStreamIndex}");
|
||||
}
|
||||
|
||||
if (string.Equals(mediaAttachment.Codec, "mjpeg", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new ResourceNotFoundException($"Attachment with stream index {attachmentStreamIndex} can't be extracted for MediaSource {mediaSourceId}");
|
||||
}
|
||||
|
||||
var attachmentStream = await GetAttachmentStream(mediaSource, mediaAttachment, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return (mediaAttachment, attachmentStream);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task ExtractAllAttachments(
|
||||
string inputFile,
|
||||
MediaSourceInfo mediaSource,
|
||||
string outputPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var shouldExtractOneByOne = mediaSource.MediaAttachments.Any(a => !string.IsNullOrEmpty(a.FileName)
|
||||
&& (a.FileName.Contains('/', StringComparison.OrdinalIgnoreCase) || a.FileName.Contains('\\', StringComparison.OrdinalIgnoreCase)));
|
||||
if (shouldExtractOneByOne)
|
||||
if (shouldExtractOneByOne && !inputFile.EndsWith(".mks", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var attachmentIndexes = mediaSource.MediaAttachments.Select(a => a.Index);
|
||||
foreach (var i in attachmentIndexes)
|
||||
foreach (var attachment in mediaSource.MediaAttachments)
|
||||
{
|
||||
var newName = Path.Join(outputPath, i.ToString(CultureInfo.InvariantCulture));
|
||||
await ExtractAttachment(inputFile, mediaSource, i, newName, cancellationToken).ConfigureAwait(false);
|
||||
if (!string.Equals(attachment.Codec, "mjpeg", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
await ExtractAttachment(inputFile, mediaSource, attachment, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
using (await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
if (!Directory.Exists(outputPath))
|
||||
{
|
||||
await ExtractAllAttachmentsInternal(
|
||||
_mediaEncoder.GetInputArgument(inputFile, mediaSource),
|
||||
outputPath,
|
||||
false,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task ExtractAllAttachmentsExternal(
|
||||
string inputArgument,
|
||||
string id,
|
||||
string outputPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using (await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
if (!File.Exists(Path.Join(outputPath, id)))
|
||||
{
|
||||
await ExtractAllAttachmentsInternal(
|
||||
inputArgument,
|
||||
outputPath,
|
||||
true,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (Directory.Exists(outputPath))
|
||||
{
|
||||
File.Create(Path.Join(outputPath, id));
|
||||
}
|
||||
}
|
||||
await ExtractAllAttachmentsInternal(
|
||||
inputFile,
|
||||
mediaSource,
|
||||
false,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ExtractAllAttachmentsInternal(
|
||||
string inputPath,
|
||||
string outputPath,
|
||||
string inputFile,
|
||||
MediaSourceInfo mediaSource,
|
||||
bool isExternal,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var inputPath = _mediaEncoder.GetInputArgument(inputFile, mediaSource);
|
||||
|
||||
ArgumentException.ThrowIfNullOrEmpty(inputPath);
|
||||
ArgumentException.ThrowIfNullOrEmpty(outputPath);
|
||||
|
||||
Directory.CreateDirectory(outputPath);
|
||||
|
||||
var processArgs = string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"-dump_attachment:t \"\" -y {0} -i {1} -t 0 -f null null",
|
||||
inputPath.EndsWith(".concat\"", StringComparison.OrdinalIgnoreCase) ? "-f concat -safe 0" : string.Empty,
|
||||
inputPath);
|
||||
|
||||
int exitCode;
|
||||
|
||||
using (var process = new Process
|
||||
{
|
||||
StartInfo = new ProcessStartInfo
|
||||
{
|
||||
Arguments = processArgs,
|
||||
FileName = _mediaEncoder.EncoderPath,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
WindowStyle = ProcessWindowStyle.Hidden,
|
||||
WorkingDirectory = outputPath,
|
||||
ErrorDialog = false
|
||||
},
|
||||
EnableRaisingEvents = true
|
||||
})
|
||||
var outputFolder = _pathManager.GetAttachmentFolderPath(mediaSource.Id);
|
||||
using (await _semaphoreLocks.LockAsync(outputFolder, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
_logger.LogInformation("{File} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments);
|
||||
|
||||
process.Start();
|
||||
|
||||
try
|
||||
if (!Directory.Exists(outputFolder))
|
||||
{
|
||||
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
|
||||
exitCode = process.ExitCode;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
process.Kill(true);
|
||||
exitCode = -1;
|
||||
}
|
||||
}
|
||||
|
||||
var failed = false;
|
||||
|
||||
if (exitCode != 0)
|
||||
{
|
||||
if (isExternal && exitCode == 1)
|
||||
{
|
||||
// ffmpeg returns exitCode 1 because there is no video or audio stream
|
||||
// this can be ignored
|
||||
Directory.CreateDirectory(outputFolder);
|
||||
}
|
||||
else
|
||||
{
|
||||
failed = true;
|
||||
|
||||
_logger.LogWarning("Deleting extracted attachments {Path} due to failure: {ExitCode}", outputPath, exitCode);
|
||||
try
|
||||
var fileNames = Directory.GetFiles(outputFolder, "*", SearchOption.TopDirectoryOnly).Select(f => Path.GetFileName(f));
|
||||
var missingFiles = mediaSource.MediaAttachments.Where(a => !fileNames.Contains(a.FileName) && !string.Equals(a.Codec, "mjpeg", StringComparison.OrdinalIgnoreCase));
|
||||
if (!missingFiles.Any())
|
||||
{
|
||||
Directory.Delete(outputPath);
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error deleting extracted attachments {Path}", outputPath);
|
||||
// Skip extraction if all files already exist
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (!Directory.Exists(outputPath))
|
||||
{
|
||||
failed = true;
|
||||
}
|
||||
|
||||
if (failed)
|
||||
{
|
||||
_logger.LogError("ffmpeg attachment extraction failed for {InputPath} to {OutputPath}", inputPath, outputPath);
|
||||
var processArgs = string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"-dump_attachment:t \"\" -y {0} -i {1} -t 0 -f null null",
|
||||
inputPath.EndsWith(".concat\"", StringComparison.OrdinalIgnoreCase) ? "-f concat -safe 0" : string.Empty,
|
||||
inputPath);
|
||||
|
||||
throw new InvalidOperationException(
|
||||
string.Format(CultureInfo.InvariantCulture, "ffmpeg attachment extraction failed for {0} to {1}", inputPath, outputPath));
|
||||
int exitCode;
|
||||
|
||||
using (var process = new Process
|
||||
{
|
||||
StartInfo = new ProcessStartInfo
|
||||
{
|
||||
Arguments = processArgs,
|
||||
FileName = _mediaEncoder.EncoderPath,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
WindowStyle = ProcessWindowStyle.Hidden,
|
||||
WorkingDirectory = outputFolder,
|
||||
ErrorDialog = false
|
||||
},
|
||||
EnableRaisingEvents = true
|
||||
})
|
||||
{
|
||||
_logger.LogInformation("{File} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments);
|
||||
|
||||
process.Start();
|
||||
|
||||
try
|
||||
{
|
||||
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
|
||||
exitCode = process.ExitCode;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
process.Kill(true);
|
||||
exitCode = -1;
|
||||
}
|
||||
}
|
||||
|
||||
var failed = false;
|
||||
|
||||
if (exitCode != 0)
|
||||
{
|
||||
if (isExternal && exitCode == 1)
|
||||
{
|
||||
// ffmpeg returns exitCode 1 because there is no video or audio stream
|
||||
// this can be ignored
|
||||
}
|
||||
else
|
||||
{
|
||||
failed = true;
|
||||
|
||||
_logger.LogWarning("Deleting extracted attachments {Path} due to failure: {ExitCode}", outputFolder, exitCode);
|
||||
try
|
||||
{
|
||||
Directory.Delete(outputFolder);
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error deleting extracted attachments {Path}", outputFolder);
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (!Directory.Exists(outputFolder))
|
||||
{
|
||||
failed = true;
|
||||
}
|
||||
|
||||
if (failed)
|
||||
{
|
||||
_logger.LogError("ffmpeg attachment extraction failed for {InputPath} to {OutputPath}", inputPath, outputFolder);
|
||||
|
||||
throw new InvalidOperationException(
|
||||
string.Format(CultureInfo.InvariantCulture, "ffmpeg attachment extraction failed for {0} to {1}", inputPath, outputFolder));
|
||||
}
|
||||
|
||||
_logger.LogInformation("ffmpeg attachment extraction completed for {InputPath} to {OutputPath}", inputPath, outputFolder);
|
||||
}
|
||||
|
||||
_logger.LogInformation("ffmpeg attachment extraction completed for {InputPath} to {OutputPath}", inputPath, outputPath);
|
||||
}
|
||||
|
||||
private async Task<Stream> GetAttachmentStream(
|
||||
|
@ -235,192 +233,31 @@ namespace MediaBrowser.MediaEncoding.Attachments
|
|||
MediaAttachment mediaAttachment,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var attachmentPath = await GetReadableFile(mediaSource.Path, mediaSource.Path, mediaSource, mediaAttachment, cancellationToken).ConfigureAwait(false);
|
||||
var attachmentPath = await ExtractAttachment(mediaSource.Path, mediaSource, mediaAttachment, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return AsyncFile.OpenRead(attachmentPath);
|
||||
}
|
||||
|
||||
private async Task<string> GetReadableFile(
|
||||
string mediaPath,
|
||||
private async Task<string> ExtractAttachment(
|
||||
string inputFile,
|
||||
MediaSourceInfo mediaSource,
|
||||
MediaAttachment mediaAttachment,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await CacheAllAttachments(mediaPath, inputFile, mediaSource, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var outputPath = GetAttachmentCachePath(mediaPath, mediaSource, mediaAttachment.Index);
|
||||
await ExtractAttachment(inputFile, mediaSource, mediaAttachment.Index, outputPath, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return outputPath;
|
||||
}
|
||||
|
||||
private async Task CacheAllAttachments(
|
||||
string mediaPath,
|
||||
string inputFile,
|
||||
MediaSourceInfo mediaSource,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var outputFileLocks = new List<IDisposable>();
|
||||
var extractableAttachmentIds = new List<int>();
|
||||
|
||||
try
|
||||
var attachmentFolderPath = _pathManager.GetAttachmentFolderPath(mediaSource.Id);
|
||||
using (await _semaphoreLocks.LockAsync(attachmentFolderPath, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
foreach (var attachment in mediaSource.MediaAttachments)
|
||||
{
|
||||
var outputPath = GetAttachmentCachePath(mediaPath, mediaSource, attachment.Index);
|
||||
|
||||
var releaser = await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (File.Exists(outputPath))
|
||||
{
|
||||
releaser.Dispose();
|
||||
continue;
|
||||
}
|
||||
|
||||
outputFileLocks.Add(releaser);
|
||||
extractableAttachmentIds.Add(attachment.Index);
|
||||
}
|
||||
|
||||
if (extractableAttachmentIds.Count > 0)
|
||||
{
|
||||
await CacheAllAttachmentsInternal(mediaPath, _mediaEncoder.GetInputArgument(inputFile, mediaSource), mediaSource, extractableAttachmentIds, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Unable to cache media attachments for File:{File}", mediaPath);
|
||||
}
|
||||
finally
|
||||
{
|
||||
outputFileLocks.ForEach(x => x.Dispose());
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CacheAllAttachmentsInternal(
|
||||
string mediaPath,
|
||||
string inputFile,
|
||||
MediaSourceInfo mediaSource,
|
||||
List<int> extractableAttachmentIds,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var outputPaths = new List<string>();
|
||||
var processArgs = string.Empty;
|
||||
|
||||
foreach (var attachmentId in extractableAttachmentIds)
|
||||
{
|
||||
var outputPath = GetAttachmentCachePath(mediaPath, mediaSource, attachmentId);
|
||||
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(outputPath) ?? throw new FileNotFoundException($"Calculated path ({outputPath}) is not valid."));
|
||||
|
||||
outputPaths.Add(outputPath);
|
||||
processArgs += string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
" -dump_attachment:{0} \"{1}\"",
|
||||
attachmentId,
|
||||
EncodingUtils.NormalizePath(outputPath));
|
||||
}
|
||||
|
||||
processArgs += string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
" -i {0} -t 0 -f null null",
|
||||
inputFile);
|
||||
|
||||
int exitCode;
|
||||
|
||||
using (var process = new Process
|
||||
{
|
||||
StartInfo = new ProcessStartInfo
|
||||
{
|
||||
Arguments = processArgs,
|
||||
FileName = _mediaEncoder.EncoderPath,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
WindowStyle = ProcessWindowStyle.Hidden,
|
||||
ErrorDialog = false
|
||||
},
|
||||
EnableRaisingEvents = true
|
||||
})
|
||||
{
|
||||
_logger.LogInformation("{File} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments);
|
||||
|
||||
process.Start();
|
||||
|
||||
try
|
||||
{
|
||||
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
|
||||
exitCode = process.ExitCode;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
process.Kill(true);
|
||||
exitCode = -1;
|
||||
}
|
||||
}
|
||||
|
||||
var failed = false;
|
||||
|
||||
if (exitCode == -1)
|
||||
{
|
||||
failed = true;
|
||||
|
||||
foreach (var outputPath in outputPaths)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogWarning("Deleting extracted media attachment due to failure: {Path}", outputPath);
|
||||
_fileSystem.DeleteFile(outputPath);
|
||||
}
|
||||
catch (FileNotFoundException)
|
||||
{
|
||||
// ffmpeg failed, so it is normal that one or more expected output files do not exist.
|
||||
// There is no need to log anything for the user here.
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error deleting extracted media attachment {Path}", outputPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var outputPath in outputPaths)
|
||||
{
|
||||
if (!File.Exists(outputPath))
|
||||
{
|
||||
_logger.LogError("ffmpeg media attachment extraction failed for {InputPath} to {OutputPath}", inputFile, outputPath);
|
||||
failed = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.LogInformation("ffmpeg media attachment extraction completed for {InputPath} to {OutputPath}", inputFile, outputPath);
|
||||
}
|
||||
}
|
||||
|
||||
if (failed)
|
||||
{
|
||||
throw new FfmpegException(
|
||||
string.Format(CultureInfo.InvariantCulture, "ffmpeg media attachment extraction failed for {0}", inputFile));
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ExtractAttachment(
|
||||
string inputFile,
|
||||
MediaSourceInfo mediaSource,
|
||||
int attachmentStreamIndex,
|
||||
string outputPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using (await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
if (!File.Exists(outputPath))
|
||||
var attachmentPath = _pathManager.GetAttachmentPath(mediaSource.Id, mediaAttachment.FileName ?? mediaAttachment.Index.ToString(CultureInfo.InvariantCulture));
|
||||
if (!File.Exists(attachmentPath))
|
||||
{
|
||||
await ExtractAttachmentInternal(
|
||||
_mediaEncoder.GetInputArgument(inputFile, mediaSource),
|
||||
attachmentStreamIndex,
|
||||
outputPath,
|
||||
mediaAttachment.Index,
|
||||
attachmentPath,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return attachmentPath;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -510,23 +347,6 @@ namespace MediaBrowser.MediaEncoding.Attachments
|
|||
_logger.LogInformation("ffmpeg attachment extraction completed for {InputPath} to {OutputPath}", inputPath, outputPath);
|
||||
}
|
||||
|
||||
private string GetAttachmentCachePath(string mediaPath, MediaSourceInfo mediaSource, int attachmentStreamIndex)
|
||||
{
|
||||
string filename;
|
||||
if (mediaSource.Protocol == MediaProtocol.File)
|
||||
{
|
||||
var date = _fileSystem.GetLastWriteTimeUtc(mediaPath);
|
||||
filename = (mediaPath + attachmentStreamIndex.ToString(CultureInfo.InvariantCulture) + "_" + date.Ticks.ToString(CultureInfo.InvariantCulture)).GetMD5().ToString("D", CultureInfo.InvariantCulture);
|
||||
}
|
||||
else
|
||||
{
|
||||
filename = (mediaPath + attachmentStreamIndex.ToString(CultureInfo.InvariantCulture)).GetMD5().ToString("D", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
var prefix = filename.AsSpan(0, 1);
|
||||
return Path.Join(_appPaths.DataPath, "attachments", prefix, filename);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
|
|
|
@ -13,10 +13,10 @@ using System.Threading;
|
|||
using System.Threading.Tasks;
|
||||
using AsyncKeyedLock;
|
||||
using MediaBrowser.Common;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Common.Extensions;
|
||||
using MediaBrowser.Common.Net;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.IO;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.MediaEncoding;
|
||||
using MediaBrowser.Model.Dto;
|
||||
|
@ -31,12 +31,12 @@ namespace MediaBrowser.MediaEncoding.Subtitles
|
|||
public sealed class SubtitleEncoder : ISubtitleEncoder, IDisposable
|
||||
{
|
||||
private readonly ILogger<SubtitleEncoder> _logger;
|
||||
private readonly IApplicationPaths _appPaths;
|
||||
private readonly IFileSystem _fileSystem;
|
||||
private readonly IMediaEncoder _mediaEncoder;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly IMediaSourceManager _mediaSourceManager;
|
||||
private readonly ISubtitleParser _subtitleParser;
|
||||
private readonly IPathManager _pathManager;
|
||||
|
||||
/// <summary>
|
||||
/// The _semaphoreLocks.
|
||||
|
@ -49,24 +49,22 @@ namespace MediaBrowser.MediaEncoding.Subtitles
|
|||
|
||||
public SubtitleEncoder(
|
||||
ILogger<SubtitleEncoder> logger,
|
||||
IApplicationPaths appPaths,
|
||||
IFileSystem fileSystem,
|
||||
IMediaEncoder mediaEncoder,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IMediaSourceManager mediaSourceManager,
|
||||
ISubtitleParser subtitleParser)
|
||||
ISubtitleParser subtitleParser,
|
||||
IPathManager pathManager)
|
||||
{
|
||||
_logger = logger;
|
||||
_appPaths = appPaths;
|
||||
_fileSystem = fileSystem;
|
||||
_mediaEncoder = mediaEncoder;
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_mediaSourceManager = mediaSourceManager;
|
||||
_subtitleParser = subtitleParser;
|
||||
_pathManager = pathManager;
|
||||
}
|
||||
|
||||
private string SubtitleCachePath => Path.Combine(_appPaths.DataPath, "subtitles");
|
||||
|
||||
private MemoryStream ConvertSubtitles(
|
||||
Stream stream,
|
||||
string inputFormat,
|
||||
|
@ -830,26 +828,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
|
|||
|
||||
private string GetSubtitleCachePath(MediaSourceInfo mediaSource, int subtitleStreamIndex, string outputSubtitleExtension)
|
||||
{
|
||||
if (mediaSource.Protocol == MediaProtocol.File)
|
||||
{
|
||||
var ticksParam = string.Empty;
|
||||
|
||||
var date = _fileSystem.GetLastWriteTimeUtc(mediaSource.Path);
|
||||
|
||||
ReadOnlySpan<char> filename = (mediaSource.Path + "_" + subtitleStreamIndex.ToString(CultureInfo.InvariantCulture) + "_" + date.Ticks.ToString(CultureInfo.InvariantCulture) + ticksParam).GetMD5() + outputSubtitleExtension;
|
||||
|
||||
var prefix = filename.Slice(0, 1);
|
||||
|
||||
return Path.Join(SubtitleCachePath, prefix, filename);
|
||||
}
|
||||
else
|
||||
{
|
||||
ReadOnlySpan<char> filename = (mediaSource.Path + "_" + subtitleStreamIndex.ToString(CultureInfo.InvariantCulture)).GetMD5() + outputSubtitleExtension;
|
||||
|
||||
var prefix = filename.Slice(0, 1);
|
||||
|
||||
return Path.Join(SubtitleCachePath, prefix, filename);
|
||||
}
|
||||
return _pathManager.GetSubtitlePath(mediaSource.Id, subtitleStreamIndex, outputSubtitleExtension);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
|
|
@ -398,24 +398,19 @@ public sealed class TranscodeManager : ITranscodeManager, IDisposable
|
|||
// If subtitles get burned in fonts may need to be extracted from the media file
|
||||
if (state.SubtitleStream is not null && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode)
|
||||
{
|
||||
var attachmentPath = Path.Combine(_appPaths.CachePath, "attachments", state.MediaSource.Id);
|
||||
if (state.MediaSource.VideoType == VideoType.Dvd || state.MediaSource.VideoType == VideoType.BluRay)
|
||||
{
|
||||
var concatPath = Path.Join(_appPaths.CachePath, "concat", state.MediaSource.Id + ".concat");
|
||||
await _attachmentExtractor.ExtractAllAttachments(concatPath, state.MediaSource, attachmentPath, cancellationTokenSource.Token).ConfigureAwait(false);
|
||||
await _attachmentExtractor.ExtractAllAttachments(concatPath, state.MediaSource, cancellationTokenSource.Token).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
await _attachmentExtractor.ExtractAllAttachments(state.MediaPath, state.MediaSource, attachmentPath, cancellationTokenSource.Token).ConfigureAwait(false);
|
||||
await _attachmentExtractor.ExtractAllAttachments(state.MediaPath, state.MediaSource, cancellationTokenSource.Token).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (state.SubtitleStream.IsExternal && Path.GetExtension(state.SubtitleStream.Path.AsSpan()).Equals(".mks", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
string subtitlePath = state.SubtitleStream.Path;
|
||||
string subtitlePathArgument = string.Format(CultureInfo.InvariantCulture, "file:\"{0}\"", subtitlePath.Replace("\"", "\\\"", StringComparison.Ordinal));
|
||||
string subtitleId = subtitlePath.GetMD5().ToString("N", CultureInfo.InvariantCulture);
|
||||
|
||||
await _attachmentExtractor.ExtractAllAttachmentsExternal(subtitlePathArgument, subtitleId, attachmentPath, cancellationTokenSource.Token).ConfigureAwait(false);
|
||||
await _attachmentExtractor.ExtractAllAttachments(state.SubtitleStream.Path, state.MediaSource, cancellationTokenSource.Token).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,51 +1,49 @@
|
|||
#nullable disable
|
||||
namespace MediaBrowser.Model.Entities
|
||||
namespace MediaBrowser.Model.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Class MediaAttachment.
|
||||
/// </summary>
|
||||
public class MediaAttachment
|
||||
{
|
||||
/// <summary>
|
||||
/// Class MediaAttachment.
|
||||
/// Gets or sets the codec.
|
||||
/// </summary>
|
||||
public class MediaAttachment
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the codec.
|
||||
/// </summary>
|
||||
/// <value>The codec.</value>
|
||||
public string Codec { get; set; }
|
||||
/// <value>The codec.</value>
|
||||
public string? Codec { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the codec tag.
|
||||
/// </summary>
|
||||
/// <value>The codec tag.</value>
|
||||
public string CodecTag { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the codec tag.
|
||||
/// </summary>
|
||||
/// <value>The codec tag.</value>
|
||||
public string? CodecTag { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the comment.
|
||||
/// </summary>
|
||||
/// <value>The comment.</value>
|
||||
public string Comment { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the comment.
|
||||
/// </summary>
|
||||
/// <value>The comment.</value>
|
||||
public string? Comment { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the index.
|
||||
/// </summary>
|
||||
/// <value>The index.</value>
|
||||
public int Index { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the index.
|
||||
/// </summary>
|
||||
/// <value>The index.</value>
|
||||
public int Index { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the filename.
|
||||
/// </summary>
|
||||
/// <value>The filename.</value>
|
||||
public string FileName { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the filename.
|
||||
/// </summary>
|
||||
/// <value>The filename.</value>
|
||||
public string? FileName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the MIME type.
|
||||
/// </summary>
|
||||
/// <value>The MIME type.</value>
|
||||
public string MimeType { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the MIME type.
|
||||
/// </summary>
|
||||
/// <value>The MIME type.</value>
|
||||
public string? MimeType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the delivery URL.
|
||||
/// </summary>
|
||||
/// <value>The delivery URL.</value>
|
||||
public string DeliveryUrl { get; set; }
|
||||
}
|
||||
/// <summary>
|
||||
/// Gets or sets the delivery URL.
|
||||
/// </summary>
|
||||
/// <value>The delivery URL.</value>
|
||||
public string? DeliveryUrl { get; set; }
|
||||
}
|
||||
|
|
|
@ -25,7 +25,7 @@ public class AttachmentStreamInfo
|
|||
/// <summary>
|
||||
/// Gets or Sets the codec of the attachment.
|
||||
/// </summary>
|
||||
public required string Codec { get; set; }
|
||||
public string? Codec { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or Sets the codec tag of the attachment.
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -0,0 +1,36 @@
|
|||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Jellyfin.Server.Implementations.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class FixAttachmentMigration : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "Codec",
|
||||
table: "AttachmentStreamInfos",
|
||||
type: "TEXT",
|
||||
nullable: true,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "TEXT");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "Codec",
|
||||
table: "AttachmentStreamInfos",
|
||||
type: "TEXT",
|
||||
nullable: false,
|
||||
defaultValue: string.Empty,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "TEXT",
|
||||
oldNullable: true);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -120,7 +120,6 @@ namespace Jellyfin.Server.Implementations.Migrations
|
|||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Codec")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CodecTag")
|
||||
|
|
Loading…
Add table
Reference in a new issue