implement video download
Some checks failed
Build and Push Multi-Arch Docker Image / build-and-push (push) Failing after 3m2s
Some checks failed
Build and Push Multi-Arch Docker Image / build-and-push (push) Failing after 3m2s
needs check, if converted files can be run on device Signed-off-by: Peter Siegmund <developer@mars3142.org>
This commit is contained in:
112
server/cinema/lib/common/video_downloader.dart
Normal file
112
server/cinema/lib/common/video_downloader.dart
Normal file
@@ -0,0 +1,112 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:injectable/injectable.dart';
|
||||
|
||||
@singleton
|
||||
class VideoDownloader {
|
||||
static const cacheDir = 'cache/video';
|
||||
static const int maxWidth = 480;
|
||||
static const int maxHeight = 320;
|
||||
|
||||
/// Downloads and resizes a YouTube video
|
||||
/// Returns the path to the resized video file, or null on failure
|
||||
Future<String?> downloadAndResize(String videoId) async {
|
||||
final videoDir = Directory('$cacheDir/$videoId');
|
||||
final originalVideoFile = File('${videoDir.path}/video_original.mp4');
|
||||
final resizedVideoFile = File('${videoDir.path}/video.mp4');
|
||||
|
||||
// Check if resized video already exists in cache
|
||||
if (await resizedVideoFile.exists()) {
|
||||
return resizedVideoFile.path;
|
||||
}
|
||||
|
||||
// Create cache directory if it doesn't exist
|
||||
if (!await videoDir.exists()) {
|
||||
await videoDir.create(recursive: true);
|
||||
}
|
||||
|
||||
final youtubeUrl = 'https://www.youtube.com/watch?v=$videoId';
|
||||
|
||||
try {
|
||||
// Use yt-dlp to download the video
|
||||
final downloadResult = await Process.run(
|
||||
'yt-dlp',
|
||||
[
|
||||
'-f', 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best',
|
||||
'-o', originalVideoFile.path,
|
||||
'--merge-output-format', 'mp4',
|
||||
youtubeUrl,
|
||||
],
|
||||
);
|
||||
|
||||
if (downloadResult.exitCode != 0) {
|
||||
print('Failed to download video $videoId: ${downloadResult.stderr}');
|
||||
await _cleanup(videoDir);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Resize video to fit within maxWidth x maxHeight using ffmpeg
|
||||
final resizeResult = await Process.run(
|
||||
'ffmpeg',
|
||||
[
|
||||
'-i', originalVideoFile.path,
|
||||
'-vf', 'scale=w=$maxWidth:h=$maxHeight:force_original_aspect_ratio=decrease',
|
||||
'-c:v', 'libx264',
|
||||
'-preset', 'medium',
|
||||
'-crf', '23',
|
||||
'-c:a', 'aac',
|
||||
'-b:a', '128k',
|
||||
'-movflags', '+faststart',
|
||||
'-y',
|
||||
resizedVideoFile.path,
|
||||
],
|
||||
);
|
||||
|
||||
if (resizeResult.exitCode != 0) {
|
||||
print('Failed to resize video $videoId: ${resizeResult.stderr}');
|
||||
await _cleanup(videoDir);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Delete original video to save space
|
||||
if (await originalVideoFile.exists()) {
|
||||
await originalVideoFile.delete();
|
||||
}
|
||||
|
||||
return resizedVideoFile.path;
|
||||
} catch (e) {
|
||||
print('Error processing video $videoId: $e');
|
||||
await _cleanup(videoDir);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if video is already cached
|
||||
Future<bool> isCached(String videoId) async {
|
||||
final resizedVideoFile = File('$cacheDir/$videoId/video.mp4');
|
||||
return resizedVideoFile.exists();
|
||||
}
|
||||
|
||||
/// Get all cached video IDs
|
||||
Future<List<String>> getCachedVideos() async {
|
||||
final dir = Directory(cacheDir);
|
||||
if (!await dir.exists()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
final videos = <String>[];
|
||||
await for (final entity in dir.list()) {
|
||||
if (entity is Directory) {
|
||||
final videoId = entity.path.split('/').last;
|
||||
videos.add(videoId);
|
||||
}
|
||||
}
|
||||
return videos;
|
||||
}
|
||||
|
||||
Future<void> _cleanup(Directory videoDir) async {
|
||||
if (await videoDir.exists()) {
|
||||
await videoDir.delete(recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:cinema/feature/poster/domain/movie.dart';
|
||||
|
||||
abstract class ImageLoader {
|
||||
Future<List<Movie>> getPosterURIs({String? language = 'de'});
|
||||
Future<List<Movie>> getMovies({String? language = 'de'});
|
||||
|
||||
Future<bool> downloadImages(Movie movie);
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ class TmDBImageLoader implements ImageLoader {
|
||||
);
|
||||
|
||||
@override
|
||||
Future<List<Movie>> getPosterURIs({String? language = 'de'}) async {
|
||||
Future<List<Movie>> getMovies({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);
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import 'package:cinema/common/dio_module.dart';
|
||||
import 'package:cinema/common/video_downloader.dart';
|
||||
import 'package:cinema/feature/poster/domain/movie.dart';
|
||||
import 'package:cinema/feature/video/data/repositories/video_loader.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:injectable/injectable.dart';
|
||||
|
||||
@Injectable(as: VideoLoader)
|
||||
class TmdbVideoLoader implements VideoLoader {
|
||||
final Dio api;
|
||||
final VideoDownloader _videoDownloader;
|
||||
|
||||
TmdbVideoLoader(@Named(dioAPI) this.api, this._videoDownloader);
|
||||
|
||||
@override
|
||||
Future<String?> getYouTubeVideoId(int movieId, {String language = 'de'}) async {
|
||||
try {
|
||||
final response = await api.get('/movie/$movieId/videos?language=$language');
|
||||
if (response.data != null && response.data['results'] != null) {
|
||||
final results = response.data['results'] as List;
|
||||
|
||||
// Filtere nur YouTube Videos
|
||||
final youtubeVideos = results.where((v) => v['site'] == 'YouTube').toList();
|
||||
if (youtubeVideos.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Sortiere nach published_at absteigend (neueste zuerst)
|
||||
youtubeVideos.sort((a, b) {
|
||||
final dateA = DateTime.tryParse(a['published_at'] ?? '') ?? DateTime(1970);
|
||||
final dateB = DateTime.tryParse(b['published_at'] ?? '') ?? DateTime(1970);
|
||||
return dateB.compareTo(dateA);
|
||||
});
|
||||
|
||||
// Suche den neuesten Trailer oder Teaser
|
||||
for (final video in youtubeVideos) {
|
||||
if (video['type'] == 'Trailer' || video['type'] == 'Teaser') {
|
||||
return video['key'] as String?;
|
||||
}
|
||||
}
|
||||
|
||||
// Falls kein Trailer/Teaser, nimm das neueste YouTube Video
|
||||
return youtubeVideos.first['key'] as String?;
|
||||
}
|
||||
} catch (e) {
|
||||
print('Error fetching video for movie $movieId: $e');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> downloadVideo(Movie movie) async {
|
||||
|
||||
final videoId = await getYouTubeVideoId(movie.id);
|
||||
if (videoId == null) {
|
||||
print('No YouTube video found for movie ${movie.id} (${movie.title})');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if already cached
|
||||
if (await _videoDownloader.isCached(videoId)) {
|
||||
print('Video already cached: ${movie.title} ($videoId)');
|
||||
return true;
|
||||
}
|
||||
|
||||
print('Downloading video: ${movie.title} ($videoId)...');
|
||||
final path = await _videoDownloader.downloadAndResize(videoId);
|
||||
|
||||
if (path != null) {
|
||||
print('Video cached successfully: ${movie.title} ($videoId)');
|
||||
return true;
|
||||
}
|
||||
|
||||
print('Failed to cache video: ${movie.title} ($videoId)');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import 'package:cinema/feature/poster/domain/movie.dart';
|
||||
|
||||
abstract class VideoLoader {
|
||||
Future<String?> getYouTubeVideoId(int movieId, {String language = 'de'});
|
||||
|
||||
Future<bool> downloadVideo(Movie movie);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:cinema/common/video_downloader.dart';
|
||||
import 'package:injectable/injectable.dart';
|
||||
import 'package:shelf/shelf.dart';
|
||||
import 'package:shelf_router/shelf_router.dart';
|
||||
|
||||
part 'video.service.g.dart';
|
||||
|
||||
@injectable
|
||||
class VideoService {
|
||||
final VideoDownloader _videoDownloader;
|
||||
|
||||
VideoService(this._videoDownloader);
|
||||
|
||||
@Route.get('/<videoId>')
|
||||
Future<Response> downloadVideo(Request request, String videoId) async {
|
||||
// Validate video ID (YouTube IDs are 11 characters, alphanumeric with - and _)
|
||||
if (!RegExp(r'^[a-zA-Z0-9_-]{11}$').hasMatch(videoId)) {
|
||||
return Response(
|
||||
400,
|
||||
body: jsonEncode({'error': 'Invalid YouTube video ID'}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
|
||||
// Check if already cached
|
||||
if (await _videoDownloader.isCached(videoId)) {
|
||||
return Response.ok(
|
||||
jsonEncode({
|
||||
'status': 'cached',
|
||||
'videoId': videoId,
|
||||
'path': '${VideoDownloader.cacheDir}/$videoId/video.mp4',
|
||||
}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
|
||||
// Download and resize
|
||||
final path = await _videoDownloader.downloadAndResize(videoId);
|
||||
|
||||
if (path == null) {
|
||||
return Response(
|
||||
500,
|
||||
body: jsonEncode({
|
||||
'error': 'Failed to download or resize video',
|
||||
'videoId': videoId,
|
||||
}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
|
||||
return Response.ok(
|
||||
jsonEncode({
|
||||
'status': 'downloaded',
|
||||
'videoId': videoId,
|
||||
'path': path,
|
||||
'size': '${VideoDownloader.maxWidth}x${VideoDownloader.maxHeight} (max)',
|
||||
}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
|
||||
@Route.get('/')
|
||||
Future<Response> listVideos(Request request) async {
|
||||
final videos = await _videoDownloader.getCachedVideos();
|
||||
|
||||
return Response.ok(
|
||||
jsonEncode({'videos': videos}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
|
||||
Router get router => _$VideoServiceRouter(this);
|
||||
}
|
||||
Reference in New Issue
Block a user