fix: resolve Series deserialization issues and add author sorting

- Fix genres error by parsing JSON manually to avoid Series deserialization
- Fix series blank screen by using SimpleSeries class instead of full Series
- Add alphabetical sorting to authors page
- Work around shelfsdk Series variant detection issues

The Audiobookshelf API returns Series objects that don't match any of the
variant detection patterns in the shelfsdk SeriesConverter. Since we can't
modify the external shelfsdk, we parse the API responses manually to extract
only the data we need for display.
This commit is contained in:
Claude 2025-11-20 16:17:09 +00:00
parent 5afce16532
commit f60ea72659
No known key found for this signature in database
3 changed files with 72 additions and 39 deletions

View file

@ -1,13 +1,33 @@
import 'package:hooks_riverpod/hooks_riverpod.dart' show Ref; import 'package:hooks_riverpod/hooks_riverpod.dart' show Ref;
import 'package:logging/logging.dart' show Logger; import 'package:logging/logging.dart' show Logger;
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:shelfsdk/audiobookshelf_api.dart' import 'package:shelfsdk/audiobookshelf_api.dart' show Author;
show Author, GetLibrarysSeriesResponse, LibraryFilterData;
import 'package:vaani/api/api_provider.dart' show authenticatedApiProvider; import 'package:vaani/api/api_provider.dart' show authenticatedApiProvider;
import 'package:vaani/api/library_provider.dart' show currentLibraryProvider; import 'package:vaani/api/library_provider.dart' show currentLibraryProvider;
part 'library_browser_provider.g.dart'; part 'library_browser_provider.g.dart';
/// Simple series data for display purposes
class SimpleSeries {
final String id;
final String name;
final int? numBooks;
SimpleSeries({
required this.id,
required this.name,
this.numBooks,
});
factory SimpleSeries.fromJson(Map<String, dynamic> json) {
return SimpleSeries(
id: json['id'] as String,
name: json['name'] as String,
numBooks: json['numBooks'] as int?,
);
}
}
final _logger = Logger('LibraryBrowserProvider'); final _logger = Logger('LibraryBrowserProvider');
/// Provider for fetching all authors in the current library /// Provider for fetching all authors in the current library
@ -43,37 +63,59 @@ Future<List<String>> libraryGenres(Ref ref) async {
return []; return [];
} }
final filterData = await api.libraries.getFilterData(libraryId: currentLibrary.id); // Use raw API call to avoid Series deserialization issues in LibraryFilterData
final genres = await api.getJson<List<String>>(
path: '/api/libraries/${currentLibrary.id}/filterdata',
requiresAuth: true,
fromJson: (json) {
if (json is Map<String, dynamic> && json.containsKey('genres')) {
final genresList = json['genres'] as List<dynamic>;
return genresList.map((e) => e.toString()).toList();
}
return <String>[];
},
);
if (filterData == null) { if (genres == null) {
_logger.warning('Failed to fetch filter data for library ${currentLibrary.id}'); _logger.warning('Failed to fetch genres for library ${currentLibrary.id}');
return []; return [];
} }
_logger.fine('Fetched ${filterData.genres.length} genres'); _logger.fine('Fetched ${genres.length} genres');
return filterData.genres; return genres;
} }
/// Provider for fetching all series in the current library /// Provider for fetching all series in the current library
@riverpod @riverpod
Future<GetLibrarysSeriesResponse?> librarySeries(Ref ref) async { Future<List<SimpleSeries>> librarySeries(Ref ref) async {
final api = ref.watch(authenticatedApiProvider); final api = ref.watch(authenticatedApiProvider);
final currentLibrary = await ref.watch(currentLibraryProvider.future); final currentLibrary = await ref.watch(currentLibraryProvider.future);
if (currentLibrary == null) { if (currentLibrary == null) {
_logger.warning('No current library found'); _logger.warning('No current library found');
return null; return [];
} }
final series = await api.libraries.getSeries( // Use raw API call to avoid Series deserialization issues
libraryId: currentLibrary.id, final seriesList = await api.getJson<List<SimpleSeries>>(
path: '/api/libraries/${currentLibrary.id}/series',
requiresAuth: true,
fromJson: (json) {
if (json is Map<String, dynamic> && json.containsKey('results')) {
final results = json['results'] as List<dynamic>;
return results
.map((e) => SimpleSeries.fromJson(e as Map<String, dynamic>))
.toList();
}
return <SimpleSeries>[];
},
); );
if (series == null) { if (seriesList == null) {
_logger.warning('Failed to fetch series for library ${currentLibrary.id}'); _logger.warning('Failed to fetch series for library ${currentLibrary.id}');
return null; return [];
} }
_logger.fine('Fetched ${series.results.length} series (${series.total} total)'); _logger.fine('Fetched ${seriesList.length} series');
return series; return seriesList;
} }

