Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
1c850d3
feat(media): add external m3u8 upload support
alexamirante Apr 21, 2026
c223698
fix(swagger): resolve login schema generation conflict
alexamirante Apr 21, 2026
b88ac31
fix(player): handle absolute external media URLs
alexamirante Apr 21, 2026
c5992ef
chore(docker): make admin credentials configurable via env
alexamirante Apr 21, 2026
0b0405c
feat(api): support caption upload in media update
alexamirante Apr 21, 2026
690b9bd
feat(edit): disable Trim and Chapters for external HLS media
alexamirante Apr 22, 2026
3214b92
feat(upload): add external HLS source option in user dashboard
alexamirante Apr 22, 2026
e19a858
feat(api): add media property controls (enable_comments, allow_downlo…
alexamirante Apr 22, 2026
523a29f
fix(swagger): remove JSONParser from MediaList/MediaDetail parser_cla…
alexamirante Apr 22, 2026
a10293f
chore(git): ignore Python __pycache__ directories
alexamirante Apr 22, 2026
475ae3a
feat: allow managers to set duration for external m3u8 media via API
alexamirante Apr 27, 2026
b16aa80
fix: remove duplicate comma in swagger decorator
alexamirante Apr 27, 2026
1ef16d0
docs: add uploaded_poster to media POST swagger
alexamirante Apr 27, 2026
9d39c2a
feat: support playlist_ids on media POST/PUT
alexamirante Apr 29, 2026
f86f694
feat: add playlist selection in edit media page
alexamirante Apr 29, 2026
6ab81e1
fix: use media owner playlists in edit form
alexamirante Apr 29, 2026
6f3e17c
feat: sync playlist membership from edit media page
alexamirante Apr 29, 2026
2f87cdb
build: regenerate compiled JS assets after rebase onto main
alexamirante May 25, 2026
11d1d28
build: add remaining compiled assets from full frontend rebuild
alexamirante May 25, 2026
0755048
fix(migrations): merge 0015_media_external_hls_url with 0018_embedmed…
alexamirante May 26, 2026
6385363
refactor(migrations): renumber external_hls_url migration from 0015 t…
alexamirante May 26, 2026
19e198f
feat(player): support forcing quality for external HLS sources
alexamirante May 26, 2026
1d51be0
fix(player): show captions control for external HLS subtitle tracks
alexamirante May 27, 2026
50675a4
fix(media-auth): allow subtitle files in protected media paths
alexamirante May 27, 2026
fff917d
refactor: remove dead code _normalized_external_hls_url method
alexamirante May 28, 2026
0b5d85e
fix(media): generate thumbnail on create when uploaded_poster is prov…
alexamirante Jun 5, 2026
2d3ebb3
fix(playlists): derive thumbnail from media thumbnail or poster
alexamirante Jun 8, 2026
8b5e8fe
feat(api): support playlist_friendly_tokens in media create/update
alexamirante Jun 8, 2026
ff2dd92
Remove redundant playlist full-view label
alexamirante Jun 10, 2026
9a0f9ec
sync: align feat/external-url with latest main updates
alexamirante Jun 11, 2026
c521315
Make home link URL configurable via settings
alexamirante Jun 11, 2026
fad78b1
Use get_alphanumeric_and_spaces for tag normalization in API
alexamirante Jun 12, 2026
b4572f0
Add m2m_changed signal for Media.tags to update tag media count
alexamirante Jun 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,7 @@ static/video_editor/videos/sample-video.mp3
templates/todo-MS4.md
.secret_key
.secret_key.lock

# Python bytecode caches
__pycache__/
**/__pycache__/
17 changes: 17 additions & 0 deletions cms/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,13 @@
# settings that are related with UX/appearance
# whether a featured item appears enlarged with player on index page
VIDEO_PLAYER_FEATURED_VIDEO_ON_INDEX_PAGE = False
HOME_LINK_URL = "/"

# Sidebar link visibility
HIDE_HOME_LINK = False
HIDE_TAGS_LINK = False
HIDE_CATEGORIES_LINK = False
HIDE_CONTACT_LINK = False

PRE_UPLOAD_MEDIA_MESSAGE = ""

Expand Down Expand Up @@ -315,6 +322,8 @@
"drf_yasg",
"allauth.socialaccount.providers.saml",
"saml_auth.apps.SamlAuthConfig",
"allauth.socialaccount.providers.openid_connect",
"oidc_auth.apps.OidcAuthConfig",
"tinymce",
]

Expand Down Expand Up @@ -579,6 +588,14 @@
USE_SAML = False
USE_RBAC = False
USE_IDENTITY_PROVIDERS = False
USE_OIDC = False

