Schedule: remove the per-user follow (bookmark, Following filter, AnimeFlow follow calls)

Personal tracking will come from the user's AniList lists; AnimeFlow keeps
downloads, availability and requests only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-10 20:51:42 +03:00
1 parent 473c82ec0a
commit f23cc7b311
8 files changed
+18 -126

No files matched your search

@@ -248,7 +248,6 @@ class AnimeFlowScheduleItem {
final int? anidbId;
final int? tvdbId;
final String? jellyfinSeriesId;
final bool followed;
const AnimeFlowScheduleItem({
required this.episode,
@@ -257,7 +256,6 @@ class AnimeFlowScheduleItem {
this.anidbId,
this.tvdbId,
this.jellyfinSeriesId,
this.followed = false,
});
static AnimeFlowScheduleItem fromJson(Map j) => AnimeFlowScheduleItem(
@@ -267,7 +265,6 @@ class AnimeFlowScheduleItem {
anidbId: _int(j['anidb_id']),
tvdbId: _int(j['tvdb_id']),
jellyfinSeriesId: _str(j['jellyfin_series_id']),
followed: j['followed'] == true,
);
}
@@ -296,7 +293,6 @@ class AnimeFlowSeries {
final String? jellyfinSeriesId;
final SeriesState state;
final bool monitored;
final bool followed;
final int episodesTotal;
final int episodesAvailable;
final DateTime? nextAirDate;
@@ -312,7 +308,6 @@ class AnimeFlowSeries {
this.jellyfinSeriesId,
this.state = SeriesState.releasing,
this.monitored = false,
this.followed = false,
this.episodesTotal = 0,
this.episodesAvailable = 0,
this.nextAirDate,
@@ -328,7 +323,6 @@ class AnimeFlowSeries {
jellyfinSeriesId: _str(j['jellyfin_series_id']),
state: SeriesState.parse(j['state']),
monitored: j['monitored'] == true,
followed: j['followed'] == true,
episodesTotal: _int(j['episodes_total']) ?? 0,
episodesAvailable: _int(j['episodes_available']) ?? 0,
nextAirDate: _time(j['next_air_date']),
@@ -684,10 +678,6 @@ class AnimeFlowClient {
return AnimeFlowSeries.fromJson(j is Map ? j : const {});
}
/// Follow (or unfollow) a series [view].
Future<void> setFollowed(int seriesId, bool followed) =>
_call(followed ? 'PUT' : 'DELETE', '/series/$seriesId/follow');
/// Titles that can be requested [request].
Future<List<AnimeFlowSearchHit>> search(String q) async => [
for (final h in _items(await _call('GET', '/search', query: {'q': q}))) AnimeFlowSearchHit.fromJson(h),
@@ -48,8 +48,7 @@ class ScheduleEntry {
final String? reason;
/// AnimeFlow tracks the series.
final bool tracked;
final bool followed;
/// AnimeFlow's series id (follow, requests).
/// AnimeFlow's series id (requests).
final int? seriesId;
/// The AnimeFlow season of the matched episode (requests); untracked
/// on Jellyfin: the TVDB season of the AniList entry.
@@ -68,7 +67,6 @@ class ScheduleEntry {
this.progress,
this.reason,
this.tracked = false,
this.followed = false,
this.seriesId,
this.season,
this.jellyfinEpisode,
@@ -81,7 +79,6 @@ class ScheduleEntry {
progress: progress,
reason: reason,
tracked: tracked,
followed: followed,
seriesId: seriesId,
season: season,
jellyfinEpisode: jellyfinEpisode,
@@ -106,8 +103,7 @@ String _day(DateTime d) {
class _SeriesRef {
final int seriesId;
final String? jellyfinSeriesId;
bool followed;
_SeriesRef(this.seriesId, this.jellyfinSeriesId, this.followed);
_SeriesRef(this.seriesId, this.jellyfinSeriesId);
}
/// Merges AniList [airings] with AnimeFlow's [schedule] rows and tracked
@@ -138,7 +134,7 @@ List<ScheduleEntry> matchSchedule(
final refs = <int, _SeriesRef>{};
for (final s in series) {
final id = s.anilistId;
if (id != null) refs[id] ??= _SeriesRef(s.seriesId, s.jellyfinSeriesId, s.followed);
if (id != null) refs[id] ??= _SeriesRef(s.seriesId, s.jellyfinSeriesId);
}
// AnimeFlow's AniList episode number wins outright: TVDB numbering runs
// on across split entries (JoJo S6E4 is "2nd & 3rd STAGE" ep 3), so a TVDB
@@ -152,8 +148,7 @@ List<ScheduleEntry> matchSchedule(
for (final it in schedule) {
final e = it.episode, id = e.anilistId;
if (id == null) continue;
final ref = refs[id] ??= _SeriesRef(it.seriesId, it.jellyfinSeriesId, it.followed);
if (it.followed) ref.followed = true;
refs[id] ??= _SeriesRef(it.seriesId, it.jellyfinSeriesId);
if (e.anilistEpisode != null) {
numbered.add(id);
byAni.putIfAbsent('$id#${e.anilistEpisode}', () => it);
@@ -178,7 +173,6 @@ List<ScheduleEntry> matchSchedule(
progress: e.status == EpisodeStatus.downloading ? e.progress : null,
reason: e.status == EpisodeStatus.failed ? e.reason : null,
tracked: true,
followed: it.followed || (ref?.followed ?? false),
seriesId: it.seriesId,
season: e.season,
jellyfinSeriesId: it.jellyfinSeriesId ?? ref?.jellyfinSeriesId ?? lib[id]?.$1,
@@ -199,7 +193,6 @@ List<ScheduleEntry> matchSchedule(
airing: a,
status: a.airingAt.isAfter(at) ? ScheduleStatus.upcoming : ScheduleStatus.missing,
tracked: true,
followed: ref.followed,
seriesId: ref.seriesId,
jellyfinSeriesId: ref.jellyfinSeriesId ?? lib[id]?.$1,
);
@@ -127,7 +127,6 @@ class AiringCard extends StatelessWidget {
final ScheduleEntry entry;
final DateTime now;
final bool compact;
final bool followed;
final CardAction action;
/// Why [action] is disabled (no request scope, not on Jellyfin, ...);
@@ -138,20 +137,15 @@ class AiringCard extends StatelessWidget {
final bool busy;
final VoidCallback onAction;
/// Null hides the bookmark (untracked series).
final VoidCallback? onFollow;
const AiringCard({
super.key,
required this.entry,
required this.now,
required this.compact,
required this.followed,
required this.action,
required this.onAction,
this.disabledHint,
this.busy = false,
this.onFollow,
});
String get _id => '${entry.airing.anilistId}-${entry.airing.episode}';
@@ -181,22 +175,6 @@ class AiringCard extends StatelessWidget {
return disabledHint == null ? b : Tooltip(message: disabledHint!, child: b);
}
Widget? _follow(BuildContext context, double size) {
final f = onFollow;
if (f == null) return null;
return IconButton(
key: ValueKey('follow-$_id'),
onPressed: f,
tooltip: followed ? 'Stop following' : 'Follow',
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: BoxConstraints.tightFor(width: size + 10, height: size + 10),
iconSize: size,
color: followed ? Theme.of(context).colorScheme.primary : null,
icon: Icon(followed ? Icons.bookmark : Icons.bookmark_outline),
);
}
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
@@ -246,7 +224,6 @@ class AiringCard extends StatelessWidget {
],
),
),
if (followed && onFollow == null) Icon(Icons.bookmark, size: 14, color: scheme.primary),
if (requestable)
Tooltip(
message: disabledHint ?? (action == CardAction.request ? 'Request' : 'Request series'),
@@ -270,7 +247,6 @@ class AiringCard extends StatelessWidget {
);
}
final button = _button(context);
final follow = _follow(context, 16);
// The cover poster leads: it is what people recognise at a glance.
return Material(
key: ValueKey('card-$_id'),
@@ -340,26 +316,12 @@ class AiringCard extends StatelessWidget {
style: const TextStyle(fontWeight: FontWeight.w600, height: 1.25),
),
const SizedBox(height: 2),
Row(
children: [
Flexible(
child: Text(
Text(
'Episode ${a.episode}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12),
),
),
const SizedBox(width: 2),
if (follow != null)
follow
else if (followed)
Padding(
padding: const EdgeInsets.only(left: 4),
child: Icon(Icons.bookmark, size: 14, color: scheme.primary),
),
],
),
const Spacer(),
Row(
children: [
@@ -18,7 +18,7 @@ import 'schedule_card.dart';
// one thing to do about it (put it up, request it). Refreshes every
// minute while visible; ETags keep that cheap.
enum ScheduleFilter { jellyfin, following, all }
enum ScheduleFilter { jellyfin, all }
class SchedulePage extends StatefulWidget {
/// Nobody else watches (offline or solo): available episodes say Play
@@ -75,8 +75,6 @@ class _SchedulePageState extends State<SchedulePage> {
/// The sheet stays built while it slides out.
bool _sheetBuilt = false;
/// Follow toggles not yet confirmed by a reload (series id -> followed).
final _followOverride = <int, bool>{};
/// Requested in this run: "anilistId#episode" or "series:anilistId".
final _requested = <String>{};
@@ -190,11 +188,8 @@ class _SchedulePageState extends State<SchedulePage> {
animate ? _scroll.animateTo(x, duration: easeTime, curve: ease) : _scroll.jumpTo(x);
}
bool _followed(ScheduleEntry e) => (e.seriesId == null ? null : _followOverride[e.seriesId]) ?? e.followed;
bool _shows(ScheduleEntry e) => switch (_filter) {
ScheduleFilter.jellyfin => e.tracked || e.onJellyfin,
ScheduleFilter.following => _followed(e),
ScheduleFilter.all => _niche || e.tracked || e.airing.popularity >= SchedulePage.nicheBelow,
};
@@ -341,21 +336,6 @@ class _SchedulePageState extends State<SchedulePage> {
},
);
Future<void> _toggleFollow(ScheduleEntry e) async {
final c = _flow, id = e.seriesId;
if (c == null || id == null) return;
final next = !_followed(e);
setState(() => _followOverride[id] = next);
try {
await c.setFollowed(id, next);
await _load();
} catch (err) {
_toast(friendlyError(err));
} finally {
if (mounted) setState(() => _followOverride.remove(id));
}
}
Future<void> _decide(AnimeFlowRequest r, {required bool approve}) async {
final c = _flow;
if (c == null || _reqBusy.contains(r.id)) return;
@@ -393,11 +373,6 @@ class _SchedulePageState extends State<SchedulePage> {
icon: Icon(Icons.video_library_outlined),
label: Text('On Jellyfin'),
),
ButtonSegment(
value: ScheduleFilter.following,
icon: Icon(Icons.bookmark_outline),
label: Text('Following'),
),
ButtonSegment(value: ScheduleFilter.all, icon: Icon(Icons.public), label: Text('All airing')),
],
selected: {_filter},
@@ -541,12 +516,10 @@ class _SchedulePageState extends State<SchedulePage> {
entry: e,
now: now,
compact: _compact,
followed: _followed(e),
action: action,
disabledHint: hint,
busy: busy,
onAction: () => _onAction(e, action),
onFollow: e.tracked && e.seriesId != null && _flow != null ? () => _toggleFollow(e) : null,
);
}
+3 -11
View File
@@ -29,7 +29,6 @@ final seriesJson = {
'jellyfin_series_id': 'jf7',
'state': 'released',
'monitored': true,
'followed': true,
'episodes_total': 24,
'episodes_available': 20,
'next_air_date': null,
@@ -110,7 +109,7 @@ void main() {
'to': req.uri.queryParameters['to'],
'items': [
{...episodeJson, 'series_id': 7, 'title': 'Show', 'anidb_id': 70, 'tvdb_id': 700,
'jellyfin_series_id': 'jf7', 'followed': true},
'jellyfin_series_id': 'jf7'},
],
}, etag: 'W/"s1"');
},
@@ -126,7 +125,6 @@ void main() {
expect(it.anidbId, 70);
expect(it.tvdbId, 700);
expect(it.jellyfinSeriesId, 'jf7');
expect(it.followed, isTrue);
final e = it.episode;
expect((e.season, e.episode, e.absolute, e.title, e.anilistId), (2, 3, 15, 'Three', 222));
expect(e.airDate, DateTime.utc(2026, 10, 8, 19, 36, 32));
@@ -146,7 +144,7 @@ void main() {
expect(server.seen, isEmpty);
});
test('/series, /series/{id} with episodes, follow/unfollow', () async {
test('/series, /series/{id} with episodes', () async {
await serve({
'GET /series': (req, res) async => json(res, {'items': [seriesJson]}, etag: 'W/"a"'),
'GET /series/7': (req, res) async => json(res, {
@@ -156,24 +154,18 @@ void main() {
{'season': 1, 'episode': 1, 'status': 'failed', 'reason': 'No release'},
],
}),
'PUT /series/7/follow': (req, res) async => res.statusCode = 204,
'DELETE /series/7/follow': (req, res) async => res.statusCode = 204,
});
final c = client();
final s = (await c.series()).single;
expect(s.seriesId, 7);
expect(s.anilistId, 111);
expect(s.state, SeriesState.released);
expect(s.monitored && s.followed, isTrue);
expect(s.monitored, isTrue);
expect((s.episodesTotal, s.episodesAvailable, s.nextAirDate), (24, 20, null));
final d = await c.seriesDetail(7);
expect(d.episodes.map((e) => e.season), [0, 1]);
expect(d.episodes.last.status, EpisodeStatus.failed);
expect(d.episodes.last.reason, 'No release');
await c.setFollowed(7, true);
await c.setFollowed(7, false);
expect(server.seen.skip(2).map((r) => '${r.method} ${r.uri.path}'),
['PUT /api/app/v1/series/7/follow', 'DELETE /api/app/v1/series/7/follow']);
});
test('/search and the three request bodies', () async {
+5 -7
View File
@@ -20,7 +20,7 @@ Airing airing(int id, int ep, DateTime local) => Airing(
);
AnimeFlowScheduleItem item(int? anilistId, int ep, EpisodeStatus status,
{int season = 1, int? absolute, DateTime? air, int seriesId = 7, bool followed = false,
{int season = 1, int? absolute, DateTime? air, int seriesId = 7,
double? progress, String? reason}) =>
AnimeFlowScheduleItem(
episode: AnimeFlowEpisode(
@@ -36,7 +36,6 @@ AnimeFlowScheduleItem item(int? anilistId, int ep, EpisodeStatus status,
seriesId: seriesId,
title: 'Show',
jellyfinSeriesId: 'jf$seriesId',
followed: followed,
);
void main() {
@@ -150,7 +149,7 @@ void main() {
[airing(100, 3, DateTime(2026, 10, 1, 20)), airing(200, 3, DateTime(2026, 10, 9, 20))],
schedule: [
item(100, 3, EpisodeStatus.available, season: 1),
item(200, 3, EpisodeStatus.downloading, season: 2, progress: 0.4, followed: true),
item(200, 3, EpisodeStatus.downloading, season: 2, progress: 0.4),
],
series: [const AnimeFlowSeries(seriesId: 7, title: 'Show', anilistId: 100, jellyfinSeriesId: 'jf7')],
now: now,
@@ -158,7 +157,6 @@ void main() {
expect(out.map((e) => e.status), [ScheduleStatus.available, ScheduleStatus.downloading]);
expect(out.map((e) => e.season), [1, 2]);
expect(out[1].progress, 0.4);
expect(out[1].followed, isTrue);
expect(out.every((e) => e.tracked && e.seriesId == 7 && e.jellyfinSeriesId == 'jf7'), isTrue);
});
@@ -205,11 +203,11 @@ void main() {
final out = matchSchedule(
[airing(500, 2, DateTime(2026, 10, 7, 20)), airing(500, 3, DateTime(2026, 10, 14, 20))],
schedule: const [],
series: [const AnimeFlowSeries(seriesId: 9, title: 'S', anilistId: 500, followed: true)],
series: [const AnimeFlowSeries(seriesId: 9, title: 'S', anilistId: 500)],
now: now,
);
expect(out.map((e) => e.status), [ScheduleStatus.missing, ScheduleStatus.upcoming]);
expect(out.every((e) => e.followed && e.seriesId == 9), isTrue);
expect(out.every((e) => e.seriesId == 9), isTrue);
});
test('progress only while downloading, reason only when failed', () {
@@ -271,7 +269,7 @@ void main() {
return json(res, {
'items': [
{'season': 1, 'episode': 3, 'anilist_id': 100, 'status': 'available', 'series_id': 7,
'title': 'Show', 'followed': false},
'title': 'Show'},
],
});
case '/flow/series':
+2 -18
View File
@@ -88,7 +88,6 @@ List<ScheduleEntry> entries() => [
airing: airing(111, 'Avail Show', 3, day(0, 9)),
status: ScheduleStatus.available,
tracked: true,
followed: true,
seriesId: 7,
season: 1,
jellyfinSeriesId: 'jf7',
@@ -201,8 +200,6 @@ void main() {
});
case ('GET', '/search'):
json(res, {'items': searchHits});
case ('PUT' || 'DELETE', '/series/7/follow'):
res.statusCode = 204;
case ('POST', '/requests/1/approve' || '/requests/2/reject'):
json(res, {});
default:
@@ -298,13 +295,9 @@ void main() {
await done(tester);
});
testWidgets('filters: following, all airing, niche too, compact', (tester) async {
testWidgets('filters: all airing, niche too, compact', (tester) async {
await pumpPage(tester);
await tester.tap(find.text('Following'));
await tester.pump();
expect(card(111, 3), findsOneWidget);
expect(card(222, 5), findsNothing);
expect(find.text('Following'), findsNothing);
await tester.tap(find.text('All airing'));
await tester.pump();
expect(card(444, 1), findsOneWidget);
@@ -398,15 +391,6 @@ void main() {
await done(tester);
});
testWidgets('follow toggles through AnimeFlow', (tester) async {
final repo = await pumpPage(tester);
await tester.tap(find.byKey(const ValueKey('follow-111-3')));
await io(tester);
expect(server.seen.where((r) => r.method == 'DELETE' && r.uri.path.endsWith('/series/7/follow')), hasLength(1));
expect(repo.forced.length, greaterThanOrEqualTo(2)); // reloaded
await done(tester);
});
testWidgets('put it up resolves the Jellyfin episode', (tester) async {
final jf = FakeJf();
final accounts = JellyfinAccounts(
+2 -2
View File
@@ -33,8 +33,8 @@ off by deleting it in the same commit as the code. Wire changes also edit
should open the video track's URL alone, not the EDL.
4. **AniList account**: sign in (OAuth via a localhost callback), "My list"
filter on the Schedule; today's "Following" (AnimeFlow's monitored
shows) becomes "On the server".
filter on the Schedule from the user's AniList lists. This is the
Schedule's personal tracking; AnimeFlow has no per-user follow.
5. **Ops**: check shaders on Windows; the relay e2e "source drops
uncleanly and resumes" fails about half the time under the full suite.