diff --git a/lib/models/seerr/seerr_details.dart b/lib/models/seerr/seerr_details.dart new file mode 100644 index 00000000..19a0c504 --- /dev/null +++ b/lib/models/seerr/seerr_details.dart @@ -0,0 +1,153 @@ +import 'package:json_annotation/json_annotation.dart'; + +import 'seerr_media.dart'; + +part 'seerr_details.g.dart'; + +/// Full movie detail from `GET /movie/{tmdbId}` — the subset the catalog +/// surfaces need (credits, external ids, availability, air status). +@JsonSerializable(createToJson: false) +class SeerrMovieDetails { + final int id; + final String? title; + final String? overview; + final String? posterPath; + final String? backdropPath; + final String? releaseDate; + + /// Minutes. + final int? runtime; + + /// `Released` / `In Production` / `Post Production` / `Planned` / + /// `Canceled` / `Rumored`. + final String? status; + final double? voteAverage; + final int? voteCount; + final List? genres; + final SeerrCredits? credits; + final SeerrExternalIds? externalIds; + final SeerrMediaInfo? mediaInfo; + + const SeerrMovieDetails({ + required this.id, + this.title, + this.overview, + this.posterPath, + this.backdropPath, + this.releaseDate, + this.runtime, + this.status, + this.voteAverage, + this.voteCount, + this.genres, + this.credits, + this.externalIds, + this.mediaInfo, + }); + + factory SeerrMovieDetails.fromJson(Map json) => _$SeerrMovieDetailsFromJson(json); +} + +/// Full TV detail from `GET /tv/{tmdbId}`. +@JsonSerializable(createToJson: false) +class SeerrTvDetails { + final int id; + final String? name; + final String? overview; + final String? posterPath; + final String? backdropPath; + final String? firstAirDate; + final List? episodeRunTime; + + /// `Returning Series` / `Ended` / `Canceled` / `In Production` / + /// `Planned` / `Pilot`. + final String? status; + final double? voteAverage; + final int? voteCount; + final List? genres; + final List? networks; + final int? numberOfEpisodes; + final int? numberOfSeasons; + final List? seasons; + final SeerrCredits? credits; + final SeerrExternalIds? externalIds; + final SeerrMediaInfo? mediaInfo; + + const SeerrTvDetails({ + required this.id, + this.name, + this.overview, + this.posterPath, + this.backdropPath, + this.firstAirDate, + this.episodeRunTime, + this.status, + this.voteAverage, + this.voteCount, + this.genres, + this.networks, + this.numberOfEpisodes, + this.numberOfSeasons, + this.seasons, + this.credits, + this.externalIds, + this.mediaInfo, + }); + + factory SeerrTvDetails.fromJson(Map json) => _$SeerrTvDetailsFromJson(json); +} + +@JsonSerializable(createToJson: false) +class SeerrGenre { + final String? name; + const SeerrGenre({this.name}); + factory SeerrGenre.fromJson(Map json) => _$SeerrGenreFromJson(json); +} + +@JsonSerializable(createToJson: false) +class SeerrNetwork { + final String? name; + const SeerrNetwork({this.name}); + factory SeerrNetwork.fromJson(Map json) => _$SeerrNetworkFromJson(json); +} + +/// One TMDB season entry (`TvDetails.seasons[]`). Season 0 is specials. +@JsonSerializable(createToJson: false) +class SeerrSeason { + final int seasonNumber; + final String? name; + final int? episodeCount; + final String? airDate; + + const SeerrSeason({required this.seasonNumber, this.name, this.episodeCount, this.airDate}); + + factory SeerrSeason.fromJson(Map json) => _$SeerrSeasonFromJson(json); +} + +@JsonSerializable(createToJson: false) +class SeerrCredits { + final List? cast; + const SeerrCredits({this.cast}); + factory SeerrCredits.fromJson(Map json) => _$SeerrCreditsFromJson(json); +} + +@JsonSerializable(createToJson: false) +class SeerrCastMember { + final String? name; + final String? character; + final String? profilePath; + + const SeerrCastMember({this.name, this.character, this.profilePath}); + + factory SeerrCastMember.fromJson(Map json) => _$SeerrCastMemberFromJson(json); +} + +@JsonSerializable(createToJson: false) +class SeerrExternalIds { + final String? imdbId; + final int? tvdbId; + + const SeerrExternalIds({this.imdbId, this.tvdbId}); + + factory SeerrExternalIds.fromJson(Map json) => _$SeerrExternalIdsFromJson(json); +} diff --git a/lib/models/seerr/seerr_details.g.dart b/lib/models/seerr/seerr_details.g.dart new file mode 100644 index 00000000..133bb02b --- /dev/null +++ b/lib/models/seerr/seerr_details.g.dart @@ -0,0 +1,105 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'seerr_details.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +SeerrMovieDetails _$SeerrMovieDetailsFromJson(Map json) => + SeerrMovieDetails( + id: (json['id'] as num).toInt(), + title: json['title'] as String?, + overview: json['overview'] as String?, + posterPath: json['posterPath'] as String?, + backdropPath: json['backdropPath'] as String?, + releaseDate: json['releaseDate'] as String?, + runtime: (json['runtime'] as num?)?.toInt(), + status: json['status'] as String?, + voteAverage: (json['voteAverage'] as num?)?.toDouble(), + voteCount: (json['voteCount'] as num?)?.toInt(), + genres: (json['genres'] as List?) + ?.map((e) => SeerrGenre.fromJson(e as Map)) + .toList(), + credits: json['credits'] == null + ? null + : SeerrCredits.fromJson(json['credits'] as Map), + externalIds: json['externalIds'] == null + ? null + : SeerrExternalIds.fromJson( + json['externalIds'] as Map, + ), + mediaInfo: json['mediaInfo'] == null + ? null + : SeerrMediaInfo.fromJson(json['mediaInfo'] as Map), + ); + +SeerrTvDetails _$SeerrTvDetailsFromJson(Map json) => + SeerrTvDetails( + id: (json['id'] as num).toInt(), + name: json['name'] as String?, + overview: json['overview'] as String?, + posterPath: json['posterPath'] as String?, + backdropPath: json['backdropPath'] as String?, + firstAirDate: json['firstAirDate'] as String?, + episodeRunTime: (json['episodeRunTime'] as List?) + ?.map((e) => (e as num).toInt()) + .toList(), + status: json['status'] as String?, + voteAverage: (json['voteAverage'] as num?)?.toDouble(), + voteCount: (json['voteCount'] as num?)?.toInt(), + genres: (json['genres'] as List?) + ?.map((e) => SeerrGenre.fromJson(e as Map)) + .toList(), + networks: (json['networks'] as List?) + ?.map((e) => SeerrNetwork.fromJson(e as Map)) + .toList(), + numberOfEpisodes: (json['numberOfEpisodes'] as num?)?.toInt(), + numberOfSeasons: (json['numberOfSeasons'] as num?)?.toInt(), + seasons: (json['seasons'] as List?) + ?.map((e) => SeerrSeason.fromJson(e as Map)) + .toList(), + credits: json['credits'] == null + ? null + : SeerrCredits.fromJson(json['credits'] as Map), + externalIds: json['externalIds'] == null + ? null + : SeerrExternalIds.fromJson( + json['externalIds'] as Map, + ), + mediaInfo: json['mediaInfo'] == null + ? null + : SeerrMediaInfo.fromJson(json['mediaInfo'] as Map), + ); + +SeerrGenre _$SeerrGenreFromJson(Map json) => + SeerrGenre(name: json['name'] as String?); + +SeerrNetwork _$SeerrNetworkFromJson(Map json) => + SeerrNetwork(name: json['name'] as String?); + +SeerrSeason _$SeerrSeasonFromJson(Map json) => SeerrSeason( + seasonNumber: (json['seasonNumber'] as num).toInt(), + name: json['name'] as String?, + episodeCount: (json['episodeCount'] as num?)?.toInt(), + airDate: json['airDate'] as String?, +); + +SeerrCredits _$SeerrCreditsFromJson(Map json) => SeerrCredits( + cast: (json['cast'] as List?) + ?.map((e) => SeerrCastMember.fromJson(e as Map)) + .toList(), +); + +SeerrCastMember _$SeerrCastMemberFromJson(Map json) => + SeerrCastMember( + name: json['name'] as String?, + character: json['character'] as String?, + profilePath: json['profilePath'] as String?, + ); + +SeerrExternalIds _$SeerrExternalIdsFromJson(Map json) => + SeerrExternalIds( + imdbId: json['imdbId'] as String?, + tvdbId: (json['tvdbId'] as num?)?.toInt(), + ); diff --git a/lib/models/seerr/seerr_media.dart b/lib/models/seerr/seerr_media.dart new file mode 100644 index 00000000..9007dfd7 --- /dev/null +++ b/lib/models/seerr/seerr_media.dart @@ -0,0 +1,124 @@ +import 'package:json_annotation/json_annotation.dart'; + +import 'seerr_request.dart'; + +part 'seerr_media.g.dart'; + +/// Seerr availability of a title/season on the linked media server +/// (`MediaInfo.status`). +enum SeerrMediaStatus { + unknown(1), + pending(2), + processing(3), + partiallyAvailable(4), + available(5), + deleted(6); + + final int code; + const SeerrMediaStatus(this.code); + + static SeerrMediaStatus fromCode(int? code) => + values.where((v) => v.code == code).firstOrNull ?? SeerrMediaStatus.unknown; +} + +/// A movie or TV entry from Seerr's TMDB-backed discover/search endpoints. +/// +/// TMDB uses `title`/`releaseDate` for movies and `name`/`firstAirDate` for +/// TV; [displayTitle]/[date] paper over the split. `mediaType` is absent on +/// the single-type discover endpoints — the client coerces it there. +@JsonSerializable(createToJson: false) +class SeerrMedia { + final int id; + final String? mediaType; + final String? title; + final String? name; + final String? overview; + final String? posterPath; + final String? backdropPath; + final String? releaseDate; + final String? firstAirDate; + final double? voteAverage; + final int? voteCount; + final SeerrMediaInfo? mediaInfo; + + const SeerrMedia({ + required this.id, + this.mediaType, + this.title, + this.name, + this.overview, + this.posterPath, + this.backdropPath, + this.releaseDate, + this.firstAirDate, + this.voteAverage, + this.voteCount, + this.mediaInfo, + }); + + bool get isMovie => mediaType == 'movie'; + + String get displayTitle => title ?? name ?? ''; + + String? get date => releaseDate ?? firstAirDate; + + int? get year { + final d = date; + if (d == null || d.length < 4) return null; + return int.tryParse(d.substring(0, 4)); + } + + factory SeerrMedia.fromJson(Map json) => _$SeerrMediaFromJson(json); +} + +/// Seerr's knowledge of a title on the linked media server: availability +/// status plus any open requests. Absent entirely for titles Seerr has +/// never seen. +@JsonSerializable(createToJson: false) +class SeerrMediaInfo { + final int? id; + final int? tmdbId; + final int? tvdbId; + + @JsonKey(name: 'status', fromJson: SeerrMediaStatus.fromCode) + final SeerrMediaStatus status; + @JsonKey(name: 'status4k', fromJson: SeerrMediaStatus.fromCode) + final SeerrMediaStatus status4k; + + /// TV only: per-season availability. + final List? seasons; + + /// Open/settled requests for this title (used to disable already-requested + /// seasons in the request sheet). + final List? requests; + + const SeerrMediaInfo({ + this.id, + this.tmdbId, + this.tvdbId, + this.status = SeerrMediaStatus.unknown, + this.status4k = SeerrMediaStatus.unknown, + this.seasons, + this.requests, + }); + + factory SeerrMediaInfo.fromJson(Map json) => _$SeerrMediaInfoFromJson(json); +} + +/// Availability of one season (`MediaInfo.seasons[]`). +@JsonSerializable(createToJson: false) +class SeerrSeasonInfo { + final int seasonNumber; + @JsonKey(name: 'status', fromJson: SeerrMediaStatus.fromCode) + final SeerrMediaStatus status; + @JsonKey(name: 'status4k', fromJson: SeerrMediaStatus.fromCode) + final SeerrMediaStatus status4k; + + const SeerrSeasonInfo({ + required this.seasonNumber, + this.status = SeerrMediaStatus.unknown, + this.status4k = SeerrMediaStatus.unknown, + }); + + factory SeerrSeasonInfo.fromJson(Map json) => _$SeerrSeasonInfoFromJson(json); +} diff --git a/lib/models/seerr/seerr_media.g.dart b/lib/models/seerr/seerr_media.g.dart new file mode 100644 index 00000000..d4c1eb31 --- /dev/null +++ b/lib/models/seerr/seerr_media.g.dart @@ -0,0 +1,54 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'seerr_media.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +SeerrMedia _$SeerrMediaFromJson(Map json) => SeerrMedia( + id: (json['id'] as num).toInt(), + mediaType: json['mediaType'] as String?, + title: json['title'] as String?, + name: json['name'] as String?, + overview: json['overview'] as String?, + posterPath: json['posterPath'] as String?, + backdropPath: json['backdropPath'] as String?, + releaseDate: json['releaseDate'] as String?, + firstAirDate: json['firstAirDate'] as String?, + voteAverage: (json['voteAverage'] as num?)?.toDouble(), + voteCount: (json['voteCount'] as num?)?.toInt(), + mediaInfo: json['mediaInfo'] == null + ? null + : SeerrMediaInfo.fromJson(json['mediaInfo'] as Map), +); + +SeerrMediaInfo _$SeerrMediaInfoFromJson(Map json) => + SeerrMediaInfo( + id: (json['id'] as num?)?.toInt(), + tmdbId: (json['tmdbId'] as num?)?.toInt(), + tvdbId: (json['tvdbId'] as num?)?.toInt(), + status: json['status'] == null + ? SeerrMediaStatus.unknown + : SeerrMediaStatus.fromCode((json['status'] as num?)?.toInt()), + status4k: json['status4k'] == null + ? SeerrMediaStatus.unknown + : SeerrMediaStatus.fromCode((json['status4k'] as num?)?.toInt()), + seasons: (json['seasons'] as List?) + ?.map((e) => SeerrSeasonInfo.fromJson(e as Map)) + .toList(), + requests: (json['requests'] as List?) + ?.map((e) => SeerrRequest.fromJson(e as Map)) + .toList(), + ); + +SeerrSeasonInfo _$SeerrSeasonInfoFromJson(Map json) => + SeerrSeasonInfo( + seasonNumber: (json['seasonNumber'] as num).toInt(), + status: json['status'] == null + ? SeerrMediaStatus.unknown + : SeerrMediaStatus.fromCode((json['status'] as num?)?.toInt()), + status4k: json['status4k'] == null + ? SeerrMediaStatus.unknown + : SeerrMediaStatus.fromCode((json['status4k'] as num?)?.toInt()), + ); diff --git a/lib/models/seerr/seerr_page.dart b/lib/models/seerr/seerr_page.dart new file mode 100644 index 00000000..f14bdbfc --- /dev/null +++ b/lib/models/seerr/seerr_page.dart @@ -0,0 +1,27 @@ +/// One page of a paginated Seerr response. Discover/search endpoints use the +/// TMDB shape `{page, totalPages, results}`; Seerr's own listings (e.g. +/// `GET /request`) use `{pageInfo: {page, pages}, results}` — both parse. +class SeerrPage { + final List items; + final bool hasMore; + + const SeerrPage({required this.items, required this.hasMore}); + + /// [skip] drops results the mapper can't represent (person entries in + /// mixed trending/search results) — return null from [fromItem] for those. + factory SeerrPage.fromJson(Map json, T? Function(Map) fromItem) { + final info = json['pageInfo'] is Map ? json['pageInfo'] as Map : json; + final page = (info['page'] as num?)?.toInt() ?? 1; + final totalPages = ((info['pages'] ?? info['totalPages']) as num?)?.toInt() ?? page; + final results = json['results']; + return SeerrPage( + items: [ + if (results is List) + for (final item in results) + if (item is Map) + if (fromItem(item) case final T parsed) parsed, + ], + hasMore: page < totalPages, + ); + } +} diff --git a/lib/models/seerr/seerr_public_settings.dart b/lib/models/seerr/seerr_public_settings.dart new file mode 100644 index 00000000..bba5440e --- /dev/null +++ b/lib/models/seerr/seerr_public_settings.dart @@ -0,0 +1,42 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'seerr_public_settings.g.dart'; + +/// `GET /settings/public` — unauthenticated instance metadata. The connect +/// flow keys the offered sign-in methods off it; the request sheet keys the +/// 4K toggle and per-season selection off it. +@JsonSerializable(createToJson: false) +class SeerrPublicSettings { + final bool initialized; + final String? applicationTitle; + + /// Whether `/auth/local` is enabled. + final bool localLogin; + + /// Whether signing in through the linked media server is enabled. + final bool mediaServerLogin; + + /// `SeerrMediaServerType` of the linked media server. + final int? mediaServerType; + + final bool movie4kEnabled; + final bool series4kEnabled; + + /// Whether users may request individual seasons rather than whole shows. + final bool partialRequestsEnabled; + + const SeerrPublicSettings({ + this.initialized = false, + this.applicationTitle, + this.localLogin = true, + this.mediaServerLogin = true, + this.mediaServerType, + this.movie4kEnabled = false, + this.series4kEnabled = false, + this.partialRequestsEnabled = true, + }); + + String get instanceLabel => (applicationTitle?.isNotEmpty ?? false) ? applicationTitle! : 'Seerr'; + + factory SeerrPublicSettings.fromJson(Map json) => _$SeerrPublicSettingsFromJson(json); +} diff --git a/lib/models/seerr/seerr_public_settings.g.dart b/lib/models/seerr/seerr_public_settings.g.dart new file mode 100644 index 00000000..24be1311 --- /dev/null +++ b/lib/models/seerr/seerr_public_settings.g.dart @@ -0,0 +1,19 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'seerr_public_settings.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +SeerrPublicSettings _$SeerrPublicSettingsFromJson(Map json) => + SeerrPublicSettings( + initialized: json['initialized'] as bool? ?? false, + applicationTitle: json['applicationTitle'] as String?, + localLogin: json['localLogin'] as bool? ?? true, + mediaServerLogin: json['mediaServerLogin'] as bool? ?? true, + mediaServerType: (json['mediaServerType'] as num?)?.toInt(), + movie4kEnabled: json['movie4kEnabled'] as bool? ?? false, + series4kEnabled: json['series4kEnabled'] as bool? ?? false, + partialRequestsEnabled: json['partialRequestsEnabled'] as bool? ?? true, + ); diff --git a/lib/models/seerr/seerr_request.dart b/lib/models/seerr/seerr_request.dart new file mode 100644 index 00000000..7090ca5b --- /dev/null +++ b/lib/models/seerr/seerr_request.dart @@ -0,0 +1,84 @@ +import 'package:json_annotation/json_annotation.dart'; + +import 'seerr_media.dart'; + +part 'seerr_request.g.dart'; + +/// Approval state of a Seerr request (`MediaRequest.status`). +enum SeerrRequestStatus { + pending(1), + approved(2), + declined(3); + + final int code; + const SeerrRequestStatus(this.code); + + static SeerrRequestStatus fromCode(int? code) => + values.where((v) => v.code == code).firstOrNull ?? SeerrRequestStatus.pending; +} + +/// A media request as returned by `POST /request` and `GET /request`. +@JsonSerializable(createToJson: false) +class SeerrRequest { + final int id; + @JsonKey(name: 'status', fromJson: SeerrRequestStatus.fromCode) + final SeerrRequestStatus status; + final bool? is4k; + final SeerrMediaInfo? media; + + /// TV only: the seasons this request covers. + final List? seasons; + + const SeerrRequest({required this.id, required this.status, this.is4k, this.media, this.seasons}); + + factory SeerrRequest.fromJson(Map json) => _$SeerrRequestFromJson(json); +} + +/// One season within a request (`MediaRequest.seasons[]`). +@JsonSerializable(createToJson: false) +class SeerrRequestSeason { + final int seasonNumber; + + const SeerrRequestSeason({required this.seasonNumber}); + + factory SeerrRequestSeason.fromJson(Map json) => _$SeerrRequestSeasonFromJson(json); +} + +/// Body of `POST /request`. Advanced fields require `REQUEST_ADVANCED`; +/// `is4k` requires the 4K request permissions. +class SeerrRequestPayload { + final String mediaType; + + /// TMDB id. + final int mediaId; + + /// TV only: season numbers, or null for `all`. + final List? seasons; + final bool is4k; + final int? serverId; + final int? profileId; + final String? rootFolder; + final int? languageProfileId; + + const SeerrRequestPayload({ + required this.mediaType, + required this.mediaId, + this.seasons, + this.is4k = false, + this.serverId, + this.profileId, + this.rootFolder, + this.languageProfileId, + }); + + Map toJson() => { + 'mediaType': mediaType, + 'mediaId': mediaId, + if (mediaType == 'tv') 'seasons': seasons ?? 'all', + 'is4k': is4k, + if (serverId != null) 'serverId': serverId, + if (profileId != null) 'profileId': profileId, + if (rootFolder != null) 'rootFolder': rootFolder, + if (languageProfileId != null) 'languageProfileId': languageProfileId, + }; +} diff --git a/lib/models/seerr/seerr_request.g.dart b/lib/models/seerr/seerr_request.g.dart new file mode 100644 index 00000000..2392e09b --- /dev/null +++ b/lib/models/seerr/seerr_request.g.dart @@ -0,0 +1,22 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'seerr_request.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +SeerrRequest _$SeerrRequestFromJson(Map json) => SeerrRequest( + id: (json['id'] as num).toInt(), + status: SeerrRequestStatus.fromCode((json['status'] as num?)?.toInt()), + is4k: json['is4k'] as bool?, + media: json['media'] == null + ? null + : SeerrMediaInfo.fromJson(json['media'] as Map), + seasons: (json['seasons'] as List?) + ?.map((e) => SeerrRequestSeason.fromJson(e as Map)) + .toList(), +); + +SeerrRequestSeason _$SeerrRequestSeasonFromJson(Map json) => + SeerrRequestSeason(seasonNumber: (json['seasonNumber'] as num).toInt()); diff --git a/lib/models/seerr/seerr_service.dart b/lib/models/seerr/seerr_service.dart new file mode 100644 index 00000000..86594c15 --- /dev/null +++ b/lib/models/seerr/seerr_service.dart @@ -0,0 +1,66 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'seerr_service.g.dart'; + +/// One configured Radarr/Sonarr instance (`GET /service/radarr|sonarr`). +@JsonSerializable(createToJson: false) +class SeerrServiceInstance { + final int id; + final String? name; + final bool is4k; + final bool isDefault; + final String? activeDirectory; + final int? activeProfileId; + + /// Sonarr only. + final int? activeLanguageProfileId; + + const SeerrServiceInstance({ + required this.id, + this.name, + this.is4k = false, + this.isDefault = false, + this.activeDirectory, + this.activeProfileId, + this.activeLanguageProfileId, + }); + + factory SeerrServiceInstance.fromJson(Map json) => _$SeerrServiceInstanceFromJson(json); +} + +/// Quality profile / root folder / language profile options of one instance +/// (`GET /service/radarr|sonarr/{id}`). +@JsonSerializable(createToJson: false) +class SeerrServiceDetail { + final SeerrServiceInstance? server; + final List? profiles; + final List? rootFolders; + + /// Sonarr v3 only; absent on Radarr and newer Sonarr. + final List? languageProfiles; + + const SeerrServiceDetail({this.server, this.profiles, this.rootFolders, this.languageProfiles}); + + factory SeerrServiceDetail.fromJson(Map json) => _$SeerrServiceDetailFromJson(json); +} + +/// Quality or language profile option `{id, name}`. +@JsonSerializable(createToJson: false) +class SeerrServiceProfile { + final int id; + final String? name; + + const SeerrServiceProfile({required this.id, this.name}); + + factory SeerrServiceProfile.fromJson(Map json) => _$SeerrServiceProfileFromJson(json); +} + +@JsonSerializable(createToJson: false) +class SeerrRootFolder { + final int id; + final String? path; + + const SeerrRootFolder({required this.id, this.path}); + + factory SeerrRootFolder.fromJson(Map json) => _$SeerrRootFolderFromJson(json); +} diff --git a/lib/models/seerr/seerr_service.g.dart b/lib/models/seerr/seerr_service.g.dart new file mode 100644 index 00000000..6a9a2ab3 --- /dev/null +++ b/lib/models/seerr/seerr_service.g.dart @@ -0,0 +1,49 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'seerr_service.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +SeerrServiceInstance _$SeerrServiceInstanceFromJson( + Map json, +) => SeerrServiceInstance( + id: (json['id'] as num).toInt(), + name: json['name'] as String?, + is4k: json['is4k'] as bool? ?? false, + isDefault: json['isDefault'] as bool? ?? false, + activeDirectory: json['activeDirectory'] as String?, + activeProfileId: (json['activeProfileId'] as num?)?.toInt(), + activeLanguageProfileId: (json['activeLanguageProfileId'] as num?)?.toInt(), +); + +SeerrServiceDetail _$SeerrServiceDetailFromJson(Map json) => + SeerrServiceDetail( + server: json['server'] == null + ? null + : SeerrServiceInstance.fromJson( + json['server'] as Map, + ), + profiles: (json['profiles'] as List?) + ?.map((e) => SeerrServiceProfile.fromJson(e as Map)) + .toList(), + rootFolders: (json['rootFolders'] as List?) + ?.map((e) => SeerrRootFolder.fromJson(e as Map)) + .toList(), + languageProfiles: (json['languageProfiles'] as List?) + ?.map((e) => SeerrServiceProfile.fromJson(e as Map)) + .toList(), + ); + +SeerrServiceProfile _$SeerrServiceProfileFromJson(Map json) => + SeerrServiceProfile( + id: (json['id'] as num).toInt(), + name: json['name'] as String?, + ); + +SeerrRootFolder _$SeerrRootFolderFromJson(Map json) => + SeerrRootFolder( + id: (json['id'] as num).toInt(), + path: json['path'] as String?, + ); diff --git a/lib/models/seerr/seerr_session.dart b/lib/models/seerr/seerr_session.dart new file mode 100644 index 00000000..4ac87917 --- /dev/null +++ b/lib/models/seerr/seerr_session.dart @@ -0,0 +1,113 @@ +import 'dart:convert'; + +/// How the session was established — determines how a silent re-login is +/// performed when the server-side session expires. +enum SeerrAuthMethod { + /// `POST /auth/plex` with the profile's Plex account token (read live at + /// re-auth time, never copied into the session). + plex, + + /// `POST /auth/jellyfin` with stored username/password, serverType 2. + jellyfin, + + /// `POST /auth/jellyfin` with stored username/password, serverType 3. + emby, + + /// `POST /auth/local` with stored email/password. + local, +} + +/// An authenticated Seerr session for one profile: instance URL, the Express +/// session cookie, the credentials needed to re-login silently, and the +/// Seerr-side user it maps to. +/// +/// [secret] is the plaintext password while in memory; the store protects it +/// with CredentialVault before persisting. Empty for [SeerrAuthMethod.plex] +/// and after an unrecoverable decrypt failure (session then lives until the +/// cookie expires and the user must reconnect). +class SeerrSession { + final String baseUrl; + final SeerrAuthMethod method; + + /// Username (jellyfin/emby) or email (local); empty for plex. + final String identifier; + final String secret; + + /// `connect.sid` cookie value. + final String cookie; + final int userId; + + /// Seerr permission bitmask — see `SeerrPermission`. + final int permissions; + final String displayName; + + /// Instance `applicationTitle` from `/settings/public`. + final String instanceLabel; + final int createdAt; + + const SeerrSession({ + required this.baseUrl, + required this.method, + required this.identifier, + required this.secret, + required this.cookie, + required this.userId, + required this.permissions, + required this.displayName, + required this.instanceLabel, + required this.createdAt, + }); + + SeerrSession copyWith({ + String? secret, + String? cookie, + int? permissions, + String? displayName, + String? instanceLabel, + }) => SeerrSession( + baseUrl: baseUrl, + method: method, + identifier: identifier, + secret: secret ?? this.secret, + cookie: cookie ?? this.cookie, + userId: userId, + permissions: permissions ?? this.permissions, + displayName: displayName ?? this.displayName, + instanceLabel: instanceLabel ?? this.instanceLabel, + createdAt: createdAt, + ); + + Map toJson() => { + 'base_url': baseUrl, + 'method': method.name, + 'identifier': identifier, + 'secret': secret, + 'cookie': cookie, + 'user_id': userId, + 'permissions': permissions, + 'display_name': displayName, + 'instance_label': instanceLabel, + 'created_at': createdAt, + }; + + factory SeerrSession.fromJson(Map json) => SeerrSession( + baseUrl: json['base_url'] as String, + // An unknown method must not fall back to another (re-auth would post + // garbage credentials); the store's decode try/catch drops the session. + method: + SeerrAuthMethod.values.asNameMap()[json['method']] ?? + (throw ArgumentError('Unknown Seerr auth method: ${json['method']}')), + identifier: json['identifier'] as String? ?? '', + secret: json['secret'] as String? ?? '', + cookie: json['cookie'] as String? ?? '', + userId: (json['user_id'] as num).toInt(), + permissions: (json['permissions'] as num?)?.toInt() ?? 0, + displayName: json['display_name'] as String? ?? '', + instanceLabel: json['instance_label'] as String? ?? '', + createdAt: (json['created_at'] as num?)?.toInt() ?? 0, + ); + + String encode() => jsonEncode(toJson()); + + static SeerrSession decode(String raw) => SeerrSession.fromJson((jsonDecode(raw) as Map).cast()); +} diff --git a/lib/models/seerr/seerr_user.dart b/lib/models/seerr/seerr_user.dart new file mode 100644 index 00000000..69aee73d --- /dev/null +++ b/lib/models/seerr/seerr_user.dart @@ -0,0 +1,18 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'seerr_user.g.dart'; + +/// The authenticated Seerr user, as returned by the `/auth/*` login +/// endpoints and `GET /auth/me`. +@JsonSerializable(createToJson: false) +class SeerrUser { + final int id; + final String? displayName; + final String? email; + final int? permissions; + final String? avatar; + + const SeerrUser({required this.id, this.displayName, this.email, this.permissions, this.avatar}); + + factory SeerrUser.fromJson(Map json) => _$SeerrUserFromJson(json); +} diff --git a/lib/models/seerr/seerr_user.g.dart b/lib/models/seerr/seerr_user.g.dart new file mode 100644 index 00000000..06efedf6 --- /dev/null +++ b/lib/models/seerr/seerr_user.g.dart @@ -0,0 +1,15 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'seerr_user.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +SeerrUser _$SeerrUserFromJson(Map json) => SeerrUser( + id: (json['id'] as num).toInt(), + displayName: json['displayName'] as String?, + email: json['email'] as String?, + permissions: (json['permissions'] as num?)?.toInt(), + avatar: json['avatar'] as String?, +); diff --git a/lib/providers/seerr_account_provider.dart b/lib/providers/seerr_account_provider.dart new file mode 100644 index 00000000..5781deb9 --- /dev/null +++ b/lib/providers/seerr_account_provider.dart @@ -0,0 +1,165 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +import '../connection/connection_registry.dart'; +import '../mixins/disposable_change_notifier_mixin.dart'; +import '../models/seerr/seerr_session.dart'; +import '../profiles/active_plex_identity.dart'; +import '../profiles/active_profile_provider.dart'; +import '../profiles/profile_connection_registry.dart'; +import '../services/seerr/seerr_auth_service.dart'; +import '../services/seerr/seerr_client.dart'; +import '../services/seerr/seerr_session_store.dart'; +import '../utils/app_logger.dart'; + +/// Resolve the active profile's Plex token for Seerr sign-in/re-auth: +/// the profile's per-user token when a bind exists (a Home user's Seerr +/// account maps to their own plex.tv user), else the account token. +SeerrPlexTokenSupplier buildSeerrPlexTokenSupplier({ + required ActiveProfileProvider activeProfile, + required ConnectionRegistry connections, + required ProfileConnectionRegistry profileConnections, +}) { + return () async { + final identity = await resolveActivePlexIdentity( + activeProfile: activeProfile, + connections: connections, + profileConnections: profileConnections, + ); + if (identity == null) return null; + final profile = activeProfile.active; + if (profile != null) { + final pc = await profileConnections.get(profile.id, identity.account.id); + if (pc?.hasToken ?? false) return pc!.userToken; + } + return identity.account.accountToken; + }; +} + +/// Owns the active Seerr session for the currently-selected profile, +/// mirroring [TraktAccountProvider]'s rebind shape: `onActiveProfileChanged` +/// loads the profile's stored session and rebuilds the catalog client. +/// +/// Unlike the OAuth trackers there is no in-provider connect flow — the +/// connect screen drives [SeerrAuthService] itself and hands the finished +/// session to [adoptSession]. +class SeerrAccountProvider extends ChangeNotifier with DisposableChangeNotifierMixin { + SeerrAccountProvider({SeerrSessionStore? store, SeerrAuthService? authService}) + : _store = store ?? const SeerrSessionStore(), + authService = authService ?? SeerrAuthService(); + + final SeerrSessionStore _store; + final SeerrAuthService authService; + SeerrPlexTokenSupplier? _plexTokenSupplier; + + /// Store writes go through one queue: save() awaits an AES-GCM protect + /// step, so two rapid unawaited writes could otherwise persist + /// last-started-first (and a clear could lose to a still-pending save). + Future _pendingPersistence = Future.value(); + + Future _enqueuePersistence(Future Function() op) { + final run = _pendingPersistence.then((_) => op()); + _pendingPersistence = run.then( + (_) {}, + onError: (Object e) => appLogger.w('Seerr: session persistence failed', error: e), + ); + return run; + } + + SeerrSession? _session; + String _activeUserUuid = ''; + int _bindingGeneration = 0; + SeerrClient? _catalogClient; + + SeerrSession? get session => _session; + bool get isConnected => _session != null; + String? get displayName => _session?.displayName; + + /// Client for the catalog/request surfaces; null when disconnected. + SeerrClient? get catalogClient => _catalogClient; + + /// Wired once from the provider tree (the registries live above the + /// profile session subtree). + void bindPlexTokenSupplier(SeerrPlexTokenSupplier supplier) => _plexTokenSupplier = supplier; + + /// The connect screen's "Sign in with Plex" needs the same token the + /// silent re-auth path would use. Null on Jellyfin-only setups. + Future resolvePlexToken() async { + try { + return await _plexTokenSupplier?.call(); + } catch (e) { + appLogger.w('Seerr: Plex token resolution failed', error: e); + return null; + } + } + + /// Called whenever the active profile changes (or on initial load). + Future onActiveProfileChanged(String? newUserUuid) async { + if (isDisposed) return; + final userUuid = newUserUuid ?? ''; + final generation = ++_bindingGeneration; + _activeUserUuid = userUuid; + final loaded = await _store.load(userUuid); + _setSessionAndRebind(userUuid, generation, loaded); + } + + /// Persist and bind a session the connect screen established. + Future adoptSession(SeerrSession session) async { + final userUuid = _activeUserUuid; + await _enqueuePersistence(() => _store.save(userUuid, session)); + _setSessionAndRebind(userUuid, ++_bindingGeneration, session); + } + + /// Sign out server-side (best effort) and clear local state. + Future disconnect() async { + final userUuid = _activeUserUuid; + final session = _session; + _setSessionAndRebind(userUuid, ++_bindingGeneration, null); + await _enqueuePersistence(() => _store.clear(userUuid)); + if (session != null) await authService.signOut(session); + } + + void _setSessionAndRebind(String userUuid, int generation, SeerrSession? session) { + if (!_isCurrentBinding(userUuid, generation)) return; + _session = session; + _catalogClient?.dispose(); + _catalogClient = session == null + ? null + : SeerrClient( + session, + onSessionInvalidated: () => _handleSessionInvalidated(userUuid, generation), + onSessionUpdated: (next) => _handleSessionUpdated(userUuid, generation, next), + plexTokenSupplier: () async => _plexTokenSupplier?.call(), + authService: authService, + ); + safeNotifyListeners(); + } + + bool _isCurrentBinding(String userUuid, int generation) { + return !isDisposed && userUuid == _activeUserUuid && generation == _bindingGeneration; + } + + void _handleSessionUpdated(String userUuid, int generation, SeerrSession session) { + if (!_isCurrentBinding(userUuid, generation)) return; + _session = session; + unawaited(_enqueuePersistence(() => _store.save(userUuid, session))); + safeNotifyListeners(); + } + + /// Called by [SeerrClient] when silent re-auth fails permanently: clear + /// local state so the UI shows "not connected" and the user can re-link. + void _handleSessionInvalidated(String userUuid, int generation) { + if (!_isCurrentBinding(userUuid, generation)) return; + final nextGeneration = ++_bindingGeneration; + unawaited(_enqueuePersistence(() => _store.clear(userUuid))); + _setSessionAndRebind(userUuid, nextGeneration, null); + } + + @override + void dispose() { + _catalogClient?.dispose(); + _catalogClient = null; + super.dispose(); + } +} diff --git a/lib/services/catalog/seerr_catalog_source.dart b/lib/services/catalog/seerr_catalog_source.dart new file mode 100644 index 00000000..bdd900d7 --- /dev/null +++ b/lib/services/catalog/seerr_catalog_source.dart @@ -0,0 +1,168 @@ +import 'package:flutter/foundation.dart'; + +import '../../media/media_kind.dart'; +import '../../models/catalog/catalog_cast_member.dart'; +import '../../models/catalog/catalog_item.dart'; +import '../../models/seerr/seerr_details.dart'; +import '../../models/seerr/seerr_media.dart'; +import '../../models/seerr/seerr_page.dart'; +import '../../utils/external_ids.dart'; +import '../seerr/seerr_client.dart'; +import '../seerr/seerr_constants.dart'; +import 'catalog_source.dart'; + +/// [CatalogSource] backed by a Seerr instance's TMDB-based discover API. +/// +/// Wraps the catalog [SeerrClient] owned by `SeerrAccountProvider` (not owned +/// here — never disposed by this class). Seerr has no watchlist; its +/// contribution besides discovery rows is the request flow, which the +/// request surfaces reach through [client] directly. +class SeerrCatalogSource implements CatalogSource { + final SeerrClient client; + final WatchlistChangeNotifier _watchlistChanges = WatchlistChangeNotifier(); + + SeerrCatalogSource(this.client); + + @override + CatalogSourceId get id => CatalogSourceId.seerr; + + @override + String get displayName => 'Seerr'; + + @override + List get supportedRows => const [ + CatalogRowId.trending, + CatalogRowId.popularMovies, + CatalogRowId.popularShows, + CatalogRowId.upcomingMovies, + CatalogRowId.upcomingShows, + ]; + + @override + bool get supportsWatchlist => false; + + /// Whether the signed-in user may request titles of [kind] — gates the + /// detail-screen Request action. + bool canRequest(MediaKind kind) => seerrHasPermission(client.session.permissions, [ + SeerrPermission.request, + kind == MediaKind.movie ? SeerrPermission.requestMovie : SeerrPermission.requestTv, + ]); + + @override + Listenable get watchlistChanges => _watchlistChanges; + + /// Seerr pages are a fixed 20 items; [limit] cannot be honored, so callers + /// get pages of 20 with [CatalogPage.hasMore] from `totalPages`. + @override + Future fetchRow(CatalogRowId row, {int page = 1, int limit = 25}) async { + final res = await switch (row) { + CatalogRowId.trending => client.getTrending(page: page), + CatalogRowId.popularMovies => client.getPopularMovies(page: page), + CatalogRowId.popularShows => client.getPopularTv(page: page), + CatalogRowId.upcomingMovies => client.getUpcomingMovies(page: page), + CatalogRowId.upcomingShows => client.getUpcomingTv(page: page), + CatalogRowId.watchlist || + CatalogRowId.recommendedMovies || + CatalogRowId.recommendedShows || + CatalogRowId.trendingMovies || + CatalogRowId.trendingShows || + CatalogRowId.suggestedAnime || + CatalogRowId.airingAnime || + CatalogRowId.popularAnime => throw ArgumentError('Seerr does not serve ${row.name}'), + }; + return _toPage(res); + } + + @override + Future> search(String query, {int limit = 30}) async { + final trimmed = query.trim(); + if (trimmed.isEmpty) return const []; + final page = await client.search(trimmed); + return _toPage(page).items; + } + + @override + Future> fetchRelated(CatalogItem item, {int limit = 20}) async { + final tmdbId = item.ids.tmdb; + if (tmdbId == null) return const []; + final page = item.kind == MediaKind.movie + ? await client.getMovieRecommendations(tmdbId) + : await client.getTvRecommendations(tmdbId); + return _toPage(page).items.take(limit).toList(); + } + + /// Seerr requests key on TMDB ids, so any library item carrying one is in + /// scope; the watchlist action stays hidden regardless + /// ([supportsWatchlist] is false). + @override + Future resolveItemIds(MediaKind kind, ExternalIds external) async => + external.tmdb == null ? null : CatalogItemIds(tmdb: external.tmdb, imdb: external.imdb, tvdb: external.tvdb); + + @override + Future> fetchCast(CatalogItem item, {int limit = 20}) async { + final tmdbId = item.ids.tmdb; + if (tmdbId == null) return const []; + final SeerrCredits? credits; + if (item.kind == MediaKind.movie) { + credits = (await client.getMovie(tmdbId)).credits; + } else { + credits = (await client.getTv(tmdbId)).credits; + } + return [ + for (final member in (credits?.cast ?? const []).take(limit)) + if (member.name case final String name when name.isNotEmpty) + CatalogCastMember( + name: name, + secondary: member.character, + imageUrl: tmdbImageUrl(member.profilePath, 'w300'), + ), + ]; + } + + // Seerr has no watchlist: membership is always unknown and mutations are + // programming errors (the action is hidden when supportsWatchlist is false). + + @override + Future ensureWatchlistLoaded() => Future.value(); + + @override + bool? isOnWatchlist(MediaKind kind, CatalogItemIds ids) => null; + + @override + Future addToWatchlist(MediaKind kind, CatalogItemIds ids) => throw UnsupportedError('Seerr has no watchlist'); + + @override + Future removeFromWatchlist(MediaKind kind, CatalogItemIds ids) => + throw UnsupportedError('Seerr has no watchlist'); + + CatalogPage _toPage(SeerrPage page) => CatalogPage( + items: [ + for (final m in page.items) + if (m.displayTitle.isNotEmpty) _toCatalogItem(m), + ], + hasMore: page.hasMore, + ); + + CatalogItem _toCatalogItem(SeerrMedia m) => CatalogItem( + source: CatalogSourceId.seerr, + kind: m.isMovie ? MediaKind.movie : MediaKind.show, + title: m.displayTitle, + year: m.year, + overview: m.overview, + rating: m.voteAverage, + votes: m.voteCount, + ids: CatalogItemIds(tmdb: m.id), + posterUrl: tmdbImageUrl(m.posterPath, 'w600_and_h900_bestv2'), + backdropUrl: tmdbImageUrl(m.backdropPath, 'w1920_and_h800_multi_faces'), + ); + + /// Seerr serves TMDB relative paths (`/abc.jpg`); images come straight off + /// the TMDB CDN at the same sizes the Seerr web UI uses. + static String? tmdbImageUrl(String? path, String size) => + path == null || path.isEmpty ? null : 'https://image.tmdb.org/t/p/$size$path'; + + @override + void dispose() { + _watchlistChanges.dispose(); + } +} diff --git a/lib/services/seerr/seerr_auth_service.dart b/lib/services/seerr/seerr_auth_service.dart new file mode 100644 index 00000000..59d028fe --- /dev/null +++ b/lib/services/seerr/seerr_auth_service.dart @@ -0,0 +1,197 @@ +import 'package:http/http.dart' as http; + +import '../../models/seerr/seerr_public_settings.dart'; +import '../../models/seerr/seerr_session.dart'; +import '../../models/seerr/seerr_user.dart'; +import '../../utils/app_logger.dart'; +import 'seerr_constants.dart'; +import 'seerr_exceptions.dart'; +import 'seerr_http_client.dart'; + +/// Sign-in flows against a Seerr instance. Every flow ends with a captured +/// `connect.sid` cookie and the Seerr-side [SeerrUser], packed into a +/// [SeerrSession]. +class SeerrAuthService { + final http.Client Function()? httpClientFactory; + + SeerrAuthService({this.httpClientFactory}); + + SeerrHttpClient _client(String baseUrl, {String? cookie}) => + SeerrHttpClient(baseUrl: baseUrl, httpClient: httpClientFactory?.call(), cookie: cookie); + + /// Validate that [baseUrl] points at a running, initialized Seerr and + /// collect the metadata the connect flow needs. Throws [SeerrUrlException] + /// when unreachable or not set up. + Future probe(String baseUrl) async { + final client = _client(baseUrl); + try { + final SeerrResponse res; + try { + res = await client.send('GET', '/settings/public', timeout: SeerrConstants.probeTimeout, authenticated: false); + } catch (e) { + throw SeerrUrlException('Could not reach $baseUrl: $e'); + } + final data = res.data; + if (res.statusCode >= 400 || data is! Map) { + throw SeerrUrlException('No Seerr instance at $baseUrl (HTTP ${res.statusCode})'); + } + final settings = SeerrPublicSettings.fromJson(data); + if (!settings.initialized) { + throw const SeerrUrlException('Seerr instance has not completed first-run setup'); + } + return settings; + } finally { + client.dispose(); + } + } + + /// `POST /auth/plex` with a Plex account token. + Future signInWithPlex({required String baseUrl, required String plexToken}) => _signIn( + baseUrl: baseUrl, + method: SeerrAuthMethod.plex, + path: '/auth/plex', + body: {'authToken': plexToken}, + identifier: '', + secret: '', + ); + + /// `POST /auth/jellyfin` with Jellyfin or Emby credentials. + Future signInWithJellyfin({ + required String baseUrl, + required String username, + required String password, + bool emby = false, + }) => _signIn( + baseUrl: baseUrl, + method: emby ? SeerrAuthMethod.emby : SeerrAuthMethod.jellyfin, + path: '/auth/jellyfin', + body: { + 'username': username, + 'password': password, + 'serverType': emby ? SeerrMediaServerType.emby : SeerrMediaServerType.jellyfin, + }, + identifier: username, + secret: password, + ); + + /// `POST /auth/local` with a Seerr local account. + Future signInWithLocal({required String baseUrl, required String email, required String password}) => + _signIn( + baseUrl: baseUrl, + method: SeerrAuthMethod.local, + path: '/auth/local', + body: {'email': email, 'password': password}, + identifier: email, + secret: password, + ); + + /// Silent re-login using the credentials carried by [session] + /// ([plexToken] for plex-method sessions). Returns the refreshed session. + Future reauth(SeerrSession session, {String? plexToken}) async { + final fresh = await switch (session.method) { + SeerrAuthMethod.plex when plexToken != null && plexToken.isNotEmpty => signInWithPlex( + baseUrl: session.baseUrl, + plexToken: plexToken, + ), + // No token RIGHT NOW is a degraded state (identity not hydrated yet, + // vault decrypt hiccup), not a server rejection — retryable, so it + // must not unlink the session. An empty stored secret below is the + // opposite: those credentials are gone for good, so re-linking is the + // only way forward and unlinking is honest. + SeerrAuthMethod.plex => throw const SeerrReauthUnavailableException('No Plex token available for silent re-auth'), + SeerrAuthMethod.jellyfin || SeerrAuthMethod.emby when session.secret.isNotEmpty => signInWithJellyfin( + baseUrl: session.baseUrl, + username: session.identifier, + password: session.secret, + emby: session.method == SeerrAuthMethod.emby, + ), + SeerrAuthMethod.local when session.secret.isNotEmpty => signInWithLocal( + baseUrl: session.baseUrl, + email: session.identifier, + password: session.secret, + ), + _ => throw const SeerrAuthException('No stored credentials for silent re-auth'), + }; + return session.copyWith(cookie: fresh.cookie, permissions: fresh.permissions, displayName: fresh.displayName); + } + + /// Best-effort server-side sign-out; local cleanup must not depend on it. + Future signOut(SeerrSession session) async { + final client = _client(session.baseUrl, cookie: session.cookie); + try { + await client.send('POST', '/auth/logout', timeout: SeerrConstants.authTimeout); + } catch (e) { + appLogger.d('Seerr: sign-out best-effort failed', error: e); + } finally { + client.dispose(); + } + } + + Future _signIn({ + required String baseUrl, + required SeerrAuthMethod method, + required String path, + required Map body, + required String identifier, + required String secret, + }) async { + final client = _client(baseUrl); + try { + final res = await client.send( + 'POST', + path, + body: body, + timeout: SeerrConstants.authTimeout, + authenticated: false, + ); + if (res.statusCode == 401 || res.statusCode == 403) { + final message = res.data is Map + ? (res.data as Map)['message'] as String? + : null; + throw SeerrAuthException(message ?? 'Sign-in rejected', statusCode: res.statusCode); + } + SeerrHttpClient.throwForStatus(res); + if (!client.captureSessionCookie(res.response)) { + throw const SeerrAuthException('Seerr did not issue a session cookie'); + } + final user = await _resolveUser(client, res.data); + return SeerrSession( + baseUrl: client.baseUrl, + method: method, + identifier: identifier, + secret: secret, + cookie: client.cookie!, + userId: user.id, + permissions: user.permissions ?? 0, + displayName: user.displayName ?? identifier, + instanceLabel: '', + createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ); + } finally { + client.dispose(); + } + } + + /// The login endpoints return the [SeerrUser] directly; fall back to + /// `GET /auth/me` with the fresh cookie if that shape ever changes. + Future _resolveUser(SeerrHttpClient client, dynamic loginData) async { + if (loginData is Map) { + try { + return SeerrUser.fromJson(loginData); + } catch (_) { + // fall through to /auth/me + } + } + final res = await client.send('GET', '/auth/me', timeout: SeerrConstants.authTimeout); + // throwForStatus passes 401 through (it's normally the re-auth signal); + // here it means the fresh cookie was rejected — an auth failure, not a + // malformed-user-payload crash further down. + if (res.statusCode == 401 || res.statusCode == 403) { + throw SeerrAuthException('Seerr rejected the fresh session cookie', statusCode: res.statusCode); + } + SeerrHttpClient.throwForStatus(res); + final data = res.data; + if (data is Map) return SeerrUser.fromJson(data); + throw const SeerrAuthException('Seerr did not return user information'); + } +} diff --git a/lib/services/seerr/seerr_client.dart b/lib/services/seerr/seerr_client.dart new file mode 100644 index 00000000..bf5cd27e --- /dev/null +++ b/lib/services/seerr/seerr_client.dart @@ -0,0 +1,233 @@ +import 'dart:async'; + +import 'package:http/http.dart' as http; + +import '../../models/seerr/seerr_details.dart'; +import '../../models/seerr/seerr_media.dart'; +import '../../models/seerr/seerr_page.dart'; +import '../../models/seerr/seerr_public_settings.dart'; +import '../../models/seerr/seerr_request.dart'; +import '../../models/seerr/seerr_service.dart'; +import '../../models/seerr/seerr_session.dart'; +import '../../models/seerr/seerr_user.dart'; +import '../../utils/app_logger.dart'; +import '../trackers/future_coalescer.dart'; +import 'seerr_auth_service.dart'; +import 'seerr_constants.dart'; +import 'seerr_exceptions.dart'; +import 'seerr_http_client.dart'; + +/// Supplies the profile's current Plex account token at silent-re-auth time, +/// so plex-method sessions never store a token copy that could go stale. +typedef SeerrPlexTokenSupplier = Future Function(); + +/// Authenticated Seerr API client, scoped to one [SeerrSession]. +/// +/// On 401 it re-logins silently via [SeerrAuthService.reauth] (password +/// methods use the stored secret; plex uses [plexTokenSupplier]), swaps the +/// cookie, and retries once. Concurrent re-auths coalesce per instance+user +/// so a burst of in-flight 401s triggers a single login POST — the same +/// shape as `TraktClient._refreshesByToken`. +class SeerrClient { + static final KeyedFutureCoalescer _reauthsByIdentity = KeyedFutureCoalescer(); + + SeerrSession _session; + final SeerrHttpClient _http; + final SeerrAuthService _auth; + final SeerrPlexTokenSupplier? plexTokenSupplier; + + /// Fired when re-auth fails permanently (rejected credentials, no stored + /// secret). The owning provider clears local state. + final void Function() onSessionInvalidated; + + /// Fired when re-auth succeeds with a fresh cookie so the owner persists it. + final void Function(SeerrSession session)? onSessionUpdated; + + SeerrClient( + SeerrSession session, { + required this.onSessionInvalidated, + this.onSessionUpdated, + this.plexTokenSupplier, + SeerrAuthService? authService, + http.Client? httpClient, + }) : _session = session, + _http = SeerrHttpClient(baseUrl: session.baseUrl, httpClient: httpClient, cookie: session.cookie), + _auth = authService ?? SeerrAuthService(); + + SeerrSession get session => _session; + + void updateSession(SeerrSession session) { + _session = session; + _http.cookie = session.cookie; + } + + void dispose() => _http.dispose(); + + // ---------- Auth ---------- + + Future getMe() async { + final data = await _request('GET', '/auth/me'); + return SeerrUser.fromJson(data as Map); + } + + SeerrPublicSettings? _publicSettingsCache; + + /// Instance flags the request sheet gates on (4K enablement, partial + /// requests). Cached for the client's lifetime — admins change these + /// rarely and a new client is built per session rebind anyway. + Future getPublicSettings() async { + if (_publicSettingsCache case final SeerrPublicSettings cached) return cached; + final data = await _request('GET', '/settings/public'); + return _publicSettingsCache = SeerrPublicSettings.fromJson(data as Map); + } + + // ---------- Discover / search ---------- + + /// `/discover/movies` — popular movies. + Future> getPopularMovies({int page = 1}) => _mediaPage('/discover/movies', page, 'movie'); + + /// `/discover/tv` — popular series. + Future> getPopularTv({int page = 1}) => _mediaPage('/discover/tv', page, 'tv'); + + Future> getUpcomingMovies({int page = 1}) => + _mediaPage('/discover/movies/upcoming', page, 'movie'); + + Future> getUpcomingTv({int page = 1}) => _mediaPage('/discover/tv/upcoming', page, 'tv'); + + /// `/discover/trending` — mixed movies/TV/people; person entries are + /// dropped. + Future> getTrending({int page = 1}) => _mediaPage('/discover/trending', page, null); + + /// `/search` — Seerr's TMDB-backed catalog search (mixed results, person + /// entries dropped). + Future> search(String query, {int page = 1}) async { + final data = await _request('GET', '/search', query: {'query': query, 'page': page}); + return _parseMediaPage(data, null); + } + + /// TMDB "more like this" for a title; items lack `mediaType` like the + /// single-type discover endpoints. + Future> getMovieRecommendations(int tmdbId, {int page = 1}) => + _mediaPage('/movie/$tmdbId/recommendations', page, 'movie'); + + Future> getTvRecommendations(int tmdbId, {int page = 1}) => + _mediaPage('/tv/$tmdbId/recommendations', page, 'tv'); + + Future> _mediaPage(String path, int page, String? coerceMediaType) async { + final data = await _request('GET', path, query: {'page': page}); + return _parseMediaPage(data, coerceMediaType); + } + + SeerrPage _parseMediaPage(dynamic data, String? coerceMediaType) { + return SeerrPage.fromJson(data as Map, (item) { + final mediaType = item['mediaType'] as String? ?? coerceMediaType; + if (mediaType != 'movie' && mediaType != 'tv') return null; + return SeerrMedia.fromJson({...item, 'mediaType': mediaType}); + }); + } + + // ---------- Details ---------- + + Future getMovie(int tmdbId) async { + final data = await _request('GET', '/movie/$tmdbId'); + return SeerrMovieDetails.fromJson(data as Map); + } + + Future getTv(int tmdbId) async { + final data = await _request('GET', '/tv/$tmdbId'); + return SeerrTvDetails.fromJson(data as Map); + } + + // ---------- Requests ---------- + + Future createRequest(SeerrRequestPayload payload) async { + final data = await _request('POST', '/request', body: payload.toJson()); + return SeerrRequest.fromJson(data as Map); + } + + Future deleteRequest(int requestId) async { + await _request('DELETE', '/request/$requestId'); + } + + // ---------- Sonarr / Radarr options (request sheet advanced pickers) ---------- + + Future> getRadarrServices() => _serviceList('/service/radarr'); + + Future> getSonarrServices() => _serviceList('/service/sonarr'); + + Future getRadarrService(int id) => _serviceDetail('/service/radarr/$id'); + + Future getSonarrService(int id) => _serviceDetail('/service/sonarr/$id'); + + Future> _serviceList(String path) async { + final data = await _request('GET', path); + return [ + if (data is List) + for (final item in data) + if (item is Map) SeerrServiceInstance.fromJson(item), + ]; + } + + Future _serviceDetail(String path) async { + final data = await _request('GET', path); + return SeerrServiceDetail.fromJson(data as Map); + } + + // ---------- Internals ---------- + + Future _request( + String method, + String path, { + Map? query, + Map? body, + }) async { + var res = await _http.send(method, path, query: query, body: body); + if (res.statusCode == 401) { + try { + await _reauthCoalesced(); + } on SeerrAuthException { + onSessionInvalidated(); + rethrow; + } + res = await _http.send(method, path, query: query, body: body); + if (res.statusCode == 401) { + onSessionInvalidated(); + throw const SeerrAuthException('Session rejected after successful re-auth', statusCode: 401); + } + } + SeerrHttpClient.throwForStatus(res); + return res.data; + } + + Future _reauthCoalesced() async { + final identity = '${_session.baseUrl}#${_session.userId}'; + final next = await _reauthsByIdentity.run(identity, _doReauth); + // No-op for the initiating client (_doReauth adopted already); joiners + // sharing the identity pick up the fresh cookie here. + if (next.cookie != _session.cookie) _adopt(next); + } + + Future _doReauth() async { + appLogger.d('Seerr: session expired, re-authenticating silently'); + // The supplier reaches into profile/registry state with no timeout of + // its own; unbounded, a hang here would park the coalesced future in + // _reauthsByIdentity forever and wedge every future re-auth for this + // identity. A null token maps to a retryable SeerrReauthUnavailable. + final plexToken = _session.method == SeerrAuthMethod.plex + ? await _resolvePlexToken().timeout(SeerrConstants.authTimeout, onTimeout: () => null) + : null; + final next = await _auth.reauth(_session, plexToken: plexToken); + _adopt(next); + return next; + } + + /// Owns the `Future` type: calling `.timeout(onTimeout: () => + /// null)` directly on the supplier's future trips the covariant-generics + /// runtime check when a caller hands us a `Future Function()`. + Future _resolvePlexToken() async => plexTokenSupplier == null ? null : await plexTokenSupplier!(); + + void _adopt(SeerrSession next) { + updateSession(next); + onSessionUpdated?.call(next); + } +} diff --git a/lib/services/seerr/seerr_constants.dart b/lib/services/seerr/seerr_constants.dart new file mode 100644 index 00000000..830a2b5c --- /dev/null +++ b/lib/services/seerr/seerr_constants.dart @@ -0,0 +1,42 @@ +/// Constants for the Seerr (seerr-team/seerr) REST API. +abstract final class SeerrConstants { + /// Every endpoint lives under this prefix on the instance base URL. + static const String apiPath = '/api/v1'; + + /// Express session cookie issued by the auth endpoints. + static const String sessionCookieName = 'connect.sid'; + + static const Duration probeTimeout = Duration(seconds: 8); + static const Duration authTimeout = Duration(seconds: 20); + static const Duration requestTimeout = Duration(seconds: 30); +} + +/// Seerr `MediaServerType` values (server/constants/server.ts), sent as +/// `serverType` in the `/auth/jellyfin` body. +abstract final class SeerrMediaServerType { + static const int plex = 1; + static const int jellyfin = 2; + static const int emby = 3; +} + +/// Seerr permission bitmask (server/lib/permissions.ts). Only the bits the +/// app checks are named; the full mask is stored on the session untouched. +abstract final class SeerrPermission { + static const int admin = 2; + static const int manageRequests = 16; + static const int request = 32; + static const int autoApprove = 128; + static const int request4k = 1024; + static const int request4kMovie = 2048; + static const int request4kTv = 4096; + static const int requestAdvanced = 8192; + static const int requestMovie = 262144; + static const int requestTv = 524288; +} + +/// Seerr permission semantics: `ADMIN` implies everything, otherwise the +/// user needs at least one of [anyOf]. +bool seerrHasPermission(int userPermissions, List anyOf) { + if (userPermissions & SeerrPermission.admin != 0) return true; + return anyOf.any((p) => userPermissions & p != 0); +} diff --git a/lib/services/seerr/seerr_exceptions.dart b/lib/services/seerr/seerr_exceptions.dart new file mode 100644 index 00000000..81cb8962 --- /dev/null +++ b/lib/services/seerr/seerr_exceptions.dart @@ -0,0 +1,43 @@ +/// The URL doesn't point at a reachable, initialized Seerr instance. +class SeerrUrlException implements Exception { + final String message; + const SeerrUrlException(this.message); + + @override + String toString() => 'SeerrUrlException: $message'; +} + +/// Sign-in or session-refresh failure (bad credentials, revoked session). +/// [SeerrClient] treats this during re-auth as "the server rejected the +/// stored credentials" and unlinks the session. +class SeerrAuthException implements Exception { + final String message; + final int? statusCode; + const SeerrAuthException(this.message, {this.statusCode}); + + @override + String toString() => 'SeerrAuthException: $message${statusCode == null ? '' : ' ($statusCode)'}'; +} + +/// Silent re-auth could not even be ATTEMPTED — the credentials weren't +/// resolvable right now (e.g. the live Plex token supplier came up empty +/// during a degraded launch). Deliberately not a [SeerrAuthException]: +/// the failure is retryable and must not unlink the stored session. +class SeerrReauthUnavailableException implements Exception { + final String message; + const SeerrReauthUnavailableException(this.message); + + @override + String toString() => 'SeerrReauthUnavailableException: $message'; +} + +/// Non-auth API failure with a server-provided message (e.g. quota +/// exceeded on a request, duplicate request). +class SeerrApiException implements Exception { + final String message; + final int statusCode; + const SeerrApiException(this.message, {required this.statusCode}); + + @override + String toString() => 'SeerrApiException($statusCode): $message'; +} diff --git a/lib/services/seerr/seerr_http_client.dart b/lib/services/seerr/seerr_http_client.dart new file mode 100644 index 00000000..ea70f065 --- /dev/null +++ b/lib/services/seerr/seerr_http_client.dart @@ -0,0 +1,140 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +import '../../utils/abortable_http_request.dart'; +import '../../utils/app_logger.dart'; +import '../../utils/platform_http_client_stub.dart' + if (dart.library.io) '../../utils/platform_http_client_io.dart' + as platform; +import '../trackers/tracker_http_client.dart'; +import 'seerr_constants.dart'; +import 'seerr_exceptions.dart'; + +/// HTTP response paired with its decoded JSON body. `data` is null for +/// no-content responses and non-JSON bodies. +class SeerrResponse { + final http.Response response; + final dynamic data; + const SeerrResponse(this.response, this.data); + + int get statusCode => response.statusCode; +} + +/// Thin wrapper around `package:http` for Seerr API calls. +/// +/// Adds the two things the tracker HTTP layer doesn't cover: +/// 1. `connect.sid` cookie capture from `Set-Cookie` on login, replayed as +/// `Cookie:` on every subsequent request — Express session auth. +/// 2. Query encoding with `%20` for spaces: Seerr proxies `/search` to +/// TMDB, which rejects `+` in the query value, so `Uri.queryParameters` +/// (which emits `+`) cannot be used. +class SeerrHttpClient { + final String baseUrl; + final http.Client _http; + String? _cookie; + + SeerrHttpClient({required String baseUrl, http.Client? httpClient, String? cookie}) + : baseUrl = normalizeBaseUrl(baseUrl), + _http = httpClient ?? platform.createPlatformClient(), + _cookie = (cookie?.isNotEmpty ?? false) ? cookie : null; + + /// Current `connect.sid` value (no `name=` prefix); null until a login + /// response is captured or [cookie] was seeded. + String? get cookie => _cookie; + + set cookie(String? value) => _cookie = (value?.isNotEmpty ?? false) ? value : null; + + void dispose() => _http.close(); + + /// Parse `Set-Cookie` from [response] and keep the `connect.sid` value. + /// Returns true when a cookie was captured. + /// + /// `package:http` joins multiple `Set-Cookie` headers into one + /// comma-delimited string. Cookie values are URL-encoded and can't contain + /// a literal comma, so splitting on `,` and scanning each chunk for the + /// `connect.sid=` prefix is safe. + bool captureSessionCookie(http.Response response) { + final raw = response.headers['set-cookie']; + if (raw == null || raw.isEmpty) return false; + const prefix = '${SeerrConstants.sessionCookieName}='; + for (final chunk in raw.split(',')) { + final trimmed = chunk.trimLeft(); + if (!trimmed.startsWith(prefix)) continue; + final afterName = trimmed.substring(prefix.length); + final end = afterName.indexOf(';'); + final value = (end == -1 ? afterName : afterName.substring(0, end)).trim(); + if (value.isEmpty) continue; + _cookie = value; + return true; + } + return false; + } + + /// Send a request under [SeerrConstants.apiPath], returning the decoded + /// JSON body. 401 is returned to the caller (never thrown) so the client + /// can run its silent re-auth path. + Future send( + String method, + String path, { + Map? query, + Map? body, + Duration timeout = SeerrConstants.requestTimeout, + bool authenticated = true, + }) async { + if (!const {'GET', 'POST', 'PUT', 'DELETE'}.contains(method)) { + throw ArgumentError('Unsupported HTTP method: $method'); + } + final uri = _uri(path, query); + final headers = { + 'Accept': 'application/json', + if (authenticated && _cookie != null) 'Cookie': '${SeerrConstants.sessionCookieName}=$_cookie', + if (body != null) 'Content-Type': 'application/json', + }; + final sw = Stopwatch()..start(); + // Abortable so a timeout tears the request down instead of letting it + // race on — a timed-out POST /request must not land server-side after + // the UI already reported failure. + final response = await sendAbortableHttpRequest( + _http, + method, + uri, + headers: headers, + body: body == null ? null : jsonEncode(body), + timeout: timeout, + operation: 'Seerr $method $path', + ); + appLogger.d('Seerr $method $path -> ${response.statusCode} (${sw.elapsedMilliseconds}ms)'); + return SeerrResponse(response, TrackerHttpClient.decodeJson(response.body)); + } + + Uri _uri(String path, Map? query) { + final base = Uri.parse('$baseUrl${SeerrConstants.apiPath}$path'); + if (query == null || query.isEmpty) return base; + final parts = [ + for (final entry in query.entries) + if (entry.value != null) '${Uri.encodeComponent(entry.key)}=${Uri.encodeComponent(entry.value.toString())}', + ]; + return parts.isEmpty ? base : base.replace(query: parts.join('&')); + } + + /// Throw the mapped exception for a 4xx/5xx response; no-op on success. + /// 401 is the caller's re-auth signal and also passes through. + static void throwForStatus(SeerrResponse res) { + final code = res.statusCode; + if (code >= 200 && code < 300 || code == 401) return; + final data = res.data; + final message = data is Map ? data['message'] as String? : null; + throw SeerrApiException((message?.isNotEmpty ?? false) ? message! : 'HTTP $code', statusCode: code); + } + + /// Trim whitespace and trailing slashes so cookie/session identity and + /// request URLs agree on one canonical instance URL. + static String normalizeBaseUrl(String input) { + var v = input.trim(); + while (v.endsWith('/')) { + v = v.substring(0, v.length - 1); + } + return v; + } +} diff --git a/lib/services/seerr/seerr_session_store.dart b/lib/services/seerr/seerr_session_store.dart new file mode 100644 index 00000000..c100a447 --- /dev/null +++ b/lib/services/seerr/seerr_session_store.dart @@ -0,0 +1,44 @@ +import '../../models/seerr/seerr_session.dart'; +import '../../profiles/profile.dart'; +import '../base_shared_preferences_service.dart'; +import '../credential_vault.dart'; + +/// Per-Plex-profile persistence for the Seerr session, mirroring +/// `TrackerAccountStore`'s `user_{uuid}_{baseKey}` scoping. +/// +/// The password ([SeerrSession.secret]) is CredentialVault-protected at the +/// store boundary; a failed decrypt degrades to an empty secret (the session +/// keeps working until its cookie expires) rather than dropping the session. +class SeerrSessionStore { + static const String _baseKey = 'seerr_session'; + + const SeerrSessionStore(); + + String _scopedKey(String userUuid) => profileScopedPrefsKey(userUuid, _baseKey); + + Future load(String userUuid) async { + final prefs = await BaseSharedPreferencesService.sharedCache(); + final raw = prefs.getString(_scopedKey(userUuid)); + if (raw == null) return null; + try { + final session = SeerrSession.decode(raw); + if (session.secret.isEmpty) return session; + return session.copyWith(secret: await CredentialVault.reveal(session.secret) ?? ''); + } catch (_) { + return null; + } + } + + Future save(String userUuid, SeerrSession session) async { + final prefs = await BaseSharedPreferencesService.sharedCache(); + final protected = session.secret.isEmpty + ? session + : session.copyWith(secret: await CredentialVault.protect(session.secret)); + await prefs.setString(_scopedKey(userUuid), protected.encode()); + } + + Future clear(String userUuid) async { + final prefs = await BaseSharedPreferencesService.sharedCache(); + await prefs.remove(_scopedKey(userUuid)); + } +} diff --git a/test/services/catalog/seerr_catalog_source_test.dart b/test/services/catalog/seerr_catalog_source_test.dart new file mode 100644 index 00000000..30ae278a --- /dev/null +++ b/test/services/catalog/seerr_catalog_source_test.dart @@ -0,0 +1,212 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:plezy/media/media_kind.dart'; +import 'package:plezy/models/catalog/catalog_item.dart'; +import 'package:plezy/models/seerr/seerr_session.dart'; +import 'package:plezy/services/catalog/catalog_source.dart'; +import 'package:plezy/services/catalog/seerr_catalog_source.dart'; +import 'package:plezy/services/seerr/seerr_client.dart'; +import 'package:plezy/utils/external_ids.dart'; + +SeerrCatalogSource _source(MockClient mock) { + final client = SeerrClient( + const SeerrSession( + baseUrl: 'https://seerr.example.com', + method: SeerrAuthMethod.local, + identifier: 'a@b.c', + secret: 'pw', + cookie: 'cookie', + userId: 1, + permissions: 2, + displayName: 'Alice', + instanceLabel: 'Seerr', + createdAt: 0, + ), + onSessionInvalidated: () {}, + httpClient: mock, + ); + final source = SeerrCatalogSource(client); + addTearDown(() { + source.dispose(); + client.dispose(); + }); + return source; +} + +http.Response _json(Object body) => http.Response(jsonEncode(body), 200, headers: {'content-type': 'application/json'}); + +void main() { + group('SeerrCatalogSource', () { + test('trending row keeps movies and shows, drops people, maps TMDB images', () async { + final source = _source( + MockClient((request) async { + expect(request.url.path, '/api/v1/discover/trending'); + return _json({ + 'page': 1, + 'totalPages': 3, + 'results': [ + { + 'id': 603, + 'mediaType': 'movie', + 'title': 'The Matrix', + 'releaseDate': '1999-03-30', + 'posterPath': '/matrix.jpg', + 'backdropPath': '/matrix-backdrop.jpg', + 'voteAverage': 8.2, + 'voteCount': 26000, + }, + {'id': 9, 'mediaType': 'person', 'name': 'Keanu Reeves'}, + {'id': 1396, 'mediaType': 'tv', 'name': 'Breaking Bad', 'firstAirDate': '2008-01-20'}, + ], + }); + }), + ); + + final page = await source.fetchRow(CatalogRowId.trending); + expect(page.hasMore, isTrue); + expect(page.items, hasLength(2)); + + final matrix = page.items.first; + expect(matrix.source, CatalogSourceId.seerr); + expect(matrix.kind, MediaKind.movie); + expect(matrix.title, 'The Matrix'); + expect(matrix.year, 1999); + expect(matrix.rating, 8.2); + expect(matrix.votes, 26000); + expect(matrix.ids.tmdb, 603); + expect(matrix.posterUrl, 'https://image.tmdb.org/t/p/w600_and_h900_bestv2/matrix.jpg'); + expect(matrix.backdropUrl, 'https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/matrix-backdrop.jpg'); + + expect(page.items.last.kind, MediaKind.show); + expect(page.items.last.title, 'Breaking Bad'); + }); + + test('single-type rows hit their endpoint and coerce the kind', () async { + final paths = []; + final source = _source( + MockClient((request) async { + paths.add('${request.url.path}?${request.url.query}'); + return _json({ + 'page': 2, + 'totalPages': 2, + 'results': [ + {'id': 335984, 'title': 'Blade Runner 2049', 'releaseDate': '2017-10-04'}, + ], + }); + }), + ); + + final page = await source.fetchRow(CatalogRowId.upcomingMovies, page: 2); + expect(paths.single, '/api/v1/discover/movies/upcoming?page=2'); + expect(page.items.single.kind, MediaKind.movie); + expect(page.hasMore, isFalse); + }); + + test('rows Seerr does not serve throw', () { + final source = _source(MockClient((request) async => _json({}))); + expect(() => source.fetchRow(CatalogRowId.watchlist), throwsArgumentError); + expect(() => source.fetchRow(CatalogRowId.suggestedAnime), throwsArgumentError); + }); + + test('resolveItemIds needs a tmdb id', () async { + final source = _source(MockClient((request) async => _json({}))); + final resolved = await source.resolveItemIds(MediaKind.movie, const ExternalIds(tmdb: 603, imdb: 'tt0133093')); + expect(resolved?.tmdb, 603); + expect(resolved?.imdb, 'tt0133093'); + expect(await source.resolveItemIds(MediaKind.movie, const ExternalIds(imdb: 'tt0133093')), isNull); + }); + + test('fetchCast reads credits off the detail endpoint', () async { + final source = _source( + MockClient((request) async { + expect(request.url.path, '/api/v1/tv/1396'); + return _json({ + 'id': 1396, + 'name': 'Breaking Bad', + 'credits': { + 'cast': [ + {'name': 'Bryan Cranston', 'character': 'Walter White', 'profilePath': '/bc.jpg'}, + {'name': '', 'character': 'nobody'}, + {'name': 'Aaron Paul', 'character': 'Jesse Pinkman'}, + ], + }, + }); + }), + ); + + final item = CatalogItem( + source: CatalogSourceId.seerr, + kind: MediaKind.show, + title: 'Breaking Bad', + ids: const CatalogItemIds(tmdb: 1396), + ); + final cast = await source.fetchCast(item); + expect(cast, hasLength(2)); + expect(cast.first.name, 'Bryan Cranston'); + expect(cast.first.secondary, 'Walter White'); + expect(cast.first.imageUrl, 'https://image.tmdb.org/t/p/w300/bc.jpg'); + expect(cast.last.imageUrl, isNull); + }); + + test('search proxies /search and filters persons', () async { + final source = _source( + MockClient((request) async { + expect(request.url.path, '/api/v1/search'); + expect(request.url.queryParameters['query'], 'the matrix'); + return _json({ + 'page': 1, + 'totalPages': 1, + 'results': [ + {'id': 603, 'mediaType': 'movie', 'title': 'The Matrix', 'releaseDate': '1999-03-30'}, + {'id': 6384, 'mediaType': 'person', 'name': 'Keanu Reeves'}, + ], + }); + }), + ); + final items = await source.search('the matrix'); + expect(items.single.title, 'The Matrix'); + expect(items.single.ids.tmdb, 603); + }); + + test('fetchRelated proxies the recommendations endpoint and coerces the kind', () async { + final source = _source( + MockClient((request) async { + expect(request.url.path, '/api/v1/movie/603/recommendations'); + return _json({ + 'page': 1, + 'totalPages': 1, + 'results': [ + {'id': 604, 'title': 'The Matrix Reloaded', 'releaseDate': '2003-05-15'}, + ], + }); + }), + ); + final item = CatalogItem( + source: CatalogSourceId.seerr, + kind: MediaKind.movie, + title: 'The Matrix', + ids: const CatalogItemIds(tmdb: 603), + ); + final related = await source.fetchRelated(item); + expect(related.single.title, 'The Matrix Reloaded'); + expect(related.single.kind, MediaKind.movie); + }); + + test('canRequest honors the per-kind permission split', () { + // permissions: 2 = ADMIN in the fixture session → everything allowed. + final source = _source(MockClient((request) async => _json({}))); + expect(source.canRequest(MediaKind.movie), isTrue); + expect(source.canRequest(MediaKind.show), isTrue); + }); + + test('has no watchlist: membership unknown, mutations unsupported', () async { + final source = _source(MockClient((request) async => _json({}))); + expect(source.supportsWatchlist, isFalse); + expect(source.isOnWatchlist(MediaKind.movie, const CatalogItemIds(tmdb: 603)), isNull); + expect(() => source.addToWatchlist(MediaKind.movie, const CatalogItemIds(tmdb: 603)), throwsUnsupportedError); + }); + }); +} diff --git a/test/services/seerr/seerr_client_test.dart b/test/services/seerr/seerr_client_test.dart new file mode 100644 index 00000000..5c95ca96 --- /dev/null +++ b/test/services/seerr/seerr_client_test.dart @@ -0,0 +1,408 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:plezy/models/seerr/seerr_page.dart'; +import 'package:plezy/models/seerr/seerr_request.dart'; +import 'package:plezy/models/seerr/seerr_session.dart'; +import 'package:plezy/services/seerr/seerr_auth_service.dart'; +import 'package:plezy/services/seerr/seerr_client.dart'; +import 'package:plezy/services/seerr/seerr_constants.dart'; +import 'package:plezy/services/seerr/seerr_exceptions.dart'; +import 'package:plezy/services/seerr/seerr_http_client.dart'; + +SeerrSession _session({SeerrAuthMethod method = SeerrAuthMethod.jellyfin, String secret = 'hunter2'}) => SeerrSession( + baseUrl: 'https://seerr.example.com', + method: method, + identifier: 'alice', + secret: secret, + cookie: 'old-cookie', + userId: 7, + permissions: 2, + displayName: 'Alice', + instanceLabel: 'Seerr', + createdAt: 0, +); + +http.Response _json(Object body, {int status = 200, Map? headers}) => + http.Response(jsonEncode(body), status, headers: {'content-type': 'application/json', ...?headers}); + +Map _user() => {'id': 7, 'displayName': 'Alice', 'permissions': 2, 'avatar': '/a.png'}; + +void main() { + group('SeerrHttpClient', () { + test('normalizes trailing slashes off the base URL', () { + expect(SeerrHttpClient.normalizeBaseUrl(' https://seerr.example.com// '), 'https://seerr.example.com'); + }); + + test('encodes query spaces as %20, not +', () async { + late Uri seen; + final client = SeerrHttpClient( + baseUrl: 'https://seerr.example.com', + httpClient: MockClient((request) async { + seen = request.url; + return _json({'results': []}); + }), + ); + await client.send('GET', '/search', query: {'query': 'blade runner', 'page': 1}); + expect(seen.toString(), 'https://seerr.example.com/api/v1/search?query=blade%20runner&page=1'); + }); + + test('captures connect.sid out of a multi-cookie Set-Cookie header', () { + final client = SeerrHttpClient(baseUrl: 'https://seerr.example.com'); + final response = http.Response( + '', + 200, + headers: { + 'set-cookie': + 'other=1; Path=/, ${SeerrConstants.sessionCookieName}=s%3Aabc.def; Path=/; HttpOnly; SameSite=Lax', + }, + ); + expect(client.captureSessionCookie(response), isTrue); + expect(client.cookie, 's%3Aabc.def'); + }); + + test('replays the cookie on authenticated requests only', () async { + final cookies = []; + final client = SeerrHttpClient( + baseUrl: 'https://seerr.example.com', + cookie: 'abc', + httpClient: MockClient((request) async { + cookies.add(request.headers['Cookie']); + return _json({}); + }), + ); + await client.send('GET', '/auth/me'); + await client.send('GET', '/settings/public', authenticated: false); + expect(cookies, ['${SeerrConstants.sessionCookieName}=abc', null]); + }); + }); + + group('SeerrAuthService', () { + test('probe rejects an uninitialized instance', () async { + final auth = SeerrAuthService( + httpClientFactory: () => MockClient((request) async => _json({'initialized': false})), + ); + expect(() => auth.probe('https://seerr.example.com'), throwsA(isA())); + }); + + test('jellyfin sign-in posts serverType and packs the session', () async { + late Map body; + final auth = SeerrAuthService( + httpClientFactory: () => MockClient((request) async { + expect(request.url.path, '/api/v1/auth/jellyfin'); + body = jsonDecode(request.body) as Map; + return _json(_user(), headers: {'set-cookie': '${SeerrConstants.sessionCookieName}=fresh; Path=/'}); + }), + ); + final session = await auth.signInWithJellyfin( + baseUrl: 'https://seerr.example.com/', + username: 'alice', + password: 'hunter2', + ); + expect(body, {'username': 'alice', 'password': 'hunter2', 'serverType': SeerrMediaServerType.jellyfin}); + expect(session.method, SeerrAuthMethod.jellyfin); + expect(session.baseUrl, 'https://seerr.example.com'); + expect(session.cookie, 'fresh'); + expect(session.userId, 7); + expect(session.secret, 'hunter2'); + expect(session.displayName, 'Alice'); + }); + + test('plex sign-in posts the token and stores no secret', () async { + late Map body; + final auth = SeerrAuthService( + httpClientFactory: () => MockClient((request) async { + expect(request.url.path, '/api/v1/auth/plex'); + body = jsonDecode(request.body) as Map; + return _json(_user(), headers: {'set-cookie': '${SeerrConstants.sessionCookieName}=fresh'}); + }), + ); + final session = await auth.signInWithPlex(baseUrl: 'https://seerr.example.com', plexToken: 'plex-token'); + expect(body, {'authToken': 'plex-token'}); + expect(session.method, SeerrAuthMethod.plex); + expect(session.secret, isEmpty); + expect(session.identifier, isEmpty); + }); + + test('rejected credentials surface as SeerrAuthException', () async { + final auth = SeerrAuthService( + httpClientFactory: () => MockClient((request) async => _json({'message': 'nope'}, status: 401)), + ); + expect( + () => auth.signInWithLocal(baseUrl: 'https://seerr.example.com', email: 'a@b.c', password: 'x'), + throwsA(isA()), + ); + }); + }); + + group('SeerrClient silent re-auth', () { + test('401 triggers one re-login, retries, and persists the new session', () async { + var meCalls = 0; + var loginCalls = 0; + SeerrSession? updated; + final mock = MockClient((request) async { + if (request.url.path == '/api/v1/auth/jellyfin') { + loginCalls++; + expect(jsonDecode(request.body), containsPair('password', 'hunter2')); + return _json(_user(), headers: {'set-cookie': '${SeerrConstants.sessionCookieName}=fresh'}); + } + expect(request.url.path, '/api/v1/auth/me'); + meCalls++; + final cookie = request.headers['Cookie']; + if (cookie != '${SeerrConstants.sessionCookieName}=fresh') return _json({}, status: 401); + return _json(_user()); + }); + final client = SeerrClient( + _session(), + onSessionInvalidated: () => fail('must not invalidate'), + onSessionUpdated: (s) => updated = s, + authService: SeerrAuthService(httpClientFactory: () => mock), + httpClient: mock, + ); + addTearDown(client.dispose); + + final user = await client.getMe(); + expect(user.id, 7); + expect(loginCalls, 1); + expect(meCalls, 2); + expect(updated?.cookie, 'fresh'); + // The re-packed session keeps its re-auth credentials. + expect(updated?.secret, 'hunter2'); + expect(updated?.method, SeerrAuthMethod.jellyfin); + }); + + test('plex re-auth pulls the live token from the supplier', () async { + var suppliedToken = false; + final mock = MockClient((request) async { + if (request.url.path == '/api/v1/auth/plex') { + expect(jsonDecode(request.body), {'authToken': 'live-token'}); + return _json(_user(), headers: {'set-cookie': '${SeerrConstants.sessionCookieName}=fresh'}); + } + final cookie = request.headers['Cookie']; + if (cookie != '${SeerrConstants.sessionCookieName}=fresh') return _json({}, status: 401); + return _json(_user()); + }); + final client = SeerrClient( + _session(method: SeerrAuthMethod.plex, secret: ''), + onSessionInvalidated: () => fail('must not invalidate'), + plexTokenSupplier: () async { + suppliedToken = true; + return 'live-token'; + }, + authService: SeerrAuthService(httpClientFactory: () => mock), + httpClient: mock, + ); + addTearDown(client.dispose); + + await client.getMe(); + expect(suppliedToken, isTrue); + }); + + test('re-auth without stored credentials invalidates the session', () async { + var invalidated = false; + final mock = MockClient((request) async => _json({}, status: 401)); + final client = SeerrClient( + _session(secret: ''), + onSessionInvalidated: () => invalidated = true, + authService: SeerrAuthService(httpClientFactory: () => mock), + httpClient: mock, + ); + addTearDown(client.dispose); + + await expectLater(client.getMe(), throwsA(isA())); + expect(invalidated, isTrue); + }); + + test('a transiently-unresolvable plex token errors WITHOUT unlinking the session', () async { + var invalidated = false; + var loginAttempts = 0; + final mock = MockClient((request) async { + if (request.url.path == '/api/v1/auth/plex') loginAttempts++; + return _json({}, status: 401); + }); + final client = SeerrClient( + _session(method: SeerrAuthMethod.plex, secret: ''), + onSessionInvalidated: () => invalidated = true, + // Degraded launch: identity not resolvable right now. + plexTokenSupplier: () async => null, + authService: SeerrAuthService(httpClientFactory: () => mock), + httpClient: mock, + ); + addTearDown(client.dispose); + + await expectLater(client.getMe(), throwsA(isA())); + expect(invalidated, isFalse, reason: 'a retryable failure must not clear the stored session'); + expect(loginAttempts, 0); + + // Once the supplier recovers, the next 401 re-auths normally. + final recovering = SeerrClient( + _session(method: SeerrAuthMethod.plex, secret: ''), + onSessionInvalidated: () => invalidated = true, + plexTokenSupplier: () async => 'live-token', + authService: SeerrAuthService( + httpClientFactory: () => MockClient((request) async { + if (request.url.path == '/api/v1/auth/plex') { + return _json(_user(), headers: {'set-cookie': '${SeerrConstants.sessionCookieName}=fresh'}); + } + return _json(_user()); + }), + ), + httpClient: MockClient((request) async { + final cookie = request.headers['Cookie']; + if (cookie != '${SeerrConstants.sessionCookieName}=fresh') return _json({}, status: 401); + return _json(_user()); + }), + ); + addTearDown(recovering.dispose); + final user = await recovering.getMe(); + expect(user.id, 7); + expect(invalidated, isFalse); + }); + }); + + group('SeerrClient parsing', () { + SeerrClient clientWith(MockClient mock) { + final client = SeerrClient( + _session(), + onSessionInvalidated: () {}, + authService: SeerrAuthService(httpClientFactory: () => mock), + httpClient: mock, + ); + addTearDown(client.dispose); + return client; + } + + test('trending drops person results and keeps native mediaType', () async { + final client = clientWith( + MockClient( + (request) async => _json({ + 'page': 1, + 'totalPages': 2, + 'results': [ + {'id': 1, 'mediaType': 'movie', 'title': 'Blade Runner', 'releaseDate': '1982-06-25'}, + {'id': 2, 'mediaType': 'person', 'name': 'Harrison Ford'}, + {'id': 3, 'mediaType': 'tv', 'name': 'Severance', 'firstAirDate': '2022-02-18'}, + ], + }), + ), + ); + final page = await client.getTrending(); + expect(page.items.map((m) => m.displayTitle), ['Blade Runner', 'Severance']); + expect(page.items.first.isMovie, isTrue); + expect(page.items.last.isMovie, isFalse); + expect(page.items.first.year, 1982); + expect(page.hasMore, isTrue); + }); + + test('single-type discover endpoints coerce the missing mediaType', () async { + final client = clientWith( + MockClient( + (request) async => _json({ + 'page': 1, + 'totalPages': 1, + 'results': [ + {'id': 4, 'title': 'Dune', 'releaseDate': '2021-09-15'}, + ], + }), + ), + ); + final page = await client.getPopularMovies(); + expect(page.items.single.isMovie, isTrue); + expect(page.hasMore, isFalse); + }); + + test('createRequest posts the movie payload without seasons', () async { + late Map body; + final client = clientWith( + MockClient((request) async { + expect(request.method, 'POST'); + expect(request.url.path, '/api/v1/request'); + body = jsonDecode(request.body) as Map; + return _json({'id': 10, 'status': 1}, status: 201); + }), + ); + final created = await client.createRequest(const SeerrRequestPayload(mediaType: 'movie', mediaId: 603)); + expect(body, {'mediaType': 'movie', 'mediaId': 603, 'is4k': false}); + expect(created.status, SeerrRequestStatus.pending); + }); + + test('createRequest posts tv seasons, defaulting to all', () async { + final bodies = >[]; + final client = clientWith( + MockClient((request) async { + bodies.add(jsonDecode(request.body) as Map); + return _json({'id': 11, 'status': 2}); + }), + ); + await client.createRequest(const SeerrRequestPayload(mediaType: 'tv', mediaId: 1396, seasons: [1, 2])); + await client.createRequest( + const SeerrRequestPayload(mediaType: 'tv', mediaId: 1396, is4k: true, serverId: 1, profileId: 6), + ); + expect(bodies[0]['seasons'], [1, 2]); + expect(bodies[1]['seasons'], 'all'); + expect(bodies[1]['is4k'], true); + expect(bodies[1]['serverId'], 1); + expect(bodies[1]['profileId'], 6); + }); + + test('API errors carry the server message', () async { + final client = clientWith( + MockClient((request) async => _json({'message': 'Request quota exceeded'}, status: 429)), + ); + await expectLater( + client.createRequest(const SeerrRequestPayload(mediaType: 'movie', mediaId: 603)), + throwsA(isA().having((e) => e.message, 'message', 'Request quota exceeded')), + ); + }); + }); + + group('SeerrPage', () { + test('parses both the TMDB and the pageInfo pagination shapes', () { + final tmdbShape = SeerrPage.fromJson({ + 'page': 1, + 'totalPages': 3, + 'results': [ + {'id': 1}, + ], + }, (item) => item['id'] as int); + expect(tmdbShape.hasMore, isTrue); + + final pageInfoShape = SeerrPage.fromJson({ + 'pageInfo': {'page': 2, 'pages': 2}, + 'results': [ + {'id': 1}, + ], + }, (item) => item['id'] as int); + expect(pageInfoShape.hasMore, isFalse); + expect(pageInfoShape.items, [1]); + }); + }); + + group('seerrHasPermission', () { + test('admin implies everything, otherwise any-of applies', () { + expect(seerrHasPermission(SeerrPermission.admin, [SeerrPermission.request4k]), isTrue); + expect(seerrHasPermission(SeerrPermission.request, [SeerrPermission.request4k]), isFalse); + expect( + seerrHasPermission(SeerrPermission.requestMovie, [SeerrPermission.request, SeerrPermission.requestMovie]), + isTrue, + ); + }); + }); + + group('SeerrSession', () { + test('round-trips through encode/decode', () { + final decoded = SeerrSession.decode(_session().encode()); + expect(decoded.baseUrl, 'https://seerr.example.com'); + expect(decoded.method, SeerrAuthMethod.jellyfin); + expect(decoded.identifier, 'alice'); + expect(decoded.secret, 'hunter2'); + expect(decoded.cookie, 'old-cookie'); + expect(decoded.userId, 7); + expect(decoded.permissions, 2); + expect(decoded.displayName, 'Alice'); + expect(decoded.instanceLabel, 'Seerr'); + }); + }); +}