# Unified adapter supports deployments with multiple identity protocols
# (SAML + OIDC simultaneously). Single-protocol deployments can override
# this in local_settings.py with the specific adapter:
# SAML only: SOCIALACCOUNT_ADAPTER = 'saml_auth.adapter.SAMLAccountAdapter'
# OIDC only: SOCIALACCOUNT_ADAPTER = 'oidc_auth.adapter.OIDCAccountAdapter'
SOCIALACCOUNT_ADAPTER = "identity_providers.adapter.UnifiedSocialAccountAdapter"
USE_LTI = False # Enable LTI 1.3 integration
JAZZMIN_UI_TWEAKS = {"theme": "flatly"}

Expand Down
6 changes: 3 additions & 3 deletions docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ services:
ENABLE_CELERY_SHORT: 'no'
ENABLE_CELERY_LONG: 'no'
ENABLE_CELERY_BEAT: 'no'
ADMIN_USER: 'admin'
ADMIN_EMAIL: 'admin@localhost'
# ADMIN_PASSWORD: 'uncomment_and_set_password_here'
ADMIN_USER: ${ADMIN_USER:-admin}
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@localhost}
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
command: "./deploy/docker/prestart.sh"
restart: on-failure
depends_on:
Expand Down
10 changes: 9 additions & 1 deletion files/context_processors.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from cms.version import VERSION

from .frontend_translations import get_translation, get_translation_strings
from .methods import is_mediacms_editor, is_mediacms_manager
from .methods import is_mediacms_editor, is_mediacms_manager, user_allowed_to_upload


def stuff(request):
Expand All @@ -23,6 +23,8 @@ def stuff(request):
ret["CAN_LOGIN"] = settings.LOGIN_ALLOWED
ret["CAN_REGISTER"] = settings.REGISTER_ALLOWED
ret["CAN_UPLOAD_MEDIA"] = settings.UPLOAD_MEDIA_ALLOWED
# Keep UI in sync with backend upload authorization logic.
ret["USER_CAN_ADD_MEDIA"] = settings.UPLOAD_MEDIA_ALLOWED and user_allowed_to_upload(request)
ret["TIMESTAMP_IN_TIMEBAR"] = settings.TIMESTAMP_IN_TIMEBAR
ret["CAN_MENTION_IN_COMMENTS"] = settings.ALLOW_MENTION_IN_COMMENTS
ret["CAN_LIKE_MEDIA"] = settings.CAN_LIKE_MEDIA
Expand All @@ -34,6 +36,11 @@ def stuff(request):
ret["PRE_UPLOAD_MEDIA_MESSAGE"] = settings.PRE_UPLOAD_MEDIA_MESSAGE
ret["SIDEBAR_FOOTER_TEXT"] = settings.SIDEBAR_FOOTER_TEXT
ret["POST_UPLOAD_AUTHOR_MESSAGE_UNLISTED_NO_COMMENTARY"] = settings.POST_UPLOAD_AUTHOR_MESSAGE_UNLISTED_NO_COMMENTARY
ret["HIDE_HOME_LINK"] = getattr(settings, "HIDE_HOME_LINK", False)
ret["HIDE_TAGS_LINK"] = getattr(settings, "HIDE_TAGS_LINK", False)
ret["HIDE_CATEGORIES_LINK"] = getattr(settings, "HIDE_CATEGORIES_LINK", False)
ret["HIDE_CONTACT_LINK"] = getattr(settings, "HIDE_CONTACT_LINK", False)
ret["HOME_LINK_URL"] = getattr(settings, "HOME_LINK_URL", "/")
ret["IS_MEDIACMS_ADMIN"] = request.user.is_superuser
ret["IS_MEDIACMS_EDITOR"] = is_mediacms_editor(request.user)
ret["IS_MEDIACMS_MANAGER"] = is_mediacms_manager(request.user)
Expand All @@ -55,6 +62,7 @@ def stuff(request):
ret["TRANSLATION"] = get_translation(request.LANGUAGE_CODE)
ret["REPLACEMENTS"] = get_translation_strings(request.LANGUAGE_CODE)
ret["USE_SAML"] = settings.USE_SAML
ret["USE_OIDC"] = getattr(settings, "USE_OIDC", False)
ret["USE_RBAC"] = settings.USE_RBAC
ret["USE_ROUNDED_CORNERS"] = settings.USE_ROUNDED_CORNERS
ret["INCLUDE_LISTING_NUMBERS"] = settings.INCLUDE_LISTING_NUMBERS
Expand Down
64 changes: 63 additions & 1 deletion files/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from django.conf import settings

