using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Database.Implementations.Locking;
using Jellyfin.Database.Providers.Sqlite;
using Jellyfin.Server.Migrations.Routines;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Entities;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace Jellyfin.Server.Tests.Migrations;
///
/// Covers which items the stale file cleanup is allowed to delete. Resolving no library locations at
/// all puts every file-backed item outside every location, so acting on that emptied whole libraries.
///
public sealed class MigrateLinkedChildrenStaleItemsTests : IDisposable
{
private readonly SqliteConnection _connection;
private readonly DbContextOptions _dbOptions;
private readonly IApplicationPaths _applicationPaths;
private readonly string _root;
public MigrateLinkedChildrenStaleItemsTests()
{
_applicationPaths = new Mock().Object;
_root = Path.Combine(Path.GetTempPath(), "jf-stale-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(Path.Combine(_root, "media"));
Directory.CreateDirectory(Path.Combine(_root, "root", "default"));
_connection = new SqliteConnection("Data Source=:memory:");
_connection.Open();
_dbOptions = new DbContextOptionsBuilder()
.UseSqlite(_connection)
.Options;
using var context = CreateDbContext();
context.Database.EnsureCreated();
}
private string LibraryDefinitionRoot => Path.Combine(_root, "root", "default");
[Fact]
public void Perform_NoLibraryLocationsResolved_KeepsEverything()
{
var itemId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
SeedItem(itemId, SeedMediaFile("kept.flac"));
var deleted = Perform(libraryLocations: []);
Assert.Empty(deleted);
}
[Fact]
public void Perform_ItemOutsideEveryLocation_IsRemoved()
{
var itemId = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
// An empty location counts as inaccessible, which disables the removal on purpose, so the
// library has to hold something for this case to be reachable at all.
SeedMediaFile("anchor.flac");
SeedItem(itemId, Path.Combine(_root, "removed-media", "gone.flac"));
var deleted = Perform(libraryLocations: [Path.Combine(_root, "media")]);
Assert.Equal([itemId], deleted);
}
[Fact]
public void Perform_ItemInsideLocationWithMissingFile_IsRemoved()
{
var itemId = Guid.Parse("cccccccc-cccc-cccc-cccc-cccccccccccc");
SeedMediaFile("anchor.flac");
SeedItem(itemId, Path.Combine(_root, "media", "deleted.flac"));
var deleted = Perform(libraryLocations: [Path.Combine(_root, "media")]);
Assert.Equal([itemId], deleted);
}
[Fact]
public void Perform_ItemInsideLocationWithFilePresent_IsKept()
{
var itemId = Guid.Parse("dddddddd-dddd-dddd-dddd-dddddddddddd");
SeedItem(itemId, SeedMediaFile("present.flac"));
var deleted = Perform(libraryLocations: [Path.Combine(_root, "media")]);
Assert.Empty(deleted);
}
[Fact]
public void Perform_LibraryDefinitionMissing_KeepsItsItems()
{
var libraryId = Guid.Parse("11111111-1111-1111-1111-111111111111");
var itemId = Guid.Parse("22222222-2222-2222-2222-222222222222");
// The definition folder of "Movies" is gone, so it contributes no locations and everything it
// held reads as unrooted, while "Media" still resolves and makes the run look actionable.
SeedLibrary(libraryId, "Movies", createDefinitionFolder: false);
SeedMediaFile("anchor.flac");
SeedItem(itemId, Path.Combine(_root, "movies", "kept.mkv"), libraryId);
var deleted = Perform(libraryLocations: [Path.Combine(_root, "media")]);
Assert.Empty(deleted);
}
[Fact]
public void Perform_PathRemovedFromSurvivingLibrary_IsRemoved()
{
var libraryId = Guid.Parse("33333333-3333-3333-3333-333333333333");
var itemId = Guid.Parse("44444444-4444-4444-4444-444444444444");
// The library is still there; only one of its media paths went away, which is what this
// branch exists to clean up.
SeedLibrary(libraryId, "Media", createDefinitionFolder: true);
SeedMediaFile("anchor.flac");
SeedItem(itemId, Path.Combine(_root, "removed-media", "gone.flac"), libraryId);
var deleted = Perform(libraryLocations: [Path.Combine(_root, "media")]);
Assert.Equal([itemId], deleted);
}
public void Dispose()
{
_connection.Dispose();
if (Directory.Exists(_root))
{
Directory.Delete(_root, true);
}
}
private JellyfinDbContext CreateDbContext() => new(
_dbOptions,
NullLogger.Instance,
new SqliteDatabaseProvider(_applicationPaths, NullLogger.Instance),
new NoLockBehavior(NullLogger.Instance));
private void SeedLibrary(Guid id, string name, bool createDefinitionFolder)
{
var path = Path.Combine(LibraryDefinitionRoot, name);
if (createDefinitionFolder)
{
Directory.CreateDirectory(path);
}
using var context = CreateDbContext();
context.BaseItems.Add(new BaseItemEntity
{
Id = id,
Type = "MediaBrowser.Controller.Entities.CollectionFolder",
Name = name,
Path = path,
IsFolder = true
});
context.SaveChanges();
}
private string SeedMediaFile(string name)
{
var path = Path.Combine(_root, "media", name);
File.WriteAllBytes(path, [0]);
return path;
}
private void SeedItem(Guid id, string path, Guid? libraryId = null)
{
using var context = CreateDbContext();
context.BaseItems.Add(new BaseItemEntity
{
Id = id,
Type = typeof(Audio).FullName!,
Name = Path.GetFileNameWithoutExtension(path),
Path = path,
IsFolder = false,
IsVirtualItem = false
});
context.SaveChanges();
if (libraryId.HasValue)
{
context.Database.ExecuteSql(
$"INSERT INTO AncestorIds (ItemId, ParentItemId) VALUES ({id}, {libraryId.Value})");
}
}
private IReadOnlyList Perform(string[] libraryLocations, string libraryName = "Media")
{
var deleted = new List();
var libraryManager = new Mock();
libraryManager
.Setup(l => l.GetVirtualFolders())
.Returns(libraryLocations.Length == 0
? []
: [new VirtualFolderInfo { Name = libraryName, Locations = libraryLocations }]);
libraryManager
.Setup(l => l.GetItemById(It.IsAny()))
.Returns(id => new Audio { Id = id });
libraryManager
.Setup(l => l.DeleteItem(It.IsAny(), It.IsAny()))
.Callback((item, _) => deleted.Add(item.Id));
var appHost = new Mock();
appHost.Setup(h => h.ExpandVirtualPath(It.IsAny())).Returns(p => p);
var appPaths = new Mock();
appPaths.SetupGet(p => p.DataPath).Returns(Path.Combine(_root, "data"));
appPaths.SetupGet(p => p.InternalMetadataPath).Returns(Path.Combine(_root, "metadata"));
appPaths.SetupGet(p => p.DefaultUserViewsPath).Returns(LibraryDefinitionRoot);
var factory = new Mock>();
factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext);
factory.Setup(f => f.CreateDbContextAsync(It.IsAny())).ReturnsAsync(CreateDbContext);
new MigrateLinkedChildren(
NullLoggerFactory.Instance,
factory.Object,
libraryManager.Object,
appHost.Object,
appPaths.Object)
.Perform();
return deleted;
}
}