@@ -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
@@ -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<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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'});
|
||||
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user