mirror of
https://github.com/mediacms-io/mediacms.git
synced 2025-11-20 13:36:05 -05:00
Compare commits
9 Commits
v7.2.0
...
fix/preser
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75b88a6f9c | ||
|
|
f2e04cbe2d | ||
|
|
9b3d9fe1e7 | ||
|
|
ea340b6a2e | ||
|
|
ba2c31b1e6 | ||
|
|
5eb6fafb8c | ||
|
|
c035bcddf5 | ||
|
|
01912ea1f9 | ||
|
|
d9f299af4d |
@@ -1 +1 @@
|
||||
VERSION = "7.2.0"
|
||||
VERSION = "7.2.1"
|
||||
|
||||
@@ -178,14 +178,11 @@ class MediaPublishForm(forms.ModelForm):
|
||||
state = cleaned_data.get("state")
|
||||
categories = cleaned_data.get("category")
|
||||
|
||||
if getattr(settings, 'USE_RBAC', False) and 'category' in self.fields:
|
||||
if state in ['private', 'unlisted']:
|
||||
custom_permissions = self.instance.permissions.exists()
|
||||
rbac_categories = categories.filter(is_rbac_category=True).values_list('title', flat=True)
|
||||
|
||||
if rbac_categories and state in ['private', 'unlisted']:
|
||||
# Make the confirm_state field visible and add it to the layout
|
||||
if rbac_categories or custom_permissions:
|
||||
self.fields['confirm_state'].widget = forms.CheckboxInput()
|
||||
|
||||
# add it after the state field
|
||||
state_index = None
|
||||
for i, layout_item in enumerate(self.helper.layout):
|
||||
if isinstance(layout_item, CustomField) and layout_item.fields[0] == 'state':
|
||||
@@ -198,8 +195,12 @@ class MediaPublishForm(forms.ModelForm):
|
||||
self.helper.layout = Layout(*layout_items)
|
||||
|
||||
if not cleaned_data.get('confirm_state'):
|
||||
error_message = f"I understand that although media state is {state}, the media is also shared with users that have access to the following categories: {', '.join(rbac_categories)}"
|
||||
self.add_error('confirm_state', error_message)
|
||||
if rbac_categories:
|
||||
error_message = f"I understand that although media state is {state}, the media is also shared with users that have access to categories: {', '.join(rbac_categories)}"
|
||||
self.add_error('confirm_state', error_message)
|
||||
if custom_permissions:
|
||||
error_message = f"I understand that although media state is {state}, the media is also shared by me with other users, that I can see in the 'Shared by me' page"
|
||||
self.add_error('confirm_state', error_message)
|
||||
|
||||
return cleaned_data
|
||||
|
||||
|
||||
@@ -910,7 +910,9 @@ def trim_video_method(media_file_path, timestamps_list):
|
||||
return False
|
||||
|
||||
with tempfile.TemporaryDirectory(dir=settings.TEMP_DIRECTORY) as temp_dir:
|
||||
output_file = os.path.join(temp_dir, "output.mp4")
|
||||
# Detect input file extension to preserve original format
|
||||
_, input_ext = os.path.splitext(media_file_path)
|
||||
output_file = os.path.join(temp_dir, f"output{input_ext}")
|
||||
|
||||
segment_files = []
|
||||
for i, item in enumerate(timestamps_list):
|
||||
@@ -920,7 +922,7 @@ def trim_video_method(media_file_path, timestamps_list):
|
||||
|
||||
# For single timestamp, we can use the output file directly
|
||||
# For multiple timestamps, we need to create segment files
|
||||
segment_file = output_file if len(timestamps_list) == 1 else os.path.join(temp_dir, f"segment_{i}.mp4")
|
||||
segment_file = output_file if len(timestamps_list) == 1 else os.path.join(temp_dir, f"segment_{i}{input_ext}")
|
||||
|
||||
cmd = [settings.FFMPEG_COMMAND, "-y", "-ss", str(item['startTime']), "-i", media_file_path, "-t", str(duration), "-c", "copy", "-avoid_negative_ts", "1", segment_file]
|
||||
|
||||
|
||||
@@ -494,7 +494,6 @@ def copy_video(original_media, copy_encodings=True, title_suffix="(Trimmed)"):
|
||||
state=helpers.get_default_state(user=original_media.user),
|
||||
is_reviewed=original_media.is_reviewed,
|
||||
encoding_status=original_media.encoding_status,
|
||||
listable=original_media.listable,
|
||||
add_date=timezone.now(),
|
||||
video_height=original_media.video_height,
|
||||
size=original_media.size,
|
||||
@@ -714,7 +713,6 @@ def copy_media(media):
|
||||
state=helpers.get_default_state(user=media.user),
|
||||
is_reviewed=media.is_reviewed,
|
||||
encoding_status=media.encoding_status,
|
||||
listable=media.listable,
|
||||
add_date=timezone.now(),
|
||||
)
|
||||
|
||||
|
||||
@@ -282,7 +282,7 @@ class Media(models.Model):
|
||||
self.allow_whisper_transcribe != self.__original_allow_whisper_transcribe or self.allow_whisper_transcribe_and_translate != self.__original_allow_whisper_transcribe_and_translate
|
||||
)
|
||||
|
||||
if transcription_changed and self.media_type == "video":
|
||||
if transcription_changed and self.media_type in ["video", "audio"]:
|
||||
self.transcribe_function()
|
||||
|
||||
# Update the original values for next comparison
|
||||
@@ -763,6 +763,8 @@ class Media(models.Model):
|
||||
return helpers.url_from_path(self.uploaded_thumbnail.path)
|
||||
if self.thumbnail:
|
||||
return helpers.url_from_path(self.thumbnail.path)
|
||||
if self.media_type == "audio":
|
||||
return helpers.url_from_path("userlogos/poster_audio.jpg")
|
||||
return None
|
||||
|
||||
@property
|
||||
@@ -776,6 +778,9 @@ class Media(models.Model):
|
||||
return helpers.url_from_path(self.uploaded_poster.path)
|
||||
if self.poster:
|
||||
return helpers.url_from_path(self.poster.path)
|
||||
if self.media_type == "audio":
|
||||
return helpers.url_from_path("userlogos/poster_audio.jpg")
|
||||
|
||||
return None
|
||||
|
||||
@property
|
||||
|
||||
@@ -26,18 +26,6 @@ const mediaPageLinkStyles = {
|
||||
},
|
||||
} as const;
|
||||
|
||||
// Helper function to parse time string (HH:MM:SS.mmm) to seconds
|
||||
const parseTimeToSeconds = (timeString: string): number => {
|
||||
const parts = timeString.split(':');
|
||||
if (parts.length !== 3) return 0;
|
||||
|
||||
const hours = parseInt(parts[0], 10) || 0;
|
||||
const minutes = parseInt(parts[1], 10) || 0;
|
||||
const seconds = parseFloat(parts[2]) || 0;
|
||||
|
||||
return hours * 3600 + minutes * 60 + seconds;
|
||||
};
|
||||
|
||||
interface TimelineControlsProps {
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
@@ -203,17 +191,7 @@ const TimelineControls = ({
|
||||
setIsAutoSaving(true);
|
||||
|
||||
// Format segments data for API request - use ref to get latest segments and sort by start time
|
||||
// ONLY save chapters that have custom titles - filter out chapters without titles or with default names
|
||||
const chapters = clipSegmentsRef.current
|
||||
.filter((segment) => {
|
||||
// Filter out empty titles
|
||||
if (!segment.chapterTitle || !segment.chapterTitle.trim()) {
|
||||
return false;
|
||||
}
|
||||
// Filter out default chapter names like "Chapter 1", "Chapter 2", etc.
|
||||
const isDefaultName = /^Chapter \d+$/.test(segment.chapterTitle);
|
||||
return !isDefaultName;
|
||||
})
|
||||
.sort((a, b) => a.startTime - b.startTime) // Sort by start time chronologically
|
||||
.map((chapter) => ({
|
||||
startTime: formatDetailedTime(chapter.startTime),
|
||||
@@ -221,7 +199,7 @@ const TimelineControls = ({
|
||||
chapterTitle: chapter.chapterTitle,
|
||||
}));
|
||||
|
||||
logger.debug('Filtered chapters (only custom titles):', chapters);
|
||||
logger.debug('chapters', chapters);
|
||||
|
||||
const mediaId = (typeof window !== 'undefined' && (window as any).MEDIA_DATA?.mediaId) || null;
|
||||
// For testing, use '1234' if no mediaId is available
|
||||
@@ -229,13 +207,12 @@ const TimelineControls = ({
|
||||
|
||||
logger.debug('mediaId', finalMediaId);
|
||||
|
||||
if (!finalMediaId) {
|
||||
logger.debug('No mediaId, skipping auto-save');
|
||||
if (!finalMediaId || chapters.length === 0) {
|
||||
logger.debug('No mediaId or segments, skipping auto-save');
|
||||
setIsAutoSaving(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Save chapters (empty array if no chapters have titles)
|
||||
logger.debug('Auto-saving segments:', { mediaId: finalMediaId, chapters });
|
||||
|
||||
const response = await autoSaveVideo(finalMediaId, { chapters });
|
||||
@@ -291,13 +268,8 @@ const TimelineControls = ({
|
||||
// Update editing title when selected segment changes
|
||||
useEffect(() => {
|
||||
if (selectedSegment) {
|
||||
// Check if the chapter title is a default generated name (e.g., "Chapter 1", "Chapter 2", etc.)
|
||||
const isDefaultChapterName = selectedSegment.chapterTitle &&
|
||||
/^Chapter \d+$/.test(selectedSegment.chapterTitle);
|
||||
|
||||
// If it's a default name, show empty string so placeholder appears
|
||||
// If it's a custom title, show the actual title
|
||||
setEditingChapterTitle(isDefaultChapterName ? '' : (selectedSegment.chapterTitle || ''));
|
||||
// Always show the chapter title in the textarea, whether it's default or custom
|
||||
setEditingChapterTitle(selectedSegment.chapterTitle || '');
|
||||
} else {
|
||||
setEditingChapterTitle('');
|
||||
}
|
||||
@@ -522,20 +494,11 @@ const TimelineControls = ({
|
||||
|
||||
try {
|
||||
// Format chapters data for API request - sort by start time first
|
||||
// ONLY save chapters that have custom titles - filter out chapters without titles or with default names
|
||||
const chapters = clipSegments
|
||||
.filter((segment) => {
|
||||
// Filter out empty titles
|
||||
if (!segment.chapterTitle || !segment.chapterTitle.trim()) {
|
||||
return false;
|
||||
}
|
||||
// Filter out default chapter names like "Chapter 1", "Chapter 2", etc.
|
||||
const isDefaultName = /^Chapter \d+$/.test(segment.chapterTitle);
|
||||
return !isDefaultName;
|
||||
})
|
||||
.filter((segment) => segment.chapterTitle && segment.chapterTitle.trim())
|
||||
.sort((a, b) => a.startTime - b.startTime) // Sort by start time chronologically
|
||||
.map((segment) => ({
|
||||
chapterTitle: segment.chapterTitle,
|
||||
chapterTitle: segment.chapterTitle || `Chapter ${segment.id}`,
|
||||
from: formatDetailedTime(segment.startTime),
|
||||
to: formatDetailedTime(segment.endTime),
|
||||
}));
|
||||
@@ -4119,4 +4082,4 @@ const TimelineControls = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default TimelineControls;
|
||||
export default TimelineControls;
|
||||
@@ -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
|
||||
@@ -150,7 +154,7 @@ const useVideoChapters = () => {
|
||||
// Create a default segment that spans the entire video on first load
|
||||
const initialSegment: Segment = {
|
||||
id: 1,
|
||||
chapterTitle: '',
|
||||
chapterTitle: 'Chapter 1',
|
||||
startTime: 0,
|
||||
endTime: video.duration,
|
||||
};
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -21,12 +21,16 @@ function downloadOptionsList() {
|
||||
for (g in encodings_info[k]) {
|
||||
if (encodings_info[k].hasOwnProperty(g)) {
|
||||
if ('success' === encodings_info[k][g].status && 100 === encodings_info[k][g].progress && null !== encodings_info[k][g].url) {
|
||||
// Use original media URL for download instead of encoded version
|
||||
const originalUrl = media_data.original_media_url;
|
||||
const originalFilename = originalUrl ? originalUrl.substring(originalUrl.lastIndexOf('/') + 1) : media_data.title;
|
||||
|
||||
optionsList[encodings_info[k][g].title] = {
|
||||
text: k + ' - ' + g.toUpperCase() + ' (' + encodings_info[k][g].size + ')',
|
||||
link: formatInnerLink(encodings_info[k][g].url, SiteContext._currentValue.url),
|
||||
link: formatInnerLink(media_data.original_media_url, SiteContext._currentValue.url),
|
||||
linkAttr: {
|
||||
target: '_blank',
|
||||
download: media_data.title + '_' + k + '_' + g.toUpperCase(),
|
||||
download: originalFilename,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -36,12 +40,16 @@ function downloadOptionsList() {
|
||||
}
|
||||
}
|
||||
|
||||
// Extract actual filename from the original media URL
|
||||
const originalUrl = media_data.original_media_url;
|
||||
const originalFilename = originalUrl ? originalUrl.substring(originalUrl.lastIndexOf('/') + 1) : media_data.title;
|
||||
|
||||
optionsList.original_media_url = {
|
||||
text: 'Original file (' + media_data.size + ')',
|
||||
link: formatInnerLink(media_data.original_media_url, SiteContext._currentValue.url),
|
||||
linkAttr: {
|
||||
target: '_blank',
|
||||
download: media_data.title,
|
||||
download: originalFilename,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -54,6 +54,10 @@ export default class ViewerInfoTitleBanner extends React.PureComponent {
|
||||
? formatInnerLink(MediaPageStore.get('media-original-url'), SiteContext._currentValue.url)
|
||||
: null;
|
||||
|
||||
// Extract actual filename from URL for non-video downloads
|
||||
const originalUrl = MediaPageStore.get('media-original-url');
|
||||
this.downloadFilename = originalUrl ? originalUrl.substring(originalUrl.lastIndexOf('/') + 1) : this.props.title;
|
||||
|
||||
this.updateStateValues = this.updateStateValues.bind(this);
|
||||
}
|
||||
|
||||
@@ -171,7 +175,7 @@ export default class ViewerInfoTitleBanner extends React.PureComponent {
|
||||
.downloadLink ? (
|
||||
<VideoMediaDownloadLink />
|
||||
) : (
|
||||
<OtherMediaDownloadLink link={this.downloadLink} title={this.props.title} />
|
||||
<OtherMediaDownloadLink link={this.downloadLink} title={this.downloadFilename} />
|
||||
)}
|
||||
|
||||
<MediaMoreOptionsIcon allowDownload={this.props.allowDownload} />
|
||||
|
||||
@@ -77,7 +77,7 @@ export default class ViewerInfoVideoTitleBanner extends ViewerInfoTitleBanner {
|
||||
.downloadLink ? (
|
||||
<VideoMediaDownloadLink />
|
||||
) : (
|
||||
<OtherMediaDownloadLink link={this.downloadLink} title={this.props.title} />
|
||||
<OtherMediaDownloadLink link={this.downloadLink} title={this.downloadFilename} />
|
||||
)}
|
||||
|
||||
<MediaMoreOptionsIcon allowDownload={this.props.allowDownload} />
|
||||
|
||||
19
frontend/src/static/js/utils/hoc/withBulkActions.jsx
Normal file
19
frontend/src/static/js/utils/hoc/withBulkActions.jsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
import { useBulkActions } from '../hooks/useBulkActions';
|
||||
|
||||
/**
|
||||
* Higher-Order Component that provides bulk actions functionality
|
||||
* to class components via props
|
||||
*/
|
||||
export function withBulkActions(WrappedComponent) {
|
||||
return function WithBulkActionsComponent(props) {
|
||||
const bulkActions = useBulkActions();
|
||||
|
||||
return (
|
||||
<WrappedComponent
|
||||
{...props}
|
||||
bulkActions={bulkActions}
|
||||
/>
|
||||
);
|
||||
};
|
||||
}
|
||||
BIN
media_files/userlogos/poster_audio.jpg
Normal file
BIN
media_files/userlogos/poster_audio.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
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