From d60fec405a72b3570494c5e4469c8acf52806a67 Mon Sep 17 00:00:00 2001 From: PartyDonut <42371342+PartyDonut@users.noreply.github.com> Date: Mon, 6 Jan 2025 18:10:36 +0100 Subject: [PATCH] chore: Implement a fake response for testing (#202) Co-authored-by: PartyDonut --- .fvmrc | 2 +- .vscode/settings.json | 2 +- lib/fake/fake_jellyfin_open_api.dart | 703 +++++++++++++++++++++++++++ lib/providers/api_provider.dart | 7 +- lib/providers/auth_provider.dart | 2 +- lib/providers/service_provider.dart | 40 +- 6 files changed, 729 insertions(+), 27 deletions(-) create mode 100644 lib/fake/fake_jellyfin_open_api.dart diff --git a/.fvmrc b/.fvmrc index 5b5166d..0fdcb48 100644 --- a/.fvmrc +++ b/.fvmrc @@ -1,3 +1,3 @@ { - "flutter": "3.27.0" + "flutter": "3.27.1" } \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index c3c67ff..157c247 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -7,7 +7,7 @@ "LTWH", "outro" ], - "dart.flutterSdkPath": ".fvm/versions/3.27.0", + "dart.flutterSdkPath": ".fvm/versions/3.27.1", "search.exclude": { "**/.fvm": true }, diff --git a/lib/fake/fake_jellyfin_open_api.dart b/lib/fake/fake_jellyfin_open_api.dart new file mode 100644 index 0000000..5e00665 --- /dev/null +++ b/lib/fake/fake_jellyfin_open_api.dart @@ -0,0 +1,703 @@ +import 'dart:developer'; + +import 'package:chopper/chopper.dart' as chopper; +import 'package:collection/collection.dart'; +import 'package:http/http.dart' as http; + +import 'package:fladder/jellyfin/jellyfin_open_api.enums.swagger.dart' as enums; +import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart'; + +List _baseItems = [ + BaseItemDto( + parentId: FakeHelper.fakeMoviesView.id, + name: "The revenge of the viewer", + type: BaseItemKind.movie, + overview: "A simple placeholder about the revenge of a viewer", + startDate: DateTime.now(), + officialRating: "PG3", + runTimeTicks: const Duration(minutes: 30).inMilliseconds * 10000, + userData: const UserItemDataDto( + isFavorite: true, + ), + ), + BaseItemDto( + parentId: FakeHelper.fakeMoviesView.id, + name: "BasicExtinct", + type: BaseItemKind.movie, + overview: "Basic Instinct but different", + startDate: DateTime.now(), + officialRating: "PG3", + runTimeTicks: const Duration(hours: 1).inMilliseconds * 10000, + userData: const UserItemDataDto( + isFavorite: false, + played: true, + ), + ), + BaseItemDto( + parentId: FakeHelper.fakeMoviesView.id, + name: "HowToView", + type: BaseItemKind.movie, + overview: "Simple movie about how to view something", + startDate: DateTime.now(), + officialRating: "PG3", + runTimeTicks: const Duration(hours: 1, minutes: 15).inMilliseconds * 10000, + userData: const UserItemDataDto( + isFavorite: false, + played: false, + playedPercentage: 20, + ), + ), + BaseItemDto( + parentId: FakeHelper.fakeSeriesView.id, + name: "Cappybara Tales", + id: "CappyShow", + type: BaseItemKind.series, + overview: "The mysterious life of cappybara's", + startDate: DateTime.now(), + officialRating: "PG3", + ), + BaseItemDto( + parentId: FakeHelper.fakeSeriesView.id, + name: "Season 1", + id: "CappySeason1", + seriesId: "CappyShow", + seriesName: "Cappybara Tales", + seasonName: "Season 1", + indexNumber: 1, + seriesCount: 1, + type: BaseItemKind.season, + overview: "What is this mysterious creature", + runTimeTicks: const Duration(minutes: 4).inMilliseconds * 10000, + userData: const UserItemDataDto( + isFavorite: true, + played: true, + ), + ), + BaseItemDto( + parentId: FakeHelper.fakeSeriesView.id, + name: "Giant rodent", + seriesId: "CappyShow", + indexNumber: 0, + seriesCount: 1, + seasonId: "CappySeason1", + seriesName: "Cappybara Tales", + type: BaseItemKind.episode, + overview: "What is this mysterious creature", + runTimeTicks: const Duration(minutes: 4).inMilliseconds * 10000, + userData: const UserItemDataDto( + isFavorite: true, + played: true, + ), + ), + BaseItemDto( + parentId: FakeHelper.fakeSeriesView.id, + name: "Live of a cappybara", + seriesId: "CappyShow", + indexNumber: 1, + seriesCount: 1, + seasonId: "CappySeason1", + seriesName: "Cappybara Tales", + type: BaseItemKind.episode, + overview: "Daily look at cappybara's in the wild", + runTimeTicks: const Duration(minutes: 4).inMilliseconds * 10000, + userData: const UserItemDataDto( + isFavorite: true, + played: true, + playedPercentage: 20, + ), + ), +].mapIndexed((index, e) => e.id == null ? e.copyWith(id: index.toString()) : e).toList(); + +class FakeJellyfinOpenApi extends JellyfinOpenApi { + @override + Type get definitionType => throw UnimplementedError(); + + @override + Future>> usersPublicGet() async => chopper.Response( + FakeHelper.fakeCorrectResponse, + FakeHelper.fakeUsers, + ); + + @override + Future> usersAuthenticateByNamePost({ + required AuthenticateUserByName? body, + }) async { + if (body?.username == FakeHelper.fakeCorrectUser.name && body?.pw == FakeHelper.fakeCorrectPassword) { + log(FakeHelper.fakeAuthResult.accessToken ?? "Null"); + return chopper.Response( + FakeHelper.fakeCorrectResponse, + FakeHelper.fakeAuthResult, + ); + } else { + return chopper.Response(http.Response("You clicked the wrong one dummy", 401), null); + } + } + + ///Gets public information about the server. + @override + Future> systemInfoPublicGet() async { + return chopper.Response( + FakeHelper.fakeCorrectResponse, + FakeHelper.fakePublicSystemInfo, + ); + } + + @override + Future> userViewsGet({ + String? userId, + bool? includeExternalContent, + List? presetViews, + bool? includeHidden, + }) async { + return chopper.Response( + FakeHelper.fakeCorrectResponse, + BaseItemDtoQueryResult( + items: [ + FakeHelper.fakeMoviesView, + FakeHelper.fakeSeriesView, + ], + totalRecordCount: 2, + startIndex: 0, + )); + } + + @override + Future> usersMeGet() async { + return chopper.Response( + FakeHelper.fakeCorrectResponse, + FakeHelper.fakeCorrectUser, + ); + } + + @override + Future> quickConnectEnabledGet() async { + return chopper.Response( + FakeHelper.fakeCorrectResponse, + FakeHelper.fakeServerConfig.quickConnectAvailable, + ); + } + + @override + Future> systemConfigurationGet() async { + return chopper.Response( + FakeHelper.fakeCorrectResponse, + FakeHelper.fakeServerConfig, + ); + } + + @override + Future> userItemsResumeGet({ + String? userId, + int? startIndex, + int? limit, + String? searchTerm, + String? parentId, + List? fields, + List? mediaTypes, + bool? enableUserData, + int? imageTypeLimit, + List? enableImageTypes, + List? excludeItemTypes, + List? includeItemTypes, + bool? enableTotalRecordCount, + bool? enableImages, + bool? excludeActiveSessions, + }) async { + return chopper.Response( + FakeHelper.fakeCorrectResponse, + BaseItemDtoQueryResult( + items: _baseItems + .where((e) => {BaseItemKind.movie, BaseItemKind.episode}.contains(e.type)) + .where((e) => e.userData?.played != true && e.userData?.playedPercentage != 0) + .fold>( + {}, + (map, item) { + if (!map.containsKey(item.seriesId)) { + map[item.seriesId] = item; + } + return map; + }, + ) + .values + .toList(), + ), + ); + } + + @override + Future>> itemsLatestGet({ + String? userId, + String? parentId, + List? fields, + List? includeItemTypes, + bool? isPlayed, + bool? enableImages, + int? imageTypeLimit, + List? enableImageTypes, + bool? enableUserData, + int? limit, + bool? groupItems, + }) async { + return chopper.Response( + FakeHelper.fakeCorrectResponse, + _baseItems.where((e) => e.parentId == parentId).toList(), + ); + } + + @override + Future> itemsFilters2Get({ + String? userId, + String? parentId, + List? includeItemTypes, + bool? isAiring, + bool? isMovie, + bool? isSports, + bool? isKids, + bool? isNews, + bool? isSeries, + bool? recursive, + }) async { + return chopper.Response( + FakeHelper.fakeCorrectResponse, + const QueryFilters(), + ); + } + + @override + Future> itemsItemIdGet({ + String? userId, + required String? itemId, + }) async { + return chopper.Response( + FakeHelper.fakeCorrectResponse, + _baseItems.firstWhere((item) => item.id == itemId), + ); + } + + @override + Future> itemsItemIdSimilarGet({ + required String? itemId, + List? excludeArtistIds, + String? userId, + int? limit, + List? fields, + }) async { + return chopper.Response( + FakeHelper.fakeCorrectResponse, + const BaseItemDtoQueryResult(items: []), + ); + } + + @override + Future> itemsGet({ + String? userId, + String? maxOfficialRating, + bool? hasThemeSong, + bool? hasThemeVideo, + bool? hasSubtitles, + bool? hasSpecialFeature, + bool? hasTrailer, + String? adjacentTo, + int? indexNumber, + int? parentIndexNumber, + bool? hasParentalRating, + bool? isHd, + bool? is4K, + List? locationTypes, + List? excludeLocationTypes, + bool? isMissing, + bool? isUnaired, + num? minCommunityRating, + num? minCriticRating, + DateTime? minPremiereDate, + DateTime? minDateLastSaved, + DateTime? minDateLastSavedForUser, + DateTime? maxPremiereDate, + bool? hasOverview, + bool? hasImdbId, + bool? hasTmdbId, + bool? hasTvdbId, + bool? isMovie, + bool? isSeries, + bool? isNews, + bool? isKids, + bool? isSports, + List? excludeItemIds, + int? startIndex, + int? limit, + bool? recursive, + String? searchTerm, + List? sortOrder, + String? parentId, + List? fields, + List? excludeItemTypes, + List? includeItemTypes, + List? filters, + bool? isFavorite, + List? mediaTypes, + List? imageTypes, + List? sortBy, + bool? isPlayed, + List? genres, + List? officialRatings, + List? tags, + List? years, + bool? enableUserData, + int? imageTypeLimit, + List? enableImageTypes, + String? person, + List? personIds, + List? personTypes, + List? studios, + List? artists, + List? excludeArtistIds, + List? artistIds, + List? albumArtistIds, + List? contributingArtistIds, + List? albums, + List? albumIds, + List? ids, + List? videoTypes, + String? minOfficialRating, + bool? isLocked, + bool? isPlaceHolder, + bool? hasOfficialRating, + bool? collapseBoxSetItems, + int? minWidth, + int? minHeight, + int? maxWidth, + int? maxHeight, + bool? is3D, + List? seriesStatus, + String? nameStartsWithOrGreater, + String? nameStartsWith, + String? nameLessThan, + List? studioIds, + List? genreIds, + bool? enableTotalRecordCount, + bool? enableImages, + }) async { + return chopper.Response( + FakeHelper.fakeCorrectResponse, + BaseItemDtoQueryResult( + items: _baseItems + .where((e) => e.parentId == parentId) + .where((e) => includeItemTypes?.contains(e.type) ?? true) + .where((e) => recursive == false ? {BaseItemKind.movie, BaseItemKind.series}.contains(e.type) : true) + .where((e) => filters?.contains(ItemFilter.isplayed) == true ? e.userData?.played == true : true) + .where((e) => filters?.contains(ItemFilter.isunplayed) == true ? e.userData?.played == false : true) + .where( + (e) => isFavorite == true || filters?.contains(ItemFilter.isfavorite) == true + ? e.userData?.isFavorite == true + : true, + ) + .toList(), + ), + ); + } + + @override + Future> showsSeriesIdSeasonsGet({ + required String? seriesId, + String? userId, + List? fields, + bool? isSpecialSeason, + bool? isMissing, + String? adjacentTo, + bool? enableImages, + int? imageTypeLimit, + List? enableImageTypes, + bool? enableUserData, + }) async { + return chopper.Response( + FakeHelper.fakeCorrectResponse, + BaseItemDtoQueryResult( + items: _baseItems.where((e) => e.type == BaseItemKind.season && e.seriesId == seriesId).toList()), + ); + } + + @override + Future> showsSeriesIdEpisodesGet({ + required String? seriesId, + String? userId, + List? fields, + int? season, + String? seasonId, + bool? isMissing, + String? adjacentTo, + String? startItemId, + int? startIndex, + int? limit, + bool? enableImages, + int? imageTypeLimit, + List? enableImageTypes, + bool? enableUserData, + enums.ShowsSeriesIdEpisodesGetSortBy? sortBy, + }) async { + return chopper.Response( + FakeHelper.fakeCorrectResponse, + BaseItemDtoQueryResult( + items: _baseItems.where((e) => e.type == BaseItemKind.episode && e.seriesId == seriesId).toList()), + ); + } + + @override + Future> showsNextUpGet({ + String? userId, + int? startIndex, + int? limit, + List? fields, + String? seriesId, + String? parentId, + bool? enableImages, + int? imageTypeLimit, + List? enableImageTypes, + bool? enableUserData, + DateTime? nextUpDateCutoff, + bool? enableTotalRecordCount, + bool? disableFirstEpisode, + bool? enableResumable, + bool? enableRewatching, + }) async { + return chopper.Response( + FakeHelper.fakeCorrectResponse, + BaseItemDtoQueryResult( + items: _baseItems + .where((e) => e.type == BaseItemKind.episode) + .where((e) => (e.userData?.playedPercentage != null && e.userData?.played == false)) + .toList(), + ), + ); + } + + @override + Future> userPlayedItemsItemIdPost({ + String? userId, + required String? itemId, + DateTime? datePlayed, + }) async { + final item = await _updateUserData( + itemId, + (data) => UserItemDataDto( + played: true, + isFavorite: data?.isFavorite, + ), + ); + if (item.type == BaseItemKind.series) { + for (var element in _baseItems.where((e) => e.seriesId == item.id)) { + await _updateUserData( + element.id, + (data) => UserItemDataDto( + played: true, + isFavorite: data?.isFavorite, + ), + ); + } + } + if (item.type == BaseItemKind.season) { + for (var element in _baseItems.where((e) => e.seasonId == item.id)) { + await _updateUserData( + element.id, + (data) => UserItemDataDto( + played: true, + isFavorite: data?.isFavorite, + ), + ); + } + } + return chopper.Response( + FakeHelper.fakeCorrectResponse, + item.userData, + ); + } + + @override + Future> userPlayedItemsItemIdDelete({ + String? userId, + required String? itemId, + }) async { + final item = await _updateUserData( + itemId, + (data) => UserItemDataDto(played: false, isFavorite: data?.isFavorite), + ); + if (item.type == BaseItemKind.series) { + for (var element in _baseItems.where((e) => e.seriesId == item.id)) { + await _updateUserData( + element.id, + (data) => UserItemDataDto( + played: false, + isFavorite: data?.isFavorite, + ), + ); + } + } + if (item.type == BaseItemKind.season) { + for (var element in _baseItems.where((e) => e.seasonId == item.id)) { + await _updateUserData( + element.id, + (data) => UserItemDataDto( + played: false, + isFavorite: data?.isFavorite, + ), + ); + } + } + return chopper.Response( + FakeHelper.fakeCorrectResponse, + item.userData, + ); + } + + @override + Future> personsGet({ + int? limit, + String? searchTerm, + List? fields, + List? filters, + bool? isFavorite, + bool? enableUserData, + int? imageTypeLimit, + List? enableImageTypes, + List? excludePersonTypes, + List? personTypes, + String? appearsInItemId, + String? userId, + bool? enableImages, + }) async { + return chopper.Response( + FakeHelper.fakeCorrectResponse, + const BaseItemDtoQueryResult(), + ); + } + + @override + Future> userFavoriteItemsItemIdDelete({ + String? userId, + required String? itemId, + }) async { + final item = await _updateUserData( + itemId, + (data) => data?.copyWith( + isFavorite: false, + ), + ); + return chopper.Response( + FakeHelper.fakeCorrectResponse, + item.userData, + ); + } + + @override + Future> userFavoriteItemsItemIdPost({ + String? userId, + required String? itemId, + }) async { + final item = await _updateUserData( + itemId, + (data) => data?.copyWith( + isFavorite: true, + ), + ); + return chopper.Response( + FakeHelper.fakeCorrectResponse, + item.userData, + ); + } + + Future _updateUserData(String? id, Function(UserItemDataDto? old) userData) async { + final currentItem = _baseItems.firstWhere((e) => e.id == id); + final updatedItem = currentItem.copyWith( + userData: userData(currentItem.userData), + ); + + _baseItems = _baseItems.map((orig) => orig.id == id ? updatedItem : orig).toList(); + + await Future.delayed(const Duration(milliseconds: 250)); + return updatedItem; + } + + @override + Future> studiosGet({ + int? startIndex, + int? limit, + String? searchTerm, + String? parentId, + List? fields, + List? excludeItemTypes, + List? includeItemTypes, + bool? isFavorite, + bool? enableUserData, + int? imageTypeLimit, + List? enableImageTypes, + String? userId, + String? nameStartsWithOrGreater, + String? nameStartsWith, + String? nameLessThan, + bool? enableImages, + bool? enableTotalRecordCount, + }) async { + return chopper.Response( + FakeHelper.fakeCorrectResponse, + const BaseItemDtoQueryResult(), + ); + } +} + +class FakeHelper { + static http.BaseResponse fakeCorrectResponse = http.Response('', 200); + + static String fakeTestServerUrl = "http://22b469df.fladder.nl"; + + static UserDto fakeCorrectUser = const UserDto(id: '1', name: 'User 1', configuration: UserConfiguration()); + static String fakeCorrectPassword = "Txnw6RWYb8yEtD"; + + static ServerConfiguration fakeServerConfig = const ServerConfiguration( + isStartupWizardCompleted: true, + quickConnectAvailable: false, + ); + + static PublicSystemInfo fakePublicSystemInfo = PublicSystemInfo( + localAddress: FakeHelper.fakeTestServerUrl, + serverName: "Stand in server", + version: "GOOG", + startupWizardCompleted: true, + id: "sldfkjsldjkf", + ); + + static BaseItemDto fakeMoviesView = BaseItemDto( + name: "Movies", + id: 'moviesId', + serverId: fakePublicSystemInfo.id, + dateCreated: DateTime.now(), + canDelete: false, + canDownload: false, + parentId: "CollectionID", + collectionType: CollectionType.movies, + playAccess: PlayAccess.none, + childCount: 5, + ); + + static BaseItemDto fakeSeriesView = BaseItemDto( + name: "Series", + id: 'seriesId', + serverId: fakePublicSystemInfo.id, + dateCreated: DateTime.now(), + canDelete: false, + canDownload: false, + parentId: "CollectionID", + collectionType: CollectionType.tvshows, + playAccess: PlayAccess.none, + childCount: 5, + ); + + static List fakeUsers = [ + fakeCorrectUser, + const UserDto(id: '2', name: 'User 2'), + ]; + + static AuthenticationResult fakeAuthResult = AuthenticationResult( + user: fakeCorrectUser, + accessToken: 'A_TOTALLY_REAL_TOKEN', + serverId: "1", + ); +} diff --git a/lib/providers/api_provider.dart b/lib/providers/api_provider.dart index 3430657..6c14214 100644 --- a/lib/providers/api_provider.dart +++ b/lib/providers/api_provider.dart @@ -14,8 +14,7 @@ part 'api_provider.g.dart'; @riverpod class JellyApi extends _$JellyApi { @override - JellyService build() { - return JellyService( + JellyService build() => JellyService( ref, JellyfinOpenApi.create( interceptors: [ @@ -23,8 +22,8 @@ class JellyApi extends _$JellyApi { JellyResponse(ref), HttpLoggingInterceptor(level: Level.basic), ], - )); - } + ), + ); } class JellyRequest implements Interceptor { diff --git a/lib/providers/auth_provider.dart b/lib/providers/auth_provider.dart index d8b5491..5baa6f5 100644 --- a/lib/providers/auth_provider.dart +++ b/lib/providers/auth_provider.dart @@ -50,9 +50,9 @@ class AuthNotifier extends StateNotifier { state = state.copyWith(loading: true); clearAllProviders(); var response = await api.usersAuthenticateByNamePost(userName: userName, password: password); - var serverResponse = await api.systemInfoPublicGet(); CredentialsModel credentials = state.tempCredentials; if (response.isSuccessful && (response.body?.accessToken?.isNotEmpty ?? false)) { + var serverResponse = await api.systemInfoPublicGet(); credentials = credentials.copyWith( token: response.body?.accessToken ?? "", serverId: response.body?.serverId, diff --git a/lib/providers/service_provider.dart b/lib/providers/service_provider.dart index 5204c9b..621209c 100644 --- a/lib/providers/service_provider.dart +++ b/lib/providers/service_provider.dart @@ -6,6 +6,7 @@ import 'package:collection/collection.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:path/path.dart'; +import 'package:fladder/fake/fake_jellyfin_open_api.dart'; import 'package:fladder/jellyfin/enum_models.dart'; import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart'; import 'package:fladder/models/account_model.dart'; @@ -13,25 +14,12 @@ import 'package:fladder/models/credentials_model.dart'; import 'package:fladder/models/item_base_model.dart'; import 'package:fladder/models/items/media_segments_model.dart'; import 'package:fladder/models/items/trick_play_model.dart'; -import 'package:fladder/providers/api_provider.dart'; +import 'package:fladder/providers/auth_provider.dart'; import 'package:fladder/providers/image_provider.dart'; import 'package:fladder/providers/user_provider.dart'; import 'package:fladder/util/duration_extensions.dart'; import 'package:fladder/util/jellyfin_extension.dart'; -final jellyServiceProvider = StateProvider( - (ref) => JellyService( - ref, - JellyfinOpenApi.create( - interceptors: [ - JellyRequest(ref), - JellyResponse(ref), - HttpLoggingInterceptor(level: Level.basic), - ], - ), - ), -); - class ServerQueryResult { final List original; final List items; @@ -77,9 +65,20 @@ class ServerQueryResult { } class JellyService { - JellyService(this.ref, this.api); + JellyService(this.ref, this._api); + + final JellyfinOpenApi _api; + + JellyfinOpenApi get api { + var authServer = ref.read(authProvider).tempCredentials.server; + var currentServer = ref.read(userProvider)?.credentials.server; + if ((authServer.isNotEmpty ? authServer : currentServer) == FakeHelper.fakeTestServerUrl) { + return FakeJellyfinOpenApi(); + } else { + return _api; + } + } - final JellyfinOpenApi api; final Ref ref; AccountModel? get account => ref.read(userProvider); @@ -294,10 +293,11 @@ class JellyService { ); return response.copyWith( body: response.body?.items - ?.map( - (e) => ItemBaseModel.fromBaseDto(e, ref), - ) - .toList(), + ?.map( + (e) => ItemBaseModel.fromBaseDto(e, ref), + ) + .toList() ?? + [], ); }