diff --git a/lib/media/episode_collection.dart b/lib/media/episode_collection.dart index cb081fa4..e5198ddc 100644 --- a/lib/media/episode_collection.dart +++ b/lib/media/episode_collection.dart @@ -72,17 +72,16 @@ MediaItem? defaultPlaybackSeason(List seasons) { } /// Index of the first season that still has unwatched episodes, preferring -/// regular seasons over specials (mirrors [defaultPlaybackSeasonIndex]). Uses -/// leafCount/viewedLeafCount, so no episodes need to be fetched. Returns null -/// when every season is fully watched (or counts are unavailable). +/// regular seasons over specials (mirrors [defaultPlaybackSeasonIndex]). +/// Uses normalized aggregate state, so no episodes need to be fetched. +/// Returns null when every season is fully watched or counts are unavailable. int? firstUnwatchedSeasonIndex(List seasons) { int? firstSpecial; for (var i = 0; i < seasons.length; i++) { final season = seasons[i]; if (season.kind != MediaKind.season) continue; - final leaf = season.leafCount; - if (leaf == null || leaf <= 0) continue; - if ((season.viewedLeafCount ?? 0) >= leaf) continue; // fully watched + if (season.leafWatchTotal == null) continue; + if (season.isWatched) continue; if (!isSpecialSeasonNumber(season.index)) return i; // first regular season with unwatched firstSpecial ??= i; // specials only count as a last resort } diff --git a/lib/media/media_item.dart b/lib/media/media_item.dart index 42b05a5c..5a3f934c 100644 --- a/lib/media/media_item.dart +++ b/lib/media/media_item.dart @@ -459,16 +459,37 @@ sealed class MediaItem with _$MediaItem { /// rules, the unwatched-episode lookups in episode_collection.dart). bool get isUnwatchedOrInProgress => !isWatched || hasActiveProgress; - /// Whether this container (show/season) has some but not all leaves watched. - bool get isPartiallyWatched => - viewedLeafCount != null && leafCount != null && viewedLeafCount! > 0 && viewedLeafCount! < leafCount!; + /// Positive leaf total used for aggregate watch state, or null when this + /// item is a leaf or has no authoritative total. A season's direct children + /// are episodes, so [childCount] is a valid fallback there; it is not valid + /// for shows, whose direct children are seasons. + int? get leafWatchTotal { + if (!kind.usesLeafWatchCounts) return null; + final total = leafCount ?? (kind == MediaKind.season ? childCount : null); + return total != null && total > 0 ? total : null; + } - /// Whether the item is fully watched. Series/seasons consult leaf counts; - /// individual movies/episodes use [viewCount]. + /// Normalized aggregate completion in the inclusive range 0–1. + double? get leafWatchFraction { + final total = leafWatchTotal; + final viewed = viewedLeafCount; + if (total == null || viewed == null) return null; + if (viewed <= 0) return 0; + if (viewed >= total) return 1; + return viewed / total; + } + + /// Whether this container has some but not all leaves watched. + bool get isPartiallyWatched { + final fraction = leafWatchFraction; + return fraction != null && fraction > 0 && fraction < 1; + } + + /// Whether the item is fully watched. Container kinds use positive + /// aggregate leaf totals; leaf kinds use their own [viewCount]. bool get isWatched { - if (leafCount != null && viewedLeafCount != null) { - return viewedLeafCount! >= leafCount!; - } + final fraction = leafWatchFraction; + if (fraction != null) return fraction >= 1; return viewCount != null && viewCount! > 0; } @@ -476,17 +497,30 @@ sealed class MediaItem with _$MediaItem { /// `UserData.UnplayedItemCount` when leaf totals weren't requested /// (e.g. the folder tree's slim field set). int? get unwatchedCount { - if (leafCount != null && viewedLeafCount != null) return leafCount! - viewedLeafCount!; + if (!kind.usesLeafWatchCounts) return null; + + final total = leafWatchTotal; + final viewed = viewedLeafCount; + if (total != null && viewed != null) { + if (viewed <= 0) return total; + if (viewed >= total) return 0; + return total - viewed; + } + final userData = raw?['UserData']; - return userData is Map ? userData['UnplayedItemCount'] as int? : null; + final unwatched = userData is Map ? flexibleInt(userData['UnplayedItemCount']) : null; + return unwatched != null && unwatched >= 0 ? unwatched : null; } /// Copy with the watched flag applied so [isWatched] reflects it for every - /// kind: containers need their leaf counts patched, not just [viewCount]. + /// kind. This is the single mutation seam used by watch-state overlays. MediaItem withWatchedFlag(bool isWatched) { var updated = copyWith(viewCount: isWatched ? 1 : 0); - if (leafCount != null || viewedLeafCount != null) { - updated = updated.copyWith(viewedLeafCount: isWatched ? (leafCount ?? viewedLeafCount ?? 1) : 0); + final total = leafWatchTotal; + if (total != null) { + updated = updated.copyWith(viewedLeafCount: isWatched ? total : 0); + } else if (!kind.usesLeafWatchCounts && viewedLeafCount != null) { + updated = updated.copyWith(viewedLeafCount: null); } return updated; } diff --git a/lib/media/media_kind.dart b/lib/media/media_kind.dart index d1d64690..eb92bb18 100644 --- a/lib/media/media_kind.dart +++ b/lib/media/media_kind.dart @@ -32,6 +32,12 @@ enum MediaKind { bool get isPlayable => isVideo || this == track; + /// Whether this container kind derives watched state from aggregate leaves. + bool get usesLeafWatchCounts => switch (this) { + show || season || artist || album || collection || playlist || folder => true, + movie || episode || track || clip || photo || unknown => false, + }; + /// Lowercase string id used when persisting or comparing legacy code paths /// that still hold raw type strings. String get id => switch (this) { diff --git a/lib/services/jellyfin_mappers.dart b/lib/services/jellyfin_mappers.dart index b7541d4a..ebbb9612 100644 --- a/lib/services/jellyfin_mappers.dart +++ b/lib/services/jellyfin_mappers.dart @@ -164,6 +164,8 @@ class JellyfinMappers { // Folder/CollectionFolder rows resolve via fromString) classify as // folders so folder browsing never falls back to raw-map sniffing. final kind = type == null && item['IsFolder'] == true ? MediaKind.folder : MediaKind.fromString(type); + final childCount = _nonNegativeCount(item['ChildCount']); + final leafCount = _nonNegativeCount(item['RecursiveItemCount']) ?? childCount; final albumPrimaryImage = kind == MediaKind.track ? _albumPrimaryImage(item) : null; final backdropPaths = _backdropImagePaths(id, item['BackdropImageTags']); final parentBackdropPaths = _parentBackdropImagePaths(item); @@ -225,13 +227,12 @@ class JellyfinMappers { viewOffsetMs: jellyfinTicksToMs(_userData(item)?['PlaybackPositionTicks']), viewCount: _viewCount(item), lastViewedAt: jellyfinIsoToEpochSeconds(_userData(item)?['LastPlayedDate'] as String?), - // Plex semantics: `leafCount` = total leaf items (episodes for series). - // Jellyfin's `ChildCount` is direct children (seasons for a series), - // while `RecursiveItemCount` is the recursive total (episodes). Prefer - // the recursive count so series show episode counts, not season counts. - leafCount: (item['RecursiveItemCount'] as int?) ?? (item['ChildCount'] as int?), - viewedLeafCount: _viewedLeafCount(item), - childCount: item['ChildCount'] as int?, + // leafCount also drives display counts. viewedLeafCount is watched-state + // rollup and applies only to container kinds; Jellyfin may include + // unrelated child counts on leaf DTOs. + leafCount: leafCount, + viewedLeafCount: kind.usesLeafWatchCounts ? _viewedLeafCount(item, leafCount) : null, + childCount: childCount, addedAt: jellyfinIsoToEpochSeconds(item['DateCreated'] as String?), updatedAt: jellyfinIsoToEpochSeconds(item['DateLastSaved'] as String? ?? item['DateModified'] as String?), rating: (item['CommunityRating'] as num?)?.toDouble(), @@ -347,22 +348,24 @@ class JellyfinMappers { return ud is Map ? ud : null; } + static int? _nonNegativeCount(Object? value) { + final count = flexibleInt(value); + return count != null && count >= 0 ? count : null; + } + static int _viewCount(Map item) { final ud = _userData(item); if (ud?['Played'] != true) return 0; - final playCount = ud?['PlayCount']; - if (playCount is int && playCount > 0) return playCount; + final playCount = flexibleInt(ud?['PlayCount']); + if (playCount != null && playCount > 0) return playCount; return 1; } - static int? _viewedLeafCount(Map item) { - final ud = _userData(item); - final unplayed = ud?['UnplayedItemCount'] as int?; - // Pair with `leafCount` semantics — episodes recursively, not seasons. - final total = (item['RecursiveItemCount'] as int?) ?? (item['ChildCount'] as int?); + static int? _viewedLeafCount(Map item, int? total) { + final unplayed = _nonNegativeCount(_userData(item)?['UnplayedItemCount']); if (total == null || unplayed == null) return null; - final v = total - unplayed; - return v < 0 ? 0 : v; + if (unplayed >= total) return 0; + return total - unplayed; } static String? _firstString(Object? list) { diff --git a/lib/services/plex_mappers.dart b/lib/services/plex_mappers.dart index 02498781..5859e0ee 100644 --- a/lib/services/plex_mappers.dart +++ b/lib/services/plex_mappers.dart @@ -1006,7 +1006,7 @@ class PlexMappers { viewCount: dto.viewCount, lastViewedAt: dto.lastViewedAt, leafCount: dto.leafCount, - viewedLeafCount: dto.viewedLeafCount, + viewedLeafCount: kind.usesLeafWatchCounts ? dto.viewedLeafCount : null, childCount: dto.childCount, addedAt: dto.addedAt, updatedAt: dto.updatedAt, diff --git a/lib/services/trackers/anime_episode_progress_resolver.dart b/lib/services/trackers/anime_episode_progress_resolver.dart index 1ae6fca8..691d3d86 100644 --- a/lib/services/trackers/anime_episode_progress_resolver.dart +++ b/lib/services/trackers/anime_episode_progress_resolver.dart @@ -147,7 +147,7 @@ class AnimeEpisodeProgressResolver implements AnimeEpisodeProgressLookup { if (season == null || season < 0) continue; final watched = item.viewedLeafCount; if (watched == null || watched < 0) continue; - final total = item.leafCount ?? item.childCount; + final total = item.leafWatchTotal; if (progress.containsKey(season)) return null; progress[season] = _SeasonProgress(total: total, watched: watched); } diff --git a/lib/widgets/watched_indicator.dart b/lib/widgets/watched_indicator.dart index b1e45330..63ad219a 100644 --- a/lib/widgets/watched_indicator.dart +++ b/lib/widgets/watched_indicator.dart @@ -130,7 +130,7 @@ class WatchedIndicator extends StatelessWidget { ), ), ), - // Progress bar for seasons (viewedLeafCount / leafCount) + // Progress bar for seasons (viewed leaves / total leaves). if (item.isSeason && item.isPartiallyWatched) Positioned( bottom: 0, @@ -139,7 +139,7 @@ class WatchedIndicator extends StatelessWidget { child: ClipRRect( borderRadius: barRadius, child: LinearProgressIndicator( - value: item.viewedLeafCount! / item.leafCount!, + value: item.leafWatchFraction, backgroundColor: tokens(context).outline, valueColor: AlwaysStoppedAnimation(Theme.of(context).colorScheme.primary), minHeight: size.barMinHeight, diff --git a/test/media/media_item_test.dart b/test/media/media_item_test.dart index af167a82..8a33e890 100644 --- a/test/media/media_item_test.dart +++ b/test/media/media_item_test.dart @@ -76,6 +76,7 @@ void main() { serverId: 's1', ); expect(show.isWatched, isTrue); + expect(show.unwatchedCount, 0); }); test('show with no leaf info falls back to viewCount', () { @@ -88,6 +89,66 @@ void main() { ); expect(show.isWatched, isTrue); }); + + test('leaf media ignores aggregate leaf counts', () { + expect(_movie(viewCount: 0, leafCount: 1, viewedLeafCount: 1).isWatched, isFalse); + expect(_movie(viewCount: 1, leafCount: 1, viewedLeafCount: 0).isWatched, isTrue); + }); + + test('container media uses aggregate leaf counts', () { + final album = testMediaItem( + id: 'a', + backend: MediaBackend.plex, + kind: MediaKind.album, + viewCount: 0, + leafCount: 8, + viewedLeafCount: 8, + serverId: 's1', + ); + expect(album.isWatched, isTrue); + }); + + test('every media kind has explicit leaf or container watch semantics', () { + const containerKinds = [ + MediaKind.show, + MediaKind.season, + MediaKind.artist, + MediaKind.album, + MediaKind.collection, + MediaKind.playlist, + MediaKind.folder, + ]; + expect(MediaKind.values.where((kind) => kind.usesLeafWatchCounts), containerKinds); + + for (final kind in MediaKind.values) { + final item = testMediaItem( + id: kind.id, + backend: MediaBackend.plex, + kind: kind, + viewCount: 0, + leafCount: 2, + viewedLeafCount: 2, + serverId: 's1', + ); + final usesLeaves = containerKinds.contains(kind); + expect(item.isWatched, usesLeaves, reason: '${kind.id} watched semantics'); + expect(item.isPartiallyWatched, isFalse, reason: '${kind.id} partial semantics'); + expect(item.unwatchedCount, usesLeaves ? 0 : null, reason: '${kind.id} unwatched count semantics'); + } + }); + + test('zero container leaf total falls back to viewCount', () { + final show = testMediaItem( + id: 's', + backend: MediaBackend.plex, + kind: MediaKind.show, + viewCount: 1, + leafCount: 0, + viewedLeafCount: 0, + serverId: 's1', + ); + expect(show.isWatched, isTrue); + }); }); group('MediaItem.heroArtCandidates', () { @@ -184,6 +245,20 @@ void main() { expect(show.isPartiallyWatched, isTrue); }); + test('season progress uses direct episode count only when the leaf total is absent', () { + final season = testMediaItem( + id: 'season', + backend: MediaBackend.jellyfin, + kind: MediaKind.season, + childCount: 8, + viewedLeafCount: 3, + ); + + expect(season.leafWatchTotal, 8); + expect(season.leafWatchFraction, 3 / 8); + expect(season.isPartiallyWatched, isTrue); + }); + test('show with zero leaves watched is NOT partially watched', () { final show = testMediaItem( id: 's', @@ -208,12 +283,90 @@ void main() { expect(show.isPartiallyWatched, isFalse); }); + test('aggregate progress clamps contradictory counts', () { + final overReported = testMediaItem( + id: 'show', + backend: MediaBackend.plex, + kind: MediaKind.show, + leafCount: 10, + viewedLeafCount: 11, + ); + final negative = overReported.copyWith(viewedLeafCount: -1); + + expect(overReported.leafWatchFraction, 1); + expect(negative.leafWatchFraction, 0); + }); + test('movie without leaf info is NOT partially watched (concept doesn\'t apply)', () { expect(_movie(viewCount: 0).isPartiallyWatched, isFalse); expect(_movie(viewCount: 1).isPartiallyWatched, isFalse); }); }); + group('MediaItem watch-state normalization', () { + test('leaf state ignores and clears stale aggregate fields', () { + final movie = testMediaItem( + id: 'm', + backend: MediaBackend.jellyfin, + kind: MediaKind.movie, + viewCount: 0, + leafCount: 3, + viewedLeafCount: 3, + raw: { + 'UserData': {'UnplayedItemCount': '0'}, + }, + ); + + expect(movie.isWatched, isFalse); + expect(movie.isPartiallyWatched, isFalse); + expect(movie.unwatchedCount, isNull); + expect(movie.withWatchedFlag(false).viewedLeafCount, isNull); + expect(movie.withWatchedFlag(true).isWatched, isTrue); + }); + + test('container mutations update the aggregate and item flags together', () { + final album = testMediaItem( + id: 'a', + backend: MediaBackend.plex, + kind: MediaKind.album, + viewCount: 0, + leafCount: 8, + viewedLeafCount: 3, + ); + + final watched = album.withWatchedFlag(true); + expect(watched.viewCount, 1); + expect(watched.viewedLeafCount, 8); + expect(watched.isWatched, isTrue); + + final unwatched = watched.withWatchedFlag(false); + expect(unwatched.viewCount, 0); + expect(unwatched.viewedLeafCount, 0); + expect(unwatched.isWatched, isFalse); + }); + + test('container unwatched counts are clamped and tolerate string API values', () { + final overReported = testMediaItem( + id: 's1', + backend: MediaBackend.plex, + kind: MediaKind.show, + leafCount: 10, + viewedLeafCount: 11, + ); + final slimJellyfin = testMediaItem( + id: 's2', + backend: MediaBackend.jellyfin, + kind: MediaKind.show, + raw: { + 'UserData': {'UnplayedItemCount': '4'}, + }, + ); + + expect(overReported.unwatchedCount, 0); + expect(slimJellyfin.unwatchedCount, 4); + }); + }); + group('MediaItem.hasActiveProgress', () { test('viewOffset between 0 and duration counts as active progress', () { expect(_movie(durationMs: 10000, viewOffsetMs: 5000).hasActiveProgress, isTrue); @@ -399,6 +552,26 @@ void main() { expect(decoded.heroBackdropPaths, ['/show-0', '/show-1']); }); + test('cached leaf items ignore stale aggregate watch fields', () { + const original = JellyfinMediaItem( + id: 'cached-music-video', + kind: MediaKind.clip, + viewCount: 0, + leafCount: 1, + viewedLeafCount: 1, + raw: { + 'UserData': {'UnplayedItemCount': 0}, + }, + ); + + final decoded = MediaItem.fromJson(original.toJson()); + + expect(decoded.viewedLeafCount, 1); + expect(decoded.isWatched, isFalse); + expect(decoded.isPartiallyWatched, isFalse); + expect(decoded.unwatchedCount, isNull); + }); + test('missing backend keeps legacy Plex fallback', () { final decoded = MediaItem.fromJson({'id': 'legacy', 'kind': 'movie'}); diff --git a/test/services/jellyfin_mappers_test.dart b/test/services/jellyfin_mappers_test.dart index baa70dba..ef069b51 100644 --- a/test/services/jellyfin_mappers_test.dart +++ b/test/services/jellyfin_mappers_test.dart @@ -2,6 +2,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/media/ids.dart'; import 'package:plezy/media/media_backend.dart'; import 'package:plezy/media/media_kind.dart'; +import 'package:plezy/media/media_item.dart'; import 'package:plezy/media/media_stream.dart'; import 'package:plezy/services/jellyfin_mappers.dart'; import 'package:plezy/services/settings_service.dart' show EpisodePosterMode; @@ -168,6 +169,28 @@ void main() { expect(musicVideo.kind.isVideo, isTrue); }); + test('music video watch state ignores recursive and child counts', () { + MediaItem mapMusicVideo(bool played) => JellyfinMappers.mediaItem( + { + 'Id': 'music-video', + 'Name': 'Music Video', + 'Type': 'MusicVideo', + 'RecursiveItemCount': '1', + 'ChildCount': 1, + 'UserData': {'Played': played, 'PlayCount': played ? '1' : '0', 'UnplayedItemCount': '0'}, + }, + serverId: ServerId(_serverId), + absolutizer: null, + )!; + + final unplayed = mapMusicVideo(false); + final played = mapMusicVideo(true); + expect(unplayed.leafCount, 1); + expect(unplayed.viewedLeafCount, isNull); + expect(unplayed.isWatched, isFalse); + expect(played.isWatched, isTrue); + }); + test('episode preserves series/season hierarchy', () { final json = { 'Id': 'ep1', @@ -261,6 +284,27 @@ void main() { expect(item.isWatched, isFalse); }); + test('container leaf counts tolerate scalar drift and clamp invalid unplayed totals', () { + final item = JellyfinMappers.mediaItem( + { + 'Id': 's-invalid-counts', + 'Name': 'Show', + 'Type': 'Series', + 'RecursiveItemCount': '5', + 'ChildCount': 2.0, + 'UserData': {'UnplayedItemCount': '8'}, + }, + serverId: ServerId(_serverId), + absolutizer: null, + )!; + + expect(item.leafCount, 5); + expect(item.childCount, 2); + expect(item.viewedLeafCount, 0); + expect(item.unwatchedCount, 5); + expect(item.isWatched, isFalse); + }); + test('path-encodes image ids and tag query values', () { final item = JellyfinMappers.mediaItem( { diff --git a/test/services/plex_mappers_test.dart b/test/services/plex_mappers_test.dart index 915bbf0d..30ce74ae 100644 --- a/test/services/plex_mappers_test.dart +++ b/test/services/plex_mappers_test.dart @@ -289,6 +289,21 @@ void main() { expect(item.serverId, _serverId); expect(item.serverName, _serverName); }); + + test('normalizes aggregate watch counts off leaf items', () { + final item = PlexMappers.mediaItemFromJson({ + 'ratingKey': 'leaf-with-counts', + 'type': 'movie', + 'viewCount': 0, + 'leafCount': 1, + 'viewedLeafCount': 1, + }, serverId: ServerId(_serverId)); + + expect(item.leafCount, 1); + expect(item.viewedLeafCount, isNull); + expect(item.isWatched, isFalse); + expect(item.unwatchedCount, isNull); + }); }); group('PlexMappers.mediaItem (show + season + episode)', () { diff --git a/test/services/trackers/anime_episode_progress_resolver_test.dart b/test/services/trackers/anime_episode_progress_resolver_test.dart index 9aae8ee1..f49c336f 100644 --- a/test/services/trackers/anime_episode_progress_resolver_test.dart +++ b/test/services/trackers/anime_episode_progress_resolver_test.dart @@ -34,7 +34,7 @@ class _FakeMediaServerClient implements MediaServerClient { dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } -MediaItem _season(int number, {int? watched, int? total}) => testMediaItem( +MediaItem _season(int number, {int? watched, int? total, int? childCount}) => testMediaItem( id: 'season-$number', backend: MediaBackend.plex, kind: MediaKind.season, @@ -42,6 +42,7 @@ MediaItem _season(int number, {int? watched, int? total}) => testMediaItem( index: number, leafCount: total, viewedLeafCount: watched, + childCount: childCount, ); MediaItem _episode({int season = 2, int number = 6, String showId = 'show-1', int? viewCount}) => testMediaItem( @@ -113,6 +114,18 @@ void main() { expect(result?.progress, 12); }); + test('season scope uses direct episode count when the leaf total is missing', () async { + final resolver = AnimeEpisodeProgressResolver( + _FakeMediaServerClient({ + 'show-1': [_season(2, watched: 12, childCount: 12)], + }), + ); + + final result = await resolver.resolve(_episode(season: 2, number: 12), scope: AnimeProgressScope.season); + + expect(result?.progress, 12); + }); + test('show scope caps progress at known show total', () async { final resolver = AnimeEpisodeProgressResolver( _FakeMediaServerClient({ diff --git a/test/utils/episode_collection_test.dart b/test/utils/episode_collection_test.dart index f1fca06f..c899b23a 100644 --- a/test/utils/episode_collection_test.dart +++ b/test/utils/episode_collection_test.dart @@ -11,7 +11,7 @@ import 'package:plezy/utils/download_version_utils.dart'; import 'package:plezy/media/episode_collection.dart'; import '../test_helpers/media_items.dart'; -MediaItem _season(String id, {int index = 1, int? leafCount, int? viewedLeafCount}) => testMediaItem( +MediaItem _season(String id, {int index = 1, int? leafCount, int? viewedLeafCount, int? childCount}) => testMediaItem( id: id, backend: MediaBackend.plex, kind: MediaKind.season, @@ -19,6 +19,7 @@ MediaItem _season(String id, {int index = 1, int? leafCount, int? viewedLeafCoun index: index, leafCount: leafCount, viewedLeafCount: viewedLeafCount, + childCount: childCount, ); MediaItem _episode( @@ -162,6 +163,12 @@ void main() { expect(firstUnwatchedSeasonIndex([special, season1, season2, season3]), 2); }); + test('firstUnwatchedSeasonIndex uses direct episode count when the leaf total is missing', () { + final season = _season('season-1', childCount: 5, viewedLeafCount: 2); + + expect(firstUnwatchedSeasonIndex([season]), 0); + }); + test('firstUnwatchedSeasonIndex falls back to specials only when no regular season qualifies', () { final special = _season('specials', index: 0, leafCount: 3, viewedLeafCount: 1); final season1 = _season('season-1', index: 1, leafCount: 4, viewedLeafCount: 4);