from .methods import get_next_state, is_mediacms_editor
from .models import MEDIA_STATES, Category, Media, MediaPermission, Subtitle
from .models import MEDIA_STATES, Category, Media, MediaPermission, Playlist, PlaylistMedia, Subtitle
from .widgets import CategoryModalWidget

_PUBLISH_STATE_HTML = (Path(__file__).parent.parent / 'templates/cms/partials/media_publish_state.html').read_text()
Expand All @@ -23,6 +23,12 @@ class MultipleSelect(forms.CheckboxSelectMultiple):

class MediaMetadataForm(forms.ModelForm):
new_tags = forms.CharField(label="Tags", help_text="a comma separated list of tags.", required=False)
playlist_ids = forms.ModelMultipleChoiceField(
queryset=Playlist.objects.none(),
required=False,
label="Playlists",
help_text="Select playlists containing this media. Deselect to remove from a playlist.",
)

class Meta:
model = Media
Expand Down Expand Up @@ -67,6 +73,12 @@ def __init__(self, user, *args, **kwargs):

self.fields["new_tags"].initial = ", ".join([tag.title for tag in self.instance.tags.all()])

playlist_owner = self.instance.user if getattr(self.instance, "pk", None) else user
owner_playlists = Playlist.objects.filter(user=playlist_owner).order_by("-add_date")
self.fields["playlist_ids"].queryset = owner_playlists
if getattr(self.instance, "pk", None):
self.fields["playlist_ids"].initial = owner_playlists.filter(playlistmedia__media=self.instance).distinct()

self.helper = FormHelper()
self.helper.form_tag = True
self.helper.form_class = 'post-form'
Expand All @@ -77,6 +89,7 @@ def __init__(self, user, *args, **kwargs):
layout_fields = [
CustomField('title'),
CustomField('new_tags'),
CustomField('playlist_ids'),
CustomField('add_date'),
CustomField('description'),
CustomField('enable_comments'),
Expand Down Expand Up @@ -115,6 +128,55 @@ def save(self, *args, **kwargs):
data = self.cleaned_data # noqa

media = super(MediaMetadataForm, self).save(*args, **kwargs)

added_count = 0
removed_count = 0
unchanged_count = 0
full_playlists = []
selected_playlists = data.get("playlist_ids")

playlist_owner = media.user
owner_playlists = Playlist.objects.filter(user=playlist_owner)
existing_relations = PlaylistMedia.objects.filter(media=media, playlist__in=owner_playlists)

selected_playlist_ids = set()
if selected_playlists:
selected_playlist_ids = set(selected_playlists.values_list("id", flat=True))

existing_playlist_ids = set(existing_relations.values_list("playlist_id", flat=True))

to_remove_ids = existing_playlist_ids - selected_playlist_ids
if to_remove_ids:
removed_count = PlaylistMedia.objects.filter(media=media, playlist_id__in=to_remove_ids).delete()[0]

playlist_by_id = {playlist.id: playlist for playlist in owner_playlists if playlist.id in selected_playlist_ids}
to_add_ids = selected_playlist_ids - existing_playlist_ids
unchanged_count = len(selected_playlist_ids & existing_playlist_ids)

for playlist_id in to_add_ids:
playlist = playlist_by_id.get(playlist_id)
if not playlist:
continue

media_in_playlist = PlaylistMedia.objects.filter(playlist=playlist).count()
if media_in_playlist >= settings.MAX_MEDIA_PER_PLAYLIST:
full_playlists.append(playlist.title)
continue

PlaylistMedia.objects.create(
playlist=playlist,
media=media,
ordering=media_in_playlist + 1,
)
added_count += 1

self.playlist_sync_results = {
"added": added_count,
"removed": removed_count,
"unchanged": unchanged_count,
"full_playlists": full_playlists,
}

return media


Expand Down
16 changes: 16 additions & 0 deletions files/migrations/0019_media_external_hls_url.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("files", "0018_embedmediacourse"),
]

operations = [
migrations.AddField(
model_name="media",
name="external_hls_url",
field=models.URLField(blank=True, help_text="External HLS master playlist URL", max_length=1000),
),
]
29 changes: 27 additions & 2 deletions files/models/media.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ class Media(models.Model):

hls_file = models.CharField(max_length=1000, blank=True, help_text="Path to HLS file for videos")

external_hls_url = models.URLField(blank=True, max_length=1000, help_text="External HLS master playlist URL")