View file

@ -25,6 +25,10 @@ class LibraryAuthorsPage extends HookConsumerWidget {
); );
} }
// Sort authors alphabetically by name
final sortedAuthors = List<Author>.from(authors)
..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
return GridView.builder( return GridView.builder(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
@ -33,9 +37,9 @@ class LibraryAuthorsPage extends HookConsumerWidget {
crossAxisSpacing: 16, crossAxisSpacing: 16,
mainAxisSpacing: 16, mainAxisSpacing: 16,
), ),
itemCount: authors.length, itemCount: sortedAuthors.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final author = authors[index]; final author = sortedAuthors[index];
return AuthorCard(author: author); return AuthorCard(author: author);
}, },
); );

View file

@ -1,6 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shelfsdk/audiobookshelf_api.dart';
import 'package:vaani/api/library_browser_provider.dart'; import 'package:vaani/api/library_browser_provider.dart';
class LibrarySeriesPage extends HookConsumerWidget { class LibrarySeriesPage extends HookConsumerWidget {
@ -15,8 +14,8 @@ class LibrarySeriesPage extends HookConsumerWidget {
title: const Text('Series'), title: const Text('Series'),
), ),
body: seriesAsync.when( body: seriesAsync.when(
data: (seriesResponse) { data: (seriesList) {
if (seriesResponse == null || seriesResponse.results.isEmpty) { if (seriesList.isEmpty) {
return const Center( return const Center(
child: Text('No series found'), child: Text('No series found'),
); );
@ -24,9 +23,9 @@ class LibrarySeriesPage extends HookConsumerWidget {
return ListView.builder( return ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 8), padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: seriesResponse.results.length, itemCount: seriesList.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final series = seriesResponse.results[index]; final series = seriesList[index];
return SeriesListTile(series: series); return SeriesListTile(series: series);
}, },
); );
@ -55,22 +54,10 @@ class SeriesListTile extends StatelessWidget {
required this.series, required this.series,
}); });
final Series series; final SimpleSeries series;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// Extract series data based on variant
final String seriesName = series.name;
final int? numBooks = series.maybeMap(
(s) => null, // base variant
numBooks: (s) => s.numBooks,
books: (s) => s.books.length,
sequence: (s) => null,
shelf: (s) => s.books.length,
author: (s) => s.items?.length,
orElse: () => null,
);
return Card( return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: ListTile( child: ListTile(
@ -87,18 +74,18 @@ class SeriesListTile extends StatelessWidget {
), ),
), ),
title: Text( title: Text(
seriesName, series.name,
style: Theme.of(context).textTheme.titleMedium, style: Theme.of(context).textTheme.titleMedium,
), ),
subtitle: numBooks != null subtitle: series.numBooks != null
? Text('$numBooks ${numBooks == 1 ? 'book' : 'books'}') ? Text('${series.numBooks} ${series.numBooks == 1 ? 'book' : 'books'}')
: null, : null,
trailing: const Icon(Icons.chevron_right), trailing: const Icon(Icons.chevron_right),
onTap: () { onTap: () {
// TODO: Navigate to series detail page // TODO: Navigate to series detail page
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text('Tapped on $seriesName'), content: Text('Tapped on ${series.name}'),
duration: const Duration(seconds: 1), duration: const Duration(seconds: 1),
), ),
); );