Files
sm-emby-share/lib/providers/sm_account_provider.dart
T

433 lines
11 KiB
Dart
Raw Normal View History

2026-07-14 11:11:36 +08:00
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/domain/ports/sm_account_gateway.dart';
import '../core/infra/sm_account_api/sm_account_client.dart';
import 'shared/settings_persistence.dart';
import 'tmdb_settings_provider.dart';
final smAccountGatewayProvider = Provider<SmAccountGateway>(
(ref) => SmAccountClient(),
);
enum SmAuthStatus {
unauthenticated,
restoring,
authenticated,
expired,
degraded,
}
class SmAccountState {
final SmAuthStatus status;
final String email;
final String baseUrl;
final String dataRefreshToken;
final bool isVip;
final int? vipExpiresAt;
final bool vipPermanent;
const SmAccountState({
this.status = SmAuthStatus.unauthenticated,
this.email = '',
this.baseUrl = defaultBaseUrl,
this.dataRefreshToken = '',
this.isVip = false,
this.vipExpiresAt,
this.vipPermanent = false,
});
static const defaultBaseUrl = 'https://your-server.example';
String get tmdbApiBaseUrl => '${baseUrl.replaceAll(RegExp(r'/+$'), '')}/3';
bool get loggedIn => status == SmAuthStatus.authenticated;
bool get canUseDataPlane => status == SmAuthStatus.authenticated;
bool get hasActiveSession =>
status == SmAuthStatus.authenticated || status == SmAuthStatus.restoring;
bool get needsReauth =>
status == SmAuthStatus.expired || status == SmAuthStatus.degraded;
bool get hasVip => status == SmAuthStatus.authenticated && isVip;
SmAccountState copyWith({
SmAuthStatus? status,
String? email,
String? baseUrl,
String? dataRefreshToken,
bool? isVip,
int? vipExpiresAt,
bool? vipPermanent,
bool clearVipExpiresAt = false,
}) => SmAccountState(
status: status ?? this.status,
email: email ?? this.email,
baseUrl: baseUrl ?? this.baseUrl,
dataRefreshToken: dataRefreshToken ?? this.dataRefreshToken,
isVip: isVip ?? this.isVip,
vipExpiresAt: clearVipExpiresAt
? null
: (vipExpiresAt ?? this.vipExpiresAt),
vipPermanent: vipPermanent ?? this.vipPermanent,
);
}
class SmAccountNotifier extends AsyncNotifier<SmAccountState>
with SettingsPersistence {
static const _keyBaseUrl = 'smplayer.sm_account_base_url';
static const _keyEmail = 'smplayer.sm_account_email';
static const _keyCookie = 'smplayer.sm_account_cookie';
static const _keyRefreshToken = 'smplayer.sm_account_refresh_token';
static const _keyVip = 'smplayer.sm_account_vip';
static const _keyVipExpiresAt = 'smplayer.sm_account_vip_expires_at';
static const _keyVipPermanent = 'smplayer.sm_account_vip_permanent';
String? _accessToken;
DateTime? _accessTokenExpiry;
Future<String?>? _refreshInFlight;
@override
Future<SmAccountState> build() async {
final prefs = await getPrefs();
final refreshToken = prefs.getString(_keyRefreshToken)?.trim() ?? '';
final legacyCookie = prefs.getString(_keyCookie)?.trim() ?? '';
final rawBaseUrl = prefs.getString(_keyBaseUrl)?.trim();
final baseUrl = (rawBaseUrl == null || rawBaseUrl.isEmpty)
? SmAccountState.defaultBaseUrl
: rawBaseUrl;
final email = prefs.getString(_keyEmail)?.trim() ?? '';
final isVip = prefs.getBool(_keyVip) ?? false;
final vipExpiresAt = prefs.getInt(_keyVipExpiresAt);
final vipPermanent = prefs.getBool(_keyVipPermanent) ?? false;
if (legacyCookie.isNotEmpty) {
await persistField(_keyCookie, null);
}
if (refreshToken.isNotEmpty) {
Future.microtask(_restoreSession);
return SmAccountState(
status: SmAuthStatus.restoring,
email: email,
baseUrl: baseUrl,
dataRefreshToken: refreshToken,
isVip: isVip,
vipExpiresAt: vipExpiresAt,
vipPermanent: vipPermanent,
);
}
if (legacyCookie.isNotEmpty) {
return SmAccountState(
status: SmAuthStatus.degraded,
email: email,
baseUrl: baseUrl,
);
}
return SmAccountState(
status: SmAuthStatus.unauthenticated,
baseUrl: baseUrl,
);
}
Future<void> _restoreSession() async {
final current = state.value;
if (current == null ||
current.status != SmAuthStatus.restoring ||
current.dataRefreshToken.isEmpty) {
return;
}
final token = await getAccessToken(force: true);
if (token != null) {
return;
}
final after = state.value;
if (after != null &&
after.status == SmAuthStatus.restoring &&
after.dataRefreshToken.isNotEmpty) {
_setStatus(SmAuthStatus.authenticated);
}
}
void _setStatus(SmAuthStatus status) {
final current = state.value;
if (current == null || current.status == status) return;
state = AsyncValue.data(current.copyWith(status: status));
}
Future<String?> getAccessToken({bool force = false}) async {
final current = state.value;
if (current == null || current.dataRefreshToken.isEmpty) return null;
if (!force &&
_accessToken != null &&
_accessTokenExpiry != null &&
_accessTokenExpiry!.isAfter(
DateTime.now().add(const Duration(seconds: 60)),
)) {
return _accessToken;
}
final inFlight = _refreshInFlight;
if (inFlight != null) return inFlight;
final refresh = _refreshAccessToken(current);
_refreshInFlight = refresh;
try {
return await refresh;
} finally {
if (identical(_refreshInFlight, refresh)) {
_refreshInFlight = null;
}
}
}
Future<String?> _refreshAccessToken(SmAccountState current) async {
final result = await ref
.read(smAccountGatewayProvider)
.refreshAccessToken(
baseUrl: current.baseUrl,
refreshToken: current.dataRefreshToken,
);
if (!result.ok) {
_accessToken = null;
_accessTokenExpiry = null;
if (result.invalidRefreshToken) {
await _markExpired();
}
return null;
}
_accessToken = result.accessToken;
_accessTokenExpiry = DateTime.now().add(
Duration(seconds: result.expiresIn > 0 ? result.expiresIn : 900),
);
final latest = state.value ?? current;
final rotatedRefreshToken = result.refreshToken.trim();
final nextRefreshToken =
(rotatedRefreshToken.isNotEmpty &&
rotatedRefreshToken != latest.dataRefreshToken)
? rotatedRefreshToken
: latest.dataRefreshToken;
if (nextRefreshToken != latest.dataRefreshToken) {
await persistField(_keyRefreshToken, nextRefreshToken);
}
if (result.hasVipInfo) {
await persistFields(<String, dynamic>{
_keyVip: result.isVip,
_keyVipExpiresAt: result.vipExpiresAt,
_keyVipPermanent: result.vipPermanent,
});
}
state = AsyncValue.data(
latest.copyWith(
status: SmAuthStatus.authenticated,
dataRefreshToken: nextRefreshToken,
isVip: result.hasVipInfo ? result.isVip : null,
vipExpiresAt: result.hasVipInfo ? result.vipExpiresAt : null,
clearVipExpiresAt: result.hasVipInfo && result.vipExpiresAt == null,
vipPermanent: result.hasVipInfo ? result.vipPermanent : null,
),
);
return _accessToken;
}
Future<SmLoginResult> login(String email, String password) async {
final current = state.value ?? const SmAccountState();
final baseUrl = current.baseUrl.trim().isEmpty
? SmAccountState.defaultBaseUrl
: current.baseUrl.trim();
final result = await ref
.read(smAccountGatewayProvider)
.login(baseUrl: baseUrl, email: email.trim(), password: password);
if (!result.ok) return result;
await _applySession(baseUrl, result);
return result;
}
Future<SmActionResult> registerStart(String email) async {
final baseUrl = _effectiveBaseUrl();
final trimmedEmail = email.trim();
final result = await ref
.read(smAccountGatewayProvider)
.registerStart(baseUrl: baseUrl, email: trimmedEmail);
if (result.ok) {
await persistField(_keyBaseUrl, baseUrl);
}
return result;
}
Future<SmLoginResult> registerVerify(
String email,
String password,
String code,
) async {
final baseUrl = _effectiveBaseUrl();
final result = await ref
.read(smAccountGatewayProvider)
.registerVerify(
baseUrl: baseUrl,
email: email.trim(),
password: password,
code: code.trim(),
);
if (!result.ok) return result;
await _applySession(baseUrl, result);
return result;
}
Future<SmActionResult> resendRegisterCode(String email) async {
return ref
.read(smAccountGatewayProvider)
.resendCode(baseUrl: _effectiveBaseUrl(), email: email.trim());
}
Future<void> logout() async {
final current = state.value ?? const SmAccountState();
_accessToken = null;
_accessTokenExpiry = null;
_refreshInFlight = null;
if (current.dataRefreshToken.isNotEmpty) {
await ref
.read(smAccountGatewayProvider)
.logout(
baseUrl: current.baseUrl,
refreshToken: current.dataRefreshToken,
);
}
await persistFields(<String, dynamic>{
_keyEmail: null,
_keyCookie: null,
_keyRefreshToken: null,
_keyVip: null,
_keyVipExpiresAt: null,
_keyVipPermanent: null,
});
state = AsyncValue.data(
SmAccountState(
status: SmAuthStatus.unauthenticated,
baseUrl: current.baseUrl,
),
);
}
Future<void> _applySession(String baseUrl, SmLoginResult result) async {
_accessToken = result.accessToken.isNotEmpty ? result.accessToken : null;
_accessTokenExpiry = result.expiresIn > 0
? DateTime.now().add(Duration(seconds: result.expiresIn))
: null;
_refreshInFlight = null;
await persistFields(<String, dynamic>{
_keyBaseUrl: baseUrl,
_keyEmail: result.email,
_keyRefreshToken: result.refreshToken,
_keyVip: result.isVip,
_keyVipExpiresAt: result.vipExpiresAt,
_keyVipPermanent: result.vipPermanent,
_keyCookie: null,
});
final current = state.value ?? const SmAccountState();
state = AsyncValue.data(
current.copyWith(
status: SmAuthStatus.authenticated,
email: result.email,
baseUrl: baseUrl,
dataRefreshToken: result.refreshToken,
isVip: result.isVip,
vipExpiresAt: result.vipExpiresAt,
clearVipExpiresAt: result.vipExpiresAt == null,
vipPermanent: result.vipPermanent,
),
);
if (result.isVip) {
await ref.read(tmdbSettingsProvider.notifier).enableSmAccountProxy();
}
}
Future<void> _markExpired() async {
_accessToken = null;
_accessTokenExpiry = null;
_refreshInFlight = null;
await persistFields(<String, dynamic>{
_keyRefreshToken: null,
_keyVip: null,
_keyVipExpiresAt: null,
_keyVipPermanent: null,
});
final current = state.value ?? const SmAccountState();
state = AsyncValue.data(
current.copyWith(
status: SmAuthStatus.expired,
dataRefreshToken: '',
isVip: false,
clearVipExpiresAt: true,
vipPermanent: false,
),
);
}
String _effectiveBaseUrl() {
final current = state.value ?? const SmAccountState();
final v = current.baseUrl.trim();
return v.isEmpty ? SmAccountState.defaultBaseUrl : v;
}
}
final smAccountProvider =
AsyncNotifierProvider<SmAccountNotifier, SmAccountState>(
SmAccountNotifier.new,
);