Feature/persistent watch data (#14262)

This commit is contained in:
JPVenson 2025-06-10 02:14:27 +03:00 committed by GitHub
parent 3554f068fb
commit d3ad2aec60
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 1875 additions and 3 deletions

View file

@ -135,5 +135,7 @@
"TaskExtractMediaSegments": "Media Segment Scan",
"TaskExtractMediaSegmentsDescription": "Extracts or obtains media segments from MediaSegment enabled plugins.",
"TaskMoveTrickplayImages": "Migrate Trickplay Image Location",
"TaskMoveTrickplayImagesDescription": "Moves existing trickplay files according to the library settings."
"TaskMoveTrickplayImagesDescription": "Moves existing trickplay files according to the library settings.",
"CleanupUserDataTask": "User data cleanup task",
"CleanupUserDataTaskDescription": "Cleans all user data (Watch state, favorite status etc) from media that is no longer present for at least 90 days."
}

View file

@ -0,0 +1,77 @@
#pragma warning disable RS0030 // Do not use banned APIs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations;
using Jellyfin.Server.Implementations.Item;
using MediaBrowser.Model.Globalization;
using MediaBrowser.Model.Tasks;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace Emby.Server.Implementations.ScheduledTasks.Tasks;
/// <summary>
/// Task to clean up any detached userdata from the database.
/// </summary>
public class CleanupUserDataTask : IScheduledTask
{
private readonly ILocalizationManager _localization;
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
private readonly ILogger<CleanupUserDataTask> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="CleanupUserDataTask"/> class.
/// </summary>
/// <param name="localization">The localisation Provider.</param>
/// <param name="dbProvider">The DB context factory.</param>
/// <param name="logger">A logger.</param>
public CleanupUserDataTask(ILocalizationManager localization, IDbContextFactory<JellyfinDbContext> dbProvider, ILogger<CleanupUserDataTask> logger)
{
_localization = localization;
_dbProvider = dbProvider;
_logger = logger;
}
/// <inheritdoc />
public string Name => _localization.GetLocalizedString("CleanupUserDataTask");
/// <inheritdoc />
public string Description => _localization.GetLocalizedString("CleanupUserDataTaskDescription");
/// <inheritdoc />
public string Category => _localization.GetLocalizedString("TasksMaintenanceCategory");
/// <inheritdoc />
public string Key => nameof(CleanupUserDataTask);
/// <inheritdoc/>
public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
{
const int LimitDays = 90;
var userDataDate = DateTimeOffset.UtcNow.AddDays(LimitDays * -1);
var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
var detachedUserData = dbContext.UserData.Where(e => e.ItemId == BaseItemRepository.PlaceholderId);
_logger.LogInformation("There are {NoDetached} detached UserData entires.", detachedUserData.Count());
detachedUserData = detachedUserData.Where(e => e.RetentionDate < userDataDate);
_logger.LogInformation("{NoDetached} are older then {Limit} days.", detachedUserData.Count(), LimitDays);
await detachedUserData.ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
}
progress.Report(100);
}
/// <inheritdoc/>
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
{
yield break;
}
}

View file

@ -53,6 +53,11 @@ namespace Jellyfin.Server.Implementations.Item;
public sealed class BaseItemRepository
: IItemRepository
{
/// <summary>
/// Gets the placeholder id for UserData detached items.
/// </summary>
public static readonly Guid PlaceholderId = Guid.Parse("00000000-0000-0000-0000-000000000001");
/// <summary>
/// This holds all the types in the running assemblies
/// so that we can de-serialize properly when we don't have strong types.
@ -102,6 +107,14 @@ public sealed class BaseItemRepository
using var context = _dbProvider.CreateDbContext();
using var transaction = context.Database.BeginTransaction();
var date = (DateTimeOffset?)DateTimeOffset.Now;
// Detach all user watch data
context.UserData.Where(e => e.ItemId == id)
.ExecuteUpdate(e => e
.SetProperty(f => f.RetentionDate, date)
.SetProperty(f => f.ItemId, PlaceholderId));
context.AncestorIds.Where(e => e.ItemId == id || e.ParentItemId == id).ExecuteDelete();
context.AttachmentStreamInfos.Where(e => e.ItemId == id).ExecuteDelete();
context.BaseItemImageInfos.Where(e => e.ItemId == id).ExecuteDelete();
@ -491,6 +504,7 @@ public sealed class BaseItemRepository
var ids = tuples.Select(f => f.Item.Id).ToArray();
var existingItems = context.BaseItems.Where(e => ids.Contains(e.Id)).Select(f => f.Id).ToArray();
var newItems = tuples.Where(e => !existingItems.Contains(e.Item.Id)).ToArray();
foreach (var item in tuples)
{
@ -511,6 +525,19 @@ public sealed class BaseItemRepository
context.SaveChanges();
foreach (var item in newItems)
{
// reattach old userData entries
var userKeys = item.UserDataKey.ToArray();
var retentionDate = (DateTimeOffset?)null;
context.UserData
.Where(e => e.ItemId == PlaceholderId)
.Where(e => userKeys.Contains(e.CustomDataKey))
.ExecuteUpdate(e => e
.SetProperty(f => f.ItemId, item.Item.Id)
.SetProperty(f => f.RetentionDate, retentionDate));
}
var itemValueMaps = tuples
.Select(e => (Item: e.Item, Values: GetItemValuesToSave(e.Item, e.InheritedTags)))
.ToArray();

View file

@ -88,6 +88,9 @@ EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.LiveTv", "src\Jellyfin.LiveTv\Jellyfin.LiveTv.csproj", "{8C6B2B13-58A4-4506-9DAB-1F882A093FE0}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Jellyfin.Database", "Jellyfin.Database", "{4C54CE05-69C8-48FA-8785-39F7F6DB1CAD}"
ProjectSection(SolutionItems) = preProject
src\Jellyfin.Database\readme.md = src\Jellyfin.Database\readme.md
EndProjectSection
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Database.Providers.Sqlite", "src\Jellyfin.Database\Jellyfin.Database.Providers.Sqlite\Jellyfin.Database.Providers.Sqlite.csproj", "{A5590358-33CC-4B39-BDE7-DC62FEB03C76}"
EndProject

View file

@ -68,6 +68,11 @@ public class UserData
/// <value><c>null</c> if [likes] contains no value, <c>true</c> if [likes]; otherwise, <c>false</c>.</value>
public bool? Likes { get; set; }
/// <summary>
/// Gets or Sets the date the referenced <see cref="Item"/> has been deleted.
/// </summary>
public DateTimeOffset? RetentionDate { get; set; }
/// <summary>
/// Gets or sets the key.
/// </summary>

View file

@ -1,3 +1,4 @@
using System;
using Jellyfin.Database.Implementations.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
@ -53,5 +54,12 @@ public class BaseItemConfiguration : IEntityTypeConfiguration<BaseItemEntity>
builder.HasIndex(e => new { e.IsFolder, e.TopParentId, e.IsVirtualItem, e.PresentationUniqueKey, e.DateCreated });
// resume
builder.HasIndex(e => new { e.MediaType, e.TopParentId, e.IsVirtualItem, e.PresentationUniqueKey });
builder.HasData(new BaseItemEntity()
{
Id = Guid.Parse("00000000-0000-0000-0000-000000000001"),
Type = "PLACEHOLDER",
Name = "This is a placeholder item for UserData that has been detacted from its original item",
});
}
}