is_reviewed = models.BooleanField(
default=settings.MEDIA_IS_REVIEWED,
db_index=True,
Expand Down Expand Up @@ -239,6 +241,8 @@ def __init__(self, *args, **kwargs):
self.__original_allow_whisper_transcribe_and_translate = self.allow_whisper_transcribe_and_translate

def save(self, *args, **kwargs):
creating = self.pk is None

if not self.title:
self.title = self.media_file.path.split("/")[-1]

Expand Down Expand Up @@ -307,7 +311,7 @@ def save(self, *args, **kwargs):

# produce a thumbnail out of an uploaded poster
# will run only when a poster is uploaded for the first time
if self.uploaded_poster and self.uploaded_poster != self.__original_uploaded_poster:
if self.uploaded_poster and (creating or self.uploaded_poster != self.__original_uploaded_poster):
with open(self.uploaded_poster.path, "rb") as f:
# set this otherwise gets to infinite loop
self.__original_uploaded_poster = self.uploaded_poster
Expand Down Expand Up @@ -743,6 +747,9 @@ def tags_info(self):
def original_media_url(self):
"""Property used on serializers"""

if self.external_hls_url:
return self.external_hls_url

if settings.SHOW_ORIGINAL_MEDIA:
return helpers.url_from_path(self.media_file.path)
else:
Expand Down Expand Up @@ -862,6 +869,11 @@ def hls_info(self):

res = {}
valid_resolutions = [144, 240, 360, 480, 720, 1080, 1440, 2160]
if self.external_hls_url:
# For external playlists we expose the master URL directly.
res["master_file"] = self.external_hls_url
return res

if self.hls_file:
if os.path.exists(self.hls_file):
hls_file = self.hls_file
Expand Down Expand Up @@ -1029,7 +1041,8 @@ def media_save(sender, instance, created, **kwargs):
if created:
from ..methods import notify_users

instance.media_init()
if not instance.external_hls_url:
instance.media_init()
notify_users(friendly_token=instance.friendly_token, action="media_added")

instance.user.update_user_media()
Expand Down Expand Up @@ -1098,3 +1111,15 @@ def media_m2m(sender, instance, **kwargs):
if instance.tags.all():
for tag in instance.tags.all():
tag.update_tag_media()


@receiver(m2m_changed, sender=Media.tags.through)
def media_tags_m2m(sender, instance, action, pk_set, **kwargs):
if action in ("post_add", "post_remove", "post_clear"):
if instance.tags.all():
for tag in instance.tags.all():
tag.update_tag_media()
if pk_set:
from .category import Tag
for tag in Tag.objects.filter(pk__in=pk_set):
tag.update_tag_media()
7 changes: 5 additions & 2 deletions files/models/playlist.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,11 @@ def save(self, *args, **kwargs):
@property
def thumbnail_url(self):
pm = self.playlistmedia_set.filter(media__listable=True).first()
if pm and pm.media.thumbnail:
return helpers.url_from_path(pm.media.thumbnail.path)
if pm:
if pm.media.thumbnail_url:
return pm.media.thumbnail_url
if pm.media.poster_url:
return pm.media.poster_url
return None


Expand Down
3 changes: 2 additions & 1 deletion files/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ class MediaSerializer(serializers.ModelSerializer):
thumbnail_url = serializers.SerializerMethodField()
author_profile = serializers.SerializerMethodField()
author_thumbnail = serializers.SerializerMethodField()
uploaded_poster = serializers.ImageField(write_only=True, required=False, allow_null=True)

def get_url(self, obj):
return self.context["request"].build_absolute_uri(obj.get_absolute_url())
Expand Down Expand Up @@ -42,7 +43,6 @@ class Meta:
"add_date",
"media_type",
"state",
"duration",
"encoding_status",
"views",
"likes",
Expand All @@ -59,6 +59,7 @@ class Meta:
"user",
"title",
"description",
"uploaded_poster",
"add_date",
"views",
"media_type",
Expand Down
1 change: 1 addition & 0 deletions files/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
path("rss/", IndexRSSFeed()),
re_path("^rss/search", SearchRSSFeed()),
re_path(r"^record_screen", views.record_screen, name="record_screen"),
re_path(r"^add-external-hls", views.add_external_hls, name="add_external_hls"),
re_path(r"^search", views.search, name="search"),
re_path(r"^scpublisher", views.upload_media, name="upload_media"),
re_path(r"^tags", views.tags, name="tags"),
Expand Down
1 change: 1 addition & 0 deletions files/views/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
from .pages import publish_media # noqa: F401
from .pages import recommended_media # noqa: F401
from .pages import record_screen # noqa: F401
from .pages import add_external_hls # noqa: F401
from .pages import replace_media # noqa: F401
from .pages import search # noqa: F401
from .pages import setlanguage # noqa: F401
Expand Down
Loading
Loading