From 26e34a2dfc0c6aecd6d3e946e6c32f9d71e13a24 Mon Sep 17 00:00:00 2001 From: Chneemann Date: Wed, 14 May 2025 04:49:40 +0200 Subject: [PATCH] feat: implement VideoProgressService for persistent video progress tracking, refactor video player setup, and enhance backend (model/serializer/view) to support progress saving --- backend/video_app/admin.py | 14 ++- backend/video_app/models.py | 24 ++++- backend/video_app/serializer.py | 8 +- backend/video_app/views.py | 37 +++++++- backend/videoflix/urls.py | 1 + .../app/components/home/home.component.html | 5 +- .../video-player/video-player.component.ts | 64 ++++++++++--- .../app/services/video-progress.service.ts | 90 +++++++++++++++++++ 8 files changed, 222 insertions(+), 21 deletions(-) create mode 100644 frontend/src/app/services/video-progress.service.ts diff --git a/backend/video_app/admin.py b/backend/video_app/admin.py index d40b5f9..f4f3699 100644 --- a/backend/video_app/admin.py +++ b/backend/video_app/admin.py @@ -1,5 +1,5 @@ from django.contrib import admin -from .models import Video +from .models import Video, VideoProgress from import_export import resources from import_export.admin import ImportExportModelAdmin @@ -7,8 +7,18 @@ class VideoResource(resources.ModelResource): class Meta: model = Video +class VideoProgressInline(admin.TabularInline): + model = VideoProgress + extra = 0 + readonly_fields = ('user', 'position', 'updated_at') + can_delete = False + @admin.register(Video) class VideoAdmin(ImportExportModelAdmin): resource_class = VideoResource readonly_fields = ('file_path',) - list_display = ('title', 'file_path', 'genre', 'created_at') \ No newline at end of file + list_display = ('title', 'file_path', 'genre', 'created_at', 'is_available') + list_display_links = ('title',) + list_filter = ('is_available', 'genre', 'created_at') + search_fields = ('title', 'description') + inlines = [VideoProgressInline] \ No newline at end of file diff --git a/backend/video_app/models.py b/backend/video_app/models.py index 90c5fd0..9e05d89 100644 --- a/backend/video_app/models.py +++ b/backend/video_app/models.py @@ -14,4 +14,26 @@ class Video(models.Model): is_available = models.BooleanField(default=False) def __str__(self): - return f'({self.id}) {self.title}' \ No newline at end of file + return f'({self.id}) {self.title}' + + +class VideoProgress(models.Model): + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name='video_progress' + ) + video = models.ForeignKey( + 'Video', + on_delete=models.CASCADE, + related_name='progress_entries' + ) + position = models.FloatField(default=0.0) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + unique_together = ('user', 'video') + ordering = ['-updated_at'] + + def __str__(self): + return f'{self.user} - {self.video} @ {round(self.position, 1)}s' \ No newline at end of file diff --git a/backend/video_app/serializer.py b/backend/video_app/serializer.py index 036e675..65ee3de 100644 --- a/backend/video_app/serializer.py +++ b/backend/video_app/serializer.py @@ -1,12 +1,18 @@ from rest_framework import serializers from video_app.class_assets import VIDEO_GENRES -from .models import Video +from .models import Video, VideoProgress class VideoSerializer(serializers.ModelSerializer): class Meta: model = Video fields = "__all__" +class VideoProgressSerializer(serializers.ModelSerializer): + class Meta: + model = VideoProgress + fields = ['id', 'user', 'video', 'position', 'updated_at'] + read_only_fields = ['id', 'user', 'updated_at'] + class VideoUploadSerializer(serializers.ModelSerializer): class Meta: model = Video diff --git a/backend/video_app/views.py b/backend/video_app/views.py index 241babc..1179e4e 100644 --- a/backend/video_app/views.py +++ b/backend/video_app/views.py @@ -1,8 +1,9 @@ +from django.shortcuts import get_object_or_404 from rest_framework.decorators import api_view, permission_classes from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response -from .serializer import VideoSerializer, VideoUploadSerializer -from .models import Video +from .serializer import VideoProgressSerializer, VideoSerializer, VideoUploadSerializer +from .models import Video, VideoProgress from .services import save_video_file, create_video_record from .class_assets import VIDEO_GENRES from django.conf import settings @@ -70,4 +71,34 @@ def video_upload(request): video = create_video_record(serializer.validated_data, request.user, short_name, file_path) return Response(VideoSerializer(video).data, status=201) except Exception as e: - return Response({'error': f'Error while saving video: {str(e)}'}, status=500) \ No newline at end of file + return Response({'error': f'Error while saving video: {str(e)}'}, status=500) + +@api_view(['GET', 'POST']) +@permission_classes([IsAuthenticated]) +def video_progress_view(request, video_id): + """ + GET: Get last watched position of a video for the current user. + POST: Update the current watching position for the user. + """ + video = get_object_or_404(Video, id=video_id) + + if request.method == 'GET': + try: + progress = VideoProgress.objects.get(user=request.user, video=video) + serializer = VideoProgressSerializer(progress) + return Response(serializer.data) + except VideoProgress.DoesNotExist: + return Response({'position': 0.0}) + + elif request.method == 'POST': + position = request.data.get('position') + if position is None: + return Response({'error': 'No position provided'}, status=400) + + progress, _ = VideoProgress.objects.update_or_create( + user=request.user, + video=video, + defaults={'position': position} + ) + serializer = VideoProgressSerializer(progress) + return Response(serializer.data) \ No newline at end of file diff --git a/backend/videoflix/urls.py b/backend/videoflix/urls.py index 1574283..0ece5c7 100644 --- a/backend/videoflix/urls.py +++ b/backend/videoflix/urls.py @@ -25,6 +25,7 @@ urlpatterns = [ path('video/upload/', video_views.video_upload, name='video_upload'), path('video/genres/', video_views.genre_list, name='genre_list'), path('video//', video_views.check_video_resolutions, name='check_video_resolutions'), + path('video/progress/', video_views.video_progress_view, name='video-progress'), # Users URLs path('users/', user_views.user_list, name='user_list'), diff --git a/frontend/src/app/components/home/home.component.html b/frontend/src/app/components/home/home.component.html index a8dedb0..cd67145 100644 --- a/frontend/src/app/components/home/home.component.html +++ b/frontend/src/app/components/home/home.component.html @@ -70,7 +70,10 @@ Logo - + } } @if (!isLoading && videos.length === 0) { diff --git a/frontend/src/app/components/home/video-player/video-player.component.ts b/frontend/src/app/components/home/video-player/video-player.component.ts index 68c753d..ec01f1b 100644 --- a/frontend/src/app/components/home/video-player/video-player.component.ts +++ b/frontend/src/app/components/home/video-player/video-player.component.ts @@ -1,6 +1,8 @@ import { Component, OnInit, OnDestroy, ElementRef, Input } from '@angular/core'; import { ResolutionService } from '../../../services/resolution.service'; import Hls from 'hls.js'; +import { VideoProgressService } from '../../../services/video-progress.service'; +import { Video } from '../../../interfaces/video.interface'; @Component({ selector: 'app-video-player', @@ -11,6 +13,7 @@ import Hls from 'hls.js'; }) export class VideoPlayerComponent implements OnInit, OnDestroy { @Input() playVideo: string = ''; + @Input() currentVideo: Video | null = null; private hls: Hls | null = null; private videoElement: HTMLVideoElement | null = null; @@ -18,51 +21,86 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { private defaultResolution: string; /** - * Initializes the VideoPlayerComponent with the ElementRef and ResolutionService. + * Initializes the VideoPlayerComponent with the ElementRef, ResolutionService and VideoProgressService. * Get the default resolution. */ constructor( private elementRef: ElementRef, - private resolutionService: ResolutionService + private resolutionService: ResolutionService, + private videoProgressService: VideoProgressService ) { this.defaultResolution = this.resolutionService.getDefaultResolution(); } /** - * Angular lifecycle hook: Called once the component is initialized. - * Initializes the video player with the default resolution. + * Lifecycle hook: Called once the component is initialized. + * Sets up the player and starts tracking video progress. */ ngOnInit(): void { - this.initializePlayer(); + this.setupPlayer(); } /** * Angular lifecycle hook: Called once the component is about to be destroyed. - * Cleans up HLS resources. + * Cleans up HLS resources and stops tracking video progress. */ ngOnDestroy(): void { this.hls?.destroy(); + this.videoProgressService.stopTracking(); } /** - * Initializes the video player and loads the default resolution. + * Sets up the video player by initializing the video element, retrieving resolution URLs, + * and obtaining the initial playback position for the current video. It also starts tracking + * the video's playback progress and updates the video's screen dimensions. */ - private initializePlayer(): void { + private setupPlayer(): void { this.videoElement = this.elementRef.nativeElement.querySelector('video'); if (!this.playVideo || !this.videoElement) return; this.resolutionUrls = this.getResolutionUrls(); const defaultUrl = this.resolutionUrls[this.defaultResolution]; - if (Hls.isSupported()) { - this.initHlsPlayer(defaultUrl); - } else if (this.videoElement.canPlayType('application/vnd.apple.mpegurl')) { - this.initNativePlayer(defaultUrl); - } + this.videoProgressService + .getInitialProgress(this.currentVideo!.id) + .then((startTime) => { + this.initPlayer(defaultUrl, startTime); + this.videoProgressService.startTracking( + this.videoElement!, + this.currentVideo!.id + ); + }); this.updateScreenDimensions(); } + /** + * Initializes the video player using HLS.js if supported, or falls back to native HTML5 support. + * @param url - The URL of the video stream to load. + * @param startTime - The initial playback position of the video in seconds. + */ + private initPlayer(url: string, startTime: number): void { + if (!this.videoElement) return; + + if (Hls.isSupported()) { + this.hls = new Hls(); + this.hls.loadSource(url); + this.hls.attachMedia(this.videoElement); + this.hls.on(Hls.Events.MANIFEST_PARSED, () => { + if (this.videoElement) { + this.videoElement.currentTime = startTime; + this.videoElement.play(); + } + }); + } else if (this.videoElement.canPlayType('application/vnd.apple.mpegurl')) { + this.videoElement.src = url; + this.videoElement.addEventListener('canplay', () => { + this.videoElement!.currentTime = startTime; + this.videoElement!.play(); + }); + } + } + /** * Generates a mapping of available resolutions to their respective URLs. * @returns An object containing resolution-URL pairs. diff --git a/frontend/src/app/services/video-progress.service.ts b/frontend/src/app/services/video-progress.service.ts new file mode 100644 index 0000000..ce0a9fb --- /dev/null +++ b/frontend/src/app/services/video-progress.service.ts @@ -0,0 +1,90 @@ +import { Injectable } from '@angular/core'; +import { firstValueFrom, interval, Observable, Subscription } from 'rxjs'; +import { ApiService } from './api.service'; + +@Injectable({ providedIn: 'root' }) +export class VideoProgressService { + private subscription: Subscription | null = null; + + /** + * Initializes the VideoProgressService with ApiService. + */ + constructor(private apiService: ApiService) {} + + /** + * Starts tracking the playback position of a video at regular intervals. + * Progress is only saved if the video is currently playing. + * + * @param videoElement - The HTMLVideoElement to track. + * @param videoId - The unique ID of the video. + * @param intervalSec - The interval in seconds at which progress is saved (default is 5 seconds). + */ + startTracking( + videoElement: HTMLVideoElement, + videoId: number, + intervalSec = 5 + ): void { + this.stopTracking(); + this.subscription = interval(intervalSec * 1000).subscribe(() => { + const position = videoElement.currentTime; + + if (!videoElement.paused && !videoElement.ended && !isNaN(position)) { + this.saveProgress(videoId, position).subscribe({ + error: (err) => console.error('Progress save failed:', err), + }); + } + }); + } + + /** + * Stops tracking video progress and clears the subscription. + */ + stopTracking(): void { + this.subscription?.unsubscribe(); + this.subscription = null; + } + + /** + * Retrieves the last saved playback position for a given video. + * + * @param videoId - The unique ID of the video. + * @returns A promise resolving to the last saved position in seconds, or 0 if unavailable. + */ + async getInitialProgress(videoId: number): Promise { + try { + const res = await firstValueFrom(this.getProgress(videoId)); + return res?.position ?? 0; + } catch (err) { + console.warn(`No saved progress for video ${videoId}`, err); + return 0; + } + } + + /** + * Fetches the saved progress for a specific video from the backend. + * + * @param videoId - The unique ID of the video. + * @returns An observable containing the progress object with `position` in seconds. + */ + getProgress(videoId: number): Observable<{ position: number }> { + return this.apiService.get<{ position: number }>( + `/video/progress/${videoId}`, + true + ); + } + + /** + * Sends the current playback position of a video to the backend for saving. + * + * @param videoId - The unique ID of the video. + * @param position - The current playback position in seconds. + * @returns An observable representing the HTTP POST request. + */ + saveProgress(videoId: number, position: number): Observable { + return this.apiService.post( + `/video/progress/${videoId}`, + { position }, + true + ); + } +}