load trending images from tmdb at startup

- need TMDB_API_KEY as environment variable
- mount /cache for data persistence

Signed-off-by: Peter Siegmund <developer@mars3142.org>
This commit is contained in:
2025-11-17 23:21:01 +01:00
parent 4587901672
commit 4ce471599b
17 changed files with 305 additions and 10 deletions

View File

@@ -0,0 +1,39 @@
import 'dart:io';
import 'package:cinema/feature/version/version.dart';
import 'package:dio/dio.dart';
import 'package:injectable/injectable.dart';
const dioAPI = 'api';
const dioIMAGES = 'images';
@module
abstract class DioModule {
@Named(dioAPI)
@lazySingleton
Dio get apiDio =>
Dio( // Der Getter selbst wird annotiert und gibt die Instanz zurück
BaseOptions(
baseUrl: 'https://api.themoviedb.org/3',
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 10),
headers: {
'Authorization': 'Bearer ${Platform.environment['TMDB_API_KEY']}',
'Content-Type': 'application/json',
'Accept': 'application/json',
'User-Agent': 'Cinema Service (v${Version().appVersion})'
},
),
);
@Named(dioIMAGES)
@lazySingleton
Dio get imagesDio =>
Dio( // Der Getter selbst wird annotiert und gibt die Instanz zurück
BaseOptions(
baseUrl: 'https://image.tmdb.org/t/p/original',
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 10),
),
);
}

View File

@@ -0,0 +1,32 @@
import 'package:built_value/serializer.dart';
/// A custom serializer for [DateTime] objects that are represented as
/// a simple date string "yyyy-MM-dd" in JSON.
class CustomDateTimeSerializer implements PrimitiveSerializer<DateTime> {
@override
final Iterable<Type> types = const [DateTime];
@override
final String wireName = 'DateTime';
@override
Object serialize(Serializers serializers, DateTime dateTime,
{FullType specifiedType = FullType.unspecified}) {
// On serialization, convert DateTime to a "yyyy-MM-dd" string.
return dateTime.toIso8601String().substring(0, 10);
}
@override
DateTime deserialize(Serializers serializers, Object serialized,
{FullType specifiedType = FullType.unspecified}) {
// On deserialization, parse the string to a DateTime object.
// This handles formats like "2025-09-23".
final parts = (serialized as String).split('-');
final dateUtc = DateTime.utc(
int.parse(parts[0]),
int.parse(parts[1]),
int.parse(parts[2]),
);
return dateUtc;
}
}

View File

@@ -0,0 +1,20 @@
import 'package:built_collection/built_collection.dart';
import 'package:built_value/serializer.dart';
import 'package:built_value/standard_json_plugin.dart';
import 'package:cinema/feature/poster/domain/movie.dart';
import 'package:cinema/feature/poster/domain/tmdb_trending_response.dart';
import 'custom_date_time_serializer.dart';
part 'serializers.g.dart';
@SerializersFor([TmdbTrendingResponse, TmdbMovieResult])
final Serializers serializers =
(_$serializers.toBuilder()
..add(CustomDateTimeSerializer())
..addPlugin(StandardJsonPlugin())
..addBuilderFactory(
const FullType(BuiltList, [FullType(TmdbMovieResult), FullType(Movie)]),
() => ListBuilder<TmdbMovieResult>(),
))
.build();

View File

@@ -0,0 +1,7 @@
import 'package:cinema/feature/poster/domain/movie.dart';
abstract class ImageLoader {
Future<List<Movie>> getPosterURIs({String? language = 'de'});
Future<bool> downloadImages(Movie movie);
}

View File

