mirror of
https://github.com/jellyfin/jellyfin.git
synced 2025-06-27 08:50:20 -04:00
Feature/persistent watch data (#14262)
This commit is contained in:
parent
3554f068fb
commit
d3ad2aec60
10 changed files with 1875 additions and 3 deletions
|
@ -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."
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -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"));
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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");
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue