fix: download and queue episodes in watch order, Specials last
close #1414
This commit is contained in:
@@ -54,11 +54,18 @@ Future<MediaItem?> fetchFirstEpisodeForSeason(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A season number of 0 (or missing) denotes the Specials folder, which the
|
||||||
|
/// app treats as a last resort for "what to watch next" — see
|
||||||
|
/// [defaultPlaybackSeasonIndex], [firstUnwatchedSeasonIndex] and
|
||||||
|
/// [compareEpisodesByWatchOrder].
|
||||||
|
bool isSpecialSeasonNumber(int? seasonNumber) => (seasonNumber ?? 0) == 0;
|
||||||
|
|
||||||
/// Prefer the first regular season over specials, falling back to the first
|
/// Prefer the first regular season over specials, falling back to the first
|
||||||
/// season row when a show only has specials or lacks season indexes.
|
/// season row when a show only has specials or lacks season indexes.
|
||||||
int defaultPlaybackSeasonIndex(List<MediaItem> seasons) {
|
int defaultPlaybackSeasonIndex(List<MediaItem> seasons) {
|
||||||
if (seasons.isEmpty) return 0;
|
if (seasons.isEmpty) return 0;
|
||||||
final regularSeasonIndex = seasons.indexWhere((season) => season.kind == MediaKind.season && (season.index ?? 0) > 0);
|
final regularSeasonIndex =
|
||||||
|
seasons.indexWhere((season) => season.kind == MediaKind.season && !isSpecialSeasonNumber(season.index));
|
||||||
if (regularSeasonIndex != -1) return regularSeasonIndex;
|
if (regularSeasonIndex != -1) return regularSeasonIndex;
|
||||||
final firstSeasonIndex = seasons.indexWhere((season) => season.kind == MediaKind.season);
|
final firstSeasonIndex = seasons.indexWhere((season) => season.kind == MediaKind.season);
|
||||||
return firstSeasonIndex == -1 ? 0 : firstSeasonIndex;
|
return firstSeasonIndex == -1 ? 0 : firstSeasonIndex;
|
||||||
@@ -84,7 +91,7 @@ int? firstUnwatchedSeasonIndex(List<MediaItem> seasons) {
|
|||||||
final leaf = season.leafCount;
|
final leaf = season.leafCount;
|
||||||
if (leaf == null || leaf <= 0) continue;
|
if (leaf == null || leaf <= 0) continue;
|
||||||
if ((season.viewedLeafCount ?? 0) >= leaf) continue; // fully watched
|
if ((season.viewedLeafCount ?? 0) >= leaf) continue; // fully watched
|
||||||
if ((season.index ?? 0) > 0) return i; // first regular season with unwatched
|
if (!isSpecialSeasonNumber(season.index)) return i; // first regular season with unwatched
|
||||||
firstSpecial ??= i; // specials only count as a last resort
|
firstSpecial ??= i; // specials only count as a last resort
|
||||||
}
|
}
|
||||||
return firstSpecial;
|
return firstSpecial;
|
||||||
@@ -96,12 +103,38 @@ int? firstUnwatchedSeasonIndex(List<MediaItem> seasons) {
|
|||||||
MediaItem? firstUnwatchedEpisode(List<MediaItem> episodes) {
|
MediaItem? firstUnwatchedEpisode(List<MediaItem> episodes) {
|
||||||
for (final episode in episodes) {
|
for (final episode in episodes) {
|
||||||
if (episode.kind != MediaKind.episode) continue;
|
if (episode.kind != MediaKind.episode) continue;
|
||||||
if (episode.isWatched && !episode.hasActiveProgress) continue;
|
if (!episode.isUnwatchedOrInProgress) continue;
|
||||||
return episode;
|
return episode;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Orders episodes the way the app selects "what to watch next": regular
|
||||||
|
/// seasons first, Specials (season 0) last, then by season number, then
|
||||||
|
/// episode number. Mirrors the "specials are a last resort" convention used by
|
||||||
|
/// [defaultPlaybackSeasonIndex] / [firstUnwatchedSeasonIndex] and the offline
|
||||||
|
/// continue-watching sort, so a count-capped "next N" selection (download /
|
||||||
|
/// sync rule) takes the next regular episodes instead of the whole Specials
|
||||||
|
/// folder first (#1414).
|
||||||
|
///
|
||||||
|
/// The trailing id comparison keeps the order deterministic for episodes that
|
||||||
|
/// share a season/episode index — Dart's [List.sort] is not stable — so the
|
||||||
|
/// "next N" cut is stable across runs.
|
||||||
|
int compareEpisodesByWatchOrder(MediaItem a, MediaItem b) {
|
||||||
|
final aSpecial = isSpecialSeasonNumber(a.parentIndex);
|
||||||
|
final bSpecial = isSpecialSeasonNumber(b.parentIndex);
|
||||||
|
if (aSpecial != bSpecial) return aSpecial ? 1 : -1;
|
||||||
|
final season = (a.parentIndex ?? 0).compareTo(b.parentIndex ?? 0);
|
||||||
|
if (season != 0) return season;
|
||||||
|
final episode = (a.index ?? 0).compareTo(b.index ?? 0);
|
||||||
|
if (episode != 0) return episode;
|
||||||
|
return a.id.compareTo(b.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// In-place sort by [compareEpisodesByWatchOrder]. See that function for the
|
||||||
|
/// ordering rationale.
|
||||||
|
void sortEpisodesByWatchOrder(List<MediaItem> episodes) => episodes.sort(compareEpisodesByWatchOrder);
|
||||||
|
|
||||||
/// Find the season index matching an explicit navigation target or on-deck
|
/// Find the season index matching an explicit navigation target or on-deck
|
||||||
/// episode. With neither, fall back to the first season that still has
|
/// episode. With neither, fall back to the first season that still has
|
||||||
/// unwatched episodes (so a partially-watched show removed from Continue
|
/// unwatched episodes (so a partially-watched show removed from Continue
|
||||||
@@ -194,11 +227,20 @@ Future<void> _collectPlayable(
|
|||||||
MediaItem? fallback,
|
MediaItem? fallback,
|
||||||
}) async {
|
}) async {
|
||||||
final leaves = await client.fetchPlayableDescendants(parentId);
|
final leaves = await client.fetchPlayableDescendants(parentId);
|
||||||
|
// Collect into a local list and order it before handing back: the backend
|
||||||
|
// returns episodes in raw container order (Plex /grandchildren puts S00
|
||||||
|
// first), and order-capped callers ("next N unwatched" download, sync-rule
|
||||||
|
// deficit) slice the front — so without this they'd grab Specials ahead of
|
||||||
|
// regular episodes (#1414). Sort the per-call slice, not the shared `out`
|
||||||
|
// accumulator, so multi-container callers don't interleave across shows.
|
||||||
|
final collected = <MediaItem>[];
|
||||||
for (final ep in leaves) {
|
for (final ep in leaves) {
|
||||||
if (ep.kind != MediaKind.episode) continue;
|
if (ep.kind != MediaKind.episode) continue;
|
||||||
if (unwatchedOnly && ep.isWatched && !ep.hasActiveProgress) continue;
|
if (unwatchedOnly && !ep.isUnwatchedOrInProgress) continue;
|
||||||
out.add(_withFallbackLibrary(ep, fallback));
|
collected.add(_withFallbackLibrary(ep, fallback));
|
||||||
}
|
}
|
||||||
|
sortEpisodesByWatchOrder(collected);
|
||||||
|
out.addAll(collected);
|
||||||
}
|
}
|
||||||
|
|
||||||
MediaItem _withFallbackLibrary(MediaItem item, MediaItem? fallback) {
|
MediaItem _withFallbackLibrary(MediaItem item, MediaItem? fallback) {
|
||||||
|
|||||||
@@ -387,6 +387,12 @@ sealed class MediaItem with _$MediaItem {
|
|||||||
return viewOffsetMs! > 0 && viewOffsetMs! < durationMs!;
|
return viewOffsetMs! > 0 && viewOffsetMs! < durationMs!;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether this item still counts toward an "unwatched only" selection:
|
||||||
|
/// not fully watched, or watched-but-resumable (has active progress). The
|
||||||
|
/// shared predicate behind every `unwatchedOnly` filter (downloads, sync
|
||||||
|
/// 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.
|
/// Whether this container (show/season) has some but not all leaves watched.
|
||||||
bool get isPartiallyWatched =>
|
bool get isPartiallyWatched =>
|
||||||
viewedLeafCount != null && leafCount != null && viewedLeafCount! > 0 && viewedLeafCount! < leafCount!;
|
viewedLeafCount != null && leafCount != null && viewedLeafCount! > 0 && viewedLeafCount! < leafCount!;
|
||||||
|
|||||||
@@ -921,7 +921,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
|||||||
int count = 0;
|
int count = 0;
|
||||||
|
|
||||||
Future<void> queueItem(MediaItem item) async {
|
Future<void> queueItem(MediaItem item) async {
|
||||||
if (unwatchedOnly && item.isWatched && !item.hasActiveProgress) return;
|
if (unwatchedOnly && !item.isUnwatchedOrInProgress) return;
|
||||||
final queued = await _queueSingleDownload(item, client, relatedContext: relatedContext);
|
final queued = await _queueSingleDownload(item, client, relatedContext: relatedContext);
|
||||||
if (queued) count++;
|
if (queued) count++;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:flutter/foundation.dart';
|
|||||||
import '../media/ids.dart';
|
import '../media/ids.dart';
|
||||||
|
|
||||||
import '../i18n/strings.g.dart';
|
import '../i18n/strings.g.dart';
|
||||||
|
import '../media/episode_collection.dart';
|
||||||
import '../media/media_item.dart';
|
import '../media/media_item.dart';
|
||||||
import '../media/media_item_types.dart';
|
import '../media/media_item_types.dart';
|
||||||
import '../mixins/disposable_change_notifier_mixin.dart';
|
import '../mixins/disposable_change_notifier_mixin.dart';
|
||||||
@@ -85,21 +86,13 @@ class OfflineWatchProvider extends ChangeNotifier with DisposableChangeNotifierM
|
|||||||
return metadata?.viewOffsetMs;
|
return metadata?.viewOffsetMs;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get sorted episodes for a show (by season, then episode number).
|
/// Get sorted episodes for a show: regular seasons first, Specials last,
|
||||||
|
/// then season then episode — the shared [sortEpisodesByWatchOrder] order,
|
||||||
|
/// so the offline watch order matches what "download next N" selects (#1414).
|
||||||
List<MediaItem> _getSortedEpisodes(String showId) {
|
List<MediaItem> _getSortedEpisodes(String showId) {
|
||||||
final episodes = _downloadProvider.getDownloadedEpisodesForShow(showId);
|
final episodes = _downloadProvider.getDownloadedEpisodesForShow(showId);
|
||||||
if (episodes.isEmpty) return episodes;
|
if (episodes.isEmpty) return episodes;
|
||||||
|
sortEpisodesByWatchOrder(episodes);
|
||||||
// Sort Season 0 (Specials) to the end so regular seasons play first
|
|
||||||
episodes.sort((a, b) {
|
|
||||||
final aIsSpecial = (a.parentIndex ?? 0) == 0;
|
|
||||||
final bIsSpecial = (b.parentIndex ?? 0) == 0;
|
|
||||||
if (aIsSpecial != bIsSpecial) return aIsSpecial ? 1 : -1;
|
|
||||||
final seasonCompare = (a.parentIndex ?? 0).compareTo(b.parentIndex ?? 0);
|
|
||||||
if (seasonCompare != 0) return seasonCompare;
|
|
||||||
return (a.index ?? 0).compareTo(b.index ?? 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
return episodes;
|
return episodes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -119,20 +119,11 @@ extension _VideoPlayerEpisodeQueueMethods on VideoPlayerScreenState {
|
|||||||
|
|
||||||
if (episodes.isEmpty) return;
|
if (episodes.isEmpty) return;
|
||||||
|
|
||||||
// Sort by aired date, falling back to season/episode number
|
// Regular seasons first, Specials last, then season/episode — the shared
|
||||||
final sorted = List<MediaItem>.from(episodes)
|
// watch order, so offline next/prev matches what "download next N" selects
|
||||||
..sort((a, b) {
|
// and the offline continue-watching list use (#1414). Copy first so the
|
||||||
final aDate = a.originallyAvailableAt ?? '';
|
// provider's cached list isn't reordered.
|
||||||
final bDate = b.originallyAvailableAt ?? '';
|
final sorted = List<MediaItem>.from(episodes)..sort(compareEpisodesByWatchOrder);
|
||||||
if (aDate.isEmpty && bDate.isEmpty) {
|
|
||||||
final seasonCmp = (a.parentIndex ?? 0).compareTo(b.parentIndex ?? 0);
|
|
||||||
if (seasonCmp != 0) return seasonCmp;
|
|
||||||
return (a.index ?? 0).compareTo(b.index ?? 0);
|
|
||||||
}
|
|
||||||
if (aDate.isEmpty) return 1;
|
|
||||||
if (bDate.isEmpty) return -1;
|
|
||||||
return aDate.compareTo(bDate);
|
|
||||||
});
|
|
||||||
|
|
||||||
final currentIdx = sorted.indexWhere((ep) => ep.id == _currentMetadata.id);
|
final currentIdx = sorted.indexWhere((ep) => ep.id == _currentMetadata.id);
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import '../media/media_server_user_profile.dart';
|
|||||||
import '../media/media_item.dart';
|
import '../media/media_item.dart';
|
||||||
import '../media/media_item_types.dart';
|
import '../media/media_item_types.dart';
|
||||||
import '../media/media_server_client.dart';
|
import '../media/media_server_client.dart';
|
||||||
|
import '../media/episode_collection.dart';
|
||||||
import '../media/live_tv_support.dart';
|
import '../media/live_tv_support.dart';
|
||||||
import '../models/livetv_channel.dart';
|
import '../models/livetv_channel.dart';
|
||||||
import '../services/live_seek_accumulator.dart';
|
import '../services/live_seek_accumulator.dart';
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:http/http.dart' as http;
|
|||||||
import 'package:package_info_plus/package_info_plus.dart';
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
|
|
||||||
import '../connection/connection.dart';
|
import '../connection/connection.dart';
|
||||||
|
import '../media/episode_collection.dart';
|
||||||
import '../media/library_filter_result.dart';
|
import '../media/library_filter_result.dart';
|
||||||
import '../media/library_first_character.dart';
|
import '../media/library_first_character.dart';
|
||||||
import '../media/library_query.dart';
|
import '../media/library_query.dart';
|
||||||
|
|||||||
@@ -915,7 +915,13 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
|
|||||||
return _pagedMediaItems(response.data, offset: offset, requestedSize: pageSize);
|
return _pagedMediaItems(response.data, offset: offset, requestedSize: pageSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// All episodes of a series in air order, optimised for queue-building.
|
/// All episodes of a series in the app's watch order — regular seasons
|
||||||
|
/// first, Specials (season 0) last — so the client-side next/previous queue
|
||||||
|
/// matches what downloads and offline playback use (#1414). The server sort
|
||||||
|
/// ([_episodeOrderQueryParameters]) keeps paging stable (and lists Specials
|
||||||
|
/// first); [sortEpisodesByWatchOrder] then normalizes the assembled list to
|
||||||
|
/// the shared convention, leaving a single definition of "episode order".
|
||||||
|
///
|
||||||
/// Uses [_queueFields] (only `UserData`) instead of the browse field
|
/// Uses [_queueFields] (only `UserData`) instead of the browse field
|
||||||
/// set so the response stays small even for shows with thousands of
|
/// set so the response stays small even for shows with thousands of
|
||||||
/// episodes.
|
/// episodes.
|
||||||
@@ -954,6 +960,9 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
|
|||||||
startIndex += page.length;
|
startIndex += page.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Server lists Specials first (ParentIndexNumber asc); reorder to the
|
||||||
|
// shared watch order so online next/prev matches offline + downloads.
|
||||||
|
sortEpisodesByWatchOrder(all);
|
||||||
return all;
|
return all;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -428,7 +428,7 @@ class SyncRuleExecutor {
|
|||||||
switch (item.kind) {
|
switch (item.kind) {
|
||||||
case MediaKind.movie:
|
case MediaKind.movie:
|
||||||
case MediaKind.episode:
|
case MediaKind.episode:
|
||||||
if (unwatchedOnly && item.isWatched && !item.hasActiveProgress) break;
|
if (unwatchedOnly && !item.isUnwatchedOrInProgress) break;
|
||||||
out.add(item);
|
out.add(item);
|
||||||
case MediaKind.show:
|
case MediaKind.show:
|
||||||
await collectEpisodesForShow(client, item.id, unwatchedOnly: unwatchedOnly, out: out, fallback: item);
|
await collectEpisodesForShow(client, item.id, unwatchedOnly: unwatchedOnly, out: out, fallback: item);
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
name: plezy
|
name: plezy
|
||||||
description: "A beautiful Plex and Jellyfin client for Flutter"
|
description: "A beautiful Plex and Jellyfin client for Flutter"
|
||||||
publish_to: "none"
|
publish_to: "none"
|
||||||
version: 2.7.1+116
|
version: 2.7.2+118
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ">=3.12.0 <4.0.0"
|
sdk: ">=3.12.0 <4.0.0"
|
||||||
|
|||||||
@@ -191,6 +191,49 @@ void main() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('sortEpisodesByWatchOrder puts regular seasons first and Specials last', () {
|
||||||
|
final s0e1 = _episode('s0e1', parentIndex: 0, index: 1);
|
||||||
|
final s0e2 = _episode('s0e2', parentIndex: 0, index: 2);
|
||||||
|
final s1e1 = _episode('s1e1', parentIndex: 1, index: 1);
|
||||||
|
final s1e2 = _episode('s1e2', parentIndex: 1, index: 2);
|
||||||
|
final s2e1 = _episode('s2e1', parentIndex: 2, index: 1);
|
||||||
|
|
||||||
|
// Raw /grandchildren order would lead with the Specials folder.
|
||||||
|
final episodes = [s0e1, s0e2, s1e1, s1e2, s2e1];
|
||||||
|
sortEpisodesByWatchOrder(episodes);
|
||||||
|
|
||||||
|
// A "next 2 unwatched" cut now takes S01E01/S01E02, not the Specials.
|
||||||
|
expect(episodes.map((e) => e.id), ['s1e1', 's1e2', 's2e1', 's0e1', 's0e2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('compareEpisodesByWatchOrder breaks index ties on id for deterministic cuts', () {
|
||||||
|
final a = _episode('a', parentIndex: 1, index: 1);
|
||||||
|
final b = _episode('b', parentIndex: 1, index: 1);
|
||||||
|
|
||||||
|
expect(compareEpisodesByWatchOrder(a, b), lessThan(0));
|
||||||
|
expect(compareEpisodesByWatchOrder(b, a), greaterThan(0));
|
||||||
|
expect(compareEpisodesByWatchOrder(a, a), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isSpecialSeasonNumber treats season 0 and missing numbers as Specials', () {
|
||||||
|
expect(isSpecialSeasonNumber(0), isTrue);
|
||||||
|
expect(isSpecialSeasonNumber(null), isTrue);
|
||||||
|
expect(isSpecialSeasonNumber(1), isFalse);
|
||||||
|
expect(isSpecialSeasonNumber(2), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isUnwatchedOrInProgress keeps unwatched and resumable episodes', () {
|
||||||
|
// Unwatched.
|
||||||
|
expect(_episode('a', viewCount: 0).isUnwatchedOrInProgress, isTrue);
|
||||||
|
// Watched with no resume point — counts as done.
|
||||||
|
expect(_episode('b', viewCount: 1).isUnwatchedOrInProgress, isFalse);
|
||||||
|
// Watched but still resumable (re-watching) — counts as to-watch.
|
||||||
|
expect(
|
||||||
|
_episode('c', viewCount: 1, viewOffsetMs: 500, durationMs: 1000).isUnwatchedOrInProgress,
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('fetchFirstEpisodeForSeason requests only the first children page', () async {
|
test('fetchFirstEpisodeForSeason requests only the first children page', () async {
|
||||||
final episode = _episode('episode-1');
|
final episode = _episode('episode-1');
|
||||||
final client = _RecordingClient(
|
final client = _RecordingClient(
|
||||||
|
|||||||
Reference in New Issue
Block a user