From 4ce471599b939b92de4b9d433874ee25b3733eba Mon Sep 17 00:00:00 2001 From: Peter Siegmund Date: Mon, 17 Nov 2025 23:21:01 +0100 Subject: [PATCH] load trending images from tmdb at startup - need TMDB_API_KEY as environment variable - mount /cache for data persistence Signed-off-by: Peter Siegmund --- server/cinema/.gitignore | 1 + server/cinema/Dockerfile | 3 +- server/cinema/Makefile | 2 +- server/cinema/bin/server.dart | 13 +++- server/cinema/lib/common/dio_module.dart | 39 +++++++++++ .../domain/custom_date_time_serializer.dart | 32 +++++++++ .../cinema/lib/common/domain/serializers.dart | 20 ++++++ .../data/repositories/image_loader.dart | 7 ++ .../data/repositories/tmdb_image_loader.dart | 57 +++++++++++++++ .../{ => data/services}/poster.service.dart | 2 +- .../lib/feature/poster/domain/movie.dart | 18 +++++ .../{models => domain}/poster.enums.dart | 0 .../poster_request.schema.dart | 2 +- .../poster/domain/tmdb_trending_response.dart | 69 +++++++++++++++++++ .../root/{ => data/service}/root.service.dart | 0 server/cinema/pubspec.lock | 46 ++++++++++++- server/cinema/pubspec.yaml | 4 ++ 17 files changed, 305 insertions(+), 10 deletions(-) create mode 100644 server/cinema/lib/common/dio_module.dart create mode 100644 server/cinema/lib/common/domain/custom_date_time_serializer.dart create mode 100644 server/cinema/lib/common/domain/serializers.dart create mode 100644 server/cinema/lib/feature/poster/data/repositories/image_loader.dart create mode 100644 server/cinema/lib/feature/poster/data/repositories/tmdb_image_loader.dart rename server/cinema/lib/feature/poster/{ => data/services}/poster.service.dart (94%) create mode 100644 server/cinema/lib/feature/poster/domain/movie.dart rename server/cinema/lib/feature/poster/{models => domain}/poster.enums.dart (100%) rename server/cinema/lib/feature/poster/{models => domain}/poster_request.schema.dart (95%) create mode 100644 server/cinema/lib/feature/poster/domain/tmdb_trending_response.dart rename server/cinema/lib/feature/root/{ => data/service}/root.service.dart (100%) diff --git a/server/cinema/.gitignore b/server/cinema/.gitignore index 71338e8..cd2de90 100644 --- a/server/cinema/.gitignore +++ b/server/cinema/.gitignore @@ -5,3 +5,4 @@ *.freezed.dart *.config.dart *.log +assets/cache diff --git a/server/cinema/Dockerfile b/server/cinema/Dockerfile index f911bee..534de82 100644 --- a/server/cinema/Dockerfile +++ b/server/cinema/Dockerfile @@ -8,7 +8,6 @@ RUN dart pub get # Copy app source code (except anything in .dockerignore) and AOT compile app. COPY . . -RUN dart run build_runner build --delete-conflicting-outputs RUN APP_VERSION=$(awk '/^version:/{print $2}' pubspec.yaml) && \ dart compile exe bin/server.dart -o bin/server \ -DAPP_VERSION=$APP_VERSION @@ -21,5 +20,5 @@ COPY --from=build /app/bin/server /app/bin/ COPY assets / # Start server. -EXPOSE 8080 +EXPOSE 3000 CMD ["/app/bin/server"] diff --git a/server/cinema/Makefile b/server/cinema/Makefile index 934bf7e..43900bb 100644 --- a/server/cinema/Makefile +++ b/server/cinema/Makefile @@ -4,5 +4,5 @@ build: watch: dart run build_runner watch --delete-conflicting-outputs -docker: +docker: build docker build -t cinema-display . diff --git a/server/cinema/bin/server.dart b/server/cinema/bin/server.dart index a06cba7..ff18665 100644 --- a/server/cinema/bin/server.dart +++ b/server/cinema/bin/server.dart @@ -1,7 +1,8 @@ import 'dart:io'; -import 'package:cinema/feature/poster/poster.service.dart'; -import 'package:cinema/feature/root/root.service.dart'; +import 'package:cinema/feature/poster/data/repositories/image_loader.dart'; +import 'package:cinema/feature/poster/data/services/poster.service.dart'; +import 'package:cinema/feature/root/data/service/root.service.dart'; import 'package:cinema/feature/version/version.dart'; import 'package:cinema/injectable.dart'; import 'package:shelf/shelf.dart'; @@ -32,6 +33,14 @@ void main(List args) async { print(banner); } + print('Caching current trending images...'); + + final ImageLoader loader = getIt(); + final movies = await loader.getPosterURIs(); + for (var movie in movies) { + await loader.downloadImages(movie); + } + getIt().printVersion(); print('Serving at ${server.address.host}:${server.port}\n'); diff --git a/server/cinema/lib/common/dio_module.dart b/server/cinema/lib/common/dio_module.dart new file mode 100644 index 0000000..dce7314 --- /dev/null +++ b/server/cinema/lib/common/dio_module.dart @@ -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), + ), + ); +} diff --git a/server/cinema/lib/common/domain/custom_date_time_serializer.dart b/server/cinema/lib/common/domain/custom_date_time_serializer.dart new file mode 100644 index 0000000..586ec0f --- /dev/null +++ b/server/cinema/lib/common/domain/custom_date_time_serializer.dart @@ -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 { + @override + final Iterable 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; + } +} diff --git a/server/cinema/lib/common/domain/serializers.dart b/server/cinema/lib/common/domain/serializers.dart new file mode 100644 index 0000000..f5e68fa --- /dev/null +++ b/server/cinema/lib/common/domain/serializers.dart @@ -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(), + )) + .build(); diff --git a/server/cinema/lib/feature/poster/data/repositories/image_loader.dart b/server/cinema/lib/feature/poster/data/repositories/image_loader.dart new file mode 100644 index 0000000..4640210 --- /dev/null +++ b/server/cinema/lib/feature/poster/data/repositories/image_loader.dart @@ -0,0 +1,7 @@ +import 'package:cinema/feature/poster/domain/movie.dart'; + +abstract class ImageLoader { + Future> getPosterURIs({String? language = 'de'}); + + Future downloadImages(Movie movie); +} diff --git a/server/cinema/lib/feature/poster/data/repositories/tmdb_image_loader.dart b/server/cinema/lib/feature/poster/data/repositories/tmdb_image_loader.dart new file mode 100644 index 0000000..8ac84e2 --- /dev/null +++ b/server/cinema/lib/feature/poster/data/repositories/tmdb_image_loader.dart @@ -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> 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 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 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; + } +} diff --git a/server/cinema/lib/feature/poster/poster.service.dart b/server/cinema/lib/feature/poster/data/services/poster.service.dart similarity index 94% rename from server/cinema/lib/feature/poster/poster.service.dart rename to server/cinema/lib/feature/poster/data/services/poster.service.dart index dccbb51..adc4055 100644 --- a/server/cinema/lib/feature/poster/poster.service.dart +++ b/server/cinema/lib/feature/poster/data/services/poster.service.dart @@ -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'; diff --git a/server/cinema/lib/feature/poster/domain/movie.dart b/server/cinema/lib/feature/poster/domain/movie.dart new file mode 100644 index 0000000..e145dce --- /dev/null +++ b/server/cinema/lib/feature/poster/domain/movie.dart @@ -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 { + static Serializer get serializer => _$movieSerializer; + + int get id; + + String get poster; + + String get backdrop; + + Movie._(); + + factory Movie([void Function(MovieBuilder) updates]) = _$Movie; +} diff --git a/server/cinema/lib/feature/poster/models/poster.enums.dart b/server/cinema/lib/feature/poster/domain/poster.enums.dart similarity index 100% rename from server/cinema/lib/feature/poster/models/poster.enums.dart rename to server/cinema/lib/feature/poster/domain/poster.enums.dart diff --git a/server/cinema/lib/feature/poster/models/poster_request.schema.dart b/server/cinema/lib/feature/poster/domain/poster_request.schema.dart similarity index 95% rename from server/cinema/lib/feature/poster/models/poster_request.schema.dart rename to server/cinema/lib/feature/poster/domain/poster_request.schema.dart index d0b5a6a..00b98f2 100644 --- a/server/cinema/lib/feature/poster/models/poster_request.schema.dart +++ b/server/cinema/lib/feature/poster/domain/poster_request.schema.dart @@ -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); diff --git a/server/cinema/lib/feature/poster/domain/tmdb_trending_response.dart b/server/cinema/lib/feature/poster/domain/tmdb_trending_response.dart new file mode 100644 index 0000000..7642da1 --- /dev/null +++ b/server/cinema/lib/feature/poster/domain/tmdb_trending_response.dart @@ -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 { + static Serializer get serializer => _$tmdbTrendingResponseSerializer; + + int get page; + + BuiltList 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 { + static Serializer 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 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; +} diff --git a/server/cinema/lib/feature/root/root.service.dart b/server/cinema/lib/feature/root/data/service/root.service.dart similarity index 100% rename from server/cinema/lib/feature/root/root.service.dart rename to server/cinema/lib/feature/root/data/service/root.service.dart diff --git a/server/cinema/pubspec.lock b/server/cinema/pubspec.lock index f4ef752..c2a5615 100644 --- a/server/cinema/pubspec.lock +++ b/server/cinema/pubspec.lock @@ -82,7 +82,7 @@ packages: source: hosted version: "2.10.3" built_collection: - dependency: transitive + dependency: "direct main" description: name: built_collection sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" @@ -90,13 +90,21 @@ packages: source: hosted version: "5.1.1" built_value: - dependency: transitive + dependency: "direct main" description: name: built_value sha256: a30f0a0e38671e89a492c44d005b5545b830a961575bbd8336d42869ff71066d url: "https://pub.dev" source: hosted version: "8.12.0" + built_value_generator: + dependency: "direct dev" + description: + name: built_value_generator + sha256: "65f5823a2c4158384ebc845218e19286fdf5dd04f8ac2cf607b01a502be40b1b" + url: "https://pub.dev" + source: hosted + version: "8.12.0" checked_yaml: dependency: transitive description: @@ -161,6 +169,22 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.3" + dio: + dependency: "direct main" + description: + name: dio + sha256: d90ee57923d1828ac14e492ca49440f65477f4bb1263575900be731a3dac66a9 + url: "https://pub.dev" + source: hosted + version: "5.9.0" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78" + url: "https://pub.dev" + source: hosted + version: "2.1.1" ffi: dependency: transitive description: @@ -290,13 +314,21 @@ packages: source: hosted version: "0.7.2" json_annotation: - dependency: transitive + dependency: "direct main" description: name: json_annotation sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" url: "https://pub.dev" source: hosted version: "4.9.0" + json_serializable: + dependency: "direct dev" + description: + name: json_serializable + sha256: "33a040668b31b320aafa4822b7b1e177e163fc3c1e835c6750319d4ab23aa6fe" + url: "https://pub.dev" + source: hosted + version: "6.11.1" lints: dependency: "direct dev" description: @@ -465,6 +497,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.2" + source_helper: + dependency: transitive + description: + name: source_helper + sha256: "6a3c6cc82073a8797f8c4dc4572146114a39652851c157db37e964d9c7038723" + url: "https://pub.dev" + source: hosted + version: "1.3.8" source_map_stack_trace: dependency: transitive description: diff --git a/server/cinema/pubspec.yaml b/server/cinema/pubspec.yaml index d3228c7..bfeee94 100644 --- a/server/cinema/pubspec.yaml +++ b/server/cinema/pubspec.yaml @@ -7,6 +7,9 @@ environment: sdk: ^3.9.0 dependencies: + built_collection: ^5.1.1 + built_value: ^8.9.2 + dio: ^5.9.0 get_it: ^9.0.5 image: ^4.5.4 injectable: ^2.6.0 @@ -17,6 +20,7 @@ dependencies: dev_dependencies: build_runner: ^2.10.3 + built_value_generator: ^8.9.2 http: ^1.2.2 injectable_generator: ^2.9.1 lints: ^6.0.0