feat: implement VideoProgressService for persistent video progress tracking, refactor video player setup, and enhance backend (model/serializer/view) to support progress saving

This commit is contained in:
Chneemann 2025-05-14 04:49:40 +02:00
parent 77960560d3
commit 26e34a2dfc
8 changed files with 222 additions and 21 deletions

View file

@ -1,5 +1,5 @@
from django.contrib import admin from django.contrib import admin
from .models import Video from .models import Video, VideoProgress
from import_export import resources from import_export import resources
from import_export.admin import ImportExportModelAdmin from import_export.admin import ImportExportModelAdmin
@ -7,8 +7,18 @@ class VideoResource(resources.ModelResource):
class Meta: class Meta:
model = Video model = Video
class VideoProgressInline(admin.TabularInline):
model = VideoProgress
extra = 0
readonly_fields = ('user', 'position', 'updated_at')
can_delete = False
@admin.register(Video) @admin.register(Video)
class VideoAdmin(ImportExportModelAdmin): class VideoAdmin(ImportExportModelAdmin):
resource_class = VideoResource resource_class = VideoResource
readonly_fields = ('file_path',) readonly_fields = ('file_path',)
list_display = ('title', 'file_path', 'genre', 'created_at') 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]

View file

@ -14,4 +14,26 @@ class Video(models.Model):
is_available = models.BooleanField(default=False) is_available = models.BooleanField(default=False)
def __str__(self): def __str__(self):
return f'({self.id}) {self.title}' 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'

View file

@ -1,12 +1,18 @@
from rest_framework import serializers from rest_framework import serializers
from video_app.class_assets import VIDEO_GENRES from video_app.class_assets import VIDEO_GENRES
from .models import Video from .models import Video, VideoProgress
class VideoSerializer(serializers.ModelSerializer): class VideoSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = Video model = Video
fields = "__all__" 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 VideoUploadSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = Video model = Video

View file

@ -1,8 +1,9 @@
from django.shortcuts import get_object_or_404
from rest_framework.decorators import api_view, permission_classes from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response from rest_framework.response import Response
from .serializer import VideoSerializer, VideoUploadSerializer from .serializer import VideoProgressSerializer, VideoSerializer, VideoUploadSerializer
from .models import Video from .models import Video, VideoProgress
from .services import save_video_file, create_video_record from .services import save_video_file, create_video_record
from .class_assets import VIDEO_GENRES from .class_assets import VIDEO_GENRES
from django.conf import settings 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) video = create_video_record(serializer.validated_data, request.user, short_name, file_path)
return Response(VideoSerializer(video).data, status=201) return Response(VideoSerializer(video).data, status=201)
except Exception as e: except Exception as e:
return Response({'error': f'Error while saving video: {str(e)}'}, status=500) 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)

View file

@ -25,6 +25,7 @@ urlpatterns = [
path('video/upload/', video_views.video_upload, name='video_upload'), path('video/upload/', video_views.video_upload, name='video_upload'),
path('video/genres/', video_views.genre_list, name='genre_list'), path('video/genres/', video_views.genre_list, name='genre_list'),
path('video/<int:id>/', video_views.check_video_resolutions, name='check_video_resolutions'), path('video/<int:id>/', video_views.check_video_resolutions, name='check_video_resolutions'),
path('video/progress/<int:video_id>', video_views.video_progress_view, name='video-progress'),
# Users URLs # Users URLs
path('users/', user_views.user_list, name='user_list'), path('users/', user_views.user_list, name='user_list'),

View file

@ -70,7 +70,10 @@
<img src="./../../../../assets/img/logo_ci.svg" alt="Logo" /> <img src="./../../../../assets/img/logo_ci.svg" alt="Logo" />
</div> </div>
</div> </div>
<app-video-player [playVideo]="playVideo"></app-video-player> <app-video-player
[playVideo]="playVideo"
[currentVideo]="currentVideo"
></app-video-player>
</div> </div>
} } @if (!isLoading && videos.length === 0) { } } @if (!isLoading && videos.length === 0) {
<!-- If the database is empty --> <!-- If the database is empty -->

View file

@ -1,6 +1,8 @@
import { Component, OnInit, OnDestroy, ElementRef, Input } from '@angular/core'; import { Component, OnInit, OnDestroy, ElementRef, Input } from '@angular/core';
import { ResolutionService } from '../../../services/resolution.service'; import { ResolutionService } from '../../../services/resolution.service';
import Hls from 'hls.js'; import Hls from 'hls.js';
import { VideoProgressService } from '../../../services/video-progress.service';
import { Video } from '../../../interfaces/video.interface';
@Component({ @Component({
selector: 'app-video-player', selector: 'app-video-player',
@ -11,6 +13,7 @@ import Hls from 'hls.js';
}) })
export class VideoPlayerComponent implements OnInit, OnDestroy { export class VideoPlayerComponent implements OnInit, OnDestroy {
@Input() playVideo: string = ''; @Input() playVideo: string = '';
@Input() currentVideo: Video | null = null;
private hls: Hls | null = null; private hls: Hls | null = null;
private videoElement: HTMLVideoElement | null = null; private videoElement: HTMLVideoElement | null = null;
@ -18,51 +21,86 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
private defaultResolution: string; private defaultResolution: string;
/** /**
* Initializes the VideoPlayerComponent with the ElementRef and ResolutionService. * Initializes the VideoPlayerComponent with the ElementRef, ResolutionService and VideoProgressService.
* Get the default resolution. * Get the default resolution.
*/ */
constructor( constructor(
private elementRef: ElementRef, private elementRef: ElementRef,
private resolutionService: ResolutionService private resolutionService: ResolutionService,
private videoProgressService: VideoProgressService
) { ) {
this.defaultResolution = this.resolutionService.getDefaultResolution(); this.defaultResolution = this.resolutionService.getDefaultResolution();
} }
/** /**
* Angular lifecycle hook: Called once the component is initialized. * Lifecycle hook: Called once the component is initialized.
* Initializes the video player with the default resolution. * Sets up the player and starts tracking video progress.
*/ */
ngOnInit(): void { ngOnInit(): void {
this.initializePlayer(); this.setupPlayer();
} }
/** /**
* Angular lifecycle hook: Called once the component is about to be destroyed. * 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 { ngOnDestroy(): void {
this.hls?.destroy(); 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'); this.videoElement = this.elementRef.nativeElement.querySelector('video');
if (!this.playVideo || !this.videoElement) return; if (!this.playVideo || !this.videoElement) return;
this.resolutionUrls = this.getResolutionUrls(); this.resolutionUrls = this.getResolutionUrls();
const defaultUrl = this.resolutionUrls[this.defaultResolution]; const defaultUrl = this.resolutionUrls[this.defaultResolution];
if (Hls.isSupported()) { this.videoProgressService
this.initHlsPlayer(defaultUrl); .getInitialProgress(this.currentVideo!.id)
} else if (this.videoElement.canPlayType('application/vnd.apple.mpegurl')) { .then((startTime) => {
this.initNativePlayer(defaultUrl); this.initPlayer(defaultUrl, startTime);
} this.videoProgressService.startTracking(
this.videoElement!,
this.currentVideo!.id
);
});
this.updateScreenDimensions(); 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. * Generates a mapping of available resolutions to their respective URLs.
* @returns An object containing resolution-URL pairs. * @returns An object containing resolution-URL pairs.

View file

@ -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<number> {
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<any> {
return this.apiService.post(
`/video/progress/${videoId}`,
{ position },
true
);
}
}