mirror of
https://github.com/mediacms-io/mediacms.git
synced 2025-11-21 13:57:57 -05:00
Compare commits
7 Commits
feat-docke
...
feat-playl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
65d98da238 | ||
|
|
fb61b78573 | ||
|
|
a15a9403fe | ||
|
|
894e39ed2b | ||
|
|
a90fcbf8dd | ||
|
|
1b3cdfd302 | ||
|
|
cd7dd4f72c |
@@ -1 +1 @@
|
||||
VERSION = "7.2.1"
|
||||
VERSION = "7.2.5"
|
||||
|
||||
@@ -729,4 +729,6 @@ def copy_media(media):
|
||||
def is_media_allowed_type(media):
|
||||
if "all" in settings.ALLOWED_MEDIA_UPLOAD_TYPES:
|
||||
return True
|
||||
if media.media_type == "playlist":
|
||||
return True
|
||||
return media.media_type in settings.ALLOWED_MEDIA_UPLOAD_TYPES
|
||||
|
||||
42
files/migrations/0014_alter_subtitle_options_and_more.py
Normal file
42
files/migrations/0014_alter_subtitle_options_and_more.py
Normal file
@@ -0,0 +1,42 @@
|
||||
# Generated by Django 5.2.6 on 2025-11-21 12:35
|
||||
|
||||
import django.db.models.deletion
|
||||
import files.models.utils
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('files', '0013_page_tinymcemedia'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='subtitle',
|
||||
options={'ordering': ['language__title'], 'verbose_name': 'Caption', 'verbose_name_plural': 'Captions'},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='transcriptionrequest',
|
||||
options={'verbose_name': 'Caption Request', 'verbose_name_plural': 'Caption Requests'},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='videotrimrequest',
|
||||
options={'verbose_name': 'Trim Request', 'verbose_name_plural': 'Trim Requests'},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='media',
|
||||
name='linked_playlist',
|
||||
field=models.ForeignKey(blank=True, help_text='If set, this Media represents a Playlist in listings', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='media_representation', to='files.playlist'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='media',
|
||||
name='media_file',
|
||||
field=models.FileField(blank=True, help_text='media file', max_length=500, null=True, upload_to=files.models.utils.original_media_file_path, verbose_name='media file'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='media',
|
||||
name='media_type',
|
||||
field=models.CharField(blank=True, choices=[('video', 'Video'), ('image', 'Image'), ('pdf', 'Pdf'), ('audio', 'Audio'), ('playlist', 'Playlist')], db_index=True, default='video', max_length=20),
|
||||
),
|
||||
]
|
||||
@@ -85,6 +85,15 @@ class Media(models.Model):
|
||||
|
||||
likes = models.IntegerField(db_index=True, default=1)
|
||||
|
||||
linked_playlist = models.ForeignKey(
|
||||
"Playlist",
|
||||
on_delete=models.CASCADE,
|
||||
blank=True,
|
||||
null=True,
|
||||
related_name="media_representation",
|
||||
help_text="If set, this Media represents a Playlist in listings",
|
||||
)
|
||||
|
||||
listable = models.BooleanField(default=False, help_text="Whether it will appear on listings")
|
||||
|
||||
md5sum = models.CharField(max_length=50, blank=True, null=True, help_text="Not exposed, used internally")
|
||||
@@ -93,6 +102,8 @@ class Media(models.Model):
|
||||
"media file",
|
||||
upload_to=original_media_file_path,
|
||||
max_length=500,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="media file",
|
||||
)
|
||||
|
||||
@@ -240,7 +251,10 @@ class Media(models.Model):
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.title:
|
||||
self.title = self.media_file.path.split("/")[-1]
|
||||
if self.media_file:
|
||||
self.title = self.media_file.path.split("/")[-1]
|
||||
elif self.linked_playlist:
|
||||
self.title = self.linked_playlist.title
|
||||
|
||||
strip_text_items = ["title", "description"]
|
||||
for item in strip_text_items:
|
||||
@@ -295,6 +309,10 @@ class Media(models.Model):
|
||||
|
||||
self.state = helpers.get_default_state(user=self.user)
|
||||
|
||||
# Set encoding_status to success for playlist type
|
||||
if self.media_type == "playlist":
|
||||
self.encoding_status = "success"
|
||||
|
||||
# condition to appear on listings
|
||||
if self.state == "public" and self.encoding_status == "success" and self.is_reviewed is True:
|
||||
self.listable = True
|
||||
@@ -329,10 +347,17 @@ class Media(models.Model):
|
||||
|
||||
if to_transcribe:
|
||||
TranscriptionRequest.objects.create(media=self, translate_to_english=False)
|
||||
tasks.whisper_transcribe.delay(self.friendly_token, translate_to_english=False)
|
||||
tasks.whisper_transcribe.apply_async(
|
||||
args=[self.friendly_token, False],
|
||||
countdown=10,
|
||||
)
|
||||
|
||||
if to_transcribe_and_translate:
|
||||
TranscriptionRequest.objects.create(media=self, translate_to_english=True)
|
||||
tasks.whisper_transcribe.delay(self.friendly_token, translate_to_english=True)
|
||||
tasks.whisper_transcribe.apply_async(
|
||||
args=[self.friendly_token, True],
|
||||
countdown=10,
|
||||
)
|
||||
|
||||
def update_search_vector(self):
|
||||
"""
|
||||
@@ -376,11 +401,16 @@ class Media(models.Model):
|
||||
Performs all related tasks, as check for media type,
|
||||
video duration, encode
|
||||
"""
|
||||
# Skip media_init for playlist type as it has no media file to process
|
||||
if self.media_type == "playlist" or self.linked_playlist:
|
||||
return True
|
||||
|
||||
self.set_media_type()
|
||||
from ..methods import is_media_allowed_type
|
||||
|
||||
if not is_media_allowed_type(self):
|
||||
helpers.rm_file(self.media_file.path)
|
||||
if self.media_file and self.media_file.path:
|
||||
helpers.rm_file(self.media_file.path)
|
||||
if self.state == "public":
|
||||
self.state = "unlisted"
|
||||
self.save(update_fields=["state"])
|
||||
@@ -758,6 +788,9 @@ class Media(models.Model):
|
||||
Prioritize uploaded_thumbnail, if exists, then thumbnail
|
||||
that is auto-generated
|
||||
"""
|
||||
# If this media represents a playlist, use playlist's thumbnail
|
||||
if self.linked_playlist:
|
||||
return self.linked_playlist.thumbnail_url
|
||||
|
||||
if self.uploaded_thumbnail:
|
||||
return helpers.url_from_path(self.uploaded_thumbnail.path)
|
||||
@@ -773,6 +806,9 @@ class Media(models.Model):
|
||||
Prioritize uploaded_poster, if exists, then poster
|
||||
that is auto-generated
|
||||
"""
|
||||
# If this media represents a playlist, use playlist's thumbnail
|
||||
if self.linked_playlist:
|
||||
return self.linked_playlist.thumbnail_url
|
||||
|
||||
if self.uploaded_poster:
|
||||
return helpers.url_from_path(self.uploaded_poster.path)
|
||||
@@ -910,6 +946,14 @@ class Media(models.Model):
|
||||
return helpers.url_from_path(self.user.logo.path)
|
||||
|
||||
def get_absolute_url(self, api=False, edit=False):
|
||||
# If this media represents a playlist, redirect to playlist page
|
||||
if self.linked_playlist:
|
||||
if edit:
|
||||
# For now, playlist editing is not supported via media edit page
|
||||
return self.linked_playlist.get_absolute_url(api=api)
|
||||
# Start playback from first media when clicking on playlist in listings
|
||||
return self.linked_playlist.get_absolute_url(api=api, start_playback=True)
|
||||
|
||||
if edit:
|
||||
return f"{reverse('edit_media')}?m={self.friendly_token}"
|
||||
if api:
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import uuid
|
||||
|
||||
from django.db import models
|
||||
from django.db.models.signals import post_save, pre_delete
|
||||
from django.dispatch import receiver
|
||||
from django.urls import reverse
|
||||
from django.utils.html import strip_tags
|
||||
|
||||
@@ -31,7 +33,25 @@ class Playlist(models.Model):
|
||||
def media_count(self):
|
||||
return self.media.filter(listable=True).count()
|
||||
|
||||
def get_absolute_url(self, api=False):
|
||||
def get_first_media(self):
|
||||
"""Get the first media item in the playlist"""
|
||||
pm = self.playlistmedia_set.filter(media__listable=True).first()
|
||||
return pm.media if pm else None
|
||||
|
||||
def get_absolute_url(self, api=False, start_playback=False):
|
||||
"""
|
||||
Get the URL for this playlist.
|
||||
|
||||
Args:
|
||||
api: If True, return API URL
|
||||
start_playback: If True, return URL to first media with playlist context
|
||||
"""
|
||||
if start_playback and not api:
|
||||
# Get first media and return its URL with playlist parameter
|
||||
first_media = self.get_first_media()
|
||||
if first_media:
|
||||
return f"{first_media.get_absolute_url()}&pl={self.friendly_token}"
|
||||
|
||||
if api:
|
||||
return reverse("api_get_playlist", kwargs={"friendly_token": self.friendly_token})
|
||||
else:
|
||||
@@ -41,6 +61,11 @@ class Playlist(models.Model):
|
||||
def url(self):
|
||||
return self.get_absolute_url()
|
||||
|
||||
@property
|
||||
def playback_url(self):
|
||||
"""URL that starts playing the first media in the playlist"""
|
||||
return self.get_absolute_url(start_playback=True)
|
||||
|
||||
@property
|
||||
def api_url(self):
|
||||
return self.get_absolute_url(api=True)
|
||||
@@ -95,3 +120,46 @@ class PlaylistMedia(models.Model):
|
||||
|
||||
class Meta:
|
||||
ordering = ["ordering", "-action_date"]
|
||||
|
||||
|
||||
@receiver(post_save, sender=Playlist)
|
||||
def create_or_update_playlist_media(sender, instance, created, **kwargs):
|
||||
"""
|
||||
Automatically create or update a Media object that represents this Playlist in listings.
|
||||
This allows playlists to appear alongside regular media in search results and listings.
|
||||
"""
|
||||
from .media import Media
|
||||
|
||||
# Check if a Media representation already exists for this playlist
|
||||
media_representation = Media.objects.filter(linked_playlist=instance).first()
|
||||
|
||||
if media_representation:
|
||||
# Update existing media representation
|
||||
media_representation.title = instance.title
|
||||
media_representation.description = instance.description
|
||||
media_representation.user = instance.user
|
||||
media_representation.media_type = "playlist"
|
||||
media_representation.encoding_status = "success"
|
||||
media_representation.save()
|
||||
else:
|
||||
# Create new media representation for this playlist
|
||||
Media.objects.create(
|
||||
title=instance.title,
|
||||
description=instance.description,
|
||||
user=instance.user,
|
||||
linked_playlist=instance,
|
||||
media_type="playlist",
|
||||
encoding_status="success",
|
||||
# Inherit the same state and review status defaults
|
||||
)
|
||||
|
||||
|
||||
@receiver(pre_delete, sender=Playlist)
|
||||
def delete_playlist_media(sender, instance, **kwargs):
|
||||
"""
|
||||
Delete the associated Media representation when a Playlist is deleted.
|
||||
"""
|
||||
from .media import Media
|
||||
|
||||
# Delete any Media objects that represent this playlist
|
||||
Media.objects.filter(linked_playlist=instance).delete()
|
||||
|
||||
@@ -29,6 +29,7 @@ MEDIA_TYPES_SUPPORTED = (
|
||||
("image", "Image"),
|
||||
("pdf", "Pdf"),
|
||||
("audio", "Audio"),
|
||||
("playlist", "Playlist"),
|
||||
)
|
||||
|
||||
ENCODE_EXTENSIONS = (
|
||||
|
||||
@@ -69,7 +69,7 @@ class MediaList(APIView):
|
||||
if user:
|
||||
base_filters &= Q(user=user)
|
||||
|
||||
base_queryset = Media.objects.prefetch_related("user", "tags")
|
||||
base_queryset = Media.objects.prefetch_related("user", "tags").select_related("linked_playlist")
|
||||
|
||||
if not request.user.is_authenticated:
|
||||
return base_queryset.filter(base_filters)
|
||||
@@ -159,17 +159,17 @@ class MediaList(APIView):
|
||||
media = show_recommended_media(request, limit=50)
|
||||
already_sorted = True
|
||||
elif show_param == "featured":
|
||||
media = Media.objects.filter(listable=True, featured=True).prefetch_related("user", "tags")
|
||||
media = Media.objects.filter(listable=True, featured=True).prefetch_related("user", "tags").select_related("linked_playlist")
|
||||
elif show_param == "shared_by_me":
|
||||
if not self.request.user.is_authenticated:
|
||||
media = Media.objects.none()
|
||||
else:
|
||||
media = Media.objects.filter(permissions__owner_user=self.request.user).prefetch_related("user", "tags").distinct()
|
||||
media = Media.objects.filter(permissions__owner_user=self.request.user).prefetch_related("user", "tags").select_related("linked_playlist").distinct()
|
||||
elif show_param == "shared_with_me":
|
||||
if not self.request.user.is_authenticated:
|
||||
media = Media.objects.none()
|
||||
else:
|
||||
base_queryset = Media.objects.prefetch_related("user", "tags")
|
||||
base_queryset = Media.objects.prefetch_related("user", "tags").select_related("linked_playlist")
|
||||
|
||||
# Build OR conditions similar to _get_media_queryset
|
||||
conditions = Q(permissions__user=request.user)
|
||||
@@ -183,14 +183,14 @@ class MediaList(APIView):
|
||||
user_queryset = User.objects.all()
|
||||
user = get_object_or_404(user_queryset, username=author_param)
|
||||
if self.request.user == user or is_mediacms_editor(self.request.user):
|
||||
media = Media.objects.filter(user=user).prefetch_related("user", "tags")
|
||||
media = Media.objects.filter(user=user).prefetch_related("user", "tags").select_related("linked_playlist")
|
||||
else:
|
||||
media = self._get_media_queryset(request, user)
|
||||
already_sorted = True
|
||||
|
||||
else:
|
||||
if is_mediacms_editor(self.request.user):
|
||||
media = Media.objects.prefetch_related("user", "tags")
|
||||
media = Media.objects.prefetch_related("user", "tags").select_related("linked_playlist")
|
||||
else:
|
||||
media = self._get_media_queryset(request)
|
||||
already_sorted = True
|
||||
@@ -995,7 +995,7 @@ class MediaSearch(APIView):
|
||||
|
||||
if request.user.is_authenticated:
|
||||
if is_mediacms_editor(self.request.user):
|
||||
media = Media.objects.prefetch_related("user", "tags")
|
||||
media = Media.objects.prefetch_related("user", "tags").select_related("linked_playlist")
|
||||
basic_query = Q()
|
||||
else:
|
||||
basic_query = Q(listable=True) | Q(permissions__user=request.user) | Q(user=request.user)
|
||||
|
||||
@@ -20,7 +20,7 @@ const useVideoChapters = () => {
|
||||
// Sort by start time to find chronological position
|
||||
const sortedSegments = allSegments.sort((a, b) => a.startTime - b.startTime);
|
||||
// Find the index of our new segment
|
||||
const chapterIndex = sortedSegments.findIndex(seg => seg.startTime === newSegmentStartTime);
|
||||
const chapterIndex = sortedSegments.findIndex((seg) => seg.startTime === newSegmentStartTime);
|
||||
return `Chapter ${chapterIndex + 1}`;
|
||||
};
|
||||
|
||||
@@ -28,12 +28,18 @@ const useVideoChapters = () => {
|
||||
const renumberAllSegments = (segments: Segment[]): Segment[] => {
|
||||
// Sort segments by start time
|
||||
const sortedSegments = [...segments].sort((a, b) => a.startTime - b.startTime);
|
||||
|
||||
|
||||
// Renumber each segment based on its chronological position
|
||||
return sortedSegments.map((segment, index) => ({
|
||||
...segment,
|
||||
chapterTitle: `Chapter ${index + 1}`
|
||||
}));
|
||||
// Only update titles that follow the default "Chapter X" pattern to preserve custom titles
|
||||
return sortedSegments.map((segment, index) => {
|
||||
const currentTitle = segment.chapterTitle || '';
|
||||
const isDefaultTitle = /^Chapter \d+$/.test(currentTitle);
|
||||
|
||||
return {
|
||||
...segment,
|
||||
chapterTitle: isDefaultTitle ? `Chapter ${index + 1}` : currentTitle,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
// Helper function to parse time string (HH:MM:SS.mmm) to seconds
|
||||
@@ -124,9 +130,7 @@ const useVideoChapters = () => {
|
||||
let initialSegments: Segment[] = [];
|
||||
|
||||
// Check if we have existing chapters from the backend
|
||||
const existingChapters =
|
||||
(typeof window !== 'undefined' && (window as any).MEDIA_DATA?.chapters) ||
|
||||
[];
|
||||
const existingChapters = (typeof window !== 'undefined' && (window as any).MEDIA_DATA?.chapters) || [];
|
||||
|
||||
if (existingChapters.length > 0) {
|
||||
// Create segments from existing chapters
|
||||
@@ -225,7 +229,7 @@ const useVideoChapters = () => {
|
||||
logger.debug('Adding Safari-specific event listeners for audio support');
|
||||
video.addEventListener('canplay', handleCanPlay);
|
||||
video.addEventListener('loadeddata', handleLoadedData);
|
||||
|
||||
|
||||
// Additional timeout fallback for Safari audio files
|
||||
const safariTimeout = setTimeout(() => {
|
||||
if (video.duration && duration === 0) {
|
||||
@@ -261,21 +265,21 @@ const useVideoChapters = () => {
|
||||
useEffect(() => {
|
||||
if (isSafari() && videoRef.current) {
|
||||
const video = videoRef.current;
|
||||
|
||||
|
||||
const initializeSafariOnInteraction = () => {
|
||||
// Try to load video metadata by attempting to play and immediately pause
|
||||
const attemptInitialization = async () => {
|
||||
try {
|
||||
logger.debug('Safari: Attempting auto-initialization on user interaction');
|
||||
|
||||
|
||||
// Briefly play to trigger metadata loading, then pause
|
||||
await video.play();
|
||||
video.pause();
|
||||
|
||||
|
||||
// Check if we now have duration and initialize if needed
|
||||
if (video.duration > 0 && clipSegments.length === 0) {
|
||||
logger.debug('Safari: Successfully initialized metadata, creating default segment');
|
||||
|
||||
|
||||
const defaultSegment: Segment = {
|
||||
id: 1,
|
||||
chapterTitle: '',
|
||||
@@ -286,14 +290,14 @@ const useVideoChapters = () => {
|
||||
setDuration(video.duration);
|
||||
setTrimEnd(video.duration);
|
||||
setClipSegments([defaultSegment]);
|
||||
|
||||
|
||||
const initialState: EditorState = {
|
||||
trimStart: 0,
|
||||
trimEnd: video.duration,
|
||||
splitPoints: [],
|
||||
clipSegments: [defaultSegment],
|
||||
};
|
||||
|
||||
|
||||
setHistory([initialState]);
|
||||
setHistoryPosition(0);
|
||||
}
|
||||
@@ -315,7 +319,7 @@ const useVideoChapters = () => {
|
||||
// Add listeners for various user interactions
|
||||
document.addEventListener('click', handleUserInteraction);
|
||||
document.addEventListener('keydown', handleUserInteraction);
|
||||
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('click', handleUserInteraction);
|
||||
document.removeEventListener('keydown', handleUserInteraction);
|
||||
@@ -332,7 +336,7 @@ const useVideoChapters = () => {
|
||||
// This play/pause will trigger metadata loading in Safari
|
||||
await video.play();
|
||||
video.pause();
|
||||
|
||||
|
||||
// The metadata events should fire now and initialize segments
|
||||
return true;
|
||||
} catch (error) {
|
||||
@@ -564,8 +568,11 @@ const useVideoChapters = () => {
|
||||
`Updating segments with action: ${actionType}, recordHistory: ${isSignificantChange ? 'true' : 'false'}`
|
||||
);
|
||||
|
||||
// Renumber all segments to ensure proper chronological naming
|
||||
const renumberedSegments = renumberAllSegments(e.detail.segments);
|
||||
|
||||
// Update segment state immediately for UI feedback
|
||||
setClipSegments(e.detail.segments);
|
||||
setClipSegments(renumberedSegments);
|
||||
|
||||
// Always save state to history for non-intermediate actions
|
||||
if (isSignificantChange) {
|
||||
@@ -573,7 +580,7 @@ const useVideoChapters = () => {
|
||||
// ensure we capture the state properly
|
||||
setTimeout(() => {
|
||||
// Deep clone to ensure state is captured correctly
|
||||
const segmentsClone = JSON.parse(JSON.stringify(e.detail.segments));
|
||||
const segmentsClone = JSON.parse(JSON.stringify(renumberedSegments));
|
||||
|
||||
// Create a complete state snapshot
|
||||
const stateWithAction: EditorState = {
|
||||
@@ -919,10 +926,10 @@ const useVideoChapters = () => {
|
||||
const singleChapter = backendChapters[0];
|
||||
const startSeconds = parseTimeToSeconds(singleChapter.startTime);
|
||||
const endSeconds = parseTimeToSeconds(singleChapter.endTime);
|
||||
|
||||
|
||||
// Check if this single chapter spans the entire video (within 0.1 second tolerance)
|
||||
const isFullVideoChapter = startSeconds <= 0.1 && Math.abs(endSeconds - duration) <= 0.1;
|
||||
|
||||
|
||||
if (isFullVideoChapter) {
|
||||
logger.debug('Manual save: Single chapter spans full video - sending empty array');
|
||||
backendChapters = [];
|
||||
|
||||
@@ -222,6 +222,12 @@ a.item-thumb {
|
||||
}
|
||||
}
|
||||
|
||||
.item.playlist-item & {
|
||||
&:before {
|
||||
content: '\e05f'; // Material icon: playlist_play
|
||||
}
|
||||
}
|
||||
|
||||
.item.category-item & {
|
||||
&:before {
|
||||
content: '\e892';
|
||||
|
||||
@@ -31,14 +31,11 @@ export function PlaylistItem(props) {
|
||||
aria-hidden="true"
|
||||
style={!thumbnailUrl ? null : { backgroundImage: "url('" + thumbnailUrl + "')" }}
|
||||
>
|
||||
<div className="playlist-count">
|
||||
<div>
|
||||
<div>
|
||||
<span>{props.media_count}</span>
|
||||
<i className="material-icons">playlist_play</i>
|
||||
</div>
|
||||
{!thumbnailUrl ? null : (
|
||||
<div key="item-type-icon" className="item-type-icon">
|
||||
<div></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="playlist-hover-play-all">
|
||||
<div>
|
||||
@@ -53,9 +50,6 @@ export function PlaylistItem(props) {
|
||||
<UnderThumbWrapper title={props.title} link={props.link}>
|
||||
{titleComponent()}
|
||||
{metaComponents()}
|
||||
<a href={props.link} title="" className="view-full-playlist">
|
||||
VIEW FULL PLAYLIST
|
||||
</a>
|
||||
</UnderThumbWrapper>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user