feat(seerr): Seerr API, account provider, and Explore catalog source
This commit is contained in:
@@ -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<SeerrGenre>? 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<String, dynamic> 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<int>? episodeRunTime;
|
||||
|
||||
/// `Returning Series` / `Ended` / `Canceled` / `In Production` /
|
||||
/// `Planned` / `Pilot`.
|
||||
final String? status;
|
||||
final double? voteAverage;
|
||||
final int? voteCount;
|
||||
final List<SeerrGenre>? genres;
|
||||
final List<SeerrNetwork>? networks;
|
||||
final int? numberOfEpisodes;
|
||||
final int? numberOfSeasons;
|
||||
final List<SeerrSeason>? 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<String, dynamic> json) => _$SeerrTvDetailsFromJson(json);
|
||||
}
|
||||
|
||||
@JsonSerializable(createToJson: false)
|
||||
class SeerrGenre {
|
||||
final String? name;
|
||||
const SeerrGenre({this.name});
|
||||
factory SeerrGenre.fromJson(Map<String, dynamic> json) => _$SeerrGenreFromJson(json);
|
||||
}
|
||||
|
||||
@JsonSerializable(createToJson: false)
|
||||
class SeerrNetwork {
|
||||
final String? name;
|
||||
const SeerrNetwork({this.name});
|
||||
factory SeerrNetwork.fromJson(Map<String, dynamic> 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<String, dynamic> json) => _$SeerrSeasonFromJson(json);
|
||||
}
|
||||
|
||||
@JsonSerializable(createToJson: false)
|
||||
class SeerrCredits {
|
||||
final List<SeerrCastMember>? cast;
|
||||
const SeerrCredits({this.cast});
|
||||
factory SeerrCredits.fromJson(Map<String, dynamic> 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<String, dynamic> json) => _$SeerrCastMemberFromJson(json);
|
||||
}
|
||||
|
||||
@JsonSerializable(createToJson: false)
|
||||
class SeerrExternalIds {
|
||||
final String? imdbId;
|
||||
final int? tvdbId;
|
||||
|
||||
const SeerrExternalIds({this.imdbId, this.tvdbId});
|
||||
|
||||
factory SeerrExternalIds.fromJson(Map<String, dynamic> json) => _$SeerrExternalIdsFromJson(json);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'seerr_details.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
SeerrMovieDetails _$SeerrMovieDetailsFromJson(Map<String, dynamic> 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<dynamic>?)
|
||||
?.map((e) => SeerrGenre.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
credits: json['credits'] == null
|
||||
? null
|
||||
: SeerrCredits.fromJson(json['credits'] as Map<String, dynamic>),
|
||||
externalIds: json['externalIds'] == null
|
||||
? null
|
||||
: SeerrExternalIds.fromJson(
|
||||
json['externalIds'] as Map<String, dynamic>,
|
||||
),
|
||||
mediaInfo: json['mediaInfo'] == null
|
||||
? null
|
||||
: SeerrMediaInfo.fromJson(json['mediaInfo'] as Map<String, dynamic>),
|
||||
);
|
||||
|
||||
SeerrTvDetails _$SeerrTvDetailsFromJson(Map<String, dynamic> 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<dynamic>?)
|
||||
?.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<dynamic>?)
|
||||
?.map((e) => SeerrGenre.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
networks: (json['networks'] as List<dynamic>?)
|
||||
?.map((e) => SeerrNetwork.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
numberOfEpisodes: (json['numberOfEpisodes'] as num?)?.toInt(),
|
||||
numberOfSeasons: (json['numberOfSeasons'] as num?)?.toInt(),
|
||||
seasons: (json['seasons'] as List<dynamic>?)
|
||||
?.map((e) => SeerrSeason.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
credits: json['credits'] == null
|
||||
? null
|
||||
: SeerrCredits.fromJson(json['credits'] as Map<String, dynamic>),
|
||||
externalIds: json['externalIds'] == null
|
||||
? null
|
||||
: SeerrExternalIds.fromJson(
|
||||
json['externalIds'] as Map<String, dynamic>,
|
||||
),
|
||||
mediaInfo: json['mediaInfo'] == null
|
||||
? null
|
||||
: SeerrMediaInfo.fromJson(json['mediaInfo'] as Map<String, dynamic>),
|
||||
);
|
||||
|
||||
SeerrGenre _$SeerrGenreFromJson(Map<String, dynamic> json) =>
|
||||
SeerrGenre(name: json['name'] as String?);
|
||||
|
||||
SeerrNetwork _$SeerrNetworkFromJson(Map<String, dynamic> json) =>
|
||||
SeerrNetwork(name: json['name'] as String?);
|
||||
|
||||
SeerrSeason _$SeerrSeasonFromJson(Map<String, dynamic> 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<String, dynamic> json) => SeerrCredits(
|
||||
cast: (json['cast'] as List<dynamic>?)
|
||||
?.map((e) => SeerrCastMember.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
);
|
||||
|
||||
SeerrCastMember _$SeerrCastMemberFromJson(Map<String, dynamic> json) =>
|
||||
SeerrCastMember(
|
||||
name: json['name'] as String?,
|
||||
character: json['character'] as String?,
|
||||
profilePath: json['profilePath'] as String?,
|
||||
);
|
||||
|
||||
SeerrExternalIds _$SeerrExternalIdsFromJson(Map<String, dynamic> json) =>
|
||||
SeerrExternalIds(
|
||||
imdbId: json['imdbId'] as String?,
|
||||
tvdbId: (json['tvdbId'] as num?)?.toInt(),
|
||||
);
|
||||
@@ -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<String, dynamic> 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<SeerrSeasonInfo>? seasons;
|
||||
|
||||
/// Open/settled requests for this title (used to disable already-requested
|
||||
/// seasons in the request sheet).
|
||||
final List<SeerrRequest>? 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<String, dynamic> 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<String, dynamic> json) => _$SeerrSeasonInfoFromJson(json);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'seerr_media.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
SeerrMedia _$SeerrMediaFromJson(Map<String, dynamic> 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<String, dynamic>),
|
||||
);
|
||||
|
||||
SeerrMediaInfo _$SeerrMediaInfoFromJson(Map<String, dynamic> 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<dynamic>?)
|
||||
?.map((e) => SeerrSeasonInfo.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
requests: (json['requests'] as List<dynamic>?)
|
||||
?.map((e) => SeerrRequest.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
);
|
||||
|
||||
SeerrSeasonInfo _$SeerrSeasonInfoFromJson(Map<String, dynamic> 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()),
|
||||
);
|
||||
@@ -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<T> {
|
||||
final List<T> 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<String, dynamic> json, T? Function(Map<String, dynamic>) fromItem) {
|
||||
final info = json['pageInfo'] is Map<String, dynamic> ? json['pageInfo'] as Map<String, dynamic> : 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<String, dynamic>)
|
||||
if (fromItem(item) case final T parsed) parsed,
|
||||
],
|
||||
hasMore: page < totalPages,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<String, dynamic> json) => _$SeerrPublicSettingsFromJson(json);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'seerr_public_settings.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
SeerrPublicSettings _$SeerrPublicSettingsFromJson(Map<String, dynamic> 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,
|
||||
);
|
||||
@@ -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<SeerrRequestSeason>? seasons;
|
||||
|
||||
const SeerrRequest({required this.id, required this.status, this.is4k, this.media, this.seasons});
|
||||
|
||||
factory SeerrRequest.fromJson(Map<String, dynamic> 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<String, dynamic> 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<int>? 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<String, Object?> 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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'seerr_request.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
SeerrRequest _$SeerrRequestFromJson(Map<String, dynamic> 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<String, dynamic>),
|
||||
seasons: (json['seasons'] as List<dynamic>?)
|
||||
?.map((e) => SeerrRequestSeason.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
);
|
||||
|
||||
SeerrRequestSeason _$SeerrRequestSeasonFromJson(Map<String, dynamic> json) =>
|
||||
SeerrRequestSeason(seasonNumber: (json['seasonNumber'] as num).toInt());
|
||||
@@ -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<String, dynamic> 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<SeerrServiceProfile>? profiles;
|
||||
final List<SeerrRootFolder>? rootFolders;
|
||||
|
||||
/// Sonarr v3 only; absent on Radarr and newer Sonarr.
|
||||
final List<SeerrServiceProfile>? languageProfiles;
|
||||
|
||||
const SeerrServiceDetail({this.server, this.profiles, this.rootFolders, this.languageProfiles});
|
||||
|
||||
factory SeerrServiceDetail.fromJson(Map<String, dynamic> 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<String, dynamic> 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<String, dynamic> json) => _$SeerrRootFolderFromJson(json);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'seerr_service.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
SeerrServiceInstance _$SeerrServiceInstanceFromJson(
|
||||
Map<String, dynamic> 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<String, dynamic> json) =>
|
||||
SeerrServiceDetail(
|
||||
server: json['server'] == null
|
||||
? null
|
||||
: SeerrServiceInstance.fromJson(
|
||||
json['server'] as Map<String, dynamic>,
|
||||
),
|
||||
profiles: (json['profiles'] as List<dynamic>?)
|
||||
?.map((e) => SeerrServiceProfile.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
rootFolders: (json['rootFolders'] as List<dynamic>?)
|
||||
?.map((e) => SeerrRootFolder.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
languageProfiles: (json['languageProfiles'] as List<dynamic>?)
|
||||
?.map((e) => SeerrServiceProfile.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
);
|
||||
|
||||
SeerrServiceProfile _$SeerrServiceProfileFromJson(Map<String, dynamic> json) =>
|
||||
SeerrServiceProfile(
|
||||
id: (json['id'] as num).toInt(),
|
||||
name: json['name'] as String?,
|
||||
);
|
||||
|
||||
SeerrRootFolder _$SeerrRootFolderFromJson(Map<String, dynamic> json) =>
|
||||
SeerrRootFolder(
|
||||
id: (json['id'] as num).toInt(),
|
||||
path: json['path'] as String?,
|
||||
);
|
||||
@@ -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<String, Object?> 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<String, Object?> 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<String, Object?>());
|
||||
}
|
||||
@@ -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<String, dynamic> json) => _$SeerrUserFromJson(json);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'seerr_user.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
SeerrUser _$SeerrUserFromJson(Map<String, dynamic> 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?,
|
||||
);
|
||||
@@ -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<void> _pendingPersistence = Future<void>.value();
|
||||
|
||||
Future<void> _enqueuePersistence(Future<void> Function() op) {
|
||||
final run = _pendingPersistence.then((_) => op());
|
||||
_pendingPersistence = run.then<void>(
|
||||
(_) {},
|
||||
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<String?> 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<void> 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<void> 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<void> 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();
|
||||
}
|
||||
}
|
||||
@@ -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<CatalogRowId> 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<CatalogPage> 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<List<CatalogItem>> 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<List<CatalogItem>> 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<CatalogItemIds?> resolveItemIds(MediaKind kind, ExternalIds external) async =>
|
||||
external.tmdb == null ? null : CatalogItemIds(tmdb: external.tmdb, imdb: external.imdb, tvdb: external.tvdb);
|
||||
|
||||
@override
|
||||
Future<List<CatalogCastMember>> 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 <SeerrCastMember>[]).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<void> ensureWatchlistLoaded() => Future.value();
|
||||
|
||||
@override
|
||||
bool? isOnWatchlist(MediaKind kind, CatalogItemIds ids) => null;
|
||||
|
||||
@override
|
||||
Future<void> addToWatchlist(MediaKind kind, CatalogItemIds ids) => throw UnsupportedError('Seerr has no watchlist');
|
||||
|
||||
@override
|
||||
Future<void> removeFromWatchlist(MediaKind kind, CatalogItemIds ids) =>
|
||||
throw UnsupportedError('Seerr has no watchlist');
|
||||
|
||||
CatalogPage _toPage(SeerrPage<SeerrMedia> 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();
|
||||
}
|
||||
}
|
||||
@@ -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<SeerrPublicSettings> 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<String, dynamic>) {
|
||||
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<SeerrSession> 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<SeerrSession> 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<SeerrSession> 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<SeerrSession> 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<void> 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<SeerrSession> _signIn({
|
||||
required String baseUrl,
|
||||
required SeerrAuthMethod method,
|
||||
required String path,
|
||||
required Map<String, Object?> 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<String, dynamic>
|
||||
? (res.data as Map<String, dynamic>)['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<SeerrUser> _resolveUser(SeerrHttpClient client, dynamic loginData) async {
|
||||
if (loginData is Map<String, dynamic>) {
|
||||
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<String, dynamic>) return SeerrUser.fromJson(data);
|
||||
throw const SeerrAuthException('Seerr did not return user information');
|
||||
}
|
||||
}
|
||||
@@ -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<String?> 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<String, SeerrSession> _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<SeerrUser> getMe() async {
|
||||
final data = await _request('GET', '/auth/me');
|
||||
return SeerrUser.fromJson(data as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
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<SeerrPublicSettings> getPublicSettings() async {
|
||||
if (_publicSettingsCache case final SeerrPublicSettings cached) return cached;
|
||||
final data = await _request('GET', '/settings/public');
|
||||
return _publicSettingsCache = SeerrPublicSettings.fromJson(data as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
// ---------- Discover / search ----------
|
||||
|
||||
/// `/discover/movies` — popular movies.
|
||||
Future<SeerrPage<SeerrMedia>> getPopularMovies({int page = 1}) => _mediaPage('/discover/movies', page, 'movie');
|
||||
|
||||
/// `/discover/tv` — popular series.
|
||||
Future<SeerrPage<SeerrMedia>> getPopularTv({int page = 1}) => _mediaPage('/discover/tv', page, 'tv');
|
||||
|
||||
Future<SeerrPage<SeerrMedia>> getUpcomingMovies({int page = 1}) =>
|
||||
_mediaPage('/discover/movies/upcoming', page, 'movie');
|
||||
|
||||
Future<SeerrPage<SeerrMedia>> getUpcomingTv({int page = 1}) => _mediaPage('/discover/tv/upcoming', page, 'tv');
|
||||
|
||||
/// `/discover/trending` — mixed movies/TV/people; person entries are
|
||||
/// dropped.
|
||||
Future<SeerrPage<SeerrMedia>> getTrending({int page = 1}) => _mediaPage('/discover/trending', page, null);
|
||||
|
||||
/// `/search` — Seerr's TMDB-backed catalog search (mixed results, person
|
||||
/// entries dropped).
|
||||
Future<SeerrPage<SeerrMedia>> 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<SeerrPage<SeerrMedia>> getMovieRecommendations(int tmdbId, {int page = 1}) =>
|
||||
_mediaPage('/movie/$tmdbId/recommendations', page, 'movie');
|
||||
|
||||
Future<SeerrPage<SeerrMedia>> getTvRecommendations(int tmdbId, {int page = 1}) =>
|
||||
_mediaPage('/tv/$tmdbId/recommendations', page, 'tv');
|
||||
|
||||
Future<SeerrPage<SeerrMedia>> _mediaPage(String path, int page, String? coerceMediaType) async {
|
||||
final data = await _request('GET', path, query: {'page': page});
|
||||
return _parseMediaPage(data, coerceMediaType);
|
||||
}
|
||||
|
||||
SeerrPage<SeerrMedia> _parseMediaPage(dynamic data, String? coerceMediaType) {
|
||||
return SeerrPage<SeerrMedia>.fromJson(data as Map<String, dynamic>, (item) {
|
||||
final mediaType = item['mediaType'] as String? ?? coerceMediaType;
|
||||
if (mediaType != 'movie' && mediaType != 'tv') return null;
|
||||
return SeerrMedia.fromJson({...item, 'mediaType': mediaType});
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- Details ----------
|
||||
|
||||
Future<SeerrMovieDetails> getMovie(int tmdbId) async {
|
||||
final data = await _request('GET', '/movie/$tmdbId');
|
||||
return SeerrMovieDetails.fromJson(data as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
Future<SeerrTvDetails> getTv(int tmdbId) async {
|
||||
final data = await _request('GET', '/tv/$tmdbId');
|
||||
return SeerrTvDetails.fromJson(data as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
// ---------- Requests ----------
|
||||
|
||||
Future<SeerrRequest> createRequest(SeerrRequestPayload payload) async {
|
||||
final data = await _request('POST', '/request', body: payload.toJson());
|
||||
return SeerrRequest.fromJson(data as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
Future<void> deleteRequest(int requestId) async {
|
||||
await _request('DELETE', '/request/$requestId');
|
||||
}
|
||||
|
||||
// ---------- Sonarr / Radarr options (request sheet advanced pickers) ----------
|
||||
|
||||
Future<List<SeerrServiceInstance>> getRadarrServices() => _serviceList('/service/radarr');
|
||||
|
||||
Future<List<SeerrServiceInstance>> getSonarrServices() => _serviceList('/service/sonarr');
|
||||
|
||||
Future<SeerrServiceDetail> getRadarrService(int id) => _serviceDetail('/service/radarr/$id');
|
||||
|
||||
Future<SeerrServiceDetail> getSonarrService(int id) => _serviceDetail('/service/sonarr/$id');
|
||||
|
||||
Future<List<SeerrServiceInstance>> _serviceList(String path) async {
|
||||
final data = await _request('GET', path);
|
||||
return [
|
||||
if (data is List)
|
||||
for (final item in data)
|
||||
if (item is Map<String, dynamic>) SeerrServiceInstance.fromJson(item),
|
||||
];
|
||||
}
|
||||
|
||||
Future<SeerrServiceDetail> _serviceDetail(String path) async {
|
||||
final data = await _request('GET', path);
|
||||
return SeerrServiceDetail.fromJson(data as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
// ---------- Internals ----------
|
||||
|
||||
Future<dynamic> _request(
|
||||
String method,
|
||||
String path, {
|
||||
Map<String, Object?>? query,
|
||||
Map<String, Object?>? 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<void> _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<SeerrSession> _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<String?>` type: calling `.timeout(onTimeout: () =>
|
||||
/// null)` directly on the supplier's future trips the covariant-generics
|
||||
/// runtime check when a caller hands us a `Future<String> Function()`.
|
||||
Future<String?> _resolvePlexToken() async => plexTokenSupplier == null ? null : await plexTokenSupplier!();
|
||||
|
||||
void _adopt(SeerrSession next) {
|
||||
updateSession(next);
|
||||
onSessionUpdated?.call(next);
|
||||
}
|
||||
}
|
||||
@@ -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<int> anyOf) {
|
||||
if (userPermissions & SeerrPermission.admin != 0) return true;
|
||||
return anyOf.any((p) => userPermissions & p != 0);
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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<SeerrResponse> send(
|
||||
String method,
|
||||
String path, {
|
||||
Map<String, Object?>? query,
|
||||
Map<String, Object?>? 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 = <String, String>{
|
||||
'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<String, Object?>? query) {
|
||||
final base = Uri.parse('$baseUrl${SeerrConstants.apiPath}$path');
|
||||
if (query == null || query.isEmpty) return base;
|
||||
final parts = <String>[
|
||||
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<String, dynamic> ? 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;
|
||||
}
|
||||
}
|
||||
@@ -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<SeerrSession?> 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<void> 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<void> clear(String userUuid) async {
|
||||
final prefs = await BaseSharedPreferencesService.sharedCache();
|
||||
await prefs.remove(_scopedKey(userUuid));
|
||||
}
|
||||
}
|
||||
@@ -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 = <String>[];
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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<String, String>? headers}) =>
|
||||
http.Response(jsonEncode(body), status, headers: {'content-type': 'application/json', ...?headers});
|
||||
|
||||
Map<String, dynamic> _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 = <String?>[];
|
||||
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<SeerrUrlException>()));
|
||||
});
|
||||
|
||||
test('jellyfin sign-in posts serverType and packs the session', () async {
|
||||
late Map<String, dynamic> body;
|
||||
final auth = SeerrAuthService(
|
||||
httpClientFactory: () => MockClient((request) async {
|
||||
expect(request.url.path, '/api/v1/auth/jellyfin');
|
||||
body = jsonDecode(request.body) as Map<String, dynamic>;
|
||||
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<String, dynamic> body;
|
||||
final auth = SeerrAuthService(
|
||||
httpClientFactory: () => MockClient((request) async {
|
||||
expect(request.url.path, '/api/v1/auth/plex');
|
||||
body = jsonDecode(request.body) as Map<String, dynamic>;
|
||||
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<SeerrAuthException>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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<SeerrAuthException>()));
|
||||
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<SeerrReauthUnavailableException>()));
|
||||
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<String, dynamic> body;
|
||||
final client = clientWith(
|
||||
MockClient((request) async {
|
||||
expect(request.method, 'POST');
|
||||
expect(request.url.path, '/api/v1/request');
|
||||
body = jsonDecode(request.body) as Map<String, dynamic>;
|
||||
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 = <Map<String, dynamic>>[];
|
||||
final client = clientWith(
|
||||
MockClient((request) async {
|
||||
bodies.add(jsonDecode(request.body) as Map<String, dynamic>);
|
||||
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<SeerrApiException>().having((e) => e.message, 'message', 'Request quota exceeded')),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('SeerrPage', () {
|
||||
test('parses both the TMDB and the pageInfo pagination shapes', () {
|
||||
final tmdbShape = SeerrPage<int>.fromJson({
|
||||
'page': 1,
|
||||
'totalPages': 3,
|
||||
'results': [
|
||||
{'id': 1},
|
||||
],
|
||||
}, (item) => item['id'] as int);
|
||||
expect(tmdbShape.hasMore, isTrue);
|
||||
|
||||
final pageInfoShape = SeerrPage<int>.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');
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user