View file

@ -17,6 +17,6 @@ public class UserDataConfiguration : IEntityTypeConfiguration<UserData>
builder.HasIndex(d => new { d.ItemId, d.UserId, d.PlaybackPositionTicks });
builder.HasIndex(d => new { d.ItemId, d.UserId, d.IsFavorite });
builder.HasIndex(d => new { d.ItemId, d.UserId, d.LastPlayedDate });
builder.HasOne(e => e.Item);
builder.HasOne(e => e.Item).WithMany(e => e.UserData);
}
}

View file

@ -0,0 +1,39 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jellyfin.Server.Implementations.Migrations
{
/// <inheritdoc />
public partial class DetachUserDataInsteadOfDelete : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTimeOffset>(
name: "RetentionDate",
table: "UserData",
type: "TEXT",
nullable: true);
migrationBuilder.InsertData(
table: "BaseItems",
columns: new[] { "Id", "Album", "AlbumArtists", "Artists", "Audio", "ChannelId", "CleanName", "CommunityRating", "CriticRating", "CustomRating", "Data", "DateCreated", "DateLastMediaAdded", "DateLastRefreshed", "DateLastSaved", "DateModified", "EndDate", "EpisodeTitle", "ExternalId", "ExternalSeriesId", "ExternalServiceId", "ExtraIds", "ExtraType", "ForcedSortName", "Genres", "Height", "IndexNumber", "InheritedParentalRatingSubValue", "InheritedParentalRatingValue", "IsFolder", "IsInMixedFolder", "IsLocked", "IsMovie", "IsRepeat", "IsSeries", "IsVirtualItem", "LUFS", "MediaType", "Name", "NormalizationGain", "OfficialRating", "OriginalTitle", "Overview", "OwnerId", "ParentId", "ParentIndexNumber", "Path", "PreferredMetadataCountryCode", "PreferredMetadataLanguage", "PremiereDate", "PresentationUniqueKey", "PrimaryVersionId", "ProductionLocations", "ProductionYear", "RunTimeTicks", "SeasonId", "SeasonName", "SeriesId", "SeriesName", "SeriesPresentationUniqueKey", "ShowId", "Size", "SortName", "StartDate", "Studios", "Tagline", "Tags", "TopParentId", "TotalBitrate", "Type", "UnratedType", "Width" },
values: new object[] { new Guid("00000000-0000-0000-0000-000000000001"), null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, false, false, false, false, false, false, false, null, null, "This is a placeholder item for UserData that has been detacted from its original item", null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, "PLACEHOLDER", null, null });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "RetentionDate",
table: "UserData");
migrationBuilder.DeleteData(
table: "BaseItems",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000001"));
}
}
}

View file

@ -15,7 +15,7 @@ namespace Jellyfin.Server.Implementations.Migrations
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "9.0.3");
modelBuilder.HasAnnotation("ProductVersion", "9.0.5");
modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b =>
{
@ -392,6 +392,21 @@ namespace Jellyfin.Server.Implementations.Migrations
b.ToTable("BaseItems");
b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
b.HasData(
new
{
Id = new Guid("00000000-0000-0000-0000-000000000001"),
IsFolder = false,
IsInMixedFolder = false,
IsLocked = false,
IsMovie = false,
IsRepeat = false,
IsSeries = false,
IsVirtualItem = false,
Name = "This is a placeholder item for UserData that has been detacted from its original item",
Type = "PLACEHOLDER"
});
});
modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemImageInfo", b =>
@ -1373,6 +1388,9 @@ namespace Jellyfin.Server.Implementations.Migrations
b.Property<double?>("Rating")
.HasColumnType("REAL");
b.Property<DateTimeOffset?>("RetentionDate")
.HasColumnType("TEXT");
b.Property<int?>("SubtitleStreamIndex")
.HasColumnType("INTEGER");