fix(media): distinguish leaf and aggregate watch state

close #1610
This commit is contained in:
edde746
2026-07-26 14:58:38 +02:00
parent 60cc983471
commit 5ee3120a97
12 changed files with 335 additions and 41 deletions
+5 -6
View File
@@ -72,17 +72,16 @@ MediaItem? defaultPlaybackSeason(List<MediaItem> 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<MediaItem> 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
}
+47 -13
View File
@@ -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 01.
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<String, dynamic> ? userData['UnplayedItemCount'] as int? : null;
final unwatched = userData is Map<String, dynamic> ? 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;
}
+6
View File
@@ -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) {
+19 -16
View File
@@ -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<String, dynamic> ? ud : null;
}
static int? _nonNegativeCount(Object? value) {
final count = flexibleInt(value);
return count != null && count >= 0 ? count : null;
}
static int _viewCount(Map<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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) {
+1 -1
View File
@@ -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,
@@ -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);
}
+2 -2
View File
@@ -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<Color>(Theme.of(context).colorScheme.primary),
minHeight: size.barMinHeight,
+173
View File
@@ -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'});
+44
View File
@@ -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(
{
+15
View File
@@ -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)', () {
@@ -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({
+8 -1
View File
@@ -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);