Compare commits

...

14 Commits

Author SHA1 Message Date
Markos Gogoulos
65d98da238 this 2025-11-21 15:13:38 +02:00
Markos Gogoulos
fb61b78573 this 2025-11-21 15:00:29 +02:00
Markos Gogoulos
a15a9403fe wtv 2025-11-21 14:52:25 +02:00
Markos Gogoulos
894e39ed2b just your typical stub 2025-11-21 14:49:22 +02:00
Markos Gogoulos
a90fcbf8dd version bump 2025-11-21 12:30:12 +02:00
Markos Gogoulos
1b3cdfd302 fix: add delay to task creation 2025-11-21 12:30:05 +02:00
Yiannis Christodoulou
cd7dd4f72c fix: Chapter numbering and preserve custom titles on segment reorder (#1435)
* FIX: Preserve custom chapter titles when renumbering (151)

Updated the renumberAllSegments function to only update chapter titles that match the default 'Chapter X' pattern, preserving any custom titles. Also ensured segments are renumbered after updates for consistent chronological naming.

* build assets (chapters editor)
2025-11-21 12:29:19 +02:00
Markos Gogoulos
9b3d9fe1e7 trim (#1431) 2025-11-13 12:42:48 +02:00
Markos Gogoulos
ea340b6a2e V7 f4 (#1430) 2025-11-13 12:30:25 +02:00
Markos Gogoulos
ba2c31b1e6 fix: static files (#1429) 2025-11-12 14:08:02 +02:00
Yiannis Christodoulou
5eb6fafb8c fix: Show default chapter names in textarea instead of placeholder text (#1428)
* Refactor chapter filtering and auto-save logic

Simplified chapter filtering to only exclude empty titles, allowing default chapter names. Updated auto-save logic to skip saving when there are no chapters or mediaId. Removed unused helper function and improved debug logging.

* Show default chapter title in editor and set initial title

The chapter title is now always displayed in the textarea, including default names like 'Chapter 1'. Also, the initial segment is created with 'Chapter 1' as its title instead of an empty string for better clarity.

* build assets
2025-11-12 14:04:07 +02:00
Markos Gogoulos
c035bcddf5 small 7.2.x fixes 2025-11-11 19:51:42 +02:00
Markos Gogoulos
01912ea1f9 fix: adjust poster url for audio 2025-11-11 13:21:10 +02:00
Markos Gogoulos
d9f299af4d V7 small fixes (#1426) 2025-11-11 13:15:36 +02:00
23 changed files with 314 additions and 150 deletions

View File

@@ -1 +1 @@
VERSION = "7.2.0"
VERSION = "7.2.5"

View File

@@ -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,7 +195,11 @@ 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)}"
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

View File

@@ -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]

View 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(),
)
@@ -731,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

View 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),
),
]

View File

@@ -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:
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:
@@ -282,7 +296,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
@@ -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,10 +401,15 @@ 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):
if self.media_file and self.media_file.path:
helpers.rm_file(self.media_file.path)
if self.state == "public":
self.state = "unlisted"
@@ -758,11 +788,16 @@ 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)
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
@@ -771,11 +806,17 @@ 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)
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
@@ -905,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:

View File

@@ -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()

View File

@@ -29,6 +29,7 @@ MEDIA_TYPES_SUPPORTED = (
("image", "Image"),
("pdf", "Pdf"),
("audio", "Audio"),
("playlist", "Playlist"),
)
ENCODE_EXTENSIONS = (

View File

@@ -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)

View File

@@ -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),
}));

View File

@@ -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}`;
};
@@ -30,10 +30,16 @@ const useVideoChapters = () => {
const sortedSegments = [...segments].sort((a, b) => a.startTime - b.startTime);
// Renumber each segment based on its chronological position
return sortedSegments.map((segment, index) => ({
// 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: `Chapter ${index + 1}`
}));
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,
};
@@ -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 = {

View File

@@ -222,6 +222,12 @@ a.item-thumb {
}
}
.item.playlist-item & {
&:before {
content: '\e05f'; // Material icon: playlist_play
}
}
.item.category-item & {
&:before {
content: '\e892';

View File

@@ -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>
</div>
{!thumbnailUrl ? null : (
<div key="item-type-icon" className="item-type-icon">
<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>

View File

@@ -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,
},
};

View File

@@ -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} />

View File

@@ -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} />

View 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}
/>
);
};
}

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long