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

217 lines
6.0 KiB
Dart
Raw Normal View History

2026-07-14 11:11:36 +08:00
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_riverpod/legacy.dart';
import '../core/contracts/trakt/trakt_calendar_entry.dart';
import '../core/domain/ports/tmdb_gateway.dart';
import '../shared/mappers/tmdb_image_url.dart';
import '../shared/utils/app_logger.dart';
import 'di_providers.dart';
import 'sm_account_provider.dart';
import 'tmdb_settings_provider.dart';
const _tag = 'TraktCalendar';
enum TraktCalendarFilter { all, shows, movies }
final traktCalendarFilterProvider = StateProvider<TraktCalendarFilter>(
(ref) => TraktCalendarFilter.all,
);
final traktCalendarProvider = FutureProvider<List<TraktCalendarEntry>>((
ref,
) async {
final authed = ref.read(authedTraktGatewayProvider);
if (!await authed.isConnected()) return const [];
final filter = ref.watch(traktCalendarFilterProvider);
final entries = <TraktCalendarEntry>[];
if (filter != TraktCalendarFilter.movies) {
try {
final raw = await authed.fetchCalendarShows();
entries.addAll(
raw
.map(TraktCalendarEntry.fromShowJson)
.whereType<TraktCalendarEntry>(),
);
} catch (e) {
AppLogger.warn(_tag, 'fetchCalendarShows failed', e);
}
}
if (filter != TraktCalendarFilter.shows) {
try {
final raw = await authed.fetchCalendarMovies();
entries.addAll(
raw
.map(TraktCalendarEntry.fromMovieJson)
.whereType<TraktCalendarEntry>(),
);
} catch (e) {
AppLogger.warn(_tag, 'fetchCalendarMovies failed', e);
}
}
entries.sort((a, b) => a.firstAired.compareTo(b.firstAired));
if (entries.isEmpty) return const [];
final tmdbConfig = await _resolveTmdbConfig(ref);
if (tmdbConfig == null) return entries;
final gateway = ref.read(tmdbGatewayProvider);
final cache = <String, _TmdbPosterResult>{};
const enrichConcurrency = 5;
final enriched = <TraktCalendarEntry>[];
for (var i = 0; i < entries.length; i += enrichConcurrency) {
final chunk = entries.skip(i).take(enrichConcurrency);
final results = await Future.wait(
chunk.map((entry) async {
final id = entry.tmdbId;
if (id == null || id <= 0) return entry;
final cacheKey = '${entry.mediaType}:$id';
try {
final cached = cache[cacheKey] ??= await _fetchPoster(
gateway,
tmdbConfig.config,
tmdbConfig.language,
id.toString(),
entry.mediaType,
);
var result = entry;
if (cached.posterUrl != null) {
result = result.withPoster(cached.posterUrl);
}
if (cached.title != null && cached.title!.isNotEmpty) {
result = result.withTitle(cached.title!);
}
return result;
} catch (_) {
return entry;
}
}),
);
enriched.addAll(results);
}
return enriched;
});
Future<_TmdbReadyConfig?> _resolveTmdbConfig(Ref ref) async {
final settings = await ref.watch(tmdbSettingsProvider.future);
if (!settings.canRequest) return null;
if (settings.accessMode == TmdbAccessMode.smAccount) {
final account = await ref.watch(smAccountProvider.future);
if (!account.canUseDataPlane) return null;
final notifier = ref.read(smAccountProvider.notifier);
final token = await notifier.getAccessToken();
if (token == null) return null;
return _TmdbReadyConfig(
config: TmdbRequestConfig(
apiBaseUrl: account.tmdbApiBaseUrl,
imageBaseUrl: TmdbSettingsState.officialImageBaseUrl,
accessToken: token,
apiKey: '',
onUnauthorized: () => notifier.getAccessToken(force: true),
),
language: settings.language,
);
}
return _TmdbReadyConfig(
config: TmdbRequestConfig(
apiBaseUrl: settings.effectiveApiBaseUrl,
imageBaseUrl: settings.effectiveImageBaseUrl,
accessToken: settings.effectiveAccessToken,
apiKey: settings.effectiveApiKey,
),
language: settings.language,
);
}
Future<_TmdbPosterResult> _fetchPoster(
TmdbGateway gateway,
TmdbRequestConfig config,
String language,
String tmdbId,
String mediaType,
) async {
final detail = await gateway.getMediaDetail(
config,
tmdbId,
mediaType,
language: language,
);
final posterUrl = TmdbImageUrl.poster(
config.imageBaseUrl,
detail?.posterPath,
size: 'w342',
);
return _TmdbPosterResult(posterUrl: posterUrl, title: detail?.title);
}
class _TmdbReadyConfig {
final TmdbRequestConfig config;
final String language;
const _TmdbReadyConfig({required this.config, required this.language});
}
class _TmdbPosterResult {
final String? posterUrl;
final String? title;
const _TmdbPosterResult({this.posterUrl, this.title});
}
List<CalendarDateGroup> groupByDate(List<TraktCalendarEntry> entries) {
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final groups = <String, List<TraktCalendarEntry>>{};
final groupOrder = <String, DateTime>{};
for (final e in entries) {
final local = e.firstAired.toLocal();
final date = DateTime(local.year, local.month, local.day);
final label = _dateLabel(date, today);
(groups[label] ??= []).add(e);
groupOrder.putIfAbsent(label, () => date);
}
final sorted = groups.entries.toList()
..sort((a, b) => groupOrder[a.key]!.compareTo(groupOrder[b.key]!));
return [
for (final e in sorted) CalendarDateGroup(label: e.key, entries: e.value),
];
}
String _dateLabel(DateTime date, DateTime today) {
final diff = date.difference(today).inDays;
if (diff == 0) return '今天';
if (diff == 1) return '明天';
final weekday = _weekdayName(date.weekday);
return '$weekday ${date.month}/${date.day}';
}
String _weekdayName(int weekday) => switch (weekday) {
1 => '周一',
2 => '周二',
3 => '周三',
4 => '周四',
5 => '周五',
6 => '周六',
_ => '周日',
};
class CalendarDateGroup {
final String label;
final List<TraktCalendarEntry> entries;
const CalendarDateGroup({required this.label, required this.entries});
}