feat(seerr): Seerr API, account provider, and Explore catalog source

This commit is contained in:
edde746
2026-07-10 07:08:42 +02:00
parent 2aa084287d
commit b4943f8c50
24 changed files with 2543 additions and 0 deletions
+153
View File
@@ -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);
}
+105
View File
@@ -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(),
);
+124
View File
@@ -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);
}
+54
View File
@@ -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()),
);
+27
View File
@@ -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,
);
+84
View File
@@ -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,
};
}
+22
View File
@@ -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());
+66
View File
@@ -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);
}
+49
View File
@@ -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?,
);
+113
View File
@@ -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?>());
}
+18
View File
@@ -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);
}
+15
View File
@@ -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?,
);