@@ -0,0 +1,57 @@
import 'dart:io';
import 'package:cinema/common/dio_module.dart';
import 'package:cinema/common/domain/serializers.dart';
import 'package:cinema/feature/poster/data/repositories/image_loader.dart';
import 'package:cinema/feature/poster/domain/movie.dart';
import 'package:cinema/feature/poster/domain/tmdb_trending_response.dart';
import 'package:dio/dio.dart';
import 'package:injectable/injectable.dart';
@Injectable(as: ImageLoader)
class TmDBImageLoader implements ImageLoader {
final Dio api;
final Dio images;
final String imageBaseUrl = 'https://image.tmdb.org/t/p/w500';
TmDBImageLoader(@Named(dioAPI) this.api, @Named(dioIMAGES) this.images);
@override
Future<List<Movie>> getPosterURIs({String? language = 'de'}) async {
final response = await api.get('/trending/movie/week?language=$language');
if (response.data != null) {
final data = serializers.deserializeWith(TmdbTrendingResponse.serializer, response.data);
return data?.results
.map(
(movie) => Movie(
(b) => b
..id = movie.id
..poster = movie.posterPath
..backdrop = movie.backdropPath,
),
)
.toList() ??
[];
}
return [];
}
@override
Future<bool> downloadImages(Movie movie) async {
await downloadImage(movie.poster, "cache/movie/${movie.id}/poster.png");
await downloadImage(movie.backdrop, "cache/movie/${movie.id}/backdrop.png");
return true;
}
Future<bool> downloadImage(String url, String filename) async {
final file = File(filename);
if (await file.exists()) {
return false;
}
await file.parent.create(recursive: true);
final response = await images.get(url, options: Options(responseType: ResponseType.bytes));
await file.writeAsBytes(response.data);
return true;
}
}

View File

@@ -1,6 +1,6 @@
import 'dart:convert';
import 'package:cinema/feature/poster/models/poster_request.schema.dart';
import 'package:cinema/feature/poster/domain/poster_request.schema.dart';
import 'package:injectable/injectable.dart';
import 'package:shelf/shelf.dart';
import 'package:shelf_router/shelf_router.dart';

View File

@@ -0,0 +1,18 @@
import 'package:built_value/built_value.dart';
import 'package:built_value/serializer.dart';
part 'movie.g.dart';
abstract class Movie implements Built<Movie, MovieBuilder> {
static Serializer<Movie> get serializer => _$movieSerializer;
int get id;
String get poster;
String get backdrop;
Movie._();
factory Movie([void Function(MovieBuilder) updates]) = _$Movie;
}

View File

@@ -1,4 +1,4 @@
import 'package:cinema/feature/poster/models/poster.enums.dart';
import 'package:cinema/feature/poster/domain/poster.enums.dart';
import 'package:zard/zard.dart';
final _orientations = PosterOrientation.values.map((e) => e.name);

View File

@@ -0,0 +1,69 @@
import 'dart:convert';
import 'package:built_collection/built_collection.dart';
import 'package:built_value/built_value.dart';
import 'package:built_value/serializer.dart';
part 'tmdb_trending_response.g.dart';
abstract class TmdbTrendingResponse implements Built<TmdbTrendingResponse, TmdbTrendingResponseBuilder> {
static Serializer<TmdbTrendingResponse> get serializer => _$tmdbTrendingResponseSerializer;
int get page;
BuiltList<TmdbMovieResult> get results;
@BuiltValueField(wireName: 'total_pages')
int get totalPages;
@BuiltValueField(wireName: 'total_results')
int get totalResults;
TmdbTrendingResponse._();
factory TmdbTrendingResponse([void Function(TmdbTrendingResponseBuilder) updates]) = _$TmdbTrendingResponse;
}
abstract class TmdbMovieResult implements Built<TmdbMovieResult, TmdbMovieResultBuilder> {
static Serializer<TmdbMovieResult> get serializer => _$tmdbMovieResultSerializer;
bool get adult;
@BuiltValueField(wireName: 'backdrop_path')
String get backdropPath;
int get id;
String get title;
@BuiltValueField(wireName: 'original_title')
String get originalTitle;
String get overview;
@BuiltValueField(wireName: 'poster_path')
String get posterPath;
@BuiltValueField(wireName: 'media_type')
String get mediaType;
@BuiltValueField(wireName: 'genre_ids')
BuiltList<int> get genreIds;
double get popularity;
@BuiltValueField(wireName: 'release_date')
DateTime get releaseDate;
bool get video;
@BuiltValueField(wireName: 'vote_average')
double get voteAverage;
@BuiltValueField(wireName: 'vote_count')
int get voteCount;
TmdbMovieResult._();
factory TmdbMovieResult([void Function(TmdbMovieResultBuilder) updates]) = _$TmdbMovieResult;
}