| | | 1 | | #pragma warning disable RS0030 // Do not use banned APIs |
| | | 2 | | |
| | | 3 | | using System; |
| | | 4 | | using System.Collections.Generic; |
| | | 5 | | using System.Globalization; |
| | | 6 | | using System.Linq; |
| | | 7 | | using Jellyfin.Data.Enums; |
| | | 8 | | using Jellyfin.Database.Implementations; |
| | | 9 | | using Jellyfin.Database.Implementations.Entities; |
| | | 10 | | using Jellyfin.Database.Implementations.Enums; |
| | | 11 | | using Jellyfin.Extensions; |
| | | 12 | | using MediaBrowser.Controller.Entities; |
| | | 13 | | using MediaBrowser.Controller.Persistence; |
| | | 14 | | using MediaBrowser.Model.Dto; |
| | | 15 | | using Microsoft.EntityFrameworkCore; |
| | | 16 | | |
| | | 17 | | namespace Jellyfin.Server.Implementations.Item; |
| | | 18 | | |
| | | 19 | | /// <summary> |
| | | 20 | | /// Provides item counting and played-status query operations. |
| | | 21 | | /// </summary> |
| | | 22 | | public class ItemCountService : IItemCountService |
| | | 23 | | { |
| | | 24 | | private readonly IDbContextFactory<JellyfinDbContext> _dbProvider; |
| | | 25 | | private readonly IItemTypeLookup _itemTypeLookup; |
| | | 26 | | private readonly IItemQueryHelpers _queryHelpers; |
| | | 27 | | |
| | | 28 | | /// <summary> |
| | | 29 | | /// Initializes a new instance of the <see cref="ItemCountService"/> class. |
| | | 30 | | /// </summary> |
| | | 31 | | /// <param name="dbProvider">The database context factory.</param> |
| | | 32 | | /// <param name="itemTypeLookup">The item type lookup.</param> |
| | | 33 | | /// <param name="queryHelpers">The shared query helpers.</param> |
| | | 34 | | public ItemCountService( |
| | | 35 | | IDbContextFactory<JellyfinDbContext> dbProvider, |
| | | 36 | | IItemTypeLookup itemTypeLookup, |
| | | 37 | | IItemQueryHelpers queryHelpers) |
| | | 38 | | { |
| | 50 | 39 | | _dbProvider = dbProvider; |
| | 50 | 40 | | _itemTypeLookup = itemTypeLookup; |
| | 50 | 41 | | _queryHelpers = queryHelpers; |
| | 50 | 42 | | } |
| | | 43 | | |
| | | 44 | | /// <inheritdoc/> |
| | | 45 | | public int GetCount(InternalItemsQuery filter) |
| | | 46 | | { |
| | 0 | 47 | | ArgumentNullException.ThrowIfNull(filter); |
| | 0 | 48 | | _queryHelpers.PrepareFilterQuery(filter); |
| | | 49 | | |
| | 0 | 50 | | using var context = _dbProvider.CreateDbContext(); |
| | 0 | 51 | | var dbQuery = _queryHelpers.TranslateQuery(context.BaseItems.AsNoTracking(), context, filter); |
| | | 52 | | |
| | 0 | 53 | | return dbQuery.Count(); |
| | 0 | 54 | | } |
| | | 55 | | |
| | | 56 | | /// <inheritdoc /> |
| | | 57 | | public ItemCounts GetItemCounts(InternalItemsQuery filter) |
| | | 58 | | { |
| | 0 | 59 | | ArgumentNullException.ThrowIfNull(filter); |
| | 0 | 60 | | _queryHelpers.PrepareFilterQuery(filter); |
| | | 61 | | |
| | 0 | 62 | | using var context = _dbProvider.CreateDbContext(); |
| | 0 | 63 | | var dbQuery = _queryHelpers.TranslateQuery(context.BaseItems.AsNoTracking(), context, filter); |
| | | 64 | | |
| | 0 | 65 | | var counts = dbQuery |
| | 0 | 66 | | .GroupBy(x => x.Type) |
| | 0 | 67 | | .Select(x => new { x.Key, Count = x.Count() }) |
| | 0 | 68 | | .ToArray(); |
| | | 69 | | |
| | 0 | 70 | | var lookup = _itemTypeLookup.BaseItemKindNames; |
| | 0 | 71 | | var result = new ItemCounts |
| | 0 | 72 | | { |
| | 0 | 73 | | ItemCount = counts.Sum(c => c.Count) |
| | 0 | 74 | | }; |
| | 0 | 75 | | foreach (var count in counts) |
| | | 76 | | { |
| | 0 | 77 | | if (string.Equals(count.Key, lookup[BaseItemKind.MusicAlbum], StringComparison.Ordinal)) |
| | | 78 | | { |
| | 0 | 79 | | result.AlbumCount = count.Count; |
| | | 80 | | } |
| | 0 | 81 | | else if (string.Equals(count.Key, lookup[BaseItemKind.MusicArtist], StringComparison.Ordinal)) |
| | | 82 | | { |
| | 0 | 83 | | result.ArtistCount = count.Count; |
| | | 84 | | } |
| | 0 | 85 | | else if (string.Equals(count.Key, lookup[BaseItemKind.Episode], StringComparison.Ordinal)) |
| | | 86 | | { |
| | 0 | 87 | | result.EpisodeCount = count.Count; |
| | | 88 | | } |
| | 0 | 89 | | else if (string.Equals(count.Key, lookup[BaseItemKind.Movie], StringComparison.Ordinal)) |
| | | 90 | | { |
| | 0 | 91 | | result.MovieCount = count.Count; |
| | | 92 | | } |
| | 0 | 93 | | else if (string.Equals(count.Key, lookup[BaseItemKind.MusicVideo], StringComparison.Ordinal)) |
| | | 94 | | { |
| | 0 | 95 | | result.MusicVideoCount = count.Count; |
| | | 96 | | } |
| | 0 | 97 | | else if (string.Equals(count.Key, lookup[BaseItemKind.LiveTvProgram], StringComparison.Ordinal)) |
| | | 98 | | { |
| | 0 | 99 | | result.ProgramCount = count.Count; |
| | | 100 | | } |
| | 0 | 101 | | else if (string.Equals(count.Key, lookup[BaseItemKind.Series], StringComparison.Ordinal)) |
| | | 102 | | { |
| | 0 | 103 | | result.SeriesCount = count.Count; |
| | | 104 | | } |
| | 0 | 105 | | else if (string.Equals(count.Key, lookup[BaseItemKind.Audio], StringComparison.Ordinal)) |
| | | 106 | | { |
| | 0 | 107 | | result.SongCount = count.Count; |
| | | 108 | | } |
| | 0 | 109 | | else if (string.Equals(count.Key, lookup[BaseItemKind.Trailer], StringComparison.Ordinal)) |
| | | 110 | | { |
| | 0 | 111 | | result.TrailerCount = count.Count; |
| | | 112 | | } |
| | 0 | 113 | | else if (string.Equals(count.Key, lookup[BaseItemKind.BoxSet], StringComparison.Ordinal)) |
| | | 114 | | { |
| | 0 | 115 | | result.BoxSetCount = count.Count; |
| | | 116 | | } |
| | 0 | 117 | | else if (string.Equals(count.Key, lookup[BaseItemKind.Book], StringComparison.Ordinal)) |
| | | 118 | | { |
| | 0 | 119 | | result.BookCount = count.Count; |
| | | 120 | | } |
| | | 121 | | } |
| | | 122 | | |
| | 0 | 123 | | return result; |
| | 0 | 124 | | } |
| | | 125 | | |
| | | 126 | | /// <inheritdoc /> |
| | | 127 | | public ItemCounts GetItemCountsForNameItem(BaseItemKind kind, Guid id, BaseItemKind[] relatedItemKinds, InternalItem |
| | | 128 | | { |
| | 20 | 129 | | return GetItemCountsForNameItems(kind, [id], relatedItemKinds, accessFilter)[id]; |
| | | 130 | | } |
| | | 131 | | |
| | | 132 | | private static ItemValueType[] GetItemValueTypes(BaseItemKind kind) |
| | 33 | 133 | | => kind switch |
| | 33 | 134 | | { |
| | 5 | 135 | | BaseItemKind.MusicArtist => [ItemValueType.Artist, ItemValueType.AlbumArtist], |
| | 20 | 136 | | BaseItemKind.Genre or BaseItemKind.MusicGenre => [ItemValueType.Genre], |
| | 0 | 137 | | BaseItemKind.Studio => [ItemValueType.Studios], |
| | 8 | 138 | | _ => [] |
| | 33 | 139 | | }; |
| | | 140 | | |
| | | 141 | | /// <inheritdoc /> |
| | | 142 | | public Dictionary<Guid, ItemCounts> GetItemCountsForNameItems(BaseItemKind kind, IReadOnlyList<Guid> ids, BaseItemKi |
| | | 143 | | { |
| | 34 | 144 | | ArgumentNullException.ThrowIfNull(ids); |
| | 34 | 145 | | ArgumentNullException.ThrowIfNull(relatedItemKinds); |
| | 34 | 146 | | ArgumentNullException.ThrowIfNull(accessFilter); |
| | | 147 | | |
| | 34 | 148 | | var result = new Dictionary<Guid, ItemCounts>(); |
| | 34 | 149 | | if (ids.Count == 0) |
| | | 150 | | { |
| | 0 | 151 | | return result; |
| | | 152 | | } |
| | | 153 | | |
| | 34 | 154 | | using var context = _dbProvider.CreateDbContext(); |
| | | 155 | | |
| | 34 | 156 | | var idsArray = ids as Guid[] ?? ids.ToArray(); |
| | 34 | 157 | | var nameItems = context.BaseItems.AsNoTracking() |
| | 34 | 158 | | .WhereOneOrMany(idsArray, e => e.Id) |
| | 34 | 159 | | .Select(e => new NameItem(e.Id, e.Name, e.CleanName)) |
| | 34 | 160 | | .ToArray(); |
| | | 161 | | |
| | 80272 | 162 | | foreach (var id in ids) |
| | | 163 | | { |
| | 40102 | 164 | | result[id] = new ItemCounts(); |
| | | 165 | | } |
| | | 166 | | |
| | 34 | 167 | | if (nameItems.Length == 0) |
| | | 168 | | { |
| | 1 | 169 | | return result; |
| | | 170 | | } |
| | | 171 | | |
| | 33 | 172 | | var typeNames = relatedItemKinds.Select(k => _itemTypeLookup.BaseItemKindNames[k]).ToArray(); |
| | 33 | 173 | | var related = _queryHelpers.ApplyAccessFiltering( |
| | 33 | 174 | | context, |
| | 33 | 175 | | context.BaseItems.AsNoTracking().Where(e => typeNames.Contains(e.Type)), |
| | 33 | 176 | | accessFilter); |
| | | 177 | | |
| | 33 | 178 | | var valueTypes = GetItemValueTypes(kind); |
| | 33 | 179 | | if (valueTypes.Length > 0) |
| | | 180 | | { |
| | 25 | 181 | | CountByItemValue(context, related, kind, relatedItemKinds, valueTypes, nameItems, result); |
| | | 182 | | } |
| | 8 | 183 | | else if (kind == BaseItemKind.Person) |
| | | 184 | | { |
| | 3 | 185 | | CountByPersonName(context, related, nameItems, result); |
| | | 186 | | } |
| | 5 | 187 | | else if (kind == BaseItemKind.Year) |
| | | 188 | | { |
| | 5 | 189 | | CountByProductionYear(related, nameItems, result); |
| | | 190 | | } |
| | | 191 | | |
| | 33 | 192 | | return result; |
| | 34 | 193 | | } |
| | | 194 | | |
| | | 195 | | private void CountByItemValue( |
| | | 196 | | JellyfinDbContext context, |
| | | 197 | | IQueryable<BaseItemEntity> related, |
| | | 198 | | BaseItemKind kind, |
| | | 199 | | BaseItemKind[] relatedItemKinds, |
| | | 200 | | ItemValueType[] valueTypes, |
| | | 201 | | NameItem[] nameItems, |
| | | 202 | | Dictionary<Guid, ItemCounts> result) |
| | | 203 | | { |
| | 25 | 204 | | var cleanNames = nameItems |
| | 25 | 205 | | .Select(n => n.CleanName) |
| | 25 | 206 | | .OfType<string>() |
| | 25 | 207 | | .Distinct(StringComparer.Ordinal) |
| | 25 | 208 | | .ToArray(); |
| | | 209 | | |
| | 25 | 210 | | if (cleanNames.Length == 0) |
| | | 211 | | { |
| | 0 | 212 | | return; |
| | | 213 | | } |
| | | 214 | | |
| | 25 | 215 | | var grouped = context.ItemValuesMap.AsNoTracking() |
| | 25 | 216 | | .Where(ivm => valueTypes.Contains(ivm.ItemValue.Type)) |
| | 25 | 217 | | .WhereOneOrMany(cleanNames, ivm => ivm.ItemValue.CleanValue) |
| | 25 | 218 | | .Join(related, ivm => ivm.ItemId, e => e.Id, (ivm, e) => new { ivm.ItemValue.CleanValue, e.Type, e.Id }) |
| | 25 | 219 | | .GroupBy(x => new { x.CleanValue, x.Type }) |
| | 25 | 220 | | .Select(g => new { g.Key.CleanValue, g.Key.Type, Count = g.Select(x => x.Id).Distinct().Count() }) |
| | 25 | 221 | | .ToArray(); |
| | | 222 | | |
| | 25 | 223 | | var byCleanName = grouped |
| | 25 | 224 | | .GroupBy(g => g.CleanValue, StringComparer.Ordinal) |
| | 25 | 225 | | .ToDictionary(g => g.Key, g => g.Select(x => (x.Type, x.Count)).ToArray(), StringComparer.Ordinal); |
| | | 226 | | |
| | 25 | 227 | | var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series]; |
| | 25 | 228 | | var episodeRollUp = RollsUpEpisodes(kind, relatedItemKinds) |
| | 25 | 229 | | && Array.Exists(grouped, g => string.Equals(g.Type, seriesTypeName, StringComparison.Ordinal)) |
| | 25 | 230 | | ? CountEpisodesOfTaggedSeriesByCleanName(context, related, valueTypes, cleanNames) |
| | 25 | 231 | | : null; |
| | | 232 | | |
| | 232 | 233 | | foreach (var nameItem in nameItems) |
| | | 234 | | { |
| | 91 | 235 | | if (nameItem.CleanName is null || !byCleanName.TryGetValue(nameItem.CleanName, out var counts)) |
| | | 236 | | { |
| | | 237 | | continue; |
| | | 238 | | } |
| | | 239 | | |
| | 27 | 240 | | var itemCounts = ItemCountBuilder.Build(_itemTypeLookup, counts); |
| | | 241 | | |
| | 27 | 242 | | if (episodeRollUp is not null) |
| | | 243 | | { |
| | 8 | 244 | | var rollUp = episodeRollUp.GetValueOrDefault(nameItem.CleanName); |
| | | 245 | | |
| | | 246 | | // Episodes of a tagged series count towards it even when untagged themselves, and |
| | | 247 | | // a tagged episode of a tagged series must not be counted a second time. |
| | 8 | 248 | | var directEpisodeCount = itemCounts.EpisodeCount - rollUp.TaggedEpisodesOfTaggedSeries; |
| | 8 | 249 | | ItemCountBuilder.SetEpisodeCount(itemCounts, rollUp.EpisodesOfTaggedSeries + directEpisodeCount); |
| | | 250 | | } |
| | | 251 | | |
| | 27 | 252 | | result[nameItem.Id] = itemCounts; |
| | | 253 | | } |
| | 25 | 254 | | } |
| | | 255 | | |
| | | 256 | | private void CountByPersonName( |
| | | 257 | | JellyfinDbContext context, |
| | | 258 | | IQueryable<BaseItemEntity> related, |
| | | 259 | | NameItem[] nameItems, |
| | | 260 | | Dictionary<Guid, ItemCounts> result) |
| | | 261 | | { |
| | 3 | 262 | | var names = nameItems |
| | 3 | 263 | | .Select(n => n.Name) |
| | 3 | 264 | | .OfType<string>() |
| | 3 | 265 | | .Distinct(StringComparer.Ordinal) |
| | 3 | 266 | | .ToArray(); |
| | | 267 | | |
| | 3 | 268 | | if (names.Length == 0) |
| | | 269 | | { |
| | 0 | 270 | | return; |
| | | 271 | | } |
| | | 272 | | |
| | 3 | 273 | | var grouped = context.PeopleBaseItemMap.AsNoTracking() |
| | 3 | 274 | | .WhereOneOrMany(names, m => m.People.Name) |
| | 3 | 275 | | .Join(related, m => m.ItemId, e => e.Id, (m, e) => new { m.People.Name, e.Type, e.Id }) |
| | 3 | 276 | | .GroupBy(x => new { x.Name, x.Type }) |
| | 3 | 277 | | // A person can be credited on one item more than once, in different roles. |
| | 3 | 278 | | .Select(g => new { g.Key.Name, g.Key.Type, Count = g.Select(x => x.Id).Distinct().Count() }) |
| | 3 | 279 | | .ToArray(); |
| | | 280 | | |
| | 3 | 281 | | ApplyGroupedCounts(nameItems, n => n.Name, grouped.Select(g => (g.Name, g.Type, g.Count)), result); |
| | 3 | 282 | | } |
| | | 283 | | |
| | | 284 | | private void CountByProductionYear( |
| | | 285 | | IQueryable<BaseItemEntity> related, |
| | | 286 | | NameItem[] nameItems, |
| | | 287 | | Dictionary<Guid, ItemCounts> result) |
| | | 288 | | { |
| | 5 | 289 | | var years = new List<int>(); |
| | 22 | 290 | | foreach (var nameItem in nameItems) |
| | | 291 | | { |
| | 6 | 292 | | if (int.TryParse(nameItem.Name, NumberStyles.Integer, CultureInfo.InvariantCulture, out var year) |
| | 6 | 293 | | && !years.Contains(year)) |
| | | 294 | | { |
| | 6 | 295 | | years.Add(year); |
| | | 296 | | } |
| | | 297 | | } |
| | | 298 | | |
| | 5 | 299 | | if (years.Count == 0) |
| | | 300 | | { |
| | 0 | 301 | | return; |
| | | 302 | | } |
| | | 303 | | |
| | | 304 | | // No join, so no row can be reached twice and a plain count is the distinct count. |
| | 5 | 305 | | var grouped = related |
| | 5 | 306 | | .Where(e => e.ProductionYear != null) |
| | 5 | 307 | | .WhereOneOrMany(years, e => e.ProductionYear!.Value) |
| | 5 | 308 | | .GroupBy(e => new { Year = e.ProductionYear!.Value, e.Type }) |
| | 5 | 309 | | .Select(g => new { g.Key.Year, g.Key.Type, Count = g.Count() }) |
| | 5 | 310 | | .ToArray(); |
| | | 311 | | |
| | 5 | 312 | | var byYear = grouped |
| | 5 | 313 | | .GroupBy(g => g.Year) |
| | 5 | 314 | | .ToDictionary(g => g.Key, g => g.Select(x => (x.Type, x.Count)).ToArray()); |
| | | 315 | | |
| | 22 | 316 | | foreach (var nameItem in nameItems) |
| | | 317 | | { |
| | 6 | 318 | | if (int.TryParse(nameItem.Name, NumberStyles.Integer, CultureInfo.InvariantCulture, out var year) |
| | 6 | 319 | | && byYear.TryGetValue(year, out var counts)) |
| | | 320 | | { |
| | 6 | 321 | | result[nameItem.Id] = ItemCountBuilder.Build(_itemTypeLookup, counts); |
| | | 322 | | } |
| | | 323 | | } |
| | 5 | 324 | | } |
| | | 325 | | |
| | | 326 | | private void ApplyGroupedCounts( |
| | | 327 | | NameItem[] nameItems, |
| | | 328 | | Func<NameItem, string?> keySelector, |
| | | 329 | | IEnumerable<(string Key, string Type, int Count)> grouped, |
| | | 330 | | Dictionary<Guid, ItemCounts> result) |
| | | 331 | | { |
| | 3 | 332 | | var byKey = grouped |
| | 3 | 333 | | .GroupBy(g => g.Key, StringComparer.Ordinal) |
| | 3 | 334 | | .ToDictionary(g => g.Key, g => g.Select(x => (x.Type, x.Count)).ToArray(), StringComparer.Ordinal); |
| | | 335 | | |
| | 14 | 336 | | foreach (var nameItem in nameItems) |
| | | 337 | | { |
| | 4 | 338 | | var key = keySelector(nameItem); |
| | 4 | 339 | | if (key is not null && byKey.TryGetValue(key, out var counts)) |
| | | 340 | | { |
| | 4 | 341 | | result[nameItem.Id] = ItemCountBuilder.Build(_itemTypeLookup, counts); |
| | | 342 | | } |
| | | 343 | | } |
| | 3 | 344 | | } |
| | | 345 | | |
| | | 346 | | private static bool RollsUpEpisodes(BaseItemKind kind, BaseItemKind[] relatedItemKinds) |
| | 25 | 347 | | => kind is BaseItemKind.Studio or BaseItemKind.Genre or BaseItemKind.MusicGenre |
| | 25 | 348 | | && relatedItemKinds.Contains(BaseItemKind.Episode) |
| | 25 | 349 | | && relatedItemKinds.Contains(BaseItemKind.Series); |
| | | 350 | | |
| | | 351 | | private Dictionary<string, (int EpisodesOfTaggedSeries, int TaggedEpisodesOfTaggedSeries)> CountEpisodesOfTaggedSeri |
| | | 352 | | JellyfinDbContext context, |
| | | 353 | | IQueryable<BaseItemEntity> related, |
| | | 354 | | ItemValueType[] valueTypes, |
| | | 355 | | string[] cleanNames) |
| | | 356 | | { |
| | 5 | 357 | | var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series]; |
| | 5 | 358 | | var episodeTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Episode]; |
| | | 359 | | |
| | 5 | 360 | | var taggedValues = context.ItemValuesMap.AsNoTracking() |
| | 5 | 361 | | .Where(ivm => valueTypes.Contains(ivm.ItemValue.Type)) |
| | 5 | 362 | | .WhereOneOrMany(cleanNames, ivm => ivm.ItemValue.CleanValue); |
| | | 363 | | |
| | | 364 | | // The series carrying each clean name. Distinct, because one item can be mapped to the |
| | | 365 | | // same clean name once per value type. |
| | 5 | 366 | | var taggedSeries = taggedValues |
| | 5 | 367 | | .Join( |
| | 5 | 368 | | related.Where(e => e.Type == seriesTypeName), |
| | 5 | 369 | | ivm => ivm.ItemId, |
| | 5 | 370 | | e => e.Id, |
| | 5 | 371 | | (ivm, e) => new { ivm.ItemValue.CleanValue, SeriesId = e.Id }) |
| | 5 | 372 | | .Distinct(); |
| | | 373 | | |
| | 5 | 374 | | var episodes = related.Where(e => e.Type == episodeTypeName && e.SeriesId != null); |
| | | 375 | | |
| | 5 | 376 | | var episodesOfTaggedSeries = taggedSeries |
| | 5 | 377 | | .Join(episodes, s => s.SeriesId, e => e.SeriesId!.Value, (s, e) => new { s.CleanValue, e.Id }) |
| | 5 | 378 | | .GroupBy(x => x.CleanValue) |
| | 5 | 379 | | .Select(g => new { CleanValue = g.Key, Count = g.Select(x => x.Id).Distinct().Count() }) |
| | 5 | 380 | | .ToArray(); |
| | | 381 | | |
| | | 382 | | // Episodes that carry the clean name themselves *and* belong to a series carrying it. The |
| | | 383 | | // roll-up already counts those, so they have to come off the directly tagged ones. |
| | 5 | 384 | | var taggedEpisodesOfTaggedSeries = taggedValues |
| | 5 | 385 | | .Join(episodes, ivm => ivm.ItemId, e => e.Id, (ivm, e) => new { ivm.ItemValue.CleanValue, e.Id, e.SeriesId } |
| | 5 | 386 | | .Join( |
| | 5 | 387 | | taggedSeries, |
| | 5 | 388 | | e => new { e.CleanValue, SeriesId = e.SeriesId!.Value }, |
| | 5 | 389 | | s => new { s.CleanValue, s.SeriesId }, |
| | 5 | 390 | | (e, s) => new { e.CleanValue, e.Id }) |
| | 5 | 391 | | .GroupBy(x => x.CleanValue) |
| | 5 | 392 | | .Select(g => new { CleanValue = g.Key, Count = g.Select(x => x.Id).Distinct().Count() }) |
| | 5 | 393 | | .ToArray(); |
| | | 394 | | |
| | 5 | 395 | | var taggedLookup = taggedEpisodesOfTaggedSeries |
| | 5 | 396 | | .ToDictionary(x => x.CleanValue, x => x.Count, StringComparer.Ordinal); |
| | | 397 | | |
| | | 398 | | // Every clean name in taggedLookup came from an episode of a tagged series, so it always |
| | | 399 | | // has a row in episodesOfTaggedSeries too - no second merge pass is needed. |
| | 5 | 400 | | var result = new Dictionary<string, (int EpisodesOfTaggedSeries, int TaggedEpisodesOfTaggedSeries)>(StringCompar |
| | 20 | 401 | | foreach (var entry in episodesOfTaggedSeries) |
| | | 402 | | { |
| | 5 | 403 | | result[entry.CleanValue] = (entry.Count, taggedLookup.GetValueOrDefault(entry.CleanValue)); |
| | | 404 | | } |
| | | 405 | | |
| | 5 | 406 | | return result; |
| | | 407 | | } |
| | | 408 | | |
| | | 409 | | /// <inheritdoc/> |
| | | 410 | | public int GetPlayedCount(InternalItemsQuery filter, Guid ancestorId) |
| | | 411 | | { |
| | 5 | 412 | | ArgumentNullException.ThrowIfNull(filter); |
| | 5 | 413 | | ArgumentNullException.ThrowIfNull(filter.User); |
| | 5 | 414 | | using var dbContext = _dbProvider.CreateDbContext(); |
| | | 415 | | |
| | 5 | 416 | | var baseQuery = BuildGroupedDescendantsQuery(dbContext, filter, ancestorId); |
| | 5 | 417 | | return baseQuery.Count(DescendantQueryHelper.IsPlayedBy(filter.User.Id)); |
| | 5 | 418 | | } |
| | | 419 | | |
| | | 420 | | /// <inheritdoc/> |
| | | 421 | | public int GetTotalCount(InternalItemsQuery filter, Guid ancestorId) |
| | | 422 | | { |
| | 3 | 423 | | ArgumentNullException.ThrowIfNull(filter); |
| | 3 | 424 | | using var dbContext = _dbProvider.CreateDbContext(); |
| | | 425 | | |
| | 3 | 426 | | var baseQuery = BuildGroupedDescendantsQuery(dbContext, filter, ancestorId); |
| | 3 | 427 | | return baseQuery.Count(); |
| | 3 | 428 | | } |
| | | 429 | | |
| | | 430 | | /// <inheritdoc/> |
| | | 431 | | public (int Played, int Total) GetPlayedAndTotalCount(InternalItemsQuery filter, Guid ancestorId) |
| | | 432 | | { |
| | 5 | 433 | | ArgumentNullException.ThrowIfNull(filter); |
| | 5 | 434 | | ArgumentNullException.ThrowIfNull(filter.User); |
| | 5 | 435 | | using var dbContext = _dbProvider.CreateDbContext(); |
| | | 436 | | |
| | 5 | 437 | | var baseQuery = BuildGroupedDescendantsQuery(dbContext, filter, ancestorId); |
| | 5 | 438 | | return GetPlayedAndTotalCountFromQuery(baseQuery, filter.User.Id); |
| | 5 | 439 | | } |
| | | 440 | | |
| | | 441 | | private IQueryable<BaseItemEntity> BuildGroupedDescendantsQuery(JellyfinDbContext dbContext, InternalItemsQuery filt |
| | | 442 | | { |
| | 13 | 443 | | var ancestorIds = GetPresentationKeyGroups(dbContext, [ancestorId])[ancestorId]; |
| | 13 | 444 | | var descendantIds = DescendantQueryHelper.GetAllDescendantIdsBatch(dbContext, ancestorIds).ToArray(); |
| | | 445 | | |
| | 13 | 446 | | var baseQuery = dbContext.BaseItems |
| | 13 | 447 | | .AsNoTracking() |
| | 13 | 448 | | .WhereOneOrMany(descendantIds, b => b.Id) |
| | 13 | 449 | | .Where(DescendantQueryHelper.IsCountableLeaf); |
| | | 450 | | |
| | 13 | 451 | | return _queryHelpers.ApplyAccessFiltering(dbContext, baseQuery, filter); |
| | | 452 | | } |
| | | 453 | | |
| | | 454 | | /// <inheritdoc/> |
| | | 455 | | public (int Played, int Total) GetPlayedAndTotalCountFromLinkedChildren(InternalItemsQuery filter, Guid parentId) |
| | | 456 | | { |
| | 1 | 457 | | ArgumentNullException.ThrowIfNull(filter); |
| | 1 | 458 | | ArgumentNullException.ThrowIfNull(filter.User); |
| | 1 | 459 | | using var dbContext = _dbProvider.CreateDbContext(); |
| | | 460 | | |
| | 1 | 461 | | var allDescendantIds = DescendantQueryHelper.GetAllDescendantIdsBatch(dbContext, [parentId]).ToArray(); |
| | 1 | 462 | | var baseQuery = dbContext.BaseItems |
| | 1 | 463 | | .WhereOneOrMany(allDescendantIds, b => b.Id) |
| | 1 | 464 | | .Where(DescendantQueryHelper.IsCountableLeaf); |
| | 1 | 465 | | baseQuery = _queryHelpers.ApplyAccessFiltering(dbContext, baseQuery, filter); |
| | | 466 | | |
| | 1 | 467 | | return GetPlayedAndTotalCountFromQuery(baseQuery, filter.User.Id); |
| | 1 | 468 | | } |
| | | 469 | | |
| | | 470 | | /// <inheritdoc/> |
| | | 471 | | public Dictionary<Guid, int> GetChildCountBatch(IReadOnlyList<Guid> parentIds, User? user) |
| | | 472 | | { |
| | 14 | 473 | | ArgumentNullException.ThrowIfNull(parentIds); |
| | | 474 | | |
| | 14 | 475 | | if (parentIds.Count == 0) |
| | | 476 | | { |
| | 0 | 477 | | return new Dictionary<Guid, int>(); |
| | | 478 | | } |
| | | 479 | | |
| | 14 | 480 | | using var dbContext = _dbProvider.CreateDbContext(); |
| | | 481 | | |
| | 14 | 482 | | var parentIdsArray = parentIds.ToArray(); |
| | | 483 | | |
| | 14 | 484 | | var includeVirtual = user is null || user.DisplayMissingEpisodes; |
| | | 485 | | |
| | 14 | 486 | | var accessibleItems = dbContext.BaseItems.AsNoTracking(); |
| | 14 | 487 | | if (user is null) |
| | | 488 | | { |
| | | 489 | | // Access filtering is what would otherwise drop an alternate version, and a child count |
| | | 490 | | // must not report a title twice just because no user was passed in. |
| | 6 | 491 | | accessibleItems = accessibleItems.Where(DescendantQueryHelper.IsDistinctLibraryItem); |
| | | 492 | | } |
| | | 493 | | else |
| | | 494 | | { |
| | 8 | 495 | | accessibleItems = _queryHelpers.ApplyAccessFiltering(dbContext, accessibleItems, new InternalItemsQuery(user |
| | | 496 | | } |
| | | 497 | | |
| | 14 | 498 | | var hierarchicalCounts = accessibleItems |
| | 14 | 499 | | .Where(b => b.ParentId.HasValue && !b.SeasonId.HasValue && (includeVirtual || !b.IsVirtualItem)) |
| | 14 | 500 | | .WhereOneOrMany(parentIdsArray, b => b.ParentId!.Value) |
| | 14 | 501 | | .GroupBy(b => b.ParentId!.Value) |
| | 14 | 502 | | .Select(g => new { ParentId = g.Key, Count = g.Count() }) |
| | 14 | 503 | | .ToDictionary(x => x.ParentId, x => x.Count); |
| | | 504 | | |
| | | 505 | | // An episode is a child of its season even when it is not stored under one: with a flat |
| | | 506 | | // structure ParentId points at the series, so counting by ParentId alone leaves the season |
| | | 507 | | // empty and counts its episodes towards the series instead. |
| | 14 | 508 | | var seasonCounts = accessibleItems |
| | 14 | 509 | | .Where(b => b.SeasonId.HasValue && (includeVirtual || !b.IsVirtualItem)) |
| | 14 | 510 | | .WhereOneOrMany(parentIdsArray, b => b.SeasonId!.Value) |
| | 14 | 511 | | .GroupBy(b => b.SeasonId!.Value) |
| | 14 | 512 | | .Select(g => new { SeasonId = g.Key, Count = g.Count() }) |
| | 14 | 513 | | .ToDictionary(x => x.SeasonId, x => x.Count); |
| | | 514 | | |
| | | 515 | | // A linked child counts only when the item it points at is one the user may open. |
| | 14 | 516 | | var linkedCounts = dbContext.LinkedChildren |
| | 14 | 517 | | .WhereOneOrMany(parentIdsArray, lc => lc.ParentId) |
| | 14 | 518 | | .Join(accessibleItems, lc => lc.ChildId, b => b.Id, (lc, b) => lc.ParentId) |
| | 14 | 519 | | .GroupBy(parentId => parentId) |
| | 14 | 520 | | .Select(g => new { ParentId = g.Key, Count = g.Count() }) |
| | 14 | 521 | | .ToDictionary(x => x.ParentId, x => x.Count); |
| | | 522 | | |
| | 14 | 523 | | var mergedChildCounts = GetMergedChildCounts(dbContext, accessibleItems, parentIdsArray, includeVirtual); |
| | | 524 | | |
| | 14 | 525 | | var result = new Dictionary<Guid, int>(); |
| | 80064 | 526 | | foreach (var parentId in parentIds) |
| | | 527 | | { |
| | 40018 | 528 | | if (mergedChildCounts.TryGetValue(parentId, out var mergedCount)) |
| | | 529 | | { |
| | 2 | 530 | | result[parentId] = mergedCount; |
| | 2 | 531 | | continue; |
| | | 532 | | } |
| | | 533 | | |
| | 40016 | 534 | | var hierarchicalCount = hierarchicalCounts.GetValueOrDefault(parentId, 0) |
| | 40016 | 535 | | + seasonCounts.GetValueOrDefault(parentId, 0); |
| | 40016 | 536 | | var linkedCount = linkedCounts.GetValueOrDefault(parentId, 0); |
| | | 537 | | |
| | 40016 | 538 | | result[parentId] = linkedCount > 0 ? linkedCount : hierarchicalCount; |
| | | 539 | | } |
| | | 540 | | |
| | 14 | 541 | | return result; |
| | 14 | 542 | | } |
| | | 543 | | |
| | | 544 | | private static Dictionary<Guid, int> GetMergedChildCounts( |
| | | 545 | | JellyfinDbContext dbContext, |
| | | 546 | | IQueryable<BaseItemEntity> accessibleItems, |
| | | 547 | | IReadOnlyList<Guid> parentIds, |
| | | 548 | | bool includeVirtual) |
| | | 549 | | { |
| | 14 | 550 | | var mergedGroups = GetPresentationKeyGroups(dbContext, parentIds) |
| | 14 | 551 | | .Where(group => group.Value.Count > 1) |
| | 14 | 552 | | .ToArray(); |
| | | 553 | | |
| | 14 | 554 | | if (mergedGroups.Length == 0) |
| | | 555 | | { |
| | 13 | 556 | | return []; |
| | | 557 | | } |
| | | 558 | | |
| | | 559 | | // Only merged folders. |
| | 1 | 560 | | var memberIds = mergedGroups.SelectMany(group => group.Value).Distinct().ToArray(); |
| | 1 | 561 | | var children = accessibleItems |
| | 1 | 562 | | .Where(b => b.ParentId.HasValue && !b.SeasonId.HasValue && (includeVirtual || !b.IsVirtualItem)) |
| | 1 | 563 | | .WhereOneOrMany(memberIds, b => b.ParentId!.Value) |
| | 1 | 564 | | .Select(b => new { ParentId = b.ParentId!.Value, b.Id, b.PresentationUniqueKey }) |
| | 1 | 565 | | .ToArray() |
| | 1 | 566 | | .Concat(accessibleItems |
| | 1 | 567 | | .Where(b => b.SeasonId.HasValue && (includeVirtual || !b.IsVirtualItem)) |
| | 1 | 568 | | .WhereOneOrMany(memberIds, b => b.SeasonId!.Value) |
| | 1 | 569 | | .Select(b => new { ParentId = b.SeasonId!.Value, b.Id, b.PresentationUniqueKey }) |
| | 1 | 570 | | .ToArray()) |
| | 1 | 571 | | .GroupBy(b => b.ParentId) |
| | 1 | 572 | | .ToDictionary( |
| | 1 | 573 | | g => g.Key, |
| | 1 | 574 | | g => g.Select(b => string.IsNullOrEmpty(b.PresentationUniqueKey) |
| | 1 | 575 | | ? b.Id.ToString("N", CultureInfo.InvariantCulture) |
| | 1 | 576 | | : b.PresentationUniqueKey).ToArray()); |
| | | 577 | | |
| | 1 | 578 | | var result = new Dictionary<Guid, int>(); |
| | 6 | 579 | | foreach (var (parentId, members) in mergedGroups) |
| | | 580 | | { |
| | 2 | 581 | | var childKeys = new HashSet<string>(StringComparer.Ordinal); |
| | 12 | 582 | | foreach (var member in members) |
| | | 583 | | { |
| | 4 | 584 | | if (children.TryGetValue(member, out var keys)) |
| | | 585 | | { |
| | 4 | 586 | | childKeys.UnionWith(keys); |
| | | 587 | | } |
| | | 588 | | } |
| | | 589 | | |
| | 2 | 590 | | result[parentId] = childKeys.Count; |
| | | 591 | | } |
| | | 592 | | |
| | 1 | 593 | | return result; |
| | | 594 | | } |
| | | 595 | | |
| | | 596 | | /// <inheritdoc/> |
| | | 597 | | public Dictionary<Guid, (int Played, int Total)> GetPlayedAndTotalCountBatch(IReadOnlyList<Guid> folderIds, User use |
| | | 598 | | { |
| | 3 | 599 | | ArgumentNullException.ThrowIfNull(folderIds); |
| | 3 | 600 | | ArgumentNullException.ThrowIfNull(user); |
| | | 601 | | |
| | 3 | 602 | | if (folderIds.Count == 0) |
| | | 603 | | { |
| | 0 | 604 | | return new Dictionary<Guid, (int Played, int Total)>(); |
| | | 605 | | } |
| | | 606 | | |
| | 3 | 607 | | using var dbContext = _dbProvider.CreateDbContext(); |
| | 3 | 608 | | var filter = new InternalItemsQuery(user); |
| | 3 | 609 | | var userId = user.Id; |
| | | 610 | | |
| | | 611 | | // Merged series and seasons are stored as one row per folder-item sharing a presentation key. |
| | 3 | 612 | | var groups = GetPresentationKeyGroups(dbContext, folderIds); |
| | 3 | 613 | | var folderIdsArray = groups.Values.SelectMany(members => members).Distinct().ToArray(); |
| | | 614 | | |
| | 3 | 615 | | var leafItems = dbContext.BaseItems |
| | 3 | 616 | | .Where(DescendantQueryHelper.IsCountableLeaf); |
| | 3 | 617 | | leafItems = _queryHelpers.ApplyAccessFiltering(dbContext, leafItems, filter); |
| | | 618 | | |
| | 3 | 619 | | var playedLeafItems = leafItems |
| | 3 | 620 | | .Select(DescendantQueryHelper.PlayedStateBy(userId)); |
| | | 621 | | |
| | 3 | 622 | | var ancestorLeaves = dbContext.AncestorIds |
| | 3 | 623 | | .WhereOneOrMany(folderIdsArray, a => a.ParentItemId) |
| | 3 | 624 | | .Join( |
| | 3 | 625 | | playedLeafItems, |
| | 3 | 626 | | a => a.ItemId, |
| | 3 | 627 | | b => b.Id, |
| | 3 | 628 | | (a, b) => new { FolderId = a.ParentItemId, b.Id, b.Played }); |
| | | 629 | | |
| | 3 | 630 | | var linkedLeaves = dbContext.LinkedChildren |
| | 3 | 631 | | .WhereOneOrMany(folderIdsArray, lc => lc.ParentId) |
| | 3 | 632 | | .Join( |
| | 3 | 633 | | playedLeafItems, |
| | 3 | 634 | | lc => lc.ChildId, |
| | 3 | 635 | | b => b.Id, |
| | 3 | 636 | | (lc, b) => new { FolderId = lc.ParentId, b.Id, b.Played }); |
| | | 637 | | |
| | 3 | 638 | | var linkedFolderLeaves = dbContext.LinkedChildren |
| | 3 | 639 | | .WhereOneOrMany(folderIdsArray, lc => lc.ParentId) |
| | 3 | 640 | | .Join( |
| | 3 | 641 | | dbContext.BaseItems.Where(b => b.IsFolder), |
| | 3 | 642 | | lc => lc.ChildId, |
| | 3 | 643 | | b => b.Id, |
| | 3 | 644 | | (lc, b) => new { lc.ParentId, FolderChildId = b.Id }) |
| | 3 | 645 | | .Join( |
| | 3 | 646 | | dbContext.AncestorIds, |
| | 3 | 647 | | x => x.FolderChildId, |
| | 3 | 648 | | a => a.ParentItemId, |
| | 3 | 649 | | (x, a) => new { x.ParentId, DescendantId = a.ItemId }) |
| | 3 | 650 | | .Join( |
| | 3 | 651 | | playedLeafItems, |
| | 3 | 652 | | x => x.DescendantId, |
| | 3 | 653 | | b => b.Id, |
| | 3 | 654 | | (x, b) => new { FolderId = x.ParentId, b.Id, b.Played }); |
| | | 655 | | |
| | 3 | 656 | | var countsByFolder = ancestorLeaves |
| | 3 | 657 | | .Union(linkedLeaves) |
| | 3 | 658 | | .Union(linkedFolderLeaves) |
| | 3 | 659 | | .GroupBy(x => x.FolderId) |
| | 3 | 660 | | .Select(g => new |
| | 3 | 661 | | { |
| | 3 | 662 | | FolderId = g.Key, |
| | 3 | 663 | | Total = g.Select(x => x.Id).Distinct().Count(), |
| | 3 | 664 | | Played = g.Where(x => x.Played).Select(x => x.Id).Distinct().Count() |
| | 3 | 665 | | }) |
| | 3 | 666 | | .ToDictionary(x => x.FolderId, x => (x.Played, x.Total)); |
| | | 667 | | |
| | 3 | 668 | | var results = new Dictionary<Guid, (int Played, int Total)>(); |
| | 12 | 669 | | foreach (var (folderId, members) in groups) |
| | | 670 | | { |
| | 3 | 671 | | var played = 0; |
| | 3 | 672 | | var total = 0; |
| | | 673 | | |
| | | 674 | | // Members of a group are distinct folders, so their leaves cannot overlap. |
| | 14 | 675 | | foreach (var member in members) |
| | | 676 | | { |
| | 4 | 677 | | if (countsByFolder.TryGetValue(member, out var counts)) |
| | | 678 | | { |
| | 4 | 679 | | played += counts.Played; |
| | 4 | 680 | | total += counts.Total; |
| | | 681 | | } |
| | | 682 | | } |
| | | 683 | | |
| | 3 | 684 | | if (total > 0 || played > 0) |
| | | 685 | | { |
| | 3 | 686 | | results[folderId] = (played, total); |
| | | 687 | | } |
| | | 688 | | } |
| | | 689 | | |
| | 3 | 690 | | return results; |
| | 3 | 691 | | } |
| | | 692 | | |
| | | 693 | | private static Dictionary<Guid, List<Guid>> GetPresentationKeyGroups(JellyfinDbContext dbContext, IReadOnlyList<Guid |
| | | 694 | | { |
| | 30 | 695 | | var requested = dbContext.BaseItems |
| | 30 | 696 | | .AsNoTracking() |
| | 30 | 697 | | .WhereOneOrMany(folderIds, e => e.Id) |
| | 30 | 698 | | .Select(e => new { e.Id, e.PresentationUniqueKey }) |
| | 30 | 699 | | .ToArray(); |
| | | 700 | | |
| | 30 | 701 | | var keys = requested |
| | 30 | 702 | | .Select(e => e.PresentationUniqueKey) |
| | 30 | 703 | | .Where(key => !string.IsNullOrEmpty(key)) |
| | 30 | 704 | | .Distinct(StringComparer.Ordinal) |
| | 30 | 705 | | .ToArray(); |
| | | 706 | | |
| | | 707 | | // Every item that is not merged carries a key derived from its own id, so in the common case |
| | | 708 | | // each group resolves back to the single folder that was asked for. |
| | 30 | 709 | | var membersByKey = keys.Length == 0 |
| | 30 | 710 | | ? [] |
| | 30 | 711 | | : dbContext.BaseItems |
| | 30 | 712 | | .AsNoTracking() |
| | 30 | 713 | | .Where(e => e.IsFolder) |
| | 30 | 714 | | .WhereOneOrMany(keys, e => e.PresentationUniqueKey!) |
| | 30 | 715 | | .Select(e => new { e.Id, Key = e.PresentationUniqueKey! }) |
| | 30 | 716 | | .ToArray() |
| | 30 | 717 | | .GroupBy(e => e.Key, StringComparer.Ordinal) |
| | 30 | 718 | | .ToDictionary(g => g.Key, g => g.Select(e => e.Id).ToList(), StringComparer.Ordinal); |
| | | 719 | | |
| | 30 | 720 | | var keyById = requested.ToDictionary(e => e.Id, e => e.PresentationUniqueKey); |
| | 30 | 721 | | var groups = new Dictionary<Guid, List<Guid>>(); |
| | 80128 | 722 | | foreach (var folderId in folderIds) |
| | | 723 | | { |
| | 40034 | 724 | | groups[folderId] = keyById.TryGetValue(folderId, out var key) |
| | 40034 | 725 | | && !string.IsNullOrEmpty(key) |
| | 40034 | 726 | | && membersByKey.TryGetValue(key, out var members) |
| | 40034 | 727 | | && members.Count > 0 |
| | 40034 | 728 | | ? members |
| | 40034 | 729 | | : [folderId]; |
| | | 730 | | } |
| | | 731 | | |
| | 30 | 732 | | return groups; |
| | | 733 | | } |
| | | 734 | | |
| | | 735 | | private static (int Played, int Total) GetPlayedAndTotalCountFromQuery(IQueryable<BaseItemEntity> query, Guid userId |
| | | 736 | | { |
| | 6 | 737 | | var result = query |
| | 6 | 738 | | .Select(DescendantQueryHelper.IsPlayedBy(userId)) |
| | 6 | 739 | | .GroupBy(_ => 1) |
| | 6 | 740 | | .OrderBy(g => g.Key) |
| | 6 | 741 | | .Select(g => new |
| | 6 | 742 | | { |
| | 6 | 743 | | Total = g.Count(), |
| | 6 | 744 | | Played = g.Count(isPlayed => isPlayed) |
| | 6 | 745 | | }) |
| | 6 | 746 | | .FirstOrDefault(); |
| | | 747 | | |
| | 6 | 748 | | return result is null ? (0, 0) : (result.Played, result.Total); |
| | | 749 | | } |
| | | 750 | | |
| | | 751 | | /// <summary> |
| | | 752 | | /// A by-name item, reduced to the three columns the counting keys off. |
| | | 753 | | /// </summary> |
| | | 754 | | /// <param name="Id">The id of the by-name item.</param> |
| | | 755 | | /// <param name="Name">The name of the by-name item.</param> |
| | | 756 | | /// <param name="CleanName">The cleaned name of the by-name item.</param> |
| | | 757 | | private sealed record NameItem(Guid Id, string? Name, string? CleanName); |
| | | 758 | | } |