Compare commits

...

28 Commits
v6.3.0 ... main

Author SHA1 Message Date
Yiannis Christodoulou
2a0cb977f2
Video.js fixes and improvements after major upgrade (#1413) 2025-10-27 11:53:35 +02:00
Yiannis Christodoulou
a5e6e7b9ca
feat: Major Upgrade to Video.js v8 — Chapters Functionality, Fixes and Improvements 2025-10-20 15:30:00 +03:00
Markos Gogoulos
b39072c8ae feat: disapprove user 2025-10-09 17:29:29 +03:00
Markos Gogoulos
f4ab60e894 fix: extend gitignore 2025-09-26 11:33:44 +03:00
Markos Gogoulos
8656b40c5b fix: url 2025-09-25 08:51:26 +03:00
Markos Gogoulos
553a25a86f
feat: pass extra css (#1392) 2025-09-24 19:01:06 +03:00
Markos Gogoulos
1c1af489f1
feat: allow portal logo override (#1391) 2025-09-24 16:26:45 +03:00
Markos Gogoulos
c4c5ecf06a update version 2025-09-21 16:18:31 +03:00
Markos Gogoulos
725cc71960 fix migration 2025-09-21 16:17:18 +03:00
Markos Gogoulos
0c1c5bbb09 add new migration 2025-09-21 16:15:46 +03:00
Markos Gogoulos
56182f0a6d feat: allow customizable about page 2025-09-21 15:47:04 +03:00
Markos Gogoulos
208f0b338b
feat: update versions for python packages, add Pages functionality (#1386)
This PR updates Django core version and also brings html pages support (that admins can create)
2025-09-21 15:38:43 +03:00
Markos Gogoulos
cbef629baf
feat: approve users, edit users through manage users page (#1383) 2025-09-20 15:16:52 +03:00
Bret.S (AKA: CyberGladius)
8e8454d8c2
Docs Update with SAML Deployement Guide and Troubleshooting (#1377)
* Docs update with SAML deployment guide.

* Docs update with SAML deployment guide. URL Fix

---------

Co-authored-by: root <git@tdcme.com>
2025-09-16 14:51:05 +03:00
Markos Gogoulos
8d982ace92
Feat whisper opts (#1368) 2025-09-04 13:39:41 +03:00
Meet Dholakia
6cee02085c
Updated the title splicing length to 100 in playlist, category and media models (#1364) 2025-09-02 19:43:46 +03:00
Markos Gogoulos
e33aa17911 fix version 2025-09-02 11:32:02 +03:00
Markos Gogoulos
a8db23f204 docs: add whisper section 2025-09-02 11:00:17 +03:00
Markos Gogoulos
d6428e3334 version bump 2025-09-01 20:34:15 +03:00
Markos Gogoulos
fd342fd1d6 version bump 2025-09-01 20:32:11 +03:00
Markos Gogoulos
7a1b32f1ba
fix: delete media (#1366) 2025-09-01 20:06:49 +03:00
Markos Gogoulos
817e16ac60
feat: whisper STT and record screen (#1363) 2025-09-01 15:11:38 +03:00
Markos Gogoulos
8cbeb72dd2
feat: add fily type and max user media uplod limits 2025-08-19 11:35:49 +03:00
Markos Gogoulos
e9f862a0ff
feat: 3 small fixes (#1347)
* fix: datetime input

* show message on upload user only

* show all media of user for editors/managers/admins
2025-08-17 19:18:47 +03:00
Markos Gogoulos
02eac68b51
fix: swagger (#1345)
* fix: swagger

* fix: swagger
2025-08-12 12:24:26 +03:00
Markos Gogoulos
e790795bfd
feat: bulk actions API 2025-08-07 13:21:12 +03:00
Markos Gogoulos
de99d84c18 feat: revert ffmpeg install 2025-07-07 13:33:45 +03:00
Markos Gogoulos
8aa89c0958 feat: use apt for installing ffmpeg 2025-07-07 13:00:33 +03:00
679 changed files with 71016 additions and 242545 deletions

View File

@ -15,15 +15,18 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v3 uses: actions/checkout@v3
- name: Docker meta - name: Login to Docker Hub
id: meta uses: docker/login-action@v2.2.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Docker meta for base image
id: meta-base
uses: docker/metadata-action@v4 uses: docker/metadata-action@v4
with: with:
# List of Docker images to use as base name for tags
images: | images: |
mediacms/mediacms mediacms/mediacms
# Generate Docker tags based on the following events/attributes
# Set latest tag for default branch
tags: | tags: |
type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', 'main') }} type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', 'main') }}
type=semver,pattern={{version}} type=semver,pattern={{version}}
@ -37,16 +40,39 @@ jobs:
org.opencontainers.image.source=https://github.com/mediacms-io/mediacms org.opencontainers.image.source=https://github.com/mediacms-io/mediacms
org.opencontainers.image.licenses=AGPL-3.0 org.opencontainers.image.licenses=AGPL-3.0
- name: Login to Docker Hub - name: Docker meta for full image
uses: docker/login-action@v2.2.0 id: meta-full
uses: docker/metadata-action@v4
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} images: |
password: ${{ secrets.DOCKERHUB_TOKEN }} mediacms/mediacms
tags: |
type=raw,value=full,enable=${{ github.ref == format('refs/heads/{0}', 'main') }}
type=semver,pattern={{version}}-full
type=semver,pattern={{major}}.{{minor}}-full
type=semver,pattern={{major}}-full
labels: |
org.opencontainers.image.title=MediaCMS Full
org.opencontainers.image.description=MediaCMS is a modern, fully featured open source video and media CMS, written in Python/Django and React, featuring a REST API. This is the full version with additional dependencies.
org.opencontainers.image.vendor=MediaCMS
org.opencontainers.image.url=https://mediacms.io/
org.opencontainers.image.source=https://github.com/mediacms-io/mediacms
org.opencontainers.image.licenses=AGPL-3.0
- name: Build and push - name: Build and push full image
uses: docker/build-push-action@v4 uses: docker/build-push-action@v4
with: with:
context: . context: .
target: full
push: ${{ github.event_name != 'pull_request' }} push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta-full.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta-full.outputs.labels }}
- name: Build and push base image
uses: docker/build-push-action@v4
with:
context: .
target: base
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta-base.outputs.tags }}
labels: ${{ steps.meta-base.outputs.labels }}

6
.gitignore vendored
View File

@ -5,6 +5,7 @@ media_files/original/
media_files/hls/ media_files/hls/
media_files/chunks/ media_files/chunks/
media_files/uploads/ media_files/uploads/
media_files/tinymce_media/
postgres_data/ postgres_data/
celerybeat-schedule celerybeat-schedule
logs/ logs/
@ -29,3 +30,8 @@ static/video_editor/videos/sample-video-37s.mp4
.DS_Store .DS_Store
static/video_editor/videos/sample-video-10m.mp4 static/video_editor/videos/sample-video-10m.mp4
static/video_editor/videos/sample-video-10s.mp4 static/video_editor/videos/sample-video-10s.mp4
frontend-tools/video-js/public/videos/sample-video-white.mp4
frontend-tools/video-editor/client/public/videos/sample-video.mp3
frontend-tools/chapters-editor/client/public/videos/sample-video.mp3
static/chapters_editor/videos/sample-video.mp3
static/video_editor/videos/sample-video.mp3

View File

@ -1 +1,3 @@
* /templates/cms/*
/templates/*.html
*.scss

21
.prettierrc Normal file
View File

@ -0,0 +1,21 @@
{
"semi": true,
"singleQuote": true,
"printWidth": 120,
"tabWidth": 4,
"useTabs": false,
"trailingComma": "es5",
"bracketSpacing": true,
"bracketSameLine": false,
"arrowParens": "always",
"endOfLine": "lf",
"embeddedLanguageFormatting": "auto",
"overrides": [
{
"files": ["*.css", "*.scss"],
"options": {
"singleQuote": false
}
}
]
}

View File

@ -1,4 +1,4 @@
FROM python:3.13.5-bookworm AS build-image FROM python:3.13.5-slim-bookworm AS build-image
# Install system dependencies needed for downloading and extracting # Install system dependencies needed for downloading and extracting
RUN apt-get update -y && \ RUN apt-get update -y && \
@ -7,9 +7,9 @@ RUN apt-get update -y && \
apt-get purge --auto-remove && \ apt-get purge --auto-remove && \
apt-get clean apt-get clean
# Install ffmpeg RUN wget -q https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz
RUN wget -q https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz && \
mkdir -p ffmpeg-tmp && \ RUN mkdir -p ffmpeg-tmp && \
tar -xf ffmpeg-release-amd64-static.tar.xz --strip-components 1 -C ffmpeg-tmp && \ tar -xf ffmpeg-release-amd64-static.tar.xz --strip-components 1 -C ffmpeg-tmp && \
cp -v ffmpeg-tmp/ffmpeg ffmpeg-tmp/ffprobe ffmpeg-tmp/qt-faststart /usr/local/bin && \ cp -v ffmpeg-tmp/ffmpeg ffmpeg-tmp/ffprobe ffmpeg-tmp/qt-faststart /usr/local/bin && \
rm -rf ffmpeg-tmp ffmpeg-release-amd64-static.tar.xz rm -rf ffmpeg-tmp ffmpeg-release-amd64-static.tar.xz
@ -23,8 +23,8 @@ RUN mkdir -p /home/mediacms.io/bento4 && \
rm -rf /home/mediacms.io/bento4/docs && \ rm -rf /home/mediacms.io/bento4/docs && \
rm Bento4-SDK-1-6-0-637.x86_64-unknown-linux.zip rm Bento4-SDK-1-6-0-637.x86_64-unknown-linux.zip
############ RUNTIME IMAGE ############ ############ BASE RUNTIME IMAGE ############
FROM python:3.13.5-bookworm AS runtime_image FROM python:3.13.5-slim-bookworm AS base
SHELL ["/bin/bash", "-c"] SHELL ["/bin/bash", "-c"]
@ -34,13 +34,47 @@ ENV CELERY_APP='cms'
ENV VIRTUAL_ENV=/home/mediacms.io ENV VIRTUAL_ENV=/home/mediacms.io
ENV PATH="$VIRTUAL_ENV/bin:$PATH" ENV PATH="$VIRTUAL_ENV/bin:$PATH"
# Install runtime system dependencies # Install system dependencies first
RUN apt-get update -y && \ RUN apt-get update -y && \
apt-get -y upgrade && \ apt-get -y upgrade && \
apt-get install --no-install-recommends supervisor nginx imagemagick procps pkg-config libxml2-dev libxmlsec1-dev libxmlsec1-openssl -y && \ apt-get install --no-install-recommends -y \
rm -rf /var/lib/apt/lists/* && \ supervisor \
apt-get purge --auto-remove && \ nginx \
apt-get clean imagemagick \
procps \
build-essential \
pkg-config \
zlib1g-dev \
zlib1g \
libxml2-dev \
libxmlsec1-dev \
libxmlsec1-openssl \
libpq-dev \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Set up virtualenv first
RUN mkdir -p /home/mediacms.io/mediacms/{logs} && \
cd /home/mediacms.io && \
python3 -m venv $VIRTUAL_ENV
# Copy requirements files
COPY requirements.txt requirements-dev.txt ./
# Install Python dependencies using pip (within virtualenv)
ARG DEVELOPMENT_MODE=False
RUN pip install --no-cache-dir uv && \
uv pip install --no-binary lxml --no-binary xmlsec -r requirements.txt && \
if [ "$DEVELOPMENT_MODE" = "True" ]; then \
echo "Installing development dependencies..." && \
uv pip install -r requirements-dev.txt; \
fi && \
apt-get purge -y --auto-remove \
build-essential \
pkg-config \
libxml2-dev \
libxmlsec1-dev \
libpq-dev
# Copy ffmpeg and Bento4 from build image # Copy ffmpeg and Bento4 from build image
COPY --from=build-image /usr/local/bin/ffmpeg /usr/local/bin/ffmpeg COPY --from=build-image /usr/local/bin/ffmpeg /usr/local/bin/ffmpeg
@ -48,28 +82,11 @@ COPY --from=build-image /usr/local/bin/ffprobe /usr/local/bin/ffprobe
COPY --from=build-image /usr/local/bin/qt-faststart /usr/local/bin/qt-faststart COPY --from=build-image /usr/local/bin/qt-faststart /usr/local/bin/qt-faststart
COPY --from=build-image /home/mediacms.io/bento4 /home/mediacms.io/bento4 COPY --from=build-image /home/mediacms.io/bento4 /home/mediacms.io/bento4
# Set up virtualenv
RUN mkdir -p /home/mediacms.io/mediacms/{logs} && \
cd /home/mediacms.io && \
python3 -m venv $VIRTUAL_ENV
# Install Python dependencies
COPY requirements.txt requirements-dev.txt ./
ARG DEVELOPMENT_MODE=False
RUN pip install --no-cache-dir --no-binary lxml,xmlsec -r requirements.txt && \
if [ "$DEVELOPMENT_MODE" = "True" ]; then \
echo "Installing development dependencies..." && \
pip install --no-cache-dir -r requirements-dev.txt; \
fi
# Copy application files # Copy application files
COPY . /home/mediacms.io/mediacms COPY . /home/mediacms.io/mediacms
WORKDIR /home/mediacms.io/mediacms WORKDIR /home/mediacms.io/mediacms
# required for sprite thumbnail generation for large video files # required for sprite thumbnail generation for large video files
COPY deploy/docker/policy.xml /etc/ImageMagick-6/policy.xml COPY deploy/docker/policy.xml /etc/ImageMagick-6/policy.xml
# Set process control environment variables # Set process control environment variables
@ -86,3 +103,11 @@ RUN chmod +x ./deploy/docker/entrypoint.sh
ENTRYPOINT ["./deploy/docker/entrypoint.sh"] ENTRYPOINT ["./deploy/docker/entrypoint.sh"]
CMD ["./deploy/docker/start.sh"] CMD ["./deploy/docker/start.sh"]
############ FULL IMAGE ############
FROM base AS full
COPY requirements-full.txt ./
RUN mkdir -p /root/.cache/ && \
chmod go+rwx /root/ && \
chmod go+rwx /root/.cache/
RUN uv pip install -r requirements-full.txt

View File

@ -1,7 +1,7 @@
.PHONY: admin-shell build-frontend .PHONY: admin-shell build-frontend
admin-shell: admin-shell:
@container_id=$$(docker-compose ps -q web); \ @container_id=$$(docker compose ps -q web); \
if [ -z "$$container_id" ]; then \ if [ -z "$$container_id" ]; then \
echo "Web container not found"; \ echo "Web container not found"; \
exit 1; \ exit 1; \

View File

@ -25,11 +25,12 @@ A demo is available at https://demo.mediacms.io
- **Complete control over your data**: host it yourself! - **Complete control over your data**: host it yourself!
- **Modern technologies**: Django/Python/Celery, React. - **Modern technologies**: Django/Python/Celery, React.
- **Support for multiple publishing workflows**: public, private, unlisted and custom - **Support for multiple publishing workflows**: public, private, unlisted and custom
- **Role-Based Access Control (RBAC)**: create RBAC categories and connect users to groups with view/edit access on their media
- **Automatic transcription**: through integration with Whisper running locally
- **Multiple media types support**: video, audio, image, pdf - **Multiple media types support**: video, audio, image, pdf
- **Multiple media classification options**: categories, tags and custom - **Multiple media classification options**: categories, tags and custom
- **Multiple media sharing options**: social media share, videos embed code generation - **Multiple media sharing options**: social media share, videos embed code generation
- **Video Trimmer**: trim video, replace, save as new or create segments - **Video Trimmer**: trim video, replace, save as new or create segments
- **Role-Based Access Control (RBAC)**: create RBAC categories and connect users to groups with view/edit access on their media
- **SAML support**: with ability to add mappings to system roles and groups - **SAML support**: with ability to add mappings to system roles and groups
- **Easy media searching**: enriched with live search functionality - **Easy media searching**: enriched with live search functionality
- **Playlists for audio and video content**: create playlists, add and reorder content - **Playlists for audio and video content**: create playlists, add and reorder content
@ -48,7 +49,7 @@ A demo is available at https://demo.mediacms.io
## Example cases ## Example cases
- **Schools, education.** Administrators and editors keep what content will be published, students are not distracted with advertisements and irrelevant content, plus they have the ability to select either to stream or download content. - **Universities, schools, education.** Administrators and editors keep what content will be published, students are not distracted with advertisements and irrelevant content, plus they have the ability to select either to stream or download content.
- **Organization sensitive content.** In cases where content is sensitive and cannot be uploaded to external sites. - **Organization sensitive content.** In cases where content is sensitive and cannot be uploaded to external sites.
- **Build a great community.** MediaCMS can be customized (URLs, logos, fonts, aesthetics) so that you create a highly customized video portal for your community! - **Build a great community.** MediaCMS can be customized (URLs, logos, fonts, aesthetics) so that you create a highly customized video portal for your community!
- **Personal portal.** Organize, categorize and host your content the way you prefer. - **Personal portal.** Organize, categorize and host your content the way you prefer.
@ -83,6 +84,7 @@ For a small to medium installation, with a few hours of video uploaded daily, an
In terms of disk space, think of what the needs will be. A general rule is to multiply by three the size of the expected uploaded videos (since the system keeps original versions, encoded versions plus HLS), so if you receive 1G of videos daily and maintain all of them, you should consider a 1T disk across a year (1G * 3 * 365). In terms of disk space, think of what the needs will be. A general rule is to multiply by three the size of the expected uploaded videos (since the system keeps original versions, encoded versions plus HLS), so if you receive 1G of videos daily and maintain all of them, you should consider a 1T disk across a year (1G * 3 * 365).
In order to support automatic transcriptions through Whisper, consider more CPUs.
## Installation / Maintanance ## Installation / Maintanance
@ -101,6 +103,7 @@ There are two ways to run MediaCMS, through Docker Compose and through installin
* [Configuration](docs/admins_docs.md#5-configuration) page * [Configuration](docs/admins_docs.md#5-configuration) page
* [Transcoding](docs/transcoding.md) page * [Transcoding](docs/transcoding.md) page
* [Developer Experience](docs/dev_exp.md) page * [Developer Experience](docs/dev_exp.md) page
* [Media Permissions](docs/media_permissions.md) page
## Technology ## Technology
@ -109,7 +112,7 @@ This software uses the following list of awesome technologies: Python, Django, D
## Who is using it ## Who is using it
- **Multiple Universities** for hosting educational videos
- **Cinemata** non-profit media, technology and culture organization - https://cinemata.org - **Cinemata** non-profit media, technology and culture organization - https://cinemata.org
- **Critical Commons** public media archive and fair use advocacy network - https://criticalcommons.org - **Critical Commons** public media archive and fair use advocacy network - https://criticalcommons.org
- **American Association of Gynecologic Laparoscopists** - https://surgeryu.org/ - **American Association of Gynecologic Laparoscopists** - https://surgeryu.org/

10
cms/auth_backends.py Normal file
View File

@ -0,0 +1,10 @@
from django.conf import settings
from django.contrib.auth.backends import ModelBackend
class ApprovalBackend(ModelBackend):
def user_can_authenticate(self, user):
can_authenticate = super().user_can_authenticate(user)
if can_authenticate and settings.USERS_NEEDS_TO_BE_APPROVED and not user.is_superuser:
return getattr(user, 'is_approved', False)
return can_authenticate

View File

@ -34,6 +34,7 @@ INSTALLED_APPS = [
"allauth.socialaccount.providers.saml", "allauth.socialaccount.providers.saml",
"saml_auth.apps.SamlAuthConfig", "saml_auth.apps.SamlAuthConfig",
"corsheaders", "corsheaders",
"tinymce",
] ]
MIDDLEWARE = [ MIDDLEWARE = [

23
cms/middleware.py Normal file
View File

@ -0,0 +1,23 @@
from django.conf import settings
from django.http import JsonResponse
from django.shortcuts import redirect
from django.urls import reverse
class ApprovalMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
if settings.USERS_NEEDS_TO_BE_APPROVED and request.user.is_authenticated and not request.user.is_superuser and not getattr(request.user, 'is_approved', False):
allowed_paths = [
reverse('approval_required'),
reverse('account_logout'),
]
if request.path not in allowed_paths:
if request.path.startswith('/api/'):
return JsonResponse({'detail': 'User account not approved.'}, status=403)
return redirect('approval_required')
response = self.get_response(request)
return response

View File

@ -1,14 +1,22 @@
from django.conf import settings from django.conf import settings
from rest_framework import permissions from rest_framework import permissions
from rest_framework.exceptions import PermissionDenied
from files.methods import is_mediacms_editor, is_mediacms_manager from files.methods import (
is_mediacms_editor,
is_mediacms_manager,
user_allowed_to_upload,
)
class IsAuthorizedToAdd(permissions.BasePermission): class IsAuthorizedToAdd(permissions.BasePermission):
def has_permission(self, request, view): def has_permission(self, request, view):
if request.method in permissions.SAFE_METHODS: if request.method in permissions.SAFE_METHODS:
return True return True
return user_allowed_to_upload(request) if not user_allowed_to_upload(request):
raise PermissionDenied("You don't have permission to upload media, or have reached max number of media uploads.")
return True
class IsAuthorizedToAddComment(permissions.BasePermission): class IsAuthorizedToAddComment(permissions.BasePermission):
@ -55,26 +63,6 @@ class IsUserOrEditor(permissions.BasePermission):
return obj.user == request.user return obj.user == request.user
def user_allowed_to_upload(request):
"""Any custom logic for whether a user is allowed
to upload content lives here
"""
if request.user.is_anonymous:
return False
if request.user.is_superuser:
return True
if settings.CAN_ADD_MEDIA == "all":
return True
elif settings.CAN_ADD_MEDIA == "email_verified":
if request.user.email_is_verified:
return True
elif settings.CAN_ADD_MEDIA == "advancedUser":
if request.user.advancedUser:
return True
return False
def user_allowed_to_comment(request): def user_allowed_to_comment(request):
"""Any custom logic for whether a user is allowed """Any custom logic for whether a user is allowed
to comment lives here to comment lives here

View File

@ -105,6 +105,23 @@ USE_L10N = True
USE_TZ = True USE_TZ = True
SITE_ID = 1 SITE_ID = 1
# these are the portal logos (dark and light)
# set new paths for svg or png if you want to override
# svg has priority over png, so if you want to use
# custom pngs and not svgs, remove the lines with svgs
# or set as empty strings
# example:
# PORTAL_LOGO_DARK_SVG = ""
# PORTAL_LOGO_LIGHT_SVG = ""
# place the files on static/images folder
PORTAL_LOGO_DARK_SVG = "/static/images/logo_dark.svg"
PORTAL_LOGO_DARK_PNG = "/static/images/logo_dark.png"
PORTAL_LOGO_LIGHT_SVG = "/static/images/logo_light.svg"
PORTAL_LOGO_LIGHT_PNG = "/static/images/logo_dark.png"
# paths to extra css files to be included, eg "/static/css/custom.css"
# place css inside static/css folder
EXTRA_CSS_PATHS = []
# protection agains anonymous users # protection agains anonymous users
# per ip address limit, for actions as like/dislike/report # per ip address limit, for actions as like/dislike/report
TIME_TO_ACTION_ANONYMOUS = 10 * 60 TIME_TO_ACTION_ANONYMOUS = 10 * 60
@ -128,6 +145,10 @@ USERS_CAN_SELF_REGISTER = True
RESTRICTED_DOMAINS_FOR_USER_REGISTRATION = ["xxx.com", "emaildomainwhatever.com"] RESTRICTED_DOMAINS_FOR_USER_REGISTRATION = ["xxx.com", "emaildomainwhatever.com"]
# by default users do not need to be approved. If this is set to True, then new users
# will have to be approved before they can login successfully
USERS_NEEDS_TO_BE_APPROVED = False
# Comma separated list of domains: ["organization.com", "private.organization.com", "org2.com"] # Comma separated list of domains: ["organization.com", "private.organization.com", "org2.com"]
# Empty list disables. # Empty list disables.
ALLOWED_DOMAINS_FOR_USER_REGISTRATION = [] ALLOWED_DOMAINS_FOR_USER_REGISTRATION = []
@ -226,7 +247,7 @@ POST_UPLOAD_AUTHOR_MESSAGE_UNLISTED_NO_COMMENTARY = ""
# only in case where unlisted workflow is used and no commentary # only in case where unlisted workflow is used and no commentary
# exists # exists
CANNOT_ADD_MEDIA_MESSAGE = "" CANNOT_ADD_MEDIA_MESSAGE = "User cannot add media, or maximum number of media uploads has been reached."
# mp4hls command, part of Bento4 # mp4hls command, part of Bento4
MP4HLS_COMMAND = "/home/mediacms.io/mediacms/Bento4-SDK-1-6-0-637.x86_64-unknown-linux/bin/mp4hls" MP4HLS_COMMAND = "/home/mediacms.io/mediacms/Bento4-SDK-1-6-0-637.x86_64-unknown-linux/bin/mp4hls"
@ -285,6 +306,7 @@ INSTALLED_APPS = [
"drf_yasg", "drf_yasg",
"allauth.socialaccount.providers.saml", "allauth.socialaccount.providers.saml",
"saml_auth.apps.SamlAuthConfig", "saml_auth.apps.SamlAuthConfig",
"tinymce",
] ]
MIDDLEWARE = [ MIDDLEWARE = [
@ -468,9 +490,49 @@ LANGUAGES = [
LANGUAGE_CODE = 'en' # default language LANGUAGE_CODE = 'en' # default language
TINYMCE_DEFAULT_CONFIG = {
"theme": "silver",
"height": 500,
"resize": "both",
"menubar": "file edit view insert format tools table help",
"menu": {
"format": {
"title": "Format",
"items": "blocks | bold italic underline strikethrough superscript subscript code | " "fontfamily fontsize align lineheight | " "forecolor backcolor removeformat",
},
},
"plugins": "advlist,autolink,autosave,lists,link,image,charmap,print,preview,anchor,"
"searchreplace,visualblocks,code,fullscreen,insertdatetime,media,table,paste,directionality,"
"code,help,wordcount,emoticons,file,image,media",
"toolbar": "undo redo | code preview | blocks | "
"bold italic | alignleft aligncenter "
"alignright alignjustify ltr rtl | bullist numlist outdent indent | "
"removeformat | restoredraft help | image media",
"branding": False, # remove branding
"promotion": False, # remove promotion
"body_class": "page-main-inner custom-page-wrapper", # class of the body element in tinymce
"block_formats": "Paragraph=p; Heading 1=h1; Heading 2=h2; Heading 3=h3;",
"formats": { # customize h2 to always have emphasis-large class
"h2": {"block": "h2", "classes": "emphasis-large"},
},
"font_size_formats": "16px 18px 24px 32px",
"images_upload_url": "/tinymce/upload/",
"images_upload_handler": "tinymce.views.upload_image",
"automatic_uploads": True,
"file_picker_types": "image",
"paste_data_images": True,
"paste_as_text": False,
"paste_enable_default_filters": True,
"paste_word_valid_elements": "b,strong,i,em,h1,h2,h3,h4,h5,h6,p,br,a,ul,ol,li",
"paste_retain_style_properties": "all",
"paste_remove_styles": False,
"paste_merge_formats": True,
"sandbox_iframes": False,
}
SPRITE_NUM_SECS = 10 SPRITE_NUM_SECS = 10
# number of seconds for sprite image. # number of seconds for sprite image.
# If you plan to change this, you must also follow the instructions on admin_docs.md # If you plan to change this, you must also follow the instructions on admins_docs.md
# to change the equivalent value in ./frontend/src/static/js/components/media-viewer/VideoViewer/index.js and then re-build frontend # to change the equivalent value in ./frontend/src/static/js/components/media-viewer/VideoViewer/index.js and then re-build frontend
# how many images will be shown on the slideshow # how many images will be shown on the slideshow
@ -498,9 +560,37 @@ ALLOW_VIDEO_TRIMMER = True
ALLOW_CUSTOM_MEDIA_URLS = False ALLOW_CUSTOM_MEDIA_URLS = False
# Whether to allow anonymous users to list all users
ALLOW_ANONYMOUS_USER_LISTING = True
# Who can see the members page
# valid choices are all, editors, admins
CAN_SEE_MEMBERS_PAGE = "all"
# Maximum number of media a user can upload
NUMBER_OF_MEDIA_USER_CAN_UPLOAD = 100
# ffmpeg options # ffmpeg options
FFMPEG_DEFAULT_PRESET = "medium" # see https://trac.ffmpeg.org/wiki/Encode/H.264 FFMPEG_DEFAULT_PRESET = "medium" # see https://trac.ffmpeg.org/wiki/Encode/H.264
# If 'all' is in the list, no check is performed
ALLOWED_MEDIA_UPLOAD_TYPES = ["video", "audio", "image", "pdf"]
# transcription options
# the mediacms-full docker image needs to be used in order to be able to use transcription
# if you are using the mediacms-full image, change USE_WHISPER_TRANSCRIBE to True
USE_WHISPER_TRANSCRIBE = False
# by default all users can request a video to be transcribed. If you want to
# allow only editors, set this to False
USER_CAN_TRANSCRIBE_VIDEO = True
# Whisper transcribe options - https://github.com/openai/whisper
WHISPER_MODEL = "base"
# show a custom text in the sidebar footer, otherwise the default will be shown if this is empty
SIDEBAR_FOOTER_TEXT = ""
try: try:
# keep a local_settings.py file for local overrides # keep a local_settings.py file for local overrides
from .local_settings import * # noqa from .local_settings import * # noqa
@ -542,3 +632,12 @@ except ImportError:
if GLOBAL_LOGIN_REQUIRED: if GLOBAL_LOGIN_REQUIRED:
auth_index = MIDDLEWARE.index("django.contrib.auth.middleware.AuthenticationMiddleware") auth_index = MIDDLEWARE.index("django.contrib.auth.middleware.AuthenticationMiddleware")
MIDDLEWARE.insert(auth_index + 1, "django.contrib.auth.middleware.LoginRequiredMiddleware") MIDDLEWARE.insert(auth_index + 1, "django.contrib.auth.middleware.LoginRequiredMiddleware")
if USERS_NEEDS_TO_BE_APPROVED:
AUTHENTICATION_BACKENDS = (
'cms.auth_backends.ApprovalBackend',
'allauth.account.auth_backends.AuthenticationBackend',
)
auth_index = MIDDLEWARE.index("django.contrib.auth.middleware.AuthenticationMiddleware")
MIDDLEWARE.insert(auth_index + 1, "cms.middleware.ApprovalMiddleware")

View File

@ -30,6 +30,7 @@ urlpatterns = [
re_path(r'^swagger(?P<format>\.json|\.yaml)$', schema_view.without_ui(cache_timeout=0), name='schema-json'), re_path(r'^swagger(?P<format>\.json|\.yaml)$', schema_view.without_ui(cache_timeout=0), name='schema-json'),
re_path(r'^swagger/$', schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui'), re_path(r'^swagger/$', schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui'),
path('docs/api/', schema_view.with_ui('redoc', cache_timeout=0), name='schema-redoc'), path('docs/api/', schema_view.with_ui('redoc', cache_timeout=0), name='schema-redoc'),
path("tinymce/", include("tinymce.urls")),
] ]
admin.site.site_header = "MediaCMS Admin" admin.site.site_header = "MediaCMS Admin"

View File

@ -1 +1 @@
VERSION = "6.3.0" VERSION = "7.0.1-beta.8"

View File

@ -30,7 +30,8 @@ fi
# We should do this only for folders that have a different owner, since it is an expensive operation # We should do this only for folders that have a different owner, since it is an expensive operation
# Also ignoring .git folder to fix this issue https://github.com/mediacms-io/mediacms/issues/934 # Also ignoring .git folder to fix this issue https://github.com/mediacms-io/mediacms/issues/934
find /home/mediacms.io/mediacms ! \( -path "*.git*" \) -exec chown www-data:$TARGET_GID {} + # Exclude package-lock.json files that may not exist or be removed during frontend setup
find /home/mediacms.io/mediacms ! \( -path "*.git*" -o -name "package-lock.json" \) -exec chown www-data:$TARGET_GID {} + 2>/dev/null || true
chmod +x /home/mediacms.io/mediacms/deploy/docker/start.sh /home/mediacms.io/mediacms/deploy/docker/prestart.sh chmod +x /home/mediacms.io/mediacms/deploy/docker/start.sh /home/mediacms.io/mediacms/deploy/docker/prestart.sh

View File

@ -1,5 +1,6 @@
#!/bin/bash #!/bin/bash
# This script builds the video editor package and deploys the frontend assets to the static directory. # This script builds the video editor package and deploys the frontend assets to the static directory.
# How to run: sh deploy/scripts/build_and_deploy.sh
# Exit on any error # Exit on any error
set -e set -e
@ -12,9 +13,21 @@ cd frontend-tools/video-editor
yarn build:django yarn build:django
cd ../../ cd ../../
# Build chapter editor package
echo "Building chapters editor package..."
cd frontend-tools/chapters-editor
yarn build:django
cd ../../
# Build video js package
echo "Building video js package..."
cd frontend-tools/video-js
yarn build:django
cd ../../
# Run npm build in the frontend container # Run npm build in the frontend container
echo "Building frontend assets..." echo "Building frontend assets..."
docker compose -f docker-compose-dev.yaml exec frontend npm run dist docker compose -f docker-compose/docker-compose-dev-updated.yaml exec frontend npm run dist
# Copy static assets to the static directory # Copy static assets to the static directory
echo "Copying static assets..." echo "Copying static assets..."
@ -22,6 +35,6 @@ cp -r frontend/dist/static/* static/
# Restart the web service # Restart the web service
echo "Restarting web service..." echo "Restarting web service..."
docker compose -f docker-compose-dev.yaml restart web docker compose -f docker-compose/docker-compose-dev-updated.yaml restart web
echo "Build and deployment completed successfully!" echo "Build and deployment completed successfully!"

View File

@ -5,6 +5,7 @@ services:
build: build:
context: . context: .
dockerfile: ./Dockerfile dockerfile: ./Dockerfile
target: base
args: args:
- DEVELOPMENT_MODE=True - DEVELOPMENT_MODE=True
image: mediacms/mediacms-dev:latest image: mediacms/mediacms-dev:latest
@ -84,6 +85,5 @@ services:
ENABLE_NGINX: 'no' ENABLE_NGINX: 'no'
ENABLE_CELERY_BEAT: 'no' ENABLE_CELERY_BEAT: 'no'
ENABLE_MIGRATIONS: 'no' ENABLE_MIGRATIONS: 'no'
DEVELOPMENT_MODE: True
depends_on: depends_on:
- web - web

5
docker-compose.full.yaml Normal file
View File

@ -0,0 +1,5 @@
version: "3"
services:
celery_worker:
image: mediacms/mediacms:full

View File

@ -33,55 +33,35 @@ services:
volumes: volumes:
- ${PWD}/frontend:/home/mediacms.io/mediacms/frontend/ - ${PWD}/frontend:/home/mediacms.io/mediacms/frontend/
- frontend_node_modules:/home/mediacms.io/mediacms/frontend/node_modules - frontend_node_modules:/home/mediacms.io/mediacms/frontend/node_modules
- player_node_modules:/home/mediacms.io/mediacms/frontend/packages/player/node_modules
- scripts_node_modules:/home/mediacms.io/mediacms/frontend/packages/scripts/node_modules - scripts_node_modules:/home/mediacms.io/mediacms/frontend/packages/scripts/node_modules
- npm_global:/home/node/.npm-global - npm_cache:/home/node/.npm
working_dir: /home/mediacms.io/mediacms/frontend/ working_dir: /home/mediacms.io/mediacms/frontend/
command: > command: >
bash -c " bash -c "
echo 'Setting up npm global directory...' && echo 'Checking dependencies...' &&
mkdir -p /home/node/.npm-global && if [ ! -f node_modules/.install-complete ]; then
chown -R node:node /home/node/.npm-global && echo 'First-time setup or dependencies changed, installing...' &&
echo 'Setting up permissions...' && npm install --legacy-peer-deps --cache /home/node/.npm &&
chown -R node:node /home/mediacms.io/mediacms/frontend &&
echo 'Cleaning up node_modules...' &&
find /home/mediacms.io/mediacms/frontend/node_modules -mindepth 1 -delete 2>/dev/null || true &&
find /home/mediacms.io/mediacms/frontend/packages/player/node_modules -mindepth 1 -delete 2>/dev/null || true &&
find /home/mediacms.io/mediacms/frontend/packages/scripts/node_modules -mindepth 1 -delete 2>/dev/null || true &&
chown -R node:node /home/mediacms.io/mediacms/frontend/node_modules &&
chown -R node:node /home/mediacms.io/mediacms/frontend/packages/player/node_modules &&
chown -R node:node /home/mediacms.io/mediacms/frontend/packages/scripts/node_modules &&
echo 'Switching to node user...' &&
su node -c '
export NPM_CONFIG_PREFIX=/home/node/.npm-global &&
echo \"Setting up frontend...\" &&
rm -f package-lock.json &&
rm -f packages/player/package-lock.json &&
rm -f packages/scripts/package-lock.json &&
echo \"Installing dependencies...\" &&
npm install --legacy-peer-deps &&
echo \"Setting up workspaces...\" &&
npm install -g npm@latest &&
cd packages/scripts && cd packages/scripts &&
npm install --legacy-peer-deps && npm install --legacy-peer-deps --cache /home/node/.npm &&
npm install rollup@2.79.1 --save-dev --legacy-peer-deps &&
npm install typescript@4.9.5 --save-dev --legacy-peer-deps &&
npm install tslib@2.6.2 --save --legacy-peer-deps &&
npm install rollup-plugin-typescript2@0.34.1 --save-dev --legacy-peer-deps &&
npm install --legacy-peer-deps &&
npm run build && npm run build &&
cd ../.. && cd ../.. &&
cd packages/player && touch node_modules/.install-complete &&
npm install --legacy-peer-deps && echo 'Dependencies installed successfully'
npm run build && else
cd ../.. && echo 'Dependencies already installed, skipping installation...' &&
echo \"Starting development server...\" && if [ ! -d packages/scripts/dist ]; then
npm run start echo 'Building scripts package...' &&
'" cd packages/scripts &&
npm run build &&
cd ../..
fi
fi &&
echo 'Starting development server...' &&
npm run start
"
env_file: env_file:
- ${PWD}/frontend/.env - ${PWD}/frontend/.env
environment:
- NPM_CONFIG_PREFIX=/home/node/.npm-global
ports: ports:
- "8088:8088" - "8088:8088"
depends_on: depends_on:
@ -140,6 +120,5 @@ services:
volumes: volumes:
frontend_node_modules: frontend_node_modules:
player_node_modules:
scripts_node_modules: scripts_node_modules:
npm_global: npm_cache:

View File

@ -26,6 +26,9 @@
- [23. SAML setup](#23-saml-setup) - [23. SAML setup](#23-saml-setup)
- [24. Identity Providers setup](#24-identity-providers-setup) - [24. Identity Providers setup](#24-identity-providers-setup)
- [25. Custom urls](#25-custom-urls) - [25. Custom urls](#25-custom-urls)
- [26. Allowed files](#26-allowed-files)
- [27. User upload limits](#27-user-upload-limits)
- [28. Whisper Transcribe for Automatic Subtitles](#28-whisper-transcribe-for-automatic-subtitles)
## 1. Welcome ## 1. Welcome
@ -122,6 +125,12 @@ migrations_1 | Created admin user with password: gwg1clfkwf
or if you have set the ADMIN_PASSWORD variable on docker-compose file you have used (example `docker-compose.yaml`), that variable will be set as the admin user's password or if you have set the ADMIN_PASSWORD variable on docker-compose file you have used (example `docker-compose.yaml`), that variable will be set as the admin user's password
`Note`: if you want to use the automatic transcriptions, you have to do one of the following:
* either use the docker-compose.full.yaml, so in this case run `docker-compose -f docker-compose.yaml -f docker-compose.full.yaml up`
* or edit the docker-compose.yaml file and set the image for the celery_worker service as mediacms/mediacms:full instead of mediacms/mediacms:latest
Plus set variable `USE_WHISPER_TRANSCRIBE = True` in the settings.py file
### Update ### Update
Get latest MediaCMS image and stop/start containers Get latest MediaCMS image and stop/start containers
@ -168,8 +177,6 @@ By default, all these services are enabled, but in order to create a scaleable d
Also see the `Dockerfile` for other environment variables which you may wish to override. Application settings, eg. `FRONTEND_HOST` can also be overridden by updating the `deploy/docker/local_settings.py` file. Also see the `Dockerfile` for other environment variables which you may wish to override. Application settings, eg. `FRONTEND_HOST` can also be overridden by updating the `deploy/docker/local_settings.py` file.
See example deployments in the sections below. These example deployments have been tested on `docker-compose version 1.27.4` running on `Docker version 19.03.13`
To run, update the configs above if necessary, build the image by running `docker compose build`, then run `docker compose run` To run, update the configs above if necessary, build the image by running `docker compose build`, then run `docker compose run`
### Simple Deployment, accessed as http://localhost ### Simple Deployment, accessed as http://localhost
@ -233,7 +240,12 @@ Docker Compose installation: edit `deploy/docker/local_settings.py`, make a chan
### 5.1 Change portal logo ### 5.1 Change portal logo
Set a new svg file for the white theme (`static/images/logo_dark.svg`) or the dark theme (`static/images/logo_light.svg`) Find the default svg files for the white theme on `static/images/logo_dark.svg` and for the dark theme on `static/images/logo_light.svg`
You can specify new svg paths to override by editing the `PORTAL_LOGO_DARK_SVG` and `PORTAL_LOGO_LIGHT_SVG` variables in `settings.py`.
You can also use custom pngs, by setting the variables `PORTAL_LOGO_DARK_PNG` and `PORTAL_LOGO_LIGHT_PNG` in `settings.py`. The svg files have priority over png files, so if both are set, svg files will be used.
In any case, make sure the files are placed on the static/images folder.
### 5.2 Set global portal title ### 5.2 Set global portal title
@ -502,6 +514,30 @@ By default `CAN_COMMENT = "all"` means that all registered users can add comment
- **advancedUser**, only users that are marked as advanced users can add comment. Admins or MediaCMS managers can make users advanced users by editing their profile and selecting advancedUser. - **advancedUser**, only users that are marked as advanced users can add comment. Admins or MediaCMS managers can make users advanced users by editing their profile and selecting advancedUser.
### 5.26 Control whether anonymous users can list all users
By default, anonymous users can view the list of all users on the platform. To restrict this to authenticated users only, set:
```
ALLOW_ANONYMOUS_USER_LISTING = False
```
When set to False, only logged-in users will be able to access the user listing API endpoint.
### 5.27 Control who can see the members page
By default `CAN_SEE_MEMBERS_PAGE = "all"` means that all registered users can see the members page. Other valid options are:
- **editors**, only MediaCMS editors can view the page
- **admins**, only MediaCMS admins can view the page
### 5.28 Require user approval on registration
By default, users do not require approval, so they can login immediately after registration (if registration is open). However, if the parameter `USERS_NEEDS_TO_BE_APPROVED` is set to `True`, they will first have to have their accounts approved by an administrator before they can successfully sign in.
Administrators can approve users through the following ways: 1. through Django administration, 2. through the users management page, 3. through editing the profile page directly. In all cases, set 'Is approved' to True.
## 6. Manage pages ## 6. Manage pages
to be written to be written
@ -947,6 +983,8 @@ Select the SAML Configurations tab, create a new one and set:
4. **Group mapping**: This creates groups associated with this IDP. Group ids as they come from SAML, associated with MediaCMS groups 4. **Group mapping**: This creates groups associated with this IDP. Group ids as they come from SAML, associated with MediaCMS groups
5. **Category Mapping**: This maps a group id (from SAML response) with a category in MediaCMS 5. **Category Mapping**: This maps a group id (from SAML response) with a category in MediaCMS
A full SAML deployment with [EntraID guide and troubleshooting steps is available here.](./saml_entraid_setup.md). This guide can be used as reference for other IDPs too.
## 24. Identity Providers setup ## 24. Identity Providers setup
A separate Django app identity_providers has been added in order to facilitate a number of configurations related to different identity providers. If this is enabled, it gives the following options: A separate Django app identity_providers has been added in order to facilitate a number of configurations related to different identity providers. If this is enabled, it gives the following options:
@ -968,3 +1006,35 @@ Visiting the admin, you will see the Identity Providers tab and you can add one.
## 25. Custom urls ## 25. Custom urls
To enable custom urls, set `ALLOW_CUSTOM_MEDIA_URLS = True` on settings.py or local_settings.py To enable custom urls, set `ALLOW_CUSTOM_MEDIA_URLS = True` on settings.py or local_settings.py
This will enable editing the URL of the media, while editing a media. If the URL is already taken you get a message you cannot update this. This will enable editing the URL of the media, while editing a media. If the URL is already taken you get a message you cannot update this.
## 26. Allowed files
MediaCMS performs identification attempts on new file uploads and only allows certain file types specified in the `ALLOWED_MEDIA_UPLOAD_TYPES` setting. By default, only ["video", "audio", "image", "pdf"] files are allowed.
When a file is not identified as one of these allowed types, the file gets removed from the system and there's an entry indicating that this is not a supported media type.
If you want to change the allowed file types, edit the `ALLOWED_MEDIA_UPLOAD_TYPES` list in your `settings.py` or `local_settings.py` file. If 'all' is specified in this list, no check is performed and all files are allowed.
## 27. User upload limits
MediaCMS allows you to set a maximum number of media files that each user can upload. This is controlled by the `NUMBER_OF_MEDIA_USER_CAN_UPLOAD` setting in `settings.py` or `local_settings.py`. By default, this is set to 100 media items per user.
When a user reaches this limit, they will no longer be able to upload new media until they delete some of their existing content. This limit applies regardless of the user's role or permissions in the system.
To change the maximum number of uploads allowed per user, modify the `NUMBER_OF_MEDIA_USER_CAN_UPLOAD` value in your settings file:
```
NUMBER_OF_MEDIA_USER_CAN_UPLOAD = 5
```
## 28. Whisper Transcribe for Automatic Subtitles
MediaCMS can integrate with OpenAI's Whisper to automatically generate subtitles for your media files. This feature is useful for making your content more accessible.
### How it works
When the whisper transcribe task is triggered for a media file, MediaCMS runs the `whisper` command-line tool to process the audio and generate a subtitle file in VTT format. The generated subtitles are then associated with the media and are available under the "automatic" language option.
### Configuration
Transcription functionality is available only for the Docker installation. To enable this feature, you must either use the `docker-compose.full.yaml` file, as it contains an image with the necessary requirements, or you can also set that celery_worker service is usine mediacms:full image instead of mediacms:latest. Then you also have to set the setting: `USE_WHISPER_TRANSCRIBE = True` in your local_settings.py file.
By default, all users have the ability to send a request for a video to be transcribed, as well as transcribed and translated to English. If you wish to change this behavior, you can edit the `settings.py` file and set `USER_CAN_TRANSCRIBE_VIDEO=False`.
The transcription uses the base model of Whisper speech-to-text by default. However, you can change the model by editing the `WHISPER_MODEL` setting in `settings.py`.

View File

@ -4,10 +4,10 @@ There is ongoing effort to provide a better developer experience and document it
## How to develop locally with Docker ## How to develop locally with Docker
First install a recent version of [Docker](https://docs.docker.com/get-docker/), and [Docker Compose](https://docs.docker.com/compose/install/). First install a recent version of [Docker](https://docs.docker.com/get-docker/), and [Docker Compose](https://docs.docker.com/compose/install/).
Then run `docker-compose -f docker-compose-dev.yaml up` Then run `docker compose -f docker-compose-dev.yaml up`
``` ```
user@user:~/mediacms$ docker-compose -f docker-compose-dev.yaml up user@user:~/mediacms$ docker compose -f docker-compose-dev.yaml up
``` ```
In a few minutes the app will be available at http://localhost . Login via admin/admin In a few minutes the app will be available at http://localhost . Login via admin/admin
@ -37,7 +37,7 @@ Django starts at http://localhost and is reloading automatically. Making any cha
If Django breaks due to an error (eg SyntaxError, while editing the code), you might have to restart it If Django breaks due to an error (eg SyntaxError, while editing the code), you might have to restart it
``` ```
docker-compose -f docker-compose-dev.yaml restart web docker compose -f docker-compose-dev.yaml restart web
``` ```
@ -62,9 +62,9 @@ In order to make changes to React code, edit code on frontend/src and check it's
### Development workflow with the frontend ### Development workflow with the frontend
1. Edit frontend/src/ files 1. Edit frontend/src/ files
2. Check changes on http://localhost:8088/ 2. Check changes on http://localhost:8088/
3. Build frontend with `docker-compose -f docker-compose-dev.yaml exec frontend npm run dist` 3. Build frontend with `docker compose -f docker-compose-dev.yaml exec frontend npm run dist`
4. Copy static files to Django static folder with`cp -r frontend/dist/static/* static/` 4. Copy static files to Django static folder with`cp -r frontend/dist/static/* static/`
5. Restart Django - `docker-compose -f docker-compose-dev.yaml restart web` so that it uses the new static files 5. Restart Django - `docker compose -f docker-compose-dev.yaml restart web` so that it uses the new static files
6. Commit the changes 6. Commit the changes
### Helper commands ### Helper commands
@ -81,7 +81,7 @@ Build the frontend:
``` ```
user@user:~/mediacms$ make build-frontend user@user:~/mediacms$ make build-frontend
docker-compose -f docker-compose-dev.yaml exec frontend npm run dist docker compose -f docker-compose-dev.yaml exec frontend npm run dist
> mediacms-frontend@0.9.1 dist /home/mediacms.io/mediacms/frontend > mediacms-frontend@0.9.1 dist /home/mediacms.io/mediacms/frontend
> mediacms-scripts rimraf ./dist && mediacms-scripts build --config=./config/mediacms.config.js --env=dist > mediacms-scripts rimraf ./dist && mediacms-scripts build --config=./config/mediacms.config.js --env=dist

View File

@ -50,8 +50,8 @@ Checkout the [Code of conduct page](../CODE_OF_CONDUCT.md) if you want to contri
To perform the Docker installation, follow instructions to install Docker + Docker compose (docs/Docker_Compose.md) and then build/start docker-compose-dev.yaml . This will run the frontend application on port 8088 on top of all other containers (including the Django web application on port 80) To perform the Docker installation, follow instructions to install Docker + Docker compose (docs/Docker_Compose.md) and then build/start docker-compose-dev.yaml . This will run the frontend application on port 8088 on top of all other containers (including the Django web application on port 80)
``` ```
docker-compose -f docker-compose-dev.yaml build docker compose -f docker-compose-dev.yaml build
docker-compose -f docker-compose-dev.yaml up docker compose -f docker-compose-dev.yaml up
``` ```
An `admin` user is created during the installation process. Its attributes are defined in `docker-compose-dev.yaml`: An `admin` user is created during the installation process. Its attributes are defined in `docker-compose-dev.yaml`:
@ -65,7 +65,7 @@ ADMIN_EMAIL: 'admin@localhost'
Eg change `frontend/src/static/js/pages/HomePage.tsx` , dev application refreshes in a number of seconds (hot reloading) and I see the changes, once I'm happy I can run Eg change `frontend/src/static/js/pages/HomePage.tsx` , dev application refreshes in a number of seconds (hot reloading) and I see the changes, once I'm happy I can run
``` ```
docker-compose -f docker-compose-dev.yaml exec -T frontend npm run dist docker compose -f docker-compose-dev.yaml exec -T frontend npm run dist
``` ```
And then in order for the changes to be visible on the application while served through nginx, And then in order for the changes to be visible on the application while served through nginx,
@ -90,7 +90,7 @@ http://localhost:8088/manage-media.html manage_media
After I make changes to the django application (eg make a change on `files/forms.py`) in order to see the changes I have to restart the web container After I make changes to the django application (eg make a change on `files/forms.py`) in order to see the changes I have to restart the web container
``` ```
docker-compose -f docker-compose-dev.yaml restart web docker compose -f docker-compose-dev.yaml restart web
``` ```
## How video is transcoded ## How video is transcoded
@ -122,19 +122,19 @@ This instructions assume that you're using the docker installation
1. start docker-compose 1. start docker-compose
``` ```
docker-compose up docker compose up
``` ```
2. Install the requirements on `requirements-dev.txt ` on web container (we'll use the web container for this) 2. Install the requirements on `requirements-dev.txt ` on web container (we'll use the web container for this)
``` ```
docker-compose exec -T web pip install -r requirements-dev.txt docker compose exec -T web pip install -r requirements-dev.txt
``` ```
3. Now you can run the existing tests 3. Now you can run the existing tests
``` ```
docker-compose exec --env TESTING=True -T web pytest docker compose exec --env TESTING=True -T web pytest
``` ```
The `TESTING=True` is passed for Django to be aware this is a testing environment (so that it runs Celery tasks as functions for example and not as background tasks, since Celery is not started in the case of pytest) The `TESTING=True` is passed for Django to be aware this is a testing environment (so that it runs Celery tasks as functions for example and not as background tasks, since Celery is not started in the case of pytest)
@ -143,13 +143,13 @@ The `TESTING=True` is passed for Django to be aware this is a testing environmen
4. You may try a single test, by specifying the path, for example 4. You may try a single test, by specifying the path, for example
``` ```
docker-compose exec --env TESTING=True -T web pytest tests/test_fixtures.py docker compose exec --env TESTING=True -T web pytest tests/test_fixtures.py
``` ```
5. You can also see the coverage 5. You can also see the coverage
``` ```
docker-compose exec --env TESTING=True -T web pytest --cov=. --cov-report=html docker compose exec --env TESTING=True -T web pytest --cov=. --cov-report=html
``` ```
and of course...you are very welcome to help us increase it ;) and of course...you are very welcome to help us increase it ;)

166
docs/media_permissions.md Normal file
View File

@ -0,0 +1,166 @@
# Media Permissions in MediaCMS
This document explains the permission system in MediaCMS, which controls who can view, edit, and manage media files.
## Overview
MediaCMS provides a flexible permission system that allows fine-grained control over media access. The system supports:
1. **Basic permissions** - Public, private, and unlisted media
2. **User-specific permissions** - Direct permissions granted to specific users
3. **Role-Based Access Control (RBAC)** - Category-based permissions through group membership
## Media States
Every media file has a state that determines its basic visibility:
- **Public** - Visible to everyone
- **Private** - Only visible to the owner and users with explicit permissions
- **Unlisted** - Not listed in public listings but accessible via direct link
## User Roles
MediaCMS has several user roles that affect permissions:
- **Regular User** - Can upload and manage their own media
- **Advanced User** - Additional capabilities (configurable)
- **MediaCMS Editor** - Can edit and review content across the platform
- **MediaCMS Manager** - Full management capabilities
- **Admin** - Complete system access
## Direct Media Permissions
The `MediaPermission` model allows granting specific permissions to individual users:
### Permission Levels
- **Viewer** - Can view the media even if it's private
- **Editor** - Can view and edit the media's metadata
- **Owner** - Full control, including deletion
## Role-Based Access Control (RBAC)
When RBAC is enabled (`USE_RBAC` setting), permissions can be managed through categories and groups:
1. Categories can be marked as RBAC-controlled
2. Users are assigned to RBAC groups with specific roles
3. RBAC groups are associated with categories
4. Users inherit permissions to media in those categories based on their role
### RBAC Roles
- **Member** - Can view media in the category
- **Contributor** - Can view and edit media in the category
- **Manager** - Full control over media in the category
## Permission Checking Methods
The User model provides several methods to check permissions:
```python
# From users/models.py
def has_member_access_to_media(self, media):
# Check if user can view the media
# ...
def has_contributor_access_to_media(self, media):
# Check if user can edit the media
# ...
def has_owner_access_to_media(self, media):
# Check if user has full control over the media
# ...
```
## How Permissions Are Applied
When a user attempts to access media, the system checks permissions in this order:
1. Is the media public? If yes, allow access.
2. Is the user the owner of the media? If yes, allow full access.
3. Does the user have direct permissions through MediaPermission? If yes, grant the corresponding access level.
4. If RBAC is enabled, does the user have access through category membership? If yes, grant the corresponding access level.
5. If none of the above, deny access.
## Media Sharing
Users can share media with others by:
1. Making it public or unlisted
2. Granting direct permissions to specific users
3. Adding it to a category that's accessible to an RBAC group
## Implementation Details
### Media Listing
When listing media, the system filters based on permissions:
```python
# Simplified example from files/views/media.py
def _get_media_queryset(self, request, user=None):
# 1. Public media
listable_media = Media.objects.filter(listable=True)
if not request.user.is_authenticated:
return listable_media
# 2. User permissions for authenticated users
user_media = Media.objects.filter(permissions__user=request.user)
# 3. RBAC for authenticated users
if getattr(settings, 'USE_RBAC', False):
rbac_categories = request.user.get_rbac_categories_as_member()
rbac_media = Media.objects.filter(category__in=rbac_categories)
# Combine all accessible media
return listable_media.union(user_media, rbac_media)
```
### Permission Checking
The system uses helper methods to check permissions:
```python
# From users/models.py
def has_member_access_to_media(self, media):
# First check if user is the owner
if media.user == self:
return True
# Then check RBAC permissions
if getattr(settings, 'USE_RBAC', False):
rbac_groups = RBACGroup.objects.filter(
memberships__user=self,
memberships__role__in=["member", "contributor", "manager"],
categories__in=media.category.all()
).distinct()
if rbac_groups.exists():
return True
# Then check MediaShare permissions for any access
media_permission_exists = MediaPermission.objects.filter(
user=self,
media=media,
).exists()
return media_permission_exists
```
## Best Practices
1. **Default to Private** - Consider setting new uploads to private by default
2. **Use Categories** - Organize media into categories for easier permission management
3. **RBAC for Teams** - Use RBAC for team collaboration scenarios
4. **Direct Permissions for Exceptions** - Use direct permissions for one-off sharing
## Configuration
The permission system can be configured through several settings:
- `USE_RBAC` - Enable/disable Role-Based Access Control
## Conclusion
MediaCMS provides a flexible and powerful permission system that can accommodate various use cases, from simple personal media libraries to complex team collaboration scenarios with fine-grained access control.

315
docs/saml_entraid_setup.md Normal file
View File

@ -0,0 +1,315 @@
# Integrating Microsoft Entra ID (formerly Azure AD) with MediaCMS via SAML Authentication
This guide provides step-by-step instructions on how to configure Microsoft Entra ID as a SAML Identity Provider (IdP) for MediaCMS, an open-source content management system. The goal is to enable single sign-on (SSO) authentication for users in a secure and scalable way.
## Table of Contents
1. [Overview](#overview)
2. [Prerequisites](#prerequisites)
3. [Step 1: Configure MediaCMS for SAML](#step-1-configure-mediacms-for-saml)
4. [Step 2: Register MediaCMS as an Enterprise App in Entra ID](#step-2-register-mediacms-as-an-enterprise-app-in-entra-id)
5. [Step 3: Configure SAML Settings in Entra ID](#step-3-configure-saml-settings-in-entra-id)
6. [Step 4: Configure SAML Settings in MediaCMS](#step-4-configure-saml-settings-in-mediacms)
7. [Step 5: Allow Users or Groups to Log Into the Application](#step-5-allow-users-or-groups-to-log-into-the-application)
8. [Step 6: Test and Validate Login Flow](#step-6-test-and-validate-login-flow)
9. [Troubleshooting](#troubleshooting)
10. [Resources](#resources)
---
## Overview
MediaCMS supports SAML 2.0 authentication by acting as a Service Provider (SP). By integrating with Microsoft Entra ID, organizations can allow users to authenticate using their existing enterprise credentials.
In our particular deployment of MediaCMS, the application is hosted internally with no direct inbound access from the public Internet. As an internal company application, it was essential to integrate it with our existing authentication systems and provide a seamless single sign-on experience. This is where the SAML protocol shines.
One of the major advantages of SAML authentication is that all communication between the Identity Provider (IdP) — in this case, Microsoft Entra ID — and the Service Provider (SP) — MediaCMS — is brokered entirely by the end user's browser. The browser initiates the authentication flow, communicates securely with Microsofts login portal, receives the identity assertion, and then passes it back to the internal MediaCMS server.
This architecture enables the MediaCMS server to remain isolated from the Internet while still participating in a modern and seamless federated login experience.
Even though the deployment method outlined in this tutorial is for EntraID on an isolated MediaCMS server, the same steps and general information could be applied to another authentication SAML provider/identity provider on a non-isolated system.
> **Note**: This guide assumes you are running MediaCMS with Django backend and that the `django-allauth` library is enabled and configured.
---
## Prerequisites
Before beginning, ensure the following:
* You have administrator access to both MediaCMS and Microsoft Entra ID (Azure portal).
* MediaCMS is installed and accessible via HTTPS, with a valid SSL certificate.
* Your MediaCMS installation has SAML support enabled (via `django-allauth`).
* You have a dedicated domain or subdomain for MediaCMS (e.g., `https://<MyMediaCMS.MyDomain.com>`).
---
## Step 1: Configure MediaCMS for SAML
The first step in enabling SAML authentication is to modify the `local_settings.py` (for Docker: `./deploy/docker/local_settings.py`) file of your MediaCMS deployment. Add the following configuration block to enable SAML support, role-based access control (RBAC), and enforce secure communication settings:
```python
USE_RBAC = True
USE_SAML = True
USE_IDENTITY_PROVIDERS = True
USE_X_FORWARDED_HOST = True
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
SECURE_SSL_REDIRECT = True
CSRF_COOKIE_SECURE = True
SESSION_COOKIE_SECURE = True
SOCIALACCOUNT_ADAPTER = 'saml_auth.adapter.SAMLAccountAdapter'
SOCIALACCOUNT_PROVIDERS = {
"saml": {
"provider_class": "saml_auth.custom.provider.CustomSAMLProvider",
}
}
```
These settings enable SAML authentication, configure MediaCMS to respect role-based access, and apply important headers and cookie policies for secure browser handling — all of which are necessary for the SAML flow to function properly.
> ⚠️ **Important**: After updating the `local_settings.py` file, you must restart your MediaCMS service (e.g., by rebooting the Docker container) in order for the changes to take effect. This step must be completed before proceeding to the next configuration stage.
---
## Step 2: Register MediaCMS as an Enterprise App in Entra ID
To begin the integration process on the Microsoft Entra ID (formerly Azure AD) side, follow the steps below to register MediaCMS as a new Enterprise Application.
### 1. Navigate to Enterprise Applications
* Log in to your [Azure Portal](https://portal.azure.com).
* Navigate to **Enterprise Applications**.
> *Note: This guide assumes you already have an existing Azure tenant and Entra ID configured with users and groups.*
### 2. Create a New Application
* Click the **+ New Application** button.
* On the next screen, choose **Create your own application**.
* Enter a name for the application (e.g., `MediaCMS`).
* Under "What are you looking to do with your application?", select **Integrate any other application you don't find in the gallery (Non-gallery)**.
* Click **Create**.
After a few moments, Azure will create the new application and redirect you to its configuration page.
---
## Step 3: Configure SAML Settings in Entra ID
### 1. Configure SAML-Based Single Sign-On
* From the application overview page, in the left-hand menu under **Manage**, click **Single sign-on**.
* You will be prompted to choose a sign-on method. Select **SAML**.
### 2. Choose a Client ID Name
Before filling out the SAML configuration, you must decide on a client ID name. This name will uniquely identify your SAML integration and appear in your login URL.
* Choose a name that is descriptive and easy to remember (e.g., `mediacms_entraid`).
* You will use this name in both MediaCMS and Entra ID configuration settings.
### 3. Fill Out Basic SAML Configuration
Now input the following values under the **Basic SAML Configuration** section:
| Field | Value |
| -------------------------- | --------------------------------------------------------------------- |
| **Identifier (Entity ID)** | `https://<MyMediaCMS.MyDomain.com>/saml/metadata/` |
| **Reply URL (ACS URL)** | `https://<MyMediaCMS.MyDomain.com>/accounts/saml/<MyClientID>/acs/` |
| **Sign-on URL** | `https://<MyMediaCMS.MyDomain.com>/accounts/saml/<MyClientID>/login/` |
| **Relay State (Optional)** | `https://<MyMediaCMS.MyDomain.com>/` |
| **Logout URL (Optional)** | `https://<MyMediaCMS.MyDomain.com>/accounts/saml/<MyClientID>/sls/` |
> 🔐 Replace `<MyClientID>` with your own chosen client ID if different.
Once these fields are filled in, save your configuration.
Keep the Azure Enterprise single sign-on configuration window up, as we are now going to configure some of the details from this Azure page into our MediaCMS system.
---
## Step 4: Configure SAML Settings in MediaCMS
In MediaCMS, start by logging into the back-end administrative web page. You will now have new options under the left-hand menu bar.
### 1. Add Login Option
* Navigate to **Identity Providers → Login Options**.
* Click **Add Login Option**.
* Give the login option a title. This title can be anything you like but it will appear to the end-user when they select a method of logging in, so ensure the name is clear. (e.g., `EntraID-SSO`).
* Set the **Login URL** to the same Sign-on URL:
```
https://<MyMediaCMS.MyDomain.com>/accounts/saml/<MyClientID>/login/
```
* Leave the ordering at `0` if you have no other authentication methods.
* Ensure the **Active** box is checked to make this an active login method.
* Click **Save** to continue.
### 2. Add ID Provider
* Navigate to **Identity Providers → ID Providers**.
* Click **Add ID Provider**.
Back in your Azure Enterprise application configuration window (at the bottom of the Single Sign-On configuration menu), find your application-specific details. They will look like the following example:
```
Example unique AppID: 123456ab-1234-12ab-ab12-abc123abc123
The unique AppID is automatically generated when you create the application.
-- Example URLs --
Login URL: https://login.microsoftonline.com/123456ab-1234-12ab-ab12-abc123abc123/saml2
Microsoft Entra Identifier: https://sts.windows.net/123456ab-1234-12ab-ab12-abc123abc123/
Logout URL: https://login.microsoftonline.com/123456ab-1234-12ab-ab12-abc123abc123/saml2
```
Back in MediaCMS's new ID Provider window, under the **General** tab:
* **Protocol**: `saml` (all lowercase)
* **Provider ID**: The Microsoft Entra Identifier (as shown above), the whole URL.
* **IDP Configuration Name**: Any unique name (e.g., `EntraID`)
* **Client ID**: The exact same client ID you used earlier when configuring EntraID (e.g., `mediacms_entraid`).
* **Sites**: Add all the sites you want this login to appear on (e.g., all of them)
Click **Save and Continue**, then go to the **SAML Configuration** tab.
On the **SAML Configuration** tab:
* **SSO URL**: Use the same Logon URL from EntraID example listed above.
* **SLO URL**: Use the Logout URL from EntraID example listed above.
* **SP Metadata URL**:
```
https://<MyMediaCMS.MyDomain.com>/saml/metadata/
```
* **IdP ID**: Use the same Microsoft Entra Identifier URL as listed above.
#### LDP Certificate
Back in Azure's Enterprise Application page (SAML certificates section), download the **Base64 Certificate**, open it in a text editor, and copy the contents into the **LDP Certificate** setting inside of MediaCMS.
### 3. Configure Identity Mappings
Map the identity attributes that Entra ID will provide to MediaCMS. Even though only UID is specified as mandatory, Entra ID will not work unless all of these details are filled in(YES, you must type NA in the fields; you cannot leave anything blank. You will get 500 errors if this is not done). You can use the exact settings below:
| Field | Value |
| -------------- | -------------------------------------------------------------------- |
| **Uid** | `http://schemas.microsoft.com/identity/claims/objectidentifier` |
| **Name** | `http://schemas.microsoft.com/identity/claims/displayname` |
| **Email** | `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress` |
| **Groups** | `NA` |
| **First name** | `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname` |
| **Last name** | `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname` |
| **User logo** | `NA` |
| **Role** | `NA` |
> Groups and Role can be changed or remapped inside the Azure Enterprise Application under **Attributes and Claims**.
Check the **Verified Email** box (since EntraID will verify the user for you). While setting up, you can enable **Save SAML Response Log** for troubleshooting purposes.
Finally, click **Save** to finish adding the new ID provider.
---
## Step 5: Allow Users or Groups to Log Into the Application
Back inside Azure AD, within your MediaCMS Enterprise Application, you must assign users or groups that are allowed to use the MediaCMS authentication sign-on.
### 1. Navigate to Users and Groups
* Open the Azure Portal and go to your **MediaCMS Enterprise Application**.
* In the left-hand **Manage** menu, click **Users and Groups**.
### 2. Assign Users or Groups
* Add individual users or groups of users who are allowed to use the EntraID authentication method with MediaCMS.
* In this example, the application was provided to all registered users inside of EntraID by using the special group **All Users**, which grants any registered user in the tenant access to MediaCMS.
> ⚠️ **Important**: Nested groups will not work. All users must be directly assigned to the group you are giving permission to. If a group contains another group, the users of the nested group will not inherit the permissions to use this application from the parent group.
---
## Step 6: Test and Validate Login Flow
At this point, you should go to your MediaCMS webpage and attempt to log in using the authentication method that you have just set up.
---
## Troubleshooting
If you're experiencing logon issues, it is helpful to first review the SAML authentication data directly.
1. Go to MediaCMS's login page. It should redirect you to Microsoft's login page.
2. Before completing the Microsoft authentication, open Firefox or Chrome Developer Tools (press **F12**) and navigate to the **Network** tab.
3. Enable **Persistent Logging**.
4. Complete the Microsoft authentication steps on your page (including two-factor authentication if enabled).
On the final step of the authentication (usually after entering a code and confirming "Stay signed in?"), you will see several POST requests going back to your MediaCMS server URL. Find the POST request that is going to your MediaCMS server's Assertion Consumer Service (ACS) URL, which will look like this:
```
https://<MyMediaCMS.MyDomain.com>/accounts/saml/<MyClientID>/acs/
```
Inside the request section of the Network tab, you will see a **Form Data** field labeled **SAMLResponse**, which contains a Base64-encoded XML string of your authenticated assertion from EntraID.
* Click into the data field of the SAML response so you can highlight and copy all of the Base64-encoded text.
* You can then take this Base64-encoded text to a tool like [CyberChef](https://gchq.github.io/CyberChef/) and use the **From Base64** decoder and **XML Beautify** to reveal the XML-formatted SAML response.
This decoded XML contains all the assertion and token details passed back to MediaCMS. You can use this information to troubleshoot any issues or misconfigurations that arise.
You can also confirm your MediaCMS server has the SAML authentication settings correct by opening a private browsing window and navigating to the following URL, which will output the current XML data that your MediaCMS server is configured with:
```
https://<MyMediaCMS.MyDomain.com>/saml/metadata/
```
You can use the returned XML data from this URL to confirm that MediaCMS is configured appropriately as expected and is providing the correct information to the identity provider.
### Infinite Redirect Loop
Another issue you might encounter is an **infinite redirect loop**. This can happen when global login is enforced and local user login is disabled.
**Symptoms:** The system continuously redirects between the homepage and the login URL.
**Root Cause:** With global login required and local login disabled, Django attempts to redirect users to the default local login page. Since that login method is unavailable, users are bounced back to the homepage, triggering the same redirect logic again — resulting in a loop.
**Solution:** Specify the correct SAML authentication URL in your local settings. For example:
* "Login Option" URL configured for EntraID in MediaCMS:
```
https://<MyDomainName>/accounts/saml/mediacms_entraid/login/
```
* Add the following line to `./deploy/docker/local_settings.py`:
```python
LOGIN_URL = "/accounts/saml/mediacms_entraid/login/"
```
This change ensures Django uses the proper SAML login route, breaking the redirect loop and allowing authentication via EntraID as intended.
> **Note:** The `LOGIN_URL` setting works because we are using the Django AllAuth module to perform the SAML authentication. If you review the AllAuth Django configuration settings, you will find that this is a setting, among other settings, that you can set inside of your local settings file that Django will pick up when using the AllAuth module. You can review the module documentation at the following URL for more details and additional settings that can be set through AllAuth via `local_settings.py`: [https://django-allauth.readthedocs.io/en/latest/account/configuration.html](https://django-allauth.readthedocs.io/en/latest/account/configuration.html)
---
## Resources
* [MediaCMS SAML Docs](https://github.com/mediacms-io/mediacms/blob/main/docs/admins_docs.md#24-identity-providers-setup)
* [Enable SAML single sign-on for an enterprise application](https://learn.microsoft.com/en-us/entra/identity/enterprise-apps/add-application-portal-setup-sso)
* [Django AllAuth](https://django-allauth.readthedocs.io/en/latest/index.html)
---
*This documentation is a work-in-progress and will be updated as further steps are dictated or completed.*

View File

@ -3,6 +3,7 @@ from django.conf import settings
from django.contrib import admin from django.contrib import admin
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import transaction from django.db import transaction
from tinymce.widgets import TinyMCE
from rbac.models import RBACGroup from rbac.models import RBACGroup
@ -13,8 +14,11 @@ from .models import (
Encoding, Encoding,
Language, Language,
Media, Media,
Page,
Subtitle, Subtitle,
Tag, Tag,
TinyMCEMedia,
TranscriptionRequest,
VideoTrimRequest, VideoTrimRequest,
) )
@ -219,14 +223,47 @@ class EncodingAdmin(admin.ModelAdmin):
has_file.short_description = "Has file" has_file.short_description = "Has file"
class TranscriptionRequestAdmin(admin.ModelAdmin):
pass
class PageAdminForm(forms.ModelForm):
description = forms.CharField(widget=TinyMCE())
def clean_description(self):
content = self.cleaned_data['description']
# Add sandbox attribute to all iframes
content = content.replace('<iframe ', '<iframe sandbox="allow-scripts allow-same-origin allow-presentation" ')
return content
class Meta:
model = Page
fields = "__all__"
class PageAdmin(admin.ModelAdmin):
form = PageAdminForm
@admin.register(TinyMCEMedia)
class TinyMCEMediaAdmin(admin.ModelAdmin):
list_display = ['original_filename', 'file_type', 'uploaded_at', 'user']
list_filter = ['file_type', 'uploaded_at']
search_fields = ['original_filename']
readonly_fields = ['uploaded_at']
date_hierarchy = 'uploaded_at'
admin.site.register(EncodeProfile, EncodeProfileAdmin) admin.site.register(EncodeProfile, EncodeProfileAdmin)
admin.site.register(Comment, CommentAdmin) admin.site.register(Comment, CommentAdmin)
admin.site.register(Media, MediaAdmin) admin.site.register(Media, MediaAdmin)
admin.site.register(Encoding, EncodingAdmin) admin.site.register(Encoding, EncodingAdmin)
admin.site.register(Category, CategoryAdmin) admin.site.register(Category, CategoryAdmin)
admin.site.register(Page, PageAdmin)
admin.site.register(Tag, TagAdmin) admin.site.register(Tag, TagAdmin)
admin.site.register(Subtitle, SubtitleAdmin) admin.site.register(Subtitle, SubtitleAdmin)
admin.site.register(Language, LanguageAdmin) admin.site.register(Language, LanguageAdmin)
admin.site.register(VideoTrimRequest, VideoTrimRequestAdmin) admin.site.register(VideoTrimRequest, VideoTrimRequestAdmin)
admin.site.register(TranscriptionRequest, TranscriptionRequestAdmin)
Media._meta.app_config.verbose_name = "Media" Media._meta.app_config.verbose_name = "Media"

View File

@ -12,6 +12,12 @@ def stuff(request):
ret["FRONTEND_HOST"] = request.build_absolute_uri('/').rstrip('/') ret["FRONTEND_HOST"] = request.build_absolute_uri('/').rstrip('/')
ret["DEFAULT_THEME"] = settings.DEFAULT_THEME ret["DEFAULT_THEME"] = settings.DEFAULT_THEME
ret["PORTAL_NAME"] = settings.PORTAL_NAME ret["PORTAL_NAME"] = settings.PORTAL_NAME
ret["PORTAL_LOGO_DARK_SVG"] = getattr(settings, 'PORTAL_LOGO_DARK_SVG', "")
ret["PORTAL_LOGO_DARK_PNG"] = getattr(settings, 'PORTAL_LOGO_DARK_PNG', "")
ret["PORTAL_LOGO_LIGHT_SVG"] = getattr(settings, 'PORTAL_LOGO_LIGHT_SVG', "")
ret["PORTAL_LOGO_LIGHT_PNG"] = getattr(settings, 'PORTAL_LOGO_LIGHT_PNG', "")
ret["EXTRA_CSS_PATHS"] = getattr(settings, 'EXTRA_CSS_PATHS', [])
ret["PORTAL_DESCRIPTION"] = settings.PORTAL_DESCRIPTION ret["PORTAL_DESCRIPTION"] = settings.PORTAL_DESCRIPTION
ret["LOAD_FROM_CDN"] = settings.LOAD_FROM_CDN ret["LOAD_FROM_CDN"] = settings.LOAD_FROM_CDN
ret["CAN_LOGIN"] = settings.LOGIN_ALLOWED ret["CAN_LOGIN"] = settings.LOGIN_ALLOWED
@ -26,10 +32,22 @@ def stuff(request):
ret["UPLOAD_MAX_SIZE"] = settings.UPLOAD_MAX_SIZE ret["UPLOAD_MAX_SIZE"] = settings.UPLOAD_MAX_SIZE
ret["UPLOAD_MAX_FILES_NUMBER"] = settings.UPLOAD_MAX_FILES_NUMBER ret["UPLOAD_MAX_FILES_NUMBER"] = settings.UPLOAD_MAX_FILES_NUMBER
ret["PRE_UPLOAD_MEDIA_MESSAGE"] = settings.PRE_UPLOAD_MEDIA_MESSAGE 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["POST_UPLOAD_AUTHOR_MESSAGE_UNLISTED_NO_COMMENTARY"] = settings.POST_UPLOAD_AUTHOR_MESSAGE_UNLISTED_NO_COMMENTARY
ret["IS_MEDIACMS_ADMIN"] = request.user.is_superuser ret["IS_MEDIACMS_ADMIN"] = request.user.is_superuser
ret["IS_MEDIACMS_EDITOR"] = is_mediacms_editor(request.user) ret["IS_MEDIACMS_EDITOR"] = is_mediacms_editor(request.user)
ret["IS_MEDIACMS_MANAGER"] = is_mediacms_manager(request.user) ret["IS_MEDIACMS_MANAGER"] = is_mediacms_manager(request.user)
ret["USERS_NEEDS_TO_BE_APPROVED"] = settings.USERS_NEEDS_TO_BE_APPROVED
can_see_members_page = False
if request.user.is_authenticated:
if settings.CAN_SEE_MEMBERS_PAGE == "all":
can_see_members_page = True
elif settings.CAN_SEE_MEMBERS_PAGE == "editors" and is_mediacms_editor(request.user):
can_see_members_page = True
elif settings.CAN_SEE_MEMBERS_PAGE == "admins" and request.user.is_superuser:
can_see_members_page = True
ret["CAN_SEE_MEMBERS_PAGE"] = can_see_members_page
ret["ALLOW_RATINGS"] = settings.ALLOW_RATINGS ret["ALLOW_RATINGS"] = settings.ALLOW_RATINGS
ret["ALLOW_RATINGS_CONFIRMED_EMAIL_ONLY"] = settings.ALLOW_RATINGS_CONFIRMED_EMAIL_ONLY ret["ALLOW_RATINGS_CONFIRMED_EMAIL_ONLY"] = settings.ALLOW_RATINGS_CONFIRMED_EMAIL_ONLY
ret["VIDEO_PLAYER_FEATURED_VIDEO_ON_INDEX_PAGE"] = settings.VIDEO_PLAYER_FEATURED_VIDEO_ON_INDEX_PAGE ret["VIDEO_PLAYER_FEATURED_VIDEO_ON_INDEX_PAGE"] = settings.VIDEO_PLAYER_FEATURED_VIDEO_ON_INDEX_PAGE

View File

@ -1,6 +1,6 @@
from crispy_forms.bootstrap import FormActions from crispy_forms.bootstrap import FormActions
from crispy_forms.helper import FormHelper from crispy_forms.helper import FormHelper
from crispy_forms.layout import Field, Layout, Submit from crispy_forms.layout import HTML, Field, Layout, Submit
from django import forms from django import forms
from django.conf import settings from django.conf import settings
@ -35,7 +35,7 @@ class MediaMetadataForm(forms.ModelForm):
widgets = { widgets = {
"new_tags": MultipleSelect(), "new_tags": MultipleSelect(),
"description": forms.Textarea(attrs={'rows': 4}), "description": forms.Textarea(attrs={'rows': 4}),
"add_date": forms.DateInput(attrs={'type': 'date'}, format='%Y-%m-%d'), "add_date": forms.DateTimeInput(attrs={'type': 'datetime-local', 'step': '1'}, format='%Y-%m-%dT%H:%M:%S'),
"thumbnail_time": forms.NumberInput(attrs={'min': 0, 'step': 0.1}), "thumbnail_time": forms.NumberInput(attrs={'min': 0, 'step': 0.1}),
} }
labels = { labels = {
@ -68,14 +68,18 @@ class MediaMetadataForm(forms.ModelForm):
self.helper.form_method = 'post' self.helper.form_method = 'post'
self.helper.form_enctype = "multipart/form-data" self.helper.form_enctype = "multipart/form-data"
self.helper.form_show_errors = False self.helper.form_show_errors = False
self.helper.layout = Layout(
layout_fields = [
CustomField('title'), CustomField('title'),
CustomField('new_tags'), CustomField('new_tags'),
CustomField('add_date'), CustomField('add_date'),
CustomField('description'), CustomField('description'),
CustomField('uploaded_poster'),
CustomField('enable_comments'), CustomField('enable_comments'),
) ]
if self.instance.media_type != "image":
layout_fields.append(CustomField('uploaded_poster'))
self.helper.layout = Layout(*layout_fields)
if self.instance.media_type == "video": if self.instance.media_type == "video":
self.helper.layout.append(CustomField('thumbnail_time')) self.helper.layout.append(CustomField('thumbnail_time'))
@ -114,14 +118,7 @@ class MediaPublishForm(forms.ModelForm):
class Meta: class Meta:
model = Media model = Media
fields = ( fields = ("category", "state", "featured", "reported_times", "is_reviewed", "allow_download")
"category",
"state",
"featured",
"reported_times",
"is_reviewed",
"allow_download",
)
widgets = { widgets = {
"category": MultipleSelect(), "category": MultipleSelect(),
@ -130,6 +127,7 @@ class MediaPublishForm(forms.ModelForm):
def __init__(self, user, *args, **kwargs): def __init__(self, user, *args, **kwargs):
self.user = user self.user = user
super(MediaPublishForm, self).__init__(*args, **kwargs) super(MediaPublishForm, self).__init__(*args, **kwargs)
if not is_mediacms_editor(user): if not is_mediacms_editor(user):
for field in ["featured", "reported_times", "is_reviewed"]: for field in ["featured", "reported_times", "is_reviewed"]:
self.fields[field].disabled = True self.fields[field].disabled = True
@ -216,16 +214,95 @@ class MediaPublishForm(forms.ModelForm):
return media return media
class WhisperSubtitlesForm(forms.ModelForm):
class Meta:
model = Media
fields = (
"allow_whisper_transcribe",
"allow_whisper_transcribe_and_translate",
)
labels = {
"allow_whisper_transcribe": "Transcription",
"allow_whisper_transcribe_and_translate": "English Translation",
}
help_texts = {
"allow_whisper_transcribe": "",
"allow_whisper_transcribe_and_translate": "",
}
def __init__(self, user, *args, **kwargs):
self.user = user
super(WhisperSubtitlesForm, self).__init__(*args, **kwargs)
if self.instance.allow_whisper_transcribe:
self.fields['allow_whisper_transcribe'].widget.attrs['readonly'] = True
self.fields['allow_whisper_transcribe'].widget.attrs['disabled'] = True
if self.instance.allow_whisper_transcribe_and_translate:
self.fields['allow_whisper_transcribe_and_translate'].widget.attrs['readonly'] = True
self.fields['allow_whisper_transcribe_and_translate'].widget.attrs['disabled'] = True
both_readonly = self.instance.allow_whisper_transcribe and self.instance.allow_whisper_transcribe_and_translate
self.helper = FormHelper()
self.helper.form_tag = True
self.helper.form_class = 'post-form'
self.helper.form_method = 'post'
self.helper.form_enctype = "multipart/form-data"
self.helper.form_show_errors = False
self.helper.layout = Layout(
CustomField('allow_whisper_transcribe'),
CustomField('allow_whisper_transcribe_and_translate'),
)
if not both_readonly:
self.helper.layout.append(FormActions(Submit('submit_whisper', 'Submit', css_class='primaryAction')))
else:
# Optional: Add a disabled button with explanatory text
self.helper.layout.append(
FormActions(Submit('submit_whisper', 'Submit', css_class='primaryAction', disabled=True), HTML('<small class="text-muted">Cannot submit - both options are already enabled</small>'))
)
def clean_allow_whisper_transcribe(self):
# Ensure the field value doesn't change if it was originally True
if self.instance and self.instance.allow_whisper_transcribe:
return self.instance.allow_whisper_transcribe
return self.cleaned_data['allow_whisper_transcribe']
def clean_allow_whisper_transcribe_and_translate(self):
# Ensure the field value doesn't change if it was originally True
if self.instance and self.instance.allow_whisper_transcribe_and_translate:
return self.instance.allow_whisper_transcribe_and_translate
return self.cleaned_data['allow_whisper_transcribe_and_translate']
class SubtitleForm(forms.ModelForm): class SubtitleForm(forms.ModelForm):
class Meta: class Meta:
model = Subtitle model = Subtitle
fields = ["language", "subtitle_file"] fields = ["language", "subtitle_file"]
labels = {
"subtitle_file": "Upload Caption File",
}
help_texts = {
"subtitle_file": "SubRip (.srt) and WebVTT (.vtt) are supported file formats.",
}
def __init__(self, media_item, *args, **kwargs): def __init__(self, media_item, *args, **kwargs):
super(SubtitleForm, self).__init__(*args, **kwargs) super(SubtitleForm, self).__init__(*args, **kwargs)
self.instance.media = media_item self.instance.media = media_item
self.fields["subtitle_file"].help_text = "SubRip (.srt) and WebVTT (.vtt) are supported file formats."
self.fields["subtitle_file"].label = "Subtitle or Closed Caption File" self.helper = FormHelper()
self.helper.form_tag = True
self.helper.form_class = 'post-form'
self.helper.form_method = 'post'
self.helper.form_enctype = "multipart/form-data"
self.helper.form_show_errors = False
self.helper.layout = Layout(
CustomField('subtitle_file'),
CustomField('language'),
)
self.helper.layout.append(FormActions(Submit('submit', 'Submit', css_class='primaryAction')))
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
self.instance.user = self.instance.media.user self.instance.user = self.instance.media.user

View File

@ -3,17 +3,20 @@ translation_strings = {
"AUTOPLAY": "تشغيل تلقائي", "AUTOPLAY": "تشغيل تلقائي",
"About": "حول", "About": "حول",
"Add a ": "أضف ", "Add a ": "أضف ",
"Browse your files": "تصفح ملفاتك",
"COMMENT": "تعليق", "COMMENT": "تعليق",
"Categories": "الفئات", "Categories": "الفئات",
"Category": "الفئة", "Category": "الفئة",
"Change Language": "تغيير اللغة", "Change Language": "تغيير اللغة",
"Change password": "تغيير كلمة المرور", "Change password": "تغيير كلمة المرور",
"Click 'Start Recording' and select the screen or tab to record. Once recording is finished, click 'Stop Recording,' and the recording will be uploaded.": "انقر على 'بدء التسجيل' واختر الشاشة أو علامة التبويب المراد تسجيلها. بمجرد الانتهاء من التسجيل، انقر على 'إيقاف التسجيل'، وسيتم تحميل التسجيل.",
"Comment": "تعليق", "Comment": "تعليق",
"Comments": "تعليقات", "Comments": "تعليقات",
"Comments are disabled": "التعليقات معطلة", "Comments are disabled": "التعليقات معطلة",
"Contact": "اتصل", "Contact": "اتصل",
"DELETE MEDIA": "حذف الوسائط", "DELETE MEDIA": "حذف الوسائط",
"DOWNLOAD": "تحميل", "DOWNLOAD": "تحميل",
"Drag and drop files": "سحب وإفلات الملفات",
"EDIT MEDIA": "تعديل الوسائط", "EDIT MEDIA": "تعديل الوسائط",
"EDIT PROFILE": "تعديل الملف الشخصي", "EDIT PROFILE": "تعديل الملف الشخصي",
"EDIT SUBTITLE": "تعديل الترجمة", "EDIT SUBTITLE": "تعديل الترجمة",
@ -42,8 +45,10 @@ translation_strings = {
"PLAYLISTS": "قوائم التشغيل", "PLAYLISTS": "قوائم التشغيل",
"Playlists": "قوائم التشغيل", "Playlists": "قوائم التشغيل",
"Powered by": "مدعوم من", "Powered by": "مدعوم من",
"Publish": "نشر",
"Published on": "نشر في", "Published on": "نشر في",
"Recommended": "موصى به", "Recommended": "موصى به",
"Record Screen": "تسجيل الشاشة",
"Register": "تسجيل", "Register": "تسجيل",
"SAVE": "حفظ", "SAVE": "حفظ",
"SEARCH": "بحث", "SEARCH": "بحث",
@ -54,9 +59,14 @@ translation_strings = {
"Select": "اختر", "Select": "اختر",
"Sign in": "تسجيل الدخول", "Sign in": "تسجيل الدخول",
"Sign out": "تسجيل الخروج", "Sign out": "تسجيل الخروج",
"Start Recording": "بدء التسجيل",
"Stop Recording": "إيقاف التسجيل",
"Subtitle was added": "تمت إضافة الترجمة", "Subtitle was added": "تمت إضافة الترجمة",
"Subtitles": "ترجمات",
"Tags": "العلامات", "Tags": "العلامات",
"Terms": "الشروط", "Terms": "الشروط",
"This works in Chrome, Safari and Edge browsers.": "هذا يعمل في متصفحات Chrome و Safari و Edge.",
"Trim": "قص",
"UPLOAD": "رفع", "UPLOAD": "رفع",
"Up next": "التالي", "Up next": "التالي",
"Upload": "رفع", "Upload": "رفع",
@ -64,10 +74,12 @@ translation_strings = {
"Uploads": "التحميلات", "Uploads": "التحميلات",
"VIEW ALL": "عرض الكل", "VIEW ALL": "عرض الكل",
"View all": "عرض الكل", "View all": "عرض الكل",
"View media": "عرض الوسائط",
"comment": "تعليق", "comment": "تعليق",
"is a modern, fully featured open source video and media CMS. It is developed to meet the needs of modern web platforms for viewing and sharing media": "هو نظام إدارة محتوى فيديو ووسائط مفتوح المصدر وحديث ومتكامل. تم تطويره لتلبية احتياجات المنصات الويب الحديثة لمشاهدة ومشاركة الوسائط", "is a modern, fully featured open source video and media CMS. It is developed to meet the needs of modern web platforms for viewing and sharing media": "هو نظام إدارة محتوى فيديو ووسائط مفتوح المصدر وحديث ومتكامل. تم تطويره لتلبية احتياجات المنصات الويب الحديثة لمشاهدة ومشاركة الوسائط",
"media in category": "وسائط في الفئة", "media in category": "وسائط في الفئة",
"media in tag": "وسائط في العلامة", "media in tag": "وسائط في العلامة",
"or": "أو",
"view": "عرض", "view": "عرض",
"views": "مشاهدات", "views": "مشاهدات",
"yet": "بعد", "yet": "بعد",

View File

@ -3,17 +3,20 @@ translation_strings = {
"AUTOPLAY": "স্বয়ংক্রিয় প্লে", "AUTOPLAY": "স্বয়ংক্রিয় প্লে",
"About": "সম্পর্কে", "About": "সম্পর্কে",
"Add a ": "যোগ করুন", "Add a ": "যোগ করুন",
"Browse your files": "আপনার ফাইল ব্রাউজ করুন",
"COMMENT": "মন্তব্য", "COMMENT": "মন্তব্য",
"Categories": "বিভাগসমূহ", "Categories": "বিভাগসমূহ",
"Category": "বিভাগ", "Category": "বিভাগ",
"Change Language": "ভাষা পরিবর্তন করুন", "Change Language": "ভাষা পরিবর্তন করুন",
"Change password": "পাসওয়ার্ড পরিবর্তন করুন", "Change password": "পাসওয়ার্ড পরিবর্তন করুন",
"Click 'Start Recording' and select the screen or tab to record. Once recording is finished, click 'Stop Recording,' and the recording will be uploaded.": "'রেকর্ডিং শুরু করুন'-এ ক্লিক করুন এবং রেকর্ড করার জন্য স্ক্রিন বা ট্যাব নির্বাচন করুন। রেকর্ডিং শেষ হলে, 'রেকর্ডিং বন্ধ করুন'-এ ক্লিক করুন এবং রেকর্ডিং আপলোড হয়ে যাবে।",
"Comment": "মন্তব্য", "Comment": "মন্তব্য",
"Comments": "মন্তব্যসমূহ", "Comments": "মন্তব্যসমূহ",
"Comments are disabled": "মন্তব্য নিষ্ক্রিয় করা হয়েছে", "Comments are disabled": "মন্তব্য নিষ্ক্রিয় করা হয়েছে",
"Contact": "যোগাযোগ", "Contact": "যোগাযোগ",
"DELETE MEDIA": "মিডিয়া মুছুন", "DELETE MEDIA": "মিডিয়া মুছুন",
"DOWNLOAD": "ডাউনলোড", "DOWNLOAD": "ডাউনলোড",
"Drag and drop files": "ফাইল টেনে আনুন",
"EDIT MEDIA": "মিডিয়া সম্পাদনা করুন", "EDIT MEDIA": "মিডিয়া সম্পাদনা করুন",
"EDIT PROFILE": "প্রোফাইল সম্পাদনা করুন", "EDIT PROFILE": "প্রোফাইল সম্পাদনা করুন",
"EDIT SUBTITLE": "সাবটাইটেল সম্পাদনা করুন", "EDIT SUBTITLE": "সাবটাইটেল সম্পাদনা করুন",
@ -42,8 +45,10 @@ translation_strings = {
"PLAYLISTS": "প্লেলিস্ট", "PLAYLISTS": "প্লেলিস্ট",
"Playlists": "প্লেলিস্ট", "Playlists": "প্লেলিস্ট",
"Powered by": "দ্বারা চালিত", "Powered by": "দ্বারা চালিত",
"Publish": "প্রকাশ করুন",
"Published on": "প্রকাশিত", "Published on": "প্রকাশিত",
"Recommended": "প্রস্তাবিত", "Recommended": "প্রস্তাবিত",
"Record Screen": "স্ক্রিন রেকর্ড করুন",
"Register": "নিবন্ধন করুন", "Register": "নিবন্ধন করুন",
"SAVE": "সংরক্ষণ করুন", "SAVE": "সংরক্ষণ করুন",
"SEARCH": "অনুসন্ধান", "SEARCH": "অনুসন্ধান",
@ -54,9 +59,14 @@ translation_strings = {
"Select": "নির্বাচন করুন", "Select": "নির্বাচন করুন",
"Sign in": "সাইন ইন করুন", "Sign in": "সাইন ইন করুন",
"Sign out": "সাইন আউট করুন", "Sign out": "সাইন আউট করুন",
"Start Recording": "রেকর্ডিং শুরু করুন",
"Stop Recording": "রেকর্ডিং বন্ধ করুন",
"Subtitle was added": "সাবটাইটেল যোগ করা হয়েছে", "Subtitle was added": "সাবটাইটেল যোগ করা হয়েছে",
"Subtitles": "সাবটাইটেল",
"Tags": "ট্যাগ", "Tags": "ট্যাগ",
"Terms": "শর্তাবলী", "Terms": "শর্তাবলী",
"This works in Chrome, Safari and Edge browsers.": "এটি ক্রোম, সাফারি এবং এজ ব্রাউজারে কাজ করে।",
"Trim": "ছাঁটাই",
"UPLOAD": "আপলোড করুন", "UPLOAD": "আপলোড করুন",
"Up next": "পরবর্তী", "Up next": "পরবর্তী",
"Upload": "আপলোড করুন", "Upload": "আপলোড করুন",
@ -64,10 +74,12 @@ translation_strings = {
"Uploads": "আপলোডসমূহ", "Uploads": "আপলোডসমূহ",
"VIEW ALL": "সব দেখুন", "VIEW ALL": "সব দেখুন",
"View all": "সব দেখুন", "View all": "সব দেখুন",
"View media": "মিডিয়া দেখুন",
"comment": "মন্তব্য", "comment": "মন্তব্য",
"is a modern, fully featured open source video and media CMS. It is developed to meet the needs of modern web platforms for viewing and sharing media": "একটি আধুনিক, সম্পূর্ণ বৈশিষ্ট্যযুক্ত ওপেন সোর্স ভিডিও এবং মিডিয়া CMS। এটি আধুনিক ওয়েব প্ল্যাটফর্মের জন্য মিডিয়া দেখার এবং শেয়ার করার প্রয়োজন মেটাতে তৈরি করা হয়েছে", "is a modern, fully featured open source video and media CMS. It is developed to meet the needs of modern web platforms for viewing and sharing media": "একটি আধুনিক, সম্পূর্ণ বৈশিষ্ট্যযুক্ত ওপেন সোর্স ভিডিও এবং মিডিয়া CMS। এটি আধুনিক ওয়েব প্ল্যাটফর্মের জন্য মিডিয়া দেখার এবং শেয়ার করার প্রয়োজন মেটাতে তৈরি করা হয়েছে",
"media in category": "বিভাগে মিডিয়া", "media in category": "বিভাগে মিডিয়া",
"media in tag": "ট্যাগে মিডিয়া", "media in tag": "ট্যাগে মিডিয়া",
"or": "অথবা",
"view": "দেখুন", "view": "দেখুন",
"views": "দেখা হয়েছে", "views": "দেখা হয়েছে",
"yet": "এখনও", "yet": "এখনও",

View File

@ -3,17 +3,20 @@ translation_strings = {
"AUTOPLAY": "Automatisk afspilning", "AUTOPLAY": "Automatisk afspilning",
"About": "Om", "About": "Om",
"Add a ": "Tilføj en ", "Add a ": "Tilføj en ",
"Browse your files": "Gennemse dine filer",
"COMMENT": "KOMMENTAR", "COMMENT": "KOMMENTAR",
"Categories": "Kategorier", "Categories": "Kategorier",
"Category": "Kategori", "Category": "Kategori",
"Change Language": "Skift sprog", "Change Language": "Skift sprog",
"Change password": "Skift adgangskode", "Change password": "Skift adgangskode",
"Click 'Start Recording' and select the screen or tab to record. Once recording is finished, click 'Stop Recording,' and the recording will be uploaded.": "Klik på 'Start optagelse' og vælg den skærm eller fane, du vil optage. Når optagelsen er færdig, skal du klikke på 'Stop optagelse', og optagelsen vil blive uploadet.",
"Comment": "Kommentar", "Comment": "Kommentar",
"Comments": "Kommentarer", "Comments": "Kommentarer",
"Comments are disabled": "Kommentarer er slået fra", "Comments are disabled": "Kommentarer er slået fra",
"Contact": "Kontakt", "Contact": "Kontakt",
"DELETE MEDIA": "SLET MEDIE", "DELETE MEDIA": "SLET MEDIE",
"DOWNLOAD": "HENT", "DOWNLOAD": "HENT",
"Drag and drop files": "Træk og slip filer",
"EDIT MEDIA": "REDIGER MEDIE", "EDIT MEDIA": "REDIGER MEDIE",
"EDIT PROFILE": "REDIGER PROFIL", "EDIT PROFILE": "REDIGER PROFIL",
"EDIT SUBTITLE": "REDIGER UNDERTEKSTER", "EDIT SUBTITLE": "REDIGER UNDERTEKSTER",
@ -42,8 +45,10 @@ translation_strings = {
"PLAYLISTS": "PLAYLISTER", "PLAYLISTS": "PLAYLISTER",
"Playlists": "Playlister", "Playlists": "Playlister",
"Powered by": "Drevet af", "Powered by": "Drevet af",
"Publish": "Udgiv",
"Published on": "Udgivet på", "Published on": "Udgivet på",
"Recommended": "Anbefalet", "Recommended": "Anbefalet",
"Record Screen": "Optag skærm",
"Register": "Registrer", "Register": "Registrer",
"SAVE": "GEM", "SAVE": "GEM",
"SEARCH": "SØG", "SEARCH": "SØG",
@ -54,9 +59,14 @@ translation_strings = {
"Select": "Vælg", "Select": "Vælg",
"Sign in": "Log ind", "Sign in": "Log ind",
"Sign out": "Log ud", "Sign out": "Log ud",
"Start Recording": "Start optagelse",
"Stop Recording": "Stop optagelse",
"Subtitle was added": "Undertekster tilføjet", "Subtitle was added": "Undertekster tilføjet",
"Subtitles": "Undertekster",
"Tags": "Tags", "Tags": "Tags",
"Terms": "Vilkår", "Terms": "Vilkår",
"This works in Chrome, Safari and Edge browsers.": "Dette virker i Chrome, Safari og Edge browsere.",
"Trim": "Beskær",
"UPLOAD": "UPLOAD", "UPLOAD": "UPLOAD",
"Up next": "Næste", "Up next": "Næste",
"Upload": "Upload", "Upload": "Upload",
@ -64,10 +74,12 @@ translation_strings = {
"Uploads": "Uploads", "Uploads": "Uploads",
"VIEW ALL": "SE ALLE", "VIEW ALL": "SE ALLE",
"View all": "Se alle", "View all": "Se alle",
"View media": "Se medie",
"comment": "kommentar", "comment": "kommentar",
"is a modern, fully featured open source video and media CMS. It is developed to meet the needs of modern web platforms for viewing and sharing media": "er et moderne, fuldt udstyret open source video og medie CMS. Det er udviklet til at imødekomme behovene for moderne webplatforme til visning og deling af medier.", "is a modern, fully featured open source video and media CMS. It is developed to meet the needs of modern web platforms for viewing and sharing media": "er et moderne, fuldt udstyret open source video og medie CMS. Det er udviklet til at imødekomme behovene for moderne webplatforme til visning og deling af medier.",
"media in category": "medier i kategori", "media in category": "medier i kategori",
"media in tag": "medier i tag", "media in tag": "medier i tag",
"or": "eller",
"view": "visning", "view": "visning",
"views": "visninger", "views": "visninger",
"yet": "endnu", "yet": "endnu",

View File

@ -3,17 +3,20 @@ translation_strings = {
"AUTOPLAY": "Automatische Wiedergabe", "AUTOPLAY": "Automatische Wiedergabe",
"About": "Über", "About": "Über",
"Add a ": "Hinzufügen eines ", "Add a ": "Hinzufügen eines ",
"Browse your files": "Durchsuchen Sie Ihre Dateien",
"COMMENT": "KOMMENTAR", "COMMENT": "KOMMENTAR",
"Categories": "Kategorien", "Categories": "Kategorien",
"Category": "Kategorie", "Category": "Kategorie",
"Change Language": "Sprache ändern", "Change Language": "Sprache ändern",
"Change password": "Passwort ändern", "Change password": "Passwort ändern",
"Click 'Start Recording' and select the screen or tab to record. Once recording is finished, click 'Stop Recording,' and the recording will be uploaded.": "Klicken Sie auf 'Aufnahme starten' und wählen Sie den Bildschirm oder Tab aus, den Sie aufnehmen möchten. Sobald die Aufnahme beendet ist, klicken Sie auf 'Aufnahme beenden', und die Aufnahme wird hochgeladen.",
"Comment": "Kommentar", "Comment": "Kommentar",
"Comments": "Kommentare", "Comments": "Kommentare",
"Comments are disabled": "Kommentare sind deaktiviert", "Comments are disabled": "Kommentare sind deaktiviert",
"Contact": "Kontakt", "Contact": "Kontakt",
"DELETE MEDIA": "MEDIEN LÖSCHEN", "DELETE MEDIA": "MEDIEN LÖSCHEN",
"DOWNLOAD": "HERUNTERLADEN", "DOWNLOAD": "HERUNTERLADEN",
"Drag and drop files": "Dateien per Drag & Drop verschieben",
"EDIT MEDIA": "MEDIEN BEARBEITEN", "EDIT MEDIA": "MEDIEN BEARBEITEN",
"EDIT PROFILE": "PROFIL BEARBEITEN", "EDIT PROFILE": "PROFIL BEARBEITEN",
"EDIT SUBTITLE": "UNTERTITEL BEARBEITEN", "EDIT SUBTITLE": "UNTERTITEL BEARBEITEN",
@ -42,8 +45,10 @@ translation_strings = {
"PLAYLISTS": "PLAYLISTS", "PLAYLISTS": "PLAYLISTS",
"Playlists": "Playlists", "Playlists": "Playlists",
"Powered by": "Bereitgestellt von", "Powered by": "Bereitgestellt von",
"Publish": "Veröffentlichen",
"Published on": "Veröffentlicht am", "Published on": "Veröffentlicht am",
"Recommended": "Empfohlen", "Recommended": "Empfohlen",
"Record Screen": "Bildschirm aufnehmen",
"Register": "Registrieren", "Register": "Registrieren",
"SAVE": "SPEICHERN", "SAVE": "SPEICHERN",
"SEARCH": "SUCHE", "SEARCH": "SUCHE",
@ -54,9 +59,14 @@ translation_strings = {
"Select": "Auswählen", "Select": "Auswählen",
"Sign in": "Anmelden", "Sign in": "Anmelden",
"Sign out": "Abmelden", "Sign out": "Abmelden",
"Start Recording": "Aufnahme starten",
"Stop Recording": "Aufnahme stoppen",
"Subtitle was added": "Untertitel wurde hinzugefügt", "Subtitle was added": "Untertitel wurde hinzugefügt",
"Subtitles": "Untertitel",
"Tags": "Tags", "Tags": "Tags",
"Terms": "Bedingungen", "Terms": "Bedingungen",
"This works in Chrome, Safari and Edge browsers.": "Dies funktioniert in den Browsern Chrome, Safari und Edge.",
"Trim": "Trimmen",
"UPLOAD": "HOCHLADEN", "UPLOAD": "HOCHLADEN",
"Up next": "Als nächstes", "Up next": "Als nächstes",
"Upload": "Hochladen", "Upload": "Hochladen",
@ -64,10 +74,12 @@ translation_strings = {
"Uploads": "Uploads", "Uploads": "Uploads",
"VIEW ALL": "ALLE ANZEIGEN", "VIEW ALL": "ALLE ANZEIGEN",
"View all": "Alle anzeigen", "View all": "Alle anzeigen",
"View media": "Medien anzeigen",
"comment": "Kommentar", "comment": "Kommentar",
"is a modern, fully featured open source video and media CMS. It is developed to meet the needs of modern web platforms for viewing and sharing media": "ist ein modernes, voll ausgestattetes Open-Source-Video- und Medien-CMS. Es wurde entwickelt, um den Anforderungen moderner Webplattformen für das Ansehen und Teilen von Medien gerecht zu werden", "is a modern, fully featured open source video and media CMS. It is developed to meet the needs of modern web platforms for viewing and sharing media": "ist ein modernes, voll ausgestattetes Open-Source-Video- und Medien-CMS. Es wurde entwickelt, um den Anforderungen moderner Webplattformen für das Ansehen und Teilen von Medien gerecht zu werden",
"media in category": "Medien in Kategorie", "media in category": "Medien in Kategorie",
"media in tag": "Medien in Tag", "media in tag": "Medien in Tag",
"or": "oder",
"view": "Ansicht", "view": "Ansicht",
"views": "Ansichten", "views": "Ansichten",
"yet": "noch", "yet": "noch",

View File

@ -3,30 +3,33 @@ translation_strings = {
"AUTOPLAY": "Αυτόματη αναπαραγωγή", "AUTOPLAY": "Αυτόματη αναπαραγωγή",
"About": "Σχετικά", "About": "Σχετικά",
"Add a ": "Προσθέστε ένα ", "Add a ": "Προσθέστε ένα ",
"Browse your files": "Περιήγηση στα αρχεία σας",
"COMMENT": "ΣΧΟΛΙΟ", "COMMENT": "ΣΧΟΛΙΟ",
"Categories": "Κατηγορίες", "Categories": "Κατηγορίες",
"Category": "Κατηγορία", "Category": "Κατηγορία",
"Change Language": "Αλλαγή Γλώσσας", "Change Language": "Αλλαγή Γλώσσας",
"Change password": "Αλλαγή κωδικού", "Change password": "Αλλαγή κωδικού",
"Click 'Start Recording' and select the screen or tab to record. Once recording is finished, click 'Stop Recording,' and the recording will be uploaded.": "Κάντε κλικ στο 'Έναρξη εγγραφής' και επιλέξτε την οθόνη ή την καρτέλα για εγγραφή. Μόλις ολοκληρωθεί η εγγραφή, κάντε κλικ στο 'Διακοπή εγγραφής' και η εγγραφή θα μεταφορτωθεί.",
"Comment": "Σχόλιο", "Comment": "Σχόλιο",
"Comments": "Σχόλια", "Comments": "Σχόλια",
"Comments are disabled": "Τα σχόλια είναι απενεργοποιημένα", "Comments are disabled": "Τα σχόλια είναι απενεργοποιημένα",
"Contact": "Επικοινωνία", "Contact": "Επικοινωνία",
"DELETE MEDIA": "ΔΙΑΓΡΑΦΗ ΑΡΧΕΙΟΥ", "DELETE MEDIA": "ΔΙΑΓΡΑΦΗ ΑΡΧΕΙΟΥ",
"DOWNLOAD": "ΚΑΤΕΒΑΣΜΑ", "DOWNLOAD": "ΚΑΤΕΒΑΣΜΑ",
"Drag and drop files": "Σύρετε και αποθέστε αρχεία",
"EDIT MEDIA": "ΕΠΕΞΕΡΓΑΣΙΑ ΑΡΧΕΙΟΥ", "EDIT MEDIA": "ΕΠΕΞΕΡΓΑΣΙΑ ΑΡΧΕΙΟΥ",
"EDIT PROFILE": "ΕΠΕΞΕΡΓΑΣΙΑ ΠΡΟΦΙΛ", "EDIT PROFILE": "ΕΠΕΞΕΡΓΑΣΙΑ ΠΡΟΦΙΛ",
"EDIT SUBTITLE": "ΕΠΕΞΕΡΓΑΣΙΑ ΥΠΟΤΙΤΛΩΝ", "EDIT SUBTITLE": "ΕΠΕΞΕΡΓΑΣΙΑ ΥΠΟΤΙΤΛΩΝ",
"Edit media": "Επεξεργασία αρχείου", "Edit media": "Επεξεργασία αρχείου",
"Edit profile": "Επεξεργασία προφιλ", "Edit profile": "Επεξεργασία προφίλ",
"Edit subtitle": "Επεξεργασία υποτίτλων", "Edit subtitle": "Επεξεργασία υποτίτλων",
"Featured": "Επιλεγμένα", "Featured": "Επιλεγμένα",
"Go": "Πήγαινε", "Go": "Μετάβαση",
"History": "Ιστορικό", "History": "Ιστορικό",
"Home": "Αρχική", "Home": "Αρχική",
"Language": "Γλώσσα", "Language": "Γλώσσα",
"Latest": "Πρόσφατα", "Latest": "Πρόσφατα",
"Liked media": "Αγαπημένα", "Liked media": "Αγαπημένα αρχεία",
"Manage comments": "Διαχείριση σχολίων", "Manage comments": "Διαχείριση σχολίων",
"Manage media": "Διαχείριση αρχείων", "Manage media": "Διαχείριση αρχείων",
"Manage users": "Διαχείριση χρηστών", "Manage users": "Διαχείριση χρηστών",
@ -42,8 +45,10 @@ translation_strings = {
"PLAYLISTS": "ΛΙΣΤΕΣ", "PLAYLISTS": "ΛΙΣΤΕΣ",
"Playlists": "Λίστες", "Playlists": "Λίστες",
"Powered by": "Υποστηρίζεται από το", "Powered by": "Υποστηρίζεται από το",
"Publish": "Δημοσίευση",
"Published on": "Δημοσιεύτηκε στις", "Published on": "Δημοσιεύτηκε στις",
"Recommended": "Προτεινόμενα", "Recommended": "Προτεινόμενα",
"Record Screen": "Καταγραφή οθόνης",
"Register": "Εγγραφή", "Register": "Εγγραφή",
"SAVE": "ΑΠΟΘΗΚΕΥΣΗ", "SAVE": "ΑΠΟΘΗΚΕΥΣΗ",
"SEARCH": "ΑΝΑΖΗΤΗΣΗ", "SEARCH": "ΑΝΑΖΗΤΗΣΗ",
@ -54,20 +59,27 @@ translation_strings = {
"Select": "Επιλογή", "Select": "Επιλογή",
"Sign in": "Σύνδεση", "Sign in": "Σύνδεση",
"Sign out": "Αποσύνδεση", "Sign out": "Αποσύνδεση",
"Start Recording": "Έναρξη εγγραφής",
"Stop Recording": "Διακοπή εγγραφής",
"Subtitle was added": "Οι υπότιτλοι προστέθηκαν", "Subtitle was added": "Οι υπότιτλοι προστέθηκαν",
"Subtitles": "Υπότιτλοι",
"Tags": "Ετικέτες", "Tags": "Ετικέτες",
"Terms": "Όροι", "Terms": "Όροι",
"This works in Chrome, Safari and Edge browsers.": "Αυτό λειτουργεί σε προγράμματα περιήγησης Chrome, Safari και Edge.",
"Trim": "Περικοπή",
"UPLOAD": "ΑΝΕΒΑΣΜΑ", "UPLOAD": "ΑΝΕΒΑΣΜΑ",
"Up next": "Επόμενο", "Up next": "Επόμενο",
"Upload": "Ανέβασμα αρχείου", "Upload": "Ανέβασμα",
"Upload media": "Ανέβασμα αρχείων", "Upload media": "Ανέβασμα αρχείων",
"Uploads": "Ανεβάσματα", "Uploads": "Ανεβάσματα",
"VIEW ALL": "ΔΕΣ ΤΑ ΟΛΑ", "VIEW ALL": "ΔΕΣ ΤΑ ΟΛΑ",
"View all": "Δές τα όλα", "View all": "Δες τα όλα",
"View media": "Προβολή αρχείου",
"comment": "σχόλιο", "comment": "σχόλιο",
"is a modern, fully featured open source video and media CMS. It is developed to meet the needs of modern web platforms for viewing and sharing media": "είναι ένα σύγχρονο, πλήρως λειτουργικό ανοιχτού κώδικα CMS βίντεο και πολυμέσων. Αναπτύχθηκε για να καλύψει τις ανάγκες των σύγχρονων πλατφορμών ιστού για την προβολή και την κοινοποίηση πολυμέσων", "is a modern, fully featured open source video and media CMS. It is developed to meet the needs of modern web platforms for viewing and sharing media": "είναι ένα σύγχρονο, πλήρως λειτουργικό ανοιχτού κώδικα CMS βίντεο και πολυμέσων. Αναπτύχθηκε για να καλύψει τις ανάγκες των σύγχρονων πλατφορμών ιστού για την προβολή και την κοινοποίηση πολυμέσων",
"media in category": "αρχεία στην κατηγορία", "media in category": "αρχεία στην κατηγορία",
"media in tag": "αρχεία με ετικέτα", "media in tag": "αρχεία με ετικέτα",
"or": "ή",
"view": "προβολή", "view": "προβολή",
"views": "προβολές", "views": "προβολές",
"yet": "ακόμα", "yet": "ακόμα",
@ -82,10 +94,10 @@ replacement_strings = {
"Jul": "Ιουλ", "Jul": "Ιουλ",
"Jun": "Ιουν", "Jun": "Ιουν",
"Mar": "Μαρ", "Mar": "Μαρ",
"May": "Μαϊ", "May": "Μάι",
"Nov": "Νοε", "Nov": "Νοε",
"Oct": "Οκτ", "Oct": "Οκτ",
"Sep": "Σεπτ", "Sep": "Σεπ",
"day ago": "μέρα πριν", "day ago": "μέρα πριν",
"days ago": "μέρες πριν", "days ago": "μέρες πριν",
"hour ago": "ώρα πριν", "hour ago": "ώρα πριν",

View File

@ -2,17 +2,20 @@ translation_strings = {
"ABOUT": "", "ABOUT": "",
"AUTOPLAY": "", "AUTOPLAY": "",
"Add a ": "", "Add a ": "",
"Browse your files": "",
"COMMENT": "", "COMMENT": "",
"Categories": "", "Categories": "",
"Category": "", "Category": "",
"Change Language": "", "Change Language": "",
"Change password": "", "Change password": "",
"About": "", "About": "",
"Click 'Start Recording' and select the screen or tab to record. Once recording is finished, click 'Stop Recording,' and the recording will be uploaded.": "",
"Comment": "", "Comment": "",
"Comments": "", "Comments": "",
"Comments are disabled": "", "Comments are disabled": "",
"Contact": "", "Contact": "",
"DELETE MEDIA": "", "DELETE MEDIA": "",
"Drag and drop files": "",
"DOWNLOAD": "", "DOWNLOAD": "",
"EDIT MEDIA": "", "EDIT MEDIA": "",
"EDIT PROFILE": "", "EDIT PROFILE": "",
@ -42,21 +45,28 @@ translation_strings = {
"PLAYLISTS": "", "PLAYLISTS": "",
"Playlists": "", "Playlists": "",
"Powered by": "", "Powered by": "",
"Publish": "",
"Published on": "", "Published on": "",
"Recommended": "", "Recommended": "",
"Record Screen": "",
"Register": "", "Register": "",
"SAVE": "", "SAVE": "",
"SEARCH": "", "SEARCH": "",
"SHARE": "", "SHARE": "",
"SHOW MORE": "", "SHOW MORE": "",
"SUBMIT": "", "SUBMIT": "",
"Subtitles": "",
"Search": "", "Search": "",
"Select": "", "Select": "",
"Sign in": "", "Sign in": "",
"Sign out": "", "Sign out": "",
"Start Recording": "",
"Stop Recording": "",
"Subtitle was added": "", "Subtitle was added": "",
"Tags": "", "Tags": "",
"Terms": "", "Terms": "",
"This works in Chrome, Safari and Edge browsers.": "",
"Trim": "",
"UPLOAD": "", "UPLOAD": "",
"Up next": "", "Up next": "",
"Upload": "", "Upload": "",
@ -64,10 +74,12 @@ translation_strings = {
"Uploads": "", "Uploads": "",
"VIEW ALL": "", "VIEW ALL": "",
"View all": "", "View all": "",
"View media": "",
"comment": "", "comment": "",
"is a modern, fully featured open source video and media CMS. It is developed to meet the needs of modern web platforms for viewing and sharing media": "", "is a modern, fully featured open source video and media CMS. It is developed to meet the needs of modern web platforms for viewing and sharing media": "",
"media in category": "", "media in category": "",
"media in tag": "", "media in tag": "",
"or": "",
"view": "", "view": "",
"views": "", "views": "",
"yet": "", "yet": "",

View File

@ -3,17 +3,20 @@ translation_strings = {
"AUTOPLAY": "Reproducción automática", "AUTOPLAY": "Reproducción automática",
"About": "Acerca de", "About": "Acerca de",
"Add a ": "Agregar un ", "Add a ": "Agregar un ",
"Browse your files": "Explorar sus archivos",
"COMMENT": "COMENTARIO", "COMMENT": "COMENTARIO",
"Categories": "Categorías", "Categories": "Categorías",
"Category": "Categoría", "Category": "Categoría",
"Change Language": "Cambiar idioma", "Change Language": "Cambiar idioma",
"Change password": "Cambiar contraseña", "Change password": "Cambiar contraseña",
"Click 'Start Recording' and select the screen or tab to record. Once recording is finished, click 'Stop Recording,' and the recording will be uploaded.": "Haga clic en 'Iniciar grabación' y seleccione la pantalla o pestaña para grabar. Una vez finalizada la grabación, haga clic en 'Detener grabación' y la grabación se subirá.",
"Comment": "Comentario", "Comment": "Comentario",
"Comments": "Comentarios", "Comments": "Comentarios",
"Comments are disabled": "Los comentarios están deshabilitados", "Comments are disabled": "Los comentarios están deshabilitados",
"Contact": "Contacto", "Contact": "Contacto",
"DELETE MEDIA": "ELIMINAR MEDIOS", "DELETE MEDIA": "ELIMINAR MEDIOS",
"DOWNLOAD": "DESCARGAR", "DOWNLOAD": "DESCARGAR",
"Drag and drop files": "Arrastre y suelte archivos",
"EDIT MEDIA": "EDITAR MEDIOS", "EDIT MEDIA": "EDITAR MEDIOS",
"EDIT PROFILE": "EDITAR PERFIL", "EDIT PROFILE": "EDITAR PERFIL",
"EDIT SUBTITLE": "EDITAR SUBTÍTULO", "EDIT SUBTITLE": "EDITAR SUBTÍTULO",
@ -42,8 +45,10 @@ translation_strings = {
"PLAYLISTS": "LISTAS DE REPRODUCCIÓN", "PLAYLISTS": "LISTAS DE REPRODUCCIÓN",
"Playlists": "Listas de reproducción", "Playlists": "Listas de reproducción",
"Powered by": "Desarrollado por", "Powered by": "Desarrollado por",
"Publish": "Publicar",
"Published on": "Publicado en", "Published on": "Publicado en",
"Recommended": "Recomendado", "Recommended": "Recomendado",
"Record Screen": "Grabar pantalla",
"Register": "Registrarse", "Register": "Registrarse",
"SAVE": "GUARDAR", "SAVE": "GUARDAR",
"SEARCH": "BUSCAR", "SEARCH": "BUSCAR",
@ -54,9 +59,14 @@ translation_strings = {
"Select": "Seleccionar", "Select": "Seleccionar",
"Sign in": "Iniciar sesión", "Sign in": "Iniciar sesión",
"Sign out": "Cerrar sesión", "Sign out": "Cerrar sesión",
"Start Recording": "Iniciar grabación",
"Stop Recording": "Detener grabación",
"Subtitle was added": "El subtítulo fue agregado", "Subtitle was added": "El subtítulo fue agregado",
"Subtitles": "Subtítulos",
"Tags": "Etiquetas", "Tags": "Etiquetas",
"Terms": "Términos", "Terms": "Términos",
"This works in Chrome, Safari and Edge browsers.": "Esto funciona en los navegadores Chrome, Safari y Edge.",
"Trim": "Recortar",
"UPLOAD": "SUBIR", "UPLOAD": "SUBIR",
"Up next": "A continuación", "Up next": "A continuación",
"Upload": "Subir", "Upload": "Subir",
@ -64,10 +74,12 @@ translation_strings = {
"Uploads": "Subidas", "Uploads": "Subidas",
"VIEW ALL": "VER TODO", "VIEW ALL": "VER TODO",
"View all": "Ver todo", "View all": "Ver todo",
"View media": "Ver medios",
"comment": "comentario", "comment": "comentario",
"is a modern, fully featured open source video and media CMS. It is developed to meet the needs of modern web platforms for viewing and sharing media": "es un CMS de video y medios de código abierto, moderno y completamente equipado. Está desarrollado para satisfacer las necesidades de las plataformas web modernas para ver y compartir medios", "is a modern, fully featured open source video and media CMS. It is developed to meet the needs of modern web platforms for viewing and sharing media": "es un CMS de video y medios de código abierto, moderno y completamente equipado. Está desarrollado para satisfacer las necesidades de las plataformas web modernas para ver y compartir medios",
"media in category": "medios en la categoría", "media in category": "medios en la categoría",
"media in tag": "medios en la etiqueta", "media in tag": "medios en la etiqueta",
"or": "o",
"view": "vista", "view": "vista",
"views": "vistas", "views": "vistas",
"yet": "aún", "yet": "aún",

View File

@ -3,18 +3,21 @@ translation_strings = {
"AUTOPLAY": "Lecture automatique", "AUTOPLAY": "Lecture automatique",
"About": "À propos", "About": "À propos",
"Add a": "Ajouter un", "Add a": "Ajouter un",
"Add a ": "", "Add a ": "Ajouter un ",
"Browse your files": "Parcourir vos fichiers",
"COMMENT": "COMMENTAIRE", "COMMENT": "COMMENTAIRE",
"Categories": "Catégories", "Categories": "Catégories",
"Category": "Catégorie", "Category": "Catégorie",
"Change Language": "Changer de langue", "Change Language": "Changer de langue",
"Change password": "Changer le mot de passe", "Change password": "Changer le mot de passe",
"Click 'Start Recording' and select the screen or tab to record. Once recording is finished, click 'Stop Recording,' and the recording will be uploaded.": "Cliquez sur 'Démarrer l'enregistrement' et sélectionnez l'écran ou l'onglet à enregistrer. Une fois l'enregistrement terminé, cliquez sur 'Arrêter l'enregistrement', et l'enregistrement sera téléversé.",
"Comment": "Commentaire", "Comment": "Commentaire",
"Comments": "Commentaires", "Comments": "Commentaires",
"Comments are disabled": "Les commentaires sont désactivés", "Comments are disabled": "Les commentaires sont désactivés",
"Contact": "Contact", "Contact": "Contact",
"DELETE MEDIA": "SUPPRIMER LE MÉDIA", "DELETE MEDIA": "SUPPRIMER LE MÉDIA",
"DOWNLOAD": "TÉLÉCHARGER", "DOWNLOAD": "TÉLÉCHARGER",
"Drag and drop files": "Glisser-déposer des fichiers",
"EDIT MEDIA": "MODIFIER LE MÉDIA", "EDIT MEDIA": "MODIFIER LE MÉDIA",
"EDIT PROFILE": "MODIFIER LE PROFIL", "EDIT PROFILE": "MODIFIER LE PROFIL",
"EDIT SUBTITLE": "MODIFIER LE SOUS-TITRE", "EDIT SUBTITLE": "MODIFIER LE SOUS-TITRE",
@ -43,8 +46,10 @@ translation_strings = {
"PLAYLISTS": "PLAYLISTS", "PLAYLISTS": "PLAYLISTS",
"Playlists": "Playlists", "Playlists": "Playlists",
"Powered by": "Propulsé par", "Powered by": "Propulsé par",
"Publish": "Publier",
"Published on": "Publié le", "Published on": "Publié le",
"Recommended": "Recommandé", "Recommended": "Recommandé",
"Record Screen": "Enregistrer l'écran",
"Register": "S'inscrire", "Register": "S'inscrire",
"SAVE": "ENREGISTRER", "SAVE": "ENREGISTRER",
"SEARCH": "RECHERCHER", "SEARCH": "RECHERCHER",
@ -55,9 +60,14 @@ translation_strings = {
"Select": "Sélectionner", "Select": "Sélectionner",
"Sign in": "Se connecter", "Sign in": "Se connecter",
"Sign out": "Se déconnecter", "Sign out": "Se déconnecter",
"Start Recording": "Commencer l'enregistrement",
"Stop Recording": "Arrêter l'enregistrement",
"Subtitle was added": "Le sous-titre a été ajouté", "Subtitle was added": "Le sous-titre a été ajouté",
"Subtitles": "Sous-titres",
"Tags": "Tags", "Tags": "Tags",
"Terms": "Conditions", "Terms": "Conditions",
"This works in Chrome, Safari and Edge browsers.": "Cela fonctionne dans les navigateurs Chrome, Safari et Edge.",
"Trim": "Couper",
"UPLOAD": "TÉLÉCHARGER", "UPLOAD": "TÉLÉCHARGER",
"Up next": "À suivre", "Up next": "À suivre",
"Upload": "Télécharger", "Upload": "Télécharger",
@ -65,10 +75,12 @@ translation_strings = {
"Uploads": "Téléchargements", "Uploads": "Téléchargements",
"VIEW ALL": "VOIR TOUT", "VIEW ALL": "VOIR TOUT",
"View all": "Voir tout", "View all": "Voir tout",
"View media": "Voir le média",
"comment": "commentaire", "comment": "commentaire",
"is a modern, fully featured open source video and media CMS. It is developed to meet the needs of modern web platforms for viewing and sharing media": "est un CMS vidéo et média open source moderne et complet. Il est développé pour répondre aux besoins des plateformes web modernes pour la visualisation et le partage de médias", "is a modern, fully featured open source video and media CMS. It is developed to meet the needs of modern web platforms for viewing and sharing media": "est un CMS vidéo et média open source moderne et complet. Il est développé pour répondre aux besoins des plateformes web modernes pour la visualisation et le partage de médias",
"media in category": "média dans la catégorie", "media in category": "média dans la catégorie",
"media in tag": "média dans le tag", "media in tag": "média dans le tag",
"or": "ou",
"view": "vue", "view": "vue",
"views": "vues", "views": "vues",
"yet": "encore", "yet": "encore",

View File

@ -1,104 +1,116 @@
translation_strings = { translation_strings = {
'ABOUT': 'על אודות', "ABOUT": "על אודות",
'AUTOPLAY': 'ניגון אוטומטי', "AUTOPLAY": "ניגון אוטומטי",
'About': 'על אודות', "About": "על אודות",
'Add a ': 'הוסף', "Add a ": "הוסף",
'COMMENT': 'תגובה', "Browse your files": "עיין בקבצים שלך",
'Categories': 'קטגוריות', "COMMENT": "תגובה",
'Category': 'קטגוריה', "Categories": "קטגוריות",
'Change Language': 'שנה שפה', "Category": "קטגוריה",
'Change password': 'שנה סיסמה', "Change Language": "שנה שפה",
'Comment': 'תגובה', "Change password": "שנה סיסמה",
'Comments': 'תגובות', "Click 'Start Recording' and select the screen or tab to record. Once recording is finished, click 'Stop Recording,' and the recording will be uploaded.": "לחץ על 'התחל הקלטה' ובחר את המסך או הכרטיסייה להקלטה. לאחר סיום ההקלטה, לחץ על 'עצור הקלטה', וההקלטה תועלה.",
'Comments are disabled': 'התגובות מושבתות', "Comment": "תגובה",
'Contact': 'צור קשר', "Comments": "תגובות",
'DELETE MEDIA': 'מחק מדיה', "Comments are disabled": "התגובות מושבתות",
'DOWNLOAD': 'הורד', "Contact": "צור קשר",
'EDIT MEDIA': 'ערוך מדיה', "DELETE MEDIA": "מחק מדיה",
'EDIT PROFILE': 'ערוך פרופיל', "DOWNLOAD": "הורד",
'EDIT SUBTITLE': 'ערוך כתוביות', "Drag and drop files": "גרור ושחרר קבצים",
'Edit media': 'ערוך מדיה', "EDIT MEDIA": "ערוך מדיה",
'Edit profile': 'ערוך פרופיל', "EDIT PROFILE": "ערוך פרופיל",
'Edit subtitle': 'ערוך כתוביות', "EDIT SUBTITLE": "ערוך כתוביות",
'Featured': 'מומלצים', "Edit media": "ערוך מדיה",
'Go': 'בצע', # in context of "execution" "Edit profile": "ערוך פרופיל",
'History': 'היסטוריה', "Edit subtitle": "ערוך כתוביות",
'Home': 'דף הבית', "Featured": "מומלצים",
'Language': 'שפה', "Go": "בצע",
'Latest': 'העדכונים האחרונים', "History": "היסטוריה",
'Liked media': 'מדיה שאהבתי', "Home": "דף הבית",
'Manage comments': 'ניהול תגובות', "Language": "שפה",
'Manage media': 'ניהול מדיה', "Latest": "העדכונים האחרונים",
'Manage users': 'ניהול משתמשים', "Liked media": "מדיה שאהבתי",
'Media': 'מדיה', "Manage comments": "ניהול תגובות",
'Media was edited': 'המדיה נערכה', "Manage media": "ניהול מדיה",
'Members': 'משתמשים', "Manage users": "ניהול משתמשים",
'My media': 'המדיה שלי', "Media": "מדיה",
'My playlists': 'הפלייליסטים שלי', "Media was edited": "המדיה נערכה",
'No': 'לא', # in context of "no comments", etc. "Members": "משתמשים",
'No comment yet': 'עדיין אין תגובות', "My media": "המדיה שלי",
'No comments yet': 'עדיין אין תגובות', "My playlists": "הפלייליסטים שלי",
'No results for': 'אין תוצאות עבור', "No": "לא",
'PLAYLISTS': 'פלייליסטים', "No comment yet": "עדיין אין תגובות",
'Playlists': 'פלייליסטים', "No comments yet": "עדיין אין תגובות",
'Powered by': 'מופעל על ידי', "No results for": "אין תוצאות עבור",
'Published on': 'פורסם בתאריך', "PLAYLISTS": "פלייליסטים",
'Recommended': 'מומלץ', "Playlists": "פלייליסטים",
'Register': 'הרשמה', "Powered by": "מופעל על ידי",
'SAVE': 'שמור', "Publish": "פרסם",
'SEARCH': 'חפש', "Published on": "פורסם בתאריך",
'SHARE': 'שתף', "Recommended": "מומלץ",
'SHOW MORE': 'הצג עוד', "Record Screen": "הקלטת מסך",
'SUBMIT': 'שלח', "Register": "הרשמה",
'Search': 'חפש', "SAVE": "שמור",
'Select': 'בחר', "SEARCH": "חפש",
'Sign in': 'התחבר', "SHARE": "שתף",
'Sign out': 'התנתק', "SHOW MORE": "הצג עוד",
'Subtitle was added': 'הכתובית נוספה', "SUBMIT": "שלח",
'Tags': 'תגיות', "Search": "חפש",
'Terms': 'תנאים', "Select": "בחר",
'UPLOAD': 'העלה', "Sign in": "התחבר",
'Up next': 'הבא בתור', "Sign out": "התנתק",
'Upload': 'העלה', "Start Recording": "התחל הקלטה",
'Upload media': 'העלה מדיה', "Stop Recording": "עצור הקלטה",
'Uploads': 'העלאות', "Subtitle was added": "הכתובית נוספה",
'VIEW ALL': 'הצג הכל', "Subtitles": "כתוביות",
'View all': 'הצג הכל', "Tags": "תגיות",
'comment': 'תגובה', "Terms": "תנאים",
'is a modern, fully featured open source video and media CMS. It is developed to meet the needs of modern web platforms for viewing and sharing media': 'מערכת ניהול מדיה ווידאו מודרנית, פתוחה ומלאה בפיצ׳רים. פותחה כדי לענות על הצרכים של פלטפורמות אינטרנט מודרניות לצפייה ושיתוף מדיה.', "This works in Chrome, Safari and Edge browsers.": "זה עובד בדפדפני Chrome, Safari ו-Edge.",
'media in category': 'מדיה בקטגוריה', "Trim": "גזירה",
'media in tag': 'מדיה בתגית', "UPLOAD": "העלה",
'view': 'צפיות', "Up next": "הבא בתור",
'views': 'צפיות', "Upload": "העלה",
'yet': 'עדיין', "Upload media": "העלה מדיה",
"Uploads": "העלאות",
"VIEW ALL": "הצג הכל",
"View all": "הצג הכל",
"View media": "צפה במדיה",
"comment": "תגובה",
"is a modern, fully featured open source video and media CMS. It is developed to meet the needs of modern web platforms for viewing and sharing media": "מערכת ניהול מדיה ווידאו מודרנית, פתוחה ומלאה בפיצ׳רים. פותחה כדי לענות על הצרכים של פלטפורמות אינטרנט מודרניות לצפייה ושיתוף מדיה.",
"media in category": "מדיה בקטגוריה",
"media in tag": "מדיה בתגית",
"or": "או",
"view": "צפיות",
"views": "צפיות",
"yet": "עדיין",
} }
replacement_strings = { replacement_strings = {
'Apr': 'אפריל', "Apr": "אפריל",
'Aug': 'אוגוסט', "Aug": "אוגוסט",
'Dec': 'דצמבר', "Dec": "דצמבר",
'Feb': 'פברואר', "Feb": "פברואר",
'Jan': 'ינואר', "Jan": "ינואר",
'Jul': 'יולי', "Jul": "יולי",
'Jun': 'יוני', "Jun": "יוני",
'Mar': 'מרץ', "Mar": "מרץ",
'May': 'מאי', "May": "מאי",
'Nov': 'נובמבר', "Nov": "נובמבר",
'Oct': 'אוקטובר', "Oct": "אוקטובר",
'Sep': 'ספטמבר', "Sep": "ספטמבר",
'day ago': 'לפני יום', "day ago": "לפני יום",
'days ago': 'לפני ימים', "days ago": "לפני ימים",
'hour ago': 'לפני שעה', "hour ago": "לפני שעה",
'hours ago': 'לפני שעות', "hours ago": "לפני שעות",
'just now': 'הרגע', "just now": "הרגע",
'minute ago': 'לפני דקה', "minute ago": "לפני דקה",
'minutes ago': 'לפני דקות', "minutes ago": "לפני דקות",
'month ago': 'לפני חודש', "month ago": "לפני חודש",
'months ago': 'לפני חודשים', "months ago": "לפני חודשים",
'second ago': 'לפני שנייה', "second ago": "לפני שנייה",
'seconds ago': 'לפני שניות', "seconds ago": "לפני שניות",
'week ago': 'לפני שבוע', "week ago": "לפני שבוע",
'weeks ago': 'לפני שבועות', "weeks ago": "לפני שבועות",
'year ago': 'לפני שנה', "year ago": "לפני שנה",
'years ago': 'לפני שנים', "years ago": "לפני שנים",
} }

View File

@ -3,17 +3,20 @@ translation_strings = {
"AUTOPLAY": "स्वतः चलाएं", "AUTOPLAY": "स्वतः चलाएं",
"About": "के बारे में", "About": "के बारे में",
"Add a ": "जोड़ें", "Add a ": "जोड़ें",
"Browse your files": "अपनी फ़ाइलें ब्राउज़ करें",
"COMMENT": "टिप्पणी", "COMMENT": "टिप्पणी",
"Categories": "श्रेणियाँ", "Categories": "श्रेणियाँ",
"Category": "श्रेणी", "Category": "श्रेणी",
"Change Language": "भाषा बदलें", "Change Language": "भाषा बदलें",
"Change password": "पासवर्ड बदलें", "Change password": "पासवर्ड बदलें",
"Click 'Start Recording' and select the screen or tab to record. Once recording is finished, click 'Stop Recording,' and the recording will be uploaded.": "'रिकॉर्डिंग प्रारंभ करें' पर क्लिक करें और रिकॉर्ड करने के लिए स्क्रीन या टैब का चयन करें। रिकॉर्डिंग समाप्त होने के बाद, 'रिकॉर्डिंग रोकें' पर क्लिक करें, और रिकॉर्डिंग अपलोड हो जाएगी।",
"Comment": "टिप्पणी", "Comment": "टिप्पणी",
"Comments": "टिप्पणियाँ", "Comments": "टिप्पणियाँ",
"Comments are disabled": "टिप्पणियाँ अक्षम हैं", "Comments are disabled": "टिप्पणियाँ अक्षम हैं",
"Contact": "संपर्क करें", "Contact": "संपर्क करें",
"DELETE MEDIA": "मीडिया हटाएं", "DELETE MEDIA": "मीडिया हटाएं",
"DOWNLOAD": "डाउनलोड करें", "DOWNLOAD": "डाउनलोड करें",
"Drag and drop files": "फ़ाइलें खींचें और छोड़ें",
"EDIT MEDIA": "मीडिया संपादित करें", "EDIT MEDIA": "मीडिया संपादित करें",
"EDIT PROFILE": "प्रोफ़ाइल संपादित करें", "EDIT PROFILE": "प्रोफ़ाइल संपादित करें",
"EDIT SUBTITLE": "उपशीर्षक संपादित करें", "EDIT SUBTITLE": "उपशीर्षक संपादित करें",
@ -42,8 +45,10 @@ translation_strings = {
"PLAYLISTS": "प्लेलिस्ट", "PLAYLISTS": "प्लेलिस्ट",
"Playlists": "प्लेलिस्ट", "Playlists": "प्लेलिस्ट",
"Powered by": "द्वारा संचालित", "Powered by": "द्वारा संचालित",
"Publish": "प्रकाशित करें",
"Published on": "पर प्रकाशित", "Published on": "पर प्रकाशित",
"Recommended": "अनुशंसित", "Recommended": "अनुशंसित",
"Record Screen": "स्क्रीन रिकॉर्ड करें",
"Register": "पंजीकरण करें", "Register": "पंजीकरण करें",
"SAVE": "सहेजें", "SAVE": "सहेजें",
"SEARCH": "खोजें", "SEARCH": "खोजें",
@ -54,9 +59,14 @@ translation_strings = {
"Select": "चुनें", "Select": "चुनें",
"Sign in": "साइन इन करें", "Sign in": "साइन इन करें",
"Sign out": "साइन आउट करें", "Sign out": "साइन आउट करें",
"Start Recording": "रिकॉर्डिंग प्रारंभ करें",
"Stop Recording": "रिकॉर्डिंग रोकें",
"Subtitle was added": "उपशीर्षक जोड़ा गया", "Subtitle was added": "उपशीर्षक जोड़ा गया",
"Subtitles": "उपशीर्षक",
"Tags": "टैग", "Tags": "टैग",
"Terms": "शर्तें", "Terms": "शर्तें",
"This works in Chrome, Safari and Edge browsers.": "यह क्रोम, सफारी और एज ब्राउज़र में काम करता है।",
"Trim": "छांटें",
"UPLOAD": "अपलोड करें", "UPLOAD": "अपलोड करें",
"Up next": "अगला", "Up next": "अगला",
"Upload": "अपलोड करें", "Upload": "अपलोड करें",
@ -64,10 +74,12 @@ translation_strings = {
"Uploads": "अपलोड", "Uploads": "अपलोड",
"VIEW ALL": "सभी देखें", "VIEW ALL": "सभी देखें",
"View all": "सभी देखें", "View all": "सभी देखें",
"View media": "मीडिया देखें",
"comment": "टिप्पणी", "comment": "टिप्पणी",
"is a modern, fully featured open source video and media CMS. It is developed to meet the needs of modern web platforms for viewing and sharing media": "एक आधुनिक, पूर्ण विशेषताओं वाला ओपन सोर्स वीडियो और मीडिया CMS है। इसे मीडिया देखने और साझा करने के लिए आधुनिक वेब प्लेटफार्मों की आवश्यकताओं को पूरा करने के लिए विकसित किया गया है", "is a modern, fully featured open source video and media CMS. It is developed to meet the needs of modern web platforms for viewing and sharing media": "एक आधुनिक, पूर्ण विशेषताओं वाला ओपन सोर्स वीडियो और मीडिया CMS है। इसे मीडिया देखने और साझा करने के लिए आधुनिक वेब प्लेटफार्मों की आवश्यकताओं को पूरा करने के लिए विकसित किया गया है",
"media in category": "श्रेणी में मीडिया", "media in category": "श्रेणी में मीडिया",
"media in tag": "टैग में मीडिया", "media in tag": "टैग में मीडिया",
"or": "या",
"view": "देखें", "view": "देखें",
"views": "दृश्य", "views": "दृश्य",
"yet": "अभी तक", "yet": "अभी तक",

View File

@ -3,17 +3,20 @@ translation_strings = {
"AUTOPLAY": "PUTAR OTOMATIS", "AUTOPLAY": "PUTAR OTOMATIS",
"About": "Tentang", "About": "Tentang",
"Add a ": "Tambahkan ", "Add a ": "Tambahkan ",
"Browse your files": "Jelajahi file Anda",
"COMMENT": "KOMENTAR", "COMMENT": "KOMENTAR",
"Categories": "Kategori", "Categories": "Kategori",
"Category": "Kategori", "Category": "Kategori",
"Change Language": "Ganti Bahasa", "Change Language": "Ganti Bahasa",
"Change password": "Ganti kata sandi", "Change password": "Ganti kata sandi",
"Click 'Start Recording' and select the screen or tab to record. Once recording is finished, click 'Stop Recording,' and the recording will be uploaded.": "Klik 'Mulai Merekam' dan pilih layar atau tab untuk merekam. Setelah perekaman selesai, klik 'Hentikan Perekaman,' dan rekaman akan diunggah.",
"Comment": "Komentar", "Comment": "Komentar",
"Comments": "Komentar", "Comments": "Komentar",
"Comments are disabled": "Komentar dinonaktifkan", "Comments are disabled": "Komentar dinonaktifkan",
"Contact": "Kontak", "Contact": "Kontak",
"DELETE MEDIA": "HAPUS MEDIA", "DELETE MEDIA": "HAPUS MEDIA",
"DOWNLOAD": "UNDUH", "DOWNLOAD": "UNDUH",
"Drag and drop files": "Seret dan lepas file",
"EDIT MEDIA": "EDIT MEDIA", "EDIT MEDIA": "EDIT MEDIA",
"EDIT PROFILE": "EDIT PROFIL", "EDIT PROFILE": "EDIT PROFIL",
"EDIT SUBTITLE": "EDIT SUBTITLE", "EDIT SUBTITLE": "EDIT SUBTITLE",
@ -42,8 +45,10 @@ translation_strings = {
"PLAYLISTS": "DAFTAR PUTAR", "PLAYLISTS": "DAFTAR PUTAR",
"Playlists": "Daftar putar", "Playlists": "Daftar putar",
"Powered by": "Didukung oleh", "Powered by": "Didukung oleh",
"Publish": "Terbitkan",
"Published on": "Diterbitkan pada", "Published on": "Diterbitkan pada",
"Recommended": "Direkomendasikan", "Recommended": "Direkomendasikan",
"Record Screen": "Rekam Layar",
"Register": "Daftar", "Register": "Daftar",
"SAVE": "SIMPAN", "SAVE": "SIMPAN",
"SEARCH": "CARI", "SEARCH": "CARI",
@ -54,9 +59,14 @@ translation_strings = {
"Select": "Pilih", "Select": "Pilih",
"Sign in": "Masuk", "Sign in": "Masuk",
"Sign out": "Keluar", "Sign out": "Keluar",
"Start Recording": "Mulai Merekam",
"Stop Recording": "Hentikan Perekaman",
"Subtitle was added": "Subtitle telah ditambahkan", "Subtitle was added": "Subtitle telah ditambahkan",
"Subtitles": "Subtitel",
"Tags": "Tag", "Tags": "Tag",
"Terms": "Ketentuan", "Terms": "Ketentuan",
"This works in Chrome, Safari and Edge browsers.": "Ini berfungsi di browser Chrome, Safari, dan Edge.",
"Trim": "Potong",
"UPLOAD": "UNGGAH", "UPLOAD": "UNGGAH",
"Up next": "Selanjutnya", "Up next": "Selanjutnya",
"Upload": "Unggah", "Upload": "Unggah",
@ -64,10 +74,12 @@ translation_strings = {
"Uploads": "Unggahan", "Uploads": "Unggahan",
"VIEW ALL": "LIHAT SEMUA", "VIEW ALL": "LIHAT SEMUA",
"View all": "Lihat semua", "View all": "Lihat semua",
"View media": "Lihat media",
"comment": "komentar", "comment": "komentar",
"is a modern, fully featured open source video and media CMS. It is developed to meet the needs of modern web platforms for viewing and sharing media": "adalah CMS video dan media open source yang modern dan lengkap. Ini dikembangkan untuk memenuhi kebutuhan platform web modern untuk menonton dan berbagi media", "is a modern, fully featured open source video and media CMS. It is developed to meet the needs of modern web platforms for viewing and sharing media": "adalah CMS video dan media open source yang modern dan lengkap. Ini dikembangkan untuk memenuhi kebutuhan platform web modern untuk menonton dan berbagi media",
"media in category": "media dalam kategori", "media in category": "media dalam kategori",
"media in tag": "media dalam tag", "media in tag": "media dalam tag",
"or": "atau",
"view": "lihat", "view": "lihat",
"views": "tampilan", "views": "tampilan",
"yet": "belum", "yet": "belum",

View File

@ -4,17 +4,20 @@ translation_strings = {
"About": "Su di noi", "About": "Su di noi",
"Add a": "Aggiungi un", "Add a": "Aggiungi un",
"Add a ": "Aggiungi un ", "Add a ": "Aggiungi un ",
"Browse your files": "Sfoglia i tuoi file",
"COMMENT": "COMMENTA", "COMMENT": "COMMENTA",
"Categories": "Categorie", "Categories": "Categorie",
"Category": "Categoria", "Category": "Categoria",
"Change Language": "Cambia lingua", "Change Language": "Cambia lingua",
"Change password": "Cambia password", "Change password": "Cambia password",
"Click 'Start Recording' and select the screen or tab to record. Once recording is finished, click 'Stop Recording,' and the recording will be uploaded.": "Fai clic su 'Avvia registrazione' e seleziona lo schermo o la scheda da registrare. Una volta terminata la registrazione, fai clic su 'Interrompi registrazione' e la registrazione verrà caricata.",
"Comment": "Commento", "Comment": "Commento",
"Comments": "Commenti", "Comments": "Commenti",
"Comments are disabled": "I commenti sono disabilitati", "Comments are disabled": "I commenti sono disabilitati",
"Contact": "Contatti", "Contact": "Contatti",
"DELETE MEDIA": "ELIMINA MEDIA", "DELETE MEDIA": "ELIMINA MEDIA",
"DOWNLOAD": "SCARICA", "DOWNLOAD": "SCARICA",
"Drag and drop files": "Trascina e rilascia i file",
"EDIT MEDIA": "MODIFICA IL MEDIA", "EDIT MEDIA": "MODIFICA IL MEDIA",
"EDIT PROFILE": "MODIFICA IL PROFILO", "EDIT PROFILE": "MODIFICA IL PROFILO",
"EDIT SUBTITLE": "MODIFICA I SOTTOTITOLI", "EDIT SUBTITLE": "MODIFICA I SOTTOTITOLI",
@ -43,8 +46,10 @@ translation_strings = {
"PLAYLISTS": "PLAYLIST", "PLAYLISTS": "PLAYLIST",
"Playlists": "Playlist", "Playlists": "Playlist",
"Powered by": "Powered by", "Powered by": "Powered by",
"Publish": "Pubblica",
"Published on": "Pubblicato il", "Published on": "Pubblicato il",
"Recommended": "Raccomandati", "Recommended": "Raccomandati",
"Record Screen": "Registra schermo",
"Register": "Registrati", "Register": "Registrati",
"SAVE": "SALVA", "SAVE": "SALVA",
"SEARCH": "CERCA", "SEARCH": "CERCA",
@ -55,9 +60,14 @@ translation_strings = {
"Select": "Seleziona", "Select": "Seleziona",
"Sign in": "Login", "Sign in": "Login",
"Sign out": "Logout", "Sign out": "Logout",
"Start Recording": "Inizia registrazione",
"Stop Recording": "Interrompi registrazione",
"Subtitle was added": "I sottotitoli sono stati aggiunti", "Subtitle was added": "I sottotitoli sono stati aggiunti",
"Subtitles": "Sottotitoli",
"Tags": "Tag", "Tags": "Tag",
"Terms": "Termini e condizioni", "Terms": "Termini e condizioni",
"This works in Chrome, Safari and Edge browsers.": "Questo funziona nei browser Chrome, Safari e Edge.",
"Trim": "Taglia",
"UPLOAD": "CARICA", "UPLOAD": "CARICA",
"Up next": "A seguire", "Up next": "A seguire",
"Upload": "Carica", "Upload": "Carica",
@ -65,10 +75,12 @@ translation_strings = {
"Uploads": "Caricamenti", "Uploads": "Caricamenti",
"VIEW ALL": "MOSTRA TUTTI", "VIEW ALL": "MOSTRA TUTTI",
"View all": "Mostra tutti", "View all": "Mostra tutti",
"View media": "Visualizza media",
"comment": "commento", "comment": "commento",
"is a modern, fully featured open source video and media CMS. It is developed to meet the needs of modern web platforms for viewing and sharing media": "è un CMS per media open source moderno e completo. È stato sviluppato per rispondere per venire incontro alle esigenze delle moderne piattaforme web di visualizzazione e condivisione media", "is a modern, fully featured open source video and media CMS. It is developed to meet the needs of modern web platforms for viewing and sharing media": "è un CMS per media open source moderno e completo. È stato sviluppato per rispondere per venire incontro alle esigenze delle moderne piattaforme web di visualizzazione e condivisione media",
"media in category": "media nella categoria", "media in category": "media nella categoria",
"media in tag": "media con tag", "media in tag": "media con tag",
"or": "o",
"view": "visualizzazione", "view": "visualizzazione",
"views": "visualizzazioni", "views": "visualizzazioni",
"yet": "ancora", "yet": "ancora",

View File

@ -3,17 +3,20 @@ translation_strings = {
"AUTOPLAY": "自動再生", "AUTOPLAY": "自動再生",
"About": "", "About": "",
"Add a ": "追加", "Add a ": "追加",
"Browse your files": "ファイルを参照",
"COMMENT": "コメント", "COMMENT": "コメント",
"Categories": "カテゴリー", "Categories": "カテゴリー",
"Category": "カテゴリー", "Category": "カテゴリー",
"Change Language": "言語を変更", "Change Language": "言語を変更",
"Change password": "パスワードを変更", "Change password": "パスワードを変更",
"Click 'Start Recording' and select the screen or tab to record. Once recording is finished, click 'Stop Recording,' and the recording will be uploaded.": "「録画開始」をクリックして、録画する画面またはタブを選択します。録画が終了したら、「録画停止」をクリックすると、録画がアップロードされます。",
"Comment": "コメント", "Comment": "コメント",
"Comments": "コメント", "Comments": "コメント",
"Comments are disabled": "コメントは無効です", "Comments are disabled": "コメントは無効です",
"Contact": "連絡先", "Contact": "連絡先",
"DELETE MEDIA": "メディアを削除", "DELETE MEDIA": "メディアを削除",
"DOWNLOAD": "ダウンロード", "DOWNLOAD": "ダウンロード",
"Drag and drop files": "ファイルをドラッグアンドドロップ",
"EDIT MEDIA": "メディアを編集", "EDIT MEDIA": "メディアを編集",
"EDIT PROFILE": "プロフィールを編集", "EDIT PROFILE": "プロフィールを編集",
"EDIT SUBTITLE": "字幕を編集", "EDIT SUBTITLE": "字幕を編集",
@ -42,8 +45,10 @@ translation_strings = {
"PLAYLISTS": "プレイリスト", "PLAYLISTS": "プレイリスト",
"Playlists": "プレイリスト", "Playlists": "プレイリスト",
"Powered by": "提供", "Powered by": "提供",
"Publish": "公開",
"Published on": "公開日", "Published on": "公開日",
"Recommended": "おすすめ", "Recommended": "おすすめ",
"Record Screen": "画面を録画",
"Register": "登録", "Register": "登録",
"SAVE": "保存", "SAVE": "保存",
"SEARCH": "検索", "SEARCH": "検索",
@ -54,9 +59,14 @@ translation_strings = {
"Select": "選択", "Select": "選択",
"Sign in": "サインイン", "Sign in": "サインイン",
"Sign out": "サインアウト", "Sign out": "サインアウト",
"Start Recording": "録画開始",
"Stop Recording": "録画停止",
"Subtitle was added": "字幕が追加されました", "Subtitle was added": "字幕が追加されました",
"Subtitles": "字幕",
"Tags": "タグ", "Tags": "タグ",
"Terms": "利用規約", "Terms": "利用規約",
"This works in Chrome, Safari and Edge browsers.": "これはChrome、Safari、Edgeブラウザで動作します。",
"Trim": "トリム",
"UPLOAD": "アップロード", "UPLOAD": "アップロード",
"Up next": "次に再生", "Up next": "次に再生",
"Upload": "アップロード", "Upload": "アップロード",
@ -64,10 +74,12 @@ translation_strings = {
"Uploads": "アップロード", "Uploads": "アップロード",
"VIEW ALL": "すべて表示", "VIEW ALL": "すべて表示",
"View all": "すべて表示", "View all": "すべて表示",
"View media": "メディアを見る",
"comment": "コメント", "comment": "コメント",
"is a modern, fully featured open source video and media CMS. It is developed to meet the needs of modern web platforms for viewing and sharing media": "は、現代のウェブプラットフォームのニーズに応えるために開発された、最新のフル機能のオープンソースビデオおよびメディアCMSです。", "is a modern, fully featured open source video and media CMS. It is developed to meet the needs of modern web platforms for viewing and sharing media": "は、現代のウェブプラットフォームのニーズに応えるために開発された、最新のフル機能のオープンソースビデオおよびメディアCMSです。",
"media in category": "カテゴリー内のメディア", "media in category": "カテゴリー内のメディア",
"media in tag": "タグ内のメディア", "media in tag": "タグ内のメディア",
"or": "または",
"view": "ビュー", "view": "ビュー",
"views": "ビュー", "views": "ビュー",
"yet": "まだ", "yet": "まだ",

View File

@ -3,17 +3,20 @@ translation_strings = {
"AUTOPLAY": "자동 재생", "AUTOPLAY": "자동 재생",
"About": "정보", "About": "정보",
"Add a ": "추가", "Add a ": "추가",
"Browse your files": "파일 찾아보기",
"COMMENT": "댓글", "COMMENT": "댓글",
"Categories": "카테고리", "Categories": "카테고리",
"Category": "카테고리", "Category": "카테고리",
"Change Language": "언어 변경", "Change Language": "언어 변경",
"Change password": "비밀번호 변경", "Change password": "비밀번호 변경",
"Click 'Start Recording' and select the screen or tab to record. Once recording is finished, click 'Stop Recording,' and the recording will be uploaded.": "'녹화 시작'을 클릭하고 녹화할 화면이나 탭을 선택하세요. 녹화가 끝나면 '녹화 중지'를 클릭하면 녹화 파일이 업로드됩니다.",
"Comment": "댓글", "Comment": "댓글",
"Comments": "댓글", "Comments": "댓글",
"Comments are disabled": "댓글이 비활성화되었습니다", "Comments are disabled": "댓글이 비활성화되었습니다",
"Contact": "연락처", "Contact": "연락처",
"DELETE MEDIA": "미디어 삭제", "DELETE MEDIA": "미디어 삭제",
"DOWNLOAD": "다운로드", "DOWNLOAD": "다운로드",
"Drag and drop files": "파일을 끌어다 놓기",
"EDIT MEDIA": "미디어 편집", "EDIT MEDIA": "미디어 편집",
"EDIT PROFILE": "프로필 편집", "EDIT PROFILE": "프로필 편집",
"EDIT SUBTITLE": "자막 편집", "EDIT SUBTITLE": "자막 편집",
@ -42,8 +45,10 @@ translation_strings = {
"PLAYLISTS": "재생 목록", "PLAYLISTS": "재생 목록",
"Playlists": "재생 목록", "Playlists": "재생 목록",
"Powered by": "제공", "Powered by": "제공",
"Publish": "게시",
"Published on": "게시일", "Published on": "게시일",
"Recommended": "추천", "Recommended": "추천",
"Record Screen": "화면 녹화",
"Register": "등록", "Register": "등록",
"SAVE": "저장", "SAVE": "저장",
"SEARCH": "검색", "SEARCH": "검색",
@ -54,9 +59,14 @@ translation_strings = {
"Select": "선택", "Select": "선택",
"Sign in": "로그인", "Sign in": "로그인",
"Sign out": "로그아웃", "Sign out": "로그아웃",
"Start Recording": "녹화 시작",
"Stop Recording": "녹화 중지",
"Subtitle was added": "자막이 추가되었습니다", "Subtitle was added": "자막이 추가되었습니다",
"Subtitles": "자막",
"Tags": "태그", "Tags": "태그",
"Terms": "약관", "Terms": "약관",
"This works in Chrome, Safari and Edge browsers.": "이 기능은 Chrome, Safari 및 Edge 브라우저에서 작동합니다.",
"Trim": "자르기",
"UPLOAD": "업로드", "UPLOAD": "업로드",
"Up next": "다음", "Up next": "다음",
"Upload": "업로드", "Upload": "업로드",
@ -64,10 +74,12 @@ translation_strings = {
"Uploads": "업로드", "Uploads": "업로드",
"VIEW ALL": "모두 보기", "VIEW ALL": "모두 보기",
"View all": "모두 보기", "View all": "모두 보기",
"View media": "미디어 보기",
"comment": "댓글", "comment": "댓글",
"is a modern, fully featured open source video and media CMS. It is developed to meet the needs of modern web platforms for viewing and sharing media": "현대적인, 완전한 기능을 갖춘 오픈 소스 비디오 및 미디어 CMS입니다. 미디어를 시청하고 공유하기 위한 현대 웹 플랫폼의 요구를 충족시키기 위해 개발되었습니다", "is a modern, fully featured open source video and media CMS. It is developed to meet the needs of modern web platforms for viewing and sharing media": "현대적인, 완전한 기능을 갖춘 오픈 소스 비디오 및 미디어 CMS입니다. 미디어를 시청하고 공유하기 위한 현대 웹 플랫폼의 요구를 충족시키기 위해 개발되었습니다",
"media in category": "카테고리의 미디어", "media in category": "카테고리의 미디어",
"media in tag": "태그의 미디어", "media in tag": "태그의 미디어",
"or": "또는",
"view": "보기", "view": "보기",
"views": "조회수", "views": "조회수",
"yet": "아직", "yet": "아직",

View File

@ -3,17 +3,20 @@ translation_strings = {
"AUTOPLAY": "AUTOMATISCH AFSPELEN", "AUTOPLAY": "AUTOMATISCH AFSPELEN",
"About": "Over", "About": "Over",
"Add a ": "Voeg een ", "Add a ": "Voeg een ",
"Browse your files": "Blader door uw bestanden",
"COMMENT": "REACTIE", "COMMENT": "REACTIE",
"Categories": "Categorieën", "Categories": "Categorieën",
"Category": "Categorie", "Category": "Categorie",
"Change Language": "Taal wijzigen", "Change Language": "Taal wijzigen",
"Change password": "Wachtwoord wijzigen", "Change password": "Wachtwoord wijzigen",
"Click 'Start Recording' and select the screen or tab to record. Once recording is finished, click 'Stop Recording,' and the recording will be uploaded.": "Klik op 'Opname starten' en selecteer het scherm of tabblad dat u wilt opnemen. Zodra de opname is voltooid, klikt u op 'Opname stoppen' en de opname wordt geüpload.",
"Comment": "Reactie", "Comment": "Reactie",
"Comments": "Reacties", "Comments": "Reacties",
"Comments are disabled": "Reacties zijn uitgeschakeld", "Comments are disabled": "Reacties zijn uitgeschakeld",
"Contact": "Contact", "Contact": "Contact",
"DELETE MEDIA": "MEDIA VERWIJDEREN", "DELETE MEDIA": "MEDIA VERWIJDEREN",
"DOWNLOAD": "DOWNLOADEN", "DOWNLOAD": "DOWNLOADEN",
"Drag and drop files": "Sleep bestanden en zet ze neer",
"EDIT MEDIA": "MEDIA BEWERKEN", "EDIT MEDIA": "MEDIA BEWERKEN",
"EDIT PROFILE": "PROFIEL BEWERKEN", "EDIT PROFILE": "PROFIEL BEWERKEN",
"EDIT SUBTITLE": "ONDERTITEL BEWERKEN", "EDIT SUBTITLE": "ONDERTITEL BEWERKEN",
@ -42,8 +45,10 @@ translation_strings = {
"PLAYLISTS": "AFSPEELLIJSTEN", "PLAYLISTS": "AFSPEELLIJSTEN",
"Playlists": "Afspeellijsten", "Playlists": "Afspeellijsten",
"Powered by": "Aangedreven door", "Powered by": "Aangedreven door",
"Publish": "Publiceren",
"Published on": "Gepubliceerd op", "Published on": "Gepubliceerd op",
"Recommended": "Aanbevolen", "Recommended": "Aanbevolen",
"Record Screen": "Scherm opnemen",
"Register": "Registreren", "Register": "Registreren",
"SAVE": "OPSLAAN", "SAVE": "OPSLAAN",
"SEARCH": "ZOEKEN", "SEARCH": "ZOEKEN",
@ -54,9 +59,14 @@ translation_strings = {
"Select": "Selecteer", "Select": "Selecteer",
"Sign in": "Inloggen", "Sign in": "Inloggen",
"Sign out": "Uitloggen", "Sign out": "Uitloggen",
"Start Recording": "Opname starten",
"Stop Recording": "Opname stoppen",
"Subtitle was added": "Ondertitel is toegevoegd", "Subtitle was added": "Ondertitel is toegevoegd",
"Subtitles": "Ondertitels",
"Tags": "Tags", "Tags": "Tags",
"Terms": "Voorwaarden", "Terms": "Voorwaarden",
"This works in Chrome, Safari and Edge browsers.": "Dit werkt in Chrome, Safari en Edge browsers.",
"Trim": "Bijsnijden",
"UPLOAD": "UPLOADEN", "UPLOAD": "UPLOADEN",
"Up next": "Hierna", "Up next": "Hierna",
"Upload": "Uploaden", "Upload": "Uploaden",
@ -64,10 +74,12 @@ translation_strings = {
"Uploads": "Uploads", "Uploads": "Uploads",
"VIEW ALL": "BEKIJK ALLES", "VIEW ALL": "BEKIJK ALLES",
"View all": "Bekijk alles", "View all": "Bekijk alles",
"View media": "Media bekijken",
"comment": "reactie", "comment": "reactie",
"is a modern, fully featured open source video and media CMS. It is developed to meet the needs of modern web platforms for viewing and sharing media": "is een modern, volledig uitgerust open source video- en media-CMS. Het is ontwikkeld om te voldoen aan de behoeften van moderne webplatforms voor het bekijken en delen van media", "is a modern, fully featured open source video and media CMS. It is developed to meet the needs of modern web platforms for viewing and sharing media": "is een modern, volledig uitgerust open source video- en media-CMS. Het is ontwikkeld om te voldoen aan de behoeften van moderne webplatforms voor het bekijken en delen van media",
"media in category": "media in categorie", "media in category": "media in categorie",
"media in tag": "media in tag", "media in tag": "media in tag",
"or": "of",
"view": "bekijk", "view": "bekijk",
"views": "weergaven", "views": "weergaven",
"yet": "nog", "yet": "nog",

View File

@ -3,17 +3,20 @@ translation_strings = {
"AUTOPLAY": "REPRODUÇÃO AUTOMÁTICA", "AUTOPLAY": "REPRODUÇÃO AUTOMÁTICA",
"About": "Sobre", "About": "Sobre",
"Add a ": "Adicionar um ", "Add a ": "Adicionar um ",
"Browse your files": "Procurar seus arquivos",
"COMMENT": "COMENTÁRIO", "COMMENT": "COMENTÁRIO",
"Categories": "Categorias", "Categories": "Categorias",
"Category": "Categoria", "Category": "Categoria",
"Change Language": "Mudar idioma", "Change Language": "Mudar idioma",
"Change password": "Mudar senha", "Change password": "Mudar senha",
"Click 'Start Recording' and select the screen or tab to record. Once recording is finished, click 'Stop Recording,' and the recording will be uploaded.": "Clique em 'Iniciar gravação' e selecione a tela ou guia para gravar. Quando a gravação terminar, clique em 'Parar gravação' e a gravação será enviada.",
"Comment": "Comentário", "Comment": "Comentário",
"Comments": "Comentários", "Comments": "Comentários",
"Comments are disabled": "Comentários estão desativados", "Comments are disabled": "Comentários estão desativados",
"Contact": "Contato", "Contact": "Contato",
"DELETE MEDIA": "EXCLUIR MÍDIA", "DELETE MEDIA": "EXCLUIR MÍDIA",
"DOWNLOAD": "BAIXAR", "DOWNLOAD": "BAIXAR",
"Drag and drop files": "Arraste e solte arquivos",
"EDIT MEDIA": "EDITAR MÍDIA", "EDIT MEDIA": "EDITAR MÍDIA",
"EDIT PROFILE": "EDITAR PERFIL", "EDIT PROFILE": "EDITAR PERFIL",
"EDIT SUBTITLE": "EDITAR LEGENDA", "EDIT SUBTITLE": "EDITAR LEGENDA",
@ -42,8 +45,10 @@ translation_strings = {
"PLAYLISTS": "PLAYLISTS", "PLAYLISTS": "PLAYLISTS",
"Playlists": "Playlists", "Playlists": "Playlists",
"Powered by": "Desenvolvido por", "Powered by": "Desenvolvido por",
"Publish": "Publicar",
"Published on": "Publicado em", "Published on": "Publicado em",
"Recommended": "Recomendado", "Recommended": "Recomendado",
"Record Screen": "Gravar tela",
"Register": "Registrar", "Register": "Registrar",
"SAVE": "SALVAR", "SAVE": "SALVAR",
"SEARCH": "PESQUISAR", "SEARCH": "PESQUISAR",
@ -54,9 +59,14 @@ translation_strings = {
"Select": "Selecionar", "Select": "Selecionar",
"Sign in": "Entrar", "Sign in": "Entrar",
"Sign out": "Sair", "Sign out": "Sair",
"Start Recording": "Iniciar Gravação",
"Stop Recording": "Parar Gravação",
"Subtitle was added": "Legenda foi adicionada", "Subtitle was added": "Legenda foi adicionada",
"Subtitles": "Legendas",
"Tags": "Tags", "Tags": "Tags",
"Terms": "Termos", "Terms": "Termos",
"This works in Chrome, Safari and Edge browsers.": "Isso funciona nos navegadores Chrome, Safari e Edge.",
"Trim": "Cortar",
"UPLOAD": "CARREGAR", "UPLOAD": "CARREGAR",
"Up next": "A seguir", "Up next": "A seguir",
"Upload": "Carregar", "Upload": "Carregar",
@ -64,10 +74,12 @@ translation_strings = {
"Uploads": "Uploads", "Uploads": "Uploads",
"VIEW ALL": "VER TODOS", "VIEW ALL": "VER TODOS",
"View all": "Ver todos", "View all": "Ver todos",
"View media": "Ver mídia",
"comment": "comentário", "comment": "comentário",
"is a modern, fully featured open source video and media CMS. It is developed to meet the needs of modern web platforms for viewing and sharing media": "é um CMS de vídeo e mídia de código aberto, moderno e completo. Foi desenvolvido para atender às necessidades das plataformas web modernas para visualização e compartilhamento de mídia", "is a modern, fully featured open source video and media CMS. It is developed to meet the needs of modern web platforms for viewing and sharing media": "é um CMS de vídeo e mídia de código aberto, moderno e completo. Foi desenvolvido para atender às necessidades das plataformas web modernas para visualização e compartilhamento de mídia",
"media in category": "mídia na categoria", "media in category": "mídia na categoria",
"media in tag": "mídia na tag", "media in tag": "mídia na tag",
"or": "ou",
"view": "visualização", "view": "visualização",
"views": "visualizações", "views": "visualizações",
"yet": "ainda", "yet": "ainda",

View File

@ -3,17 +3,20 @@ translation_strings = {
"AUTOPLAY": "Автовоспроизведение", "AUTOPLAY": "Автовоспроизведение",
"About": "О", "About": "О",
"Add a ": "Добавить ", "Add a ": "Добавить ",
"Browse your files": "Просмотреть файлы",
"COMMENT": "КОММЕНТАРИЙ", "COMMENT": "КОММЕНТАРИЙ",
"Categories": "Категории", "Categories": "Категории",
"Category": "Категория", "Category": "Категория",
"Change Language": "Изменить язык", "Change Language": "Изменить язык",
"Change password": "Изменить пароль", "Change password": "Изменить пароль",
"Click 'Start Recording' and select the screen or tab to record. Once recording is finished, click 'Stop Recording,' and the recording will be uploaded.": "Нажмите 'Начать запись' и выберите экран или вкладку для записи. После окончания записи нажмите 'Остановить запись', и запись будет загружена.",
"Comment": "Комментарий", "Comment": "Комментарий",
"Comments": "Комментарии", "Comments": "Комментарии",
"Comments are disabled": "Комментарии отключены", "Comments are disabled": "Комментарии отключены",
"Contact": "Контакт", "Contact": "Контакт",
"DELETE MEDIA": "УДАЛИТЬ МЕДИА", "DELETE MEDIA": "УДАЛИТЬ МЕДИА",
"DOWNLOAD": "СКАЧАТЬ", "DOWNLOAD": "СКАЧАТЬ",
"Drag and drop files": "Перетащите файлы",
"EDIT MEDIA": "РЕДАКТИРОВАТЬ МЕДИА", "EDIT MEDIA": "РЕДАКТИРОВАТЬ МЕДИА",
"EDIT PROFILE": "РЕДАКТИРОВАТЬ ПРОФИЛЬ", "EDIT PROFILE": "РЕДАКТИРОВАТЬ ПРОФИЛЬ",
"EDIT SUBTITLE": "РЕДАКТИРОВАТЬ СУБТИТРЫ", "EDIT SUBTITLE": "РЕДАКТИРОВАТЬ СУБТИТРЫ",
@ -42,8 +45,10 @@ translation_strings = {
"PLAYLISTS": "ПЛЕЙЛИСТЫ", "PLAYLISTS": "ПЛЕЙЛИСТЫ",
"Playlists": "Плейлисты", "Playlists": "Плейлисты",
"Powered by": "Работает на", "Powered by": "Работает на",
"Publish": "Опубликовать",
"Published on": "Опубликовано", "Published on": "Опубликовано",
"Recommended": "Рекомендуемое", "Recommended": "Рекомендуемое",
"Record Screen": "Запись экрана",
"Register": "Регистрация", "Register": "Регистрация",
"SAVE": "СОХРАНИТЬ", "SAVE": "СОХРАНИТЬ",
"SEARCH": "ПОИСК", "SEARCH": "ПОИСК",
@ -54,9 +59,14 @@ translation_strings = {
"Select": "Выбрать", "Select": "Выбрать",
"Sign in": "Войти", "Sign in": "Войти",
"Sign out": "Выйти", "Sign out": "Выйти",
"Start Recording": "Начать запись",
"Stop Recording": "Остановить запись",
"Subtitle was added": "Субтитры были добавлены", "Subtitle was added": "Субтитры были добавлены",
"Subtitles": "Субтитры",
"Tags": "Теги", "Tags": "Теги",
"Terms": "Условия", "Terms": "Условия",
"This works in Chrome, Safari and Edge browsers.": "Это работает в браузерах Chrome, Safari и Edge.",
"Trim": "Обрезать",
"UPLOAD": "ЗАГРУЗИТЬ", "UPLOAD": "ЗАГРУЗИТЬ",
"Up next": "Далее", "Up next": "Далее",
"Upload": "Загрузить", "Upload": "Загрузить",
@ -64,10 +74,12 @@ translation_strings = {
"Uploads": "Загрузки", "Uploads": "Загрузки",
"VIEW ALL": "ПОКАЗАТЬ ВСЕ", "VIEW ALL": "ПОКАЗАТЬ ВСЕ",
"View all": "Показать все", "View all": "Показать все",
"View media": "Просмотр медиа",
"comment": "комментарий", "comment": "комментарий",
"is a modern, fully featured open source video and media CMS. It is developed to meet the needs of modern web platforms for viewing and sharing media": "это современная, полнофункциональная система управления видео и медиа с открытым исходным кодом. Она разработана для удовлетворения потребностей современных веб-платформ для просмотра и обмена медиа", "is a modern, fully featured open source video and media CMS. It is developed to meet the needs of modern web platforms for viewing and sharing media": "это современная, полнофункциональная система управления видео и медиа с открытым исходным кодом. Она разработана для удовлетворения потребностей современных веб-платформ для просмотра и обмена медиа",
"media in category": "медиа в категории", "media in category": "медиа в категории",
"media in tag": "медиа в теге", "media in tag": "медиа в теге",
"or": "или",
"view": "просмотр", "view": "просмотр",
"views": "просмотры", "views": "просмотры",
"yet": "еще", "yet": "еще",

View File

@ -1,19 +1,22 @@
translation_strings = { translation_strings = {
"ABOUT": "O NAS", "ABOUT": "O NAS",
"AUTOPLAY": "SAMODEJNO PREDVAJANJE", "AUTOPLAY": "SAMODEJNO PREDVAJANJE",
"About": "O nas",
"Add a ": "Dodaj ", "Add a ": "Dodaj ",
"Browse your files": "Prebrskaj datoteke",
"COMMENT": "KOMENTAR", "COMMENT": "KOMENTAR",
"Categories": "Kategorije", "Categories": "Kategorije",
"Category": "Kategorija", "Category": "Kategorija",
"Change Language": "Spremeni jezik", "Change Language": "Spremeni jezik",
"Change password": "Spremeni geslo", "Change password": "Spremeni geslo",
"About": "O nas", "Click 'Start Recording' and select the screen or tab to record. Once recording is finished, click 'Stop Recording,' and the recording will be uploaded.": "Kliknite 'Začni snemanje' in izberite zaslon ali zavihek za snemanje. Ko je snemanje končano, kliknite 'Ustavi snemanje' in posnetek bo naložen.",
"Comment": "Komentar", "Comment": "Komentar",
"Comments": "Komentarji", "Comments": "Komentarji",
"Comments are disabled": "Komentarji so onemogočeni", "Comments are disabled": "Komentarji so onemogočeni",
"Contact": "Kontakt", "Contact": "Kontakt",
"DELETE MEDIA": "IZBRIŠI MEDIJ", "DELETE MEDIA": "IZBRIŠI MEDIJ",
"DOWNLOAD": "PRENESI", "DOWNLOAD": "PRENESI",
"Drag and drop files": "Povleci in spusti datoteke",
"EDIT MEDIA": "UREDI MEDIJ", "EDIT MEDIA": "UREDI MEDIJ",
"EDIT PROFILE": "UREDI PROFIL", "EDIT PROFILE": "UREDI PROFIL",
"EDIT SUBTITLE": "UREDI PODNAPISE", "EDIT SUBTITLE": "UREDI PODNAPISE",
@ -42,8 +45,10 @@ translation_strings = {
"PLAYLISTS": "SEZNAMI PREDVAJANJA", "PLAYLISTS": "SEZNAMI PREDVAJANJA",
"Playlists": "Seznami predvajanja", "Playlists": "Seznami predvajanja",
"Powered by": "Poganja", "Powered by": "Poganja",
"Publish": "Objavi",
"Published on": "Objavljeno", "Published on": "Objavljeno",
"Recommended": "Priporočeno", "Recommended": "Priporočeno",
"Record Screen": "Snemanje zaslona",
"Register": "Registracija", "Register": "Registracija",
"SAVE": "SHRANI", "SAVE": "SHRANI",
"SEARCH": "ISKANJE", "SEARCH": "ISKANJE",
@ -54,9 +59,14 @@ translation_strings = {
"Select": "Izberi", "Select": "Izberi",
"Sign in": "Prijava", "Sign in": "Prijava",
"Sign out": "Odjava", "Sign out": "Odjava",
"Start Recording": "Začni snemanje",
"Stop Recording": "Ustavi snemanje",
"Subtitle was added": "Podnapisi so bili dodani", "Subtitle was added": "Podnapisi so bili dodani",
"Subtitles": "Podnapisi",
"Tags": "Oznake", "Tags": "Oznake",
"Terms": "Pogoji", "Terms": "Pogoji",
"This works in Chrome, Safari and Edge browsers.": "To deluje v brskalnikih Chrome, Safari in Edge.",
"Trim": "Obreži",
"UPLOAD": "NALOŽI", "UPLOAD": "NALOŽI",
"Up next": "Naslednji", "Up next": "Naslednji",
"Upload": "Naloži", "Upload": "Naloži",
@ -64,10 +74,12 @@ translation_strings = {
"Uploads": "Naloženi", "Uploads": "Naloženi",
"VIEW ALL": "PRIKAŽI VSE", "VIEW ALL": "PRIKAŽI VSE",
"View all": "Prikaži vse", "View all": "Prikaži vse",
"View media": "Ogled medija",
"comment": "komentar", "comment": "komentar",
"is a modern, fully featured open source video and media CMS. It is developed to meet the needs of modern web platforms for viewing and sharing media": "je moderni, popolnoma opremljen odprtokodni video in medijski CMS. Razvit je za potrebe sodobnih spletnih platform za ogled in deljenje medijev", "is a modern, fully featured open source video and media CMS. It is developed to meet the needs of modern web platforms for viewing and sharing media": "je moderni, popolnoma opremljen odprtokodni video in medijski CMS. Razvit je za potrebe sodobnih spletnih platform za ogled in deljenje medijev",
"media in category": "mediji v kategoriji", "media in category": "mediji v kategoriji",
"media in tag": "mediji z oznako", "media in tag": "mediji z oznako",
"or": "ali",
"view": "ogled", "view": "ogled",
"views": "ogledi", "views": "ogledi",
"yet": "še", "yet": "še",

View File

@ -3,17 +3,20 @@ translation_strings = {
"AUTOPLAY": "OTOMATİK OYNATMA", "AUTOPLAY": "OTOMATİK OYNATMA",
"About": "Hakkında", "About": "Hakkında",
"Add a ": "Ekle ", "Add a ": "Ekle ",
"Browse your files": "Dosyalarınıza göz atın",
"COMMENT": "YORUM", "COMMENT": "YORUM",
"Categories": "Kategoriler", "Categories": "Kategoriler",
"Category": "Kategori", "Category": "Kategori",
"Change Language": "Dili Değiştir", "Change Language": "Dili Değiştir",
"Change password": "Şifreyi Değiştir", "Change password": "Şifreyi Değiştir",
"Click 'Start Recording' and select the screen or tab to record. Once recording is finished, click 'Stop Recording,' and the recording will be uploaded.": "'Kaydı Başlat'a tıklayın ve kaydedilecek ekranı veya sekmeyi seçin. Kayıt bittiğinde, 'Kaydı Durdur'a tıklayın ve kayıt yüklenecektir.",
"Comment": "Yorum", "Comment": "Yorum",
"Comments": "Yorumlar", "Comments": "Yorumlar",
"Comments are disabled": "Yorumlar devre dışı", "Comments are disabled": "Yorumlar devre dışı",
"Contact": "İletişim", "Contact": "İletişim",
"DELETE MEDIA": "MEDYAYI SİL", "DELETE MEDIA": "MEDYAYI SİL",
"DOWNLOAD": "İNDİR", "DOWNLOAD": "İNDİR",
"Drag and drop files": "Dosyaları sürükleyip bırakın",
"EDIT MEDIA": "MEDYAYI DÜZENLE", "EDIT MEDIA": "MEDYAYI DÜZENLE",
"EDIT PROFILE": "PROFİLİ DÜZENLE", "EDIT PROFILE": "PROFİLİ DÜZENLE",
"EDIT SUBTITLE": "ALT YAZIYI DÜZENLE", "EDIT SUBTITLE": "ALT YAZIYI DÜZENLE",
@ -42,8 +45,10 @@ translation_strings = {
"PLAYLISTS": "ÇALMA LİSTELERİ", "PLAYLISTS": "ÇALMA LİSTELERİ",
"Playlists": "Çalma listeleri", "Playlists": "Çalma listeleri",
"Powered by": "Tarafından desteklenmektedir", "Powered by": "Tarafından desteklenmektedir",
"Publish": "Yayınla",
"Published on": "Yayınlanma tarihi", "Published on": "Yayınlanma tarihi",
"Recommended": "Önerilen", "Recommended": "Önerilen",
"Record Screen": "Ekranı Kaydet",
"Register": "Kayıt Ol", "Register": "Kayıt Ol",
"SAVE": "KAYDET", "SAVE": "KAYDET",
"SEARCH": "ARA", "SEARCH": "ARA",
@ -54,9 +59,14 @@ translation_strings = {
"Select": "Seç", "Select": "Seç",
"Sign in": "Giriş Yap", "Sign in": "Giriş Yap",
"Sign out": "Çıkış Yap", "Sign out": "Çıkış Yap",
"Start Recording": "Kaydı Başlat",
"Stop Recording": "Kaydı Durdur",
"Subtitle was added": "Alt yazı eklendi", "Subtitle was added": "Alt yazı eklendi",
"Subtitles": "Altyazılar",
"Tags": "Etiketler", "Tags": "Etiketler",
"Terms": "Şartlar", "Terms": "Şartlar",
"This works in Chrome, Safari and Edge browsers.": "Bu, Chrome, Safari ve Edge tarayıcılarında çalışır.",
"Trim": "Kırp",
"UPLOAD": "YÜKLE", "UPLOAD": "YÜKLE",
"Up next": "Sıradaki", "Up next": "Sıradaki",
"Upload": "Yükle", "Upload": "Yükle",
@ -64,10 +74,12 @@ translation_strings = {
"Uploads": "Yüklemeler", "Uploads": "Yüklemeler",
"VIEW ALL": "HEPSİNİ GÖR", "VIEW ALL": "HEPSİNİ GÖR",
"View all": "Hepsini gör", "View all": "Hepsini gör",
"View media": "Medyayı Görüntüle",
"comment": "yorum", "comment": "yorum",
"is a modern, fully featured open source video and media CMS. It is developed to meet the needs of modern web platforms for viewing and sharing media": "modern, tam özellikli açık kaynaklı bir video ve medya CMS'sidir. Medya izleme ve paylaşma ihtiyaçlarını karşılamak için geliştirilmiştir", "is a modern, fully featured open source video and media CMS. It is developed to meet the needs of modern web platforms for viewing and sharing media": "modern, tam özellikli açık kaynaklı bir video ve medya CMS'sidir. Medya izleme ve paylaşma ihtiyaçlarını karşılamak için geliştirilmiştir",
"media in category": "kategorideki medya", "media in category": "kategorideki medya",
"media in tag": "etiketteki medya", "media in tag": "etiketteki medya",
"or": "veya",
"view": "görünüm", "view": "görünüm",
"views": "görünümler", "views": "görünümler",
"yet": "henüz", "yet": "henüz",

View File

@ -3,17 +3,20 @@ translation_strings = {
"AUTOPLAY": "خودکار پلے", "AUTOPLAY": "خودکار پلے",
"About": "کے بارے میں", "About": "کے بارے میں",
"Add a ": "شامل کریں", "Add a ": "شامل کریں",
"Browse your files": "اپنی فائلیں براؤز کریں",
"COMMENT": "تبصرہ", "COMMENT": "تبصرہ",
"Categories": "اقسام", "Categories": "اقسام",
"Category": "قسم", "Category": "قسم",
"Change Language": "زبان تبدیل کریں", "Change Language": "زبان تبدیل کریں",
"Change password": "پاس ورڈ تبدیل کریں", "Change password": "پاس ورڈ تبدیل کریں",
"Click 'Start Recording' and select the screen or tab to record. Once recording is finished, click 'Stop Recording,' and the recording will be uploaded.": "'ریکارڈنگ شروع کریں' پر کلک کریں اور ریکارڈ کرنے کے لیے اسکرین یا ٹیب منتخب کریں۔ ریکارڈنگ مکمل ہونے کے بعد، 'ریکارڈنگ بند کریں' پر کلک کریں، اور ریکارڈنگ اپ لوڈ ہو جائے گی۔",
"Comment": "تبصرہ", "Comment": "تبصرہ",
"Comments": "تبصرے", "Comments": "تبصرے",
"Comments are disabled": "تبصرے غیر فعال ہیں", "Comments are disabled": "تبصرے غیر فعال ہیں",
"Contact": "رابطہ کریں", "Contact": "رابطہ کریں",
"DELETE MEDIA": "میڈیا حذف کریں", "DELETE MEDIA": "میڈیا حذف کریں",
"DOWNLOAD": "ڈاؤن لوڈ", "DOWNLOAD": "ڈاؤن لوڈ",
"Drag and drop files": "فائلیں گھسیٹیں اور چھوڑیں",
"EDIT MEDIA": "میڈیا ترمیم کریں", "EDIT MEDIA": "میڈیا ترمیم کریں",
"EDIT PROFILE": "پروفائل ترمیم کریں", "EDIT PROFILE": "پروفائل ترمیم کریں",
"EDIT SUBTITLE": "سب ٹائٹل ترمیم کریں", "EDIT SUBTITLE": "سب ٹائٹل ترمیم کریں",
@ -42,8 +45,10 @@ translation_strings = {
"PLAYLISTS": "پلے لسٹس", "PLAYLISTS": "پلے لسٹس",
"Playlists": "پلے لسٹس", "Playlists": "پلے لسٹس",
"Powered by": "کے ذریعہ تقویت یافتہ", "Powered by": "کے ذریعہ تقویت یافتہ",
"Publish": "شائع کریں",
"Published on": "پر شائع ہوا", "Published on": "پر شائع ہوا",
"Recommended": "تجویز کردہ", "Recommended": "تجویز کردہ",
"Record Screen": "اسکرین ریکارڈ کریں",
"Register": "رجسٹر کریں", "Register": "رجسٹر کریں",
"SAVE": "محفوظ کریں", "SAVE": "محفوظ کریں",
"SEARCH": "تلاش کریں", "SEARCH": "تلاش کریں",
@ -54,9 +59,14 @@ translation_strings = {
"Select": "منتخب کریں", "Select": "منتخب کریں",
"Sign in": "سائن ان کریں", "Sign in": "سائن ان کریں",
"Sign out": "سائن آؤٹ کریں", "Sign out": "سائن آؤٹ کریں",
"Start Recording": "ریکارڈنگ شروع کریں",
"Stop Recording": "ریکارڈنگ روکیں",
"Subtitle was added": "سب ٹائٹل شامل کیا گیا", "Subtitle was added": "سب ٹائٹل شامل کیا گیا",
"Subtitles": "سب ٹائٹلز",
"Tags": "ٹیگز", "Tags": "ٹیگز",
"Terms": "شرائط", "Terms": "شرائط",
"This works in Chrome, Safari and Edge browsers.": "یہ کروم، سفاری اور ایج براؤزرز میں کام کرتا ہے۔",
"Trim": "تراشیں",
"UPLOAD": "اپ لوڈ کریں", "UPLOAD": "اپ لوڈ کریں",
"Up next": "اگلا", "Up next": "اگلا",
"Upload": "اپ لوڈ کریں", "Upload": "اپ لوڈ کریں",
@ -64,10 +74,12 @@ translation_strings = {
"Uploads": "اپ لوڈز", "Uploads": "اپ لوڈز",
"VIEW ALL": "سب دیکھیں", "VIEW ALL": "سب دیکھیں",
"View all": "سب دیکھیں", "View all": "سب دیکھیں",
"View media": "میڈیا دیکھیں",
"comment": "تبصرہ", "comment": "تبصرہ",
"is a modern, fully featured open source video and media CMS. It is developed to meet the needs of modern web platforms for viewing and sharing media": "ایک جدید، مکمل خصوصیات والا اوپن سورس ویڈیو اور میڈیا CMS ہے۔ یہ جدید ویب پلیٹ فارمز کی ضروریات کو پورا کرنے کے لئے تیار کیا گیا ہے تاکہ میڈیا دیکھنے اور شیئر کرنے کے لئے", "is a modern, fully featured open source video and media CMS. It is developed to meet the needs of modern web platforms for viewing and sharing media": "ایک جدید، مکمل خصوصیات والا اوپن سورس ویڈیو اور میڈیا CMS ہے۔ یہ جدید ویب پلیٹ فارمز کی ضروریات کو پورا کرنے کے لئے تیار کیا گیا ہے تاکہ میڈیا دیکھنے اور شیئر کرنے کے لئے",
"media in category": "زمرے میں میڈیا", "media in category": "زمرے میں میڈیا",
"media in tag": "ٹیگ میں میڈیا", "media in tag": "ٹیگ میں میڈیا",
"or": "یا",
"view": "دیکھیں", "view": "دیکھیں",
"views": "دیکھے گئے", "views": "دیکھے گئے",
"yet": "ابھی تک", "yet": "ابھی تک",

View File

@ -3,17 +3,20 @@ translation_strings = {
"AUTOPLAY": "自动播放", "AUTOPLAY": "自动播放",
"About": "关于", "About": "关于",
"Add a ": "添加一个", "Add a ": "添加一个",
"Browse your files": "浏览文件",
"COMMENT": "评论", "COMMENT": "评论",
"Categories": "分类", "Categories": "分类",
"Category": "类别", "Category": "类别",
"Change Language": "更改语言", "Change Language": "更改语言",
"Change password": "更改密码", "Change password": "更改密码",
"Click 'Start Recording' and select the screen or tab to record. Once recording is finished, click 'Stop Recording,' and the recording will be uploaded.": "点击“开始录制”并选择要录制的屏幕或标签页。录制完成后,点击“停止录制”,录制内容将被上传。",
"Comment": "评论", "Comment": "评论",
"Comments": "评论", "Comments": "评论",
"Comments are disabled": "评论已禁用", "Comments are disabled": "评论已禁用",
"Contact": "联系", "Contact": "联系",
"DELETE MEDIA": "删除媒体", "DELETE MEDIA": "删除媒体",
"DOWNLOAD": "下载", "DOWNLOAD": "下载",
"Drag and drop files": "拖放文件",
"EDIT MEDIA": "编辑媒体", "EDIT MEDIA": "编辑媒体",
"EDIT PROFILE": "编辑个人资料", "EDIT PROFILE": "编辑个人资料",
"EDIT SUBTITLE": "编辑字幕", "EDIT SUBTITLE": "编辑字幕",
@ -42,8 +45,10 @@ translation_strings = {
"PLAYLISTS": "播放列表", "PLAYLISTS": "播放列表",
"Playlists": "播放列表", "Playlists": "播放列表",
"Powered by": "由...提供技术支持", "Powered by": "由...提供技术支持",
"Publish": "发布",
"Published on": "发布于", "Published on": "发布于",
"Recommended": "推荐", "Recommended": "推荐",
"Record Screen": "录制屏幕",
"Register": "注册", "Register": "注册",
"SAVE": "保存", "SAVE": "保存",
"SEARCH": "搜索", "SEARCH": "搜索",
@ -54,9 +59,14 @@ translation_strings = {
"Select": "选择", "Select": "选择",
"Sign in": "登录", "Sign in": "登录",
"Sign out": "登出", "Sign out": "登出",
"Start Recording": "开始录制",
"Stop Recording": "停止录制",
"Subtitle was added": "字幕已添加", "Subtitle was added": "字幕已添加",
"Subtitles": "字幕",
"Tags": "标签", "Tags": "标签",
"Terms": "条款", "Terms": "条款",
"This works in Chrome, Safari and Edge browsers.": "此功能适用于 Chrome、Safari 和 Edge 浏览器。",
"Trim": "修剪",
"UPLOAD": "上传", "UPLOAD": "上传",
"Up next": "接下来", "Up next": "接下来",
"Upload": "上传", "Upload": "上传",
@ -64,10 +74,12 @@ translation_strings = {
"Uploads": "上传", "Uploads": "上传",
"VIEW ALL": "查看全部", "VIEW ALL": "查看全部",
"View all": "查看全部", "View all": "查看全部",
"View media": "查看媒体",
"comment": "评论", "comment": "评论",
"is a modern, fully featured open source video and media CMS. It is developed to meet the needs of modern web platforms for viewing and sharing media": "是一个现代化、功能齐全的开源视频和媒体CMS。它是为了满足现代网络平台观看和分享媒体的需求而开发的", "is a modern, fully featured open source video and media CMS. It is developed to meet the needs of modern web platforms for viewing and sharing media": "是一个现代化、功能齐全的开源视频和媒体CMS。它是为了满足现代网络平台观看和分享媒体的需求而开发的",
"media in category": "类别中的媒体", "media in category": "类别中的媒体",
"media in tag": "标签中的媒体", "media in tag": "标签中的媒体",
"or": "",
"view": "查看", "view": "查看",
"views": "查看", "views": "查看",
"yet": "", "yet": "",

View File

@ -1,104 +1,116 @@
translation_strings = { translation_strings = {
'ABOUT': '關於', "ABOUT": "關於",
'AUTOPLAY': '自動播放', "AUTOPLAY": "自動播放",
'About': '關於', "About": "關於",
'Add a ': '新增', "Add a ": "新增",
'COMMENT': '留言', "Browse your files": "瀏覽您的檔案",
'Categories': '分類', "COMMENT": "留言",
'Category': '分類', "Categories": "分類",
'Change Language': '切換語言', "Category": "分類",
'Change password': '變更密碼', "Change Language": "切換語言",
'Comment': '留言', "Change password": "變更密碼",
'Comments': '留言', "Click 'Start Recording' and select the screen or tab to record. Once recording is finished, click 'Stop Recording,' and the recording will be uploaded.": "點擊「開始錄製」並選擇要錄製的螢幕或分頁。錄製完成後,點擊「停止錄製」,錄製的內容將會上傳。",
'Comments are disabled': '留言功能已關閉', "Comment": "留言",
'Contact': '聯絡資訊', "Comments": "留言",
'DELETE MEDIA': '刪除影片', "Comments are disabled": "留言功能已關閉",
'DOWNLOAD': '下載', "Contact": "聯絡資訊",
'EDIT MEDIA': '編輯影片', "DELETE MEDIA": "刪除影片",
'EDIT PROFILE': '編輯個人資料', "DOWNLOAD": "下載",
'EDIT SUBTITLE': '編輯字幕', "Drag and drop files": "拖放檔案",
'Edit media': '編輯影片', "EDIT MEDIA": "編輯影片",
'Edit profile': '編輯個人資料', "EDIT PROFILE": "編輯個人資料",
'Edit subtitle': '編輯字幕', "EDIT SUBTITLE": "編輯字幕",
'Featured': '精選內容', "Edit media": "編輯影片",
'Go': '執行', # in context of "execution" "Edit profile": "編輯個人資料",
'History': '觀看紀錄', "Edit subtitle": "編輯字幕",
'Home': '首頁', "Featured": "精選內容",
'Language': '語言', "Go": "執行",
'Latest': '最新內容', "History": "觀看紀錄",
'Liked media': '我喜歡的影片', "Home": "首頁",
'Manage comments': '留言管理', "Language": "語言",
'Manage media': '媒體管理', "Latest": "最新內容",
'Manage users': '使用者管理', "Liked media": "我喜歡的影片",
'Media': '媒體', "Manage comments": "留言管理",
'Media was edited': '媒體已更新', "Manage media": "媒體管理",
'Members': '會員', "Manage users": "使用者管理",
'My media': '我的媒體', "Media": "媒體",
'My playlists': '我的播放清單', "Media was edited": "媒體已更新",
'No': '', # in context of "no comments", etc. "Members": "會員",
'No comment yet': '尚無留言', "My media": "我的媒體",
'No comments yet': '尚未有留言', "My playlists": "我的播放清單",
'No results for': '查無相關結果:', "No": "",
'PLAYLISTS': '播放清單', "No comment yet": "尚無留言",
'Playlists': '播放清單', "No comments yet": "尚未有留言",
'Powered by': '技術提供為', "No results for": "查無相關結果:",
'Published on': '發布日期為', "PLAYLISTS": "播放清單",
'Recommended': '推薦內容', "Playlists": "播放清單",
'Register': '註冊', "Powered by": "技術提供為",
'SAVE': '儲存', "Publish": "發布",
'SEARCH': '搜尋', "Published on": "發布日期為",
'SHARE': '分享', "Recommended": "推薦內容",
'SHOW MORE': '顯示更多', "Record Screen": "螢幕錄製",
'SUBMIT': '送出', "Register": "註冊",
'Search': '搜尋', "SAVE": "儲存",
'Select': '選擇', "SEARCH": "搜尋",
'Sign in': '登入', "SHARE": "分享",
'Sign out': '登出', "SHOW MORE": "顯示更多",
'Subtitle was added': '字幕已新增', "SUBMIT": "送出",
'Tags': '標籤', "Search": "搜尋",
'Terms': '使用條款', "Select": "選擇",
'UPLOAD': '上傳', "Sign in": "登入",
'Up next': '即將播放', "Sign out": "登出",
'Upload': '上傳', "Start Recording": "開始錄製",
'Upload media': '上傳媒體', "Stop Recording": "停止錄製",
'Uploads': '上傳內容', "Subtitle was added": "字幕已新增",
'VIEW ALL': '查看全部', "Subtitles": "字幕",
'View all': '瀏覽全部', "Tags": "標籤",
'comment': '留言', "Terms": "使用條款",
'is a modern, fully featured open source video and media CMS. It is developed to meet the needs of modern web platforms for viewing and sharing media': '這是一個現代化且功能完整的開源影音內容管理系統,專為現代網路平台的觀賞與分享需求所打造。', "This works in Chrome, Safari and Edge browsers.": "此功能適用於 Chrome、Safari 和 Edge 瀏覽器。",
'media in category': '此分類下的媒體', "Trim": "修剪",
'media in tag': '此標籤下的媒體', "UPLOAD": "上傳",
'view': '次觀看', "Up next": "即將播放",
'views': '次觀看', "Upload": "上傳",
'yet': ' ', # no such usage in this language, "Upload media": "上傳媒體",
"Uploads": "上傳內容",
"VIEW ALL": "查看全部",
"View all": "瀏覽全部",
"View media": "查看媒體",
"comment": "留言",
"is a modern, fully featured open source video and media CMS. It is developed to meet the needs of modern web platforms for viewing and sharing media": "這是一個現代化且功能完整的開源影音內容管理系統,專為現代網路平台的觀賞與分享需求所打造。",
"media in category": "此分類下的媒體",
"media in tag": "此標籤下的媒體",
"or": "或者",
"view": "次觀看",
"views": "次觀看",
"yet": " ",
} }
replacement_strings = { replacement_strings = {
'Apr': '四月', "Apr": "四月",
'Aug': '八月', "Aug": "八月",
'Dec': '十二月', "Dec": "十二月",
'Feb': '二月', "Feb": "二月",
'Jan': '一月', "Jan": "一月",
'Jul': '七月', "Jul": "七月",
'Jun': '六月', "Jun": "六月",
'Mar': '三月', "Mar": "三月",
'May': '五月', "May": "五月",
'Nov': '十一月', "Nov": "十一月",
'Oct': '十月', "Oct": "十月",
'Sep': '九月', "Sep": "九月",
'day ago': '天前', "day ago": "天前",
'days ago': '天前', "days ago": "天前",
'hour ago': '小時前', "hour ago": "小時前",
'hours ago': '小時前', "hours ago": "小時前",
'just now': '剛剛', "just now": "剛剛",
'minute ago': '分鐘前', "minute ago": "分鐘前",
'minutes ago': '分鐘前', "minutes ago": "分鐘前",
'month ago': '個月前', "month ago": "個月前",
'months ago': '個月前', "months ago": "個月前",
'second ago': '秒前', "second ago": "秒前",
'seconds ago': '秒前', "seconds ago": "秒前",
'week ago': '週前', "week ago": "週前",
'weeks ago': '週前', "weeks ago": "週前",
'year ago': '年前', "year ago": "年前",
'years ago': '年前', "years ago": "年前",
} }

View File

@ -1,3 +1,5 @@
from django.conf import settings
from django.db.models import Q
from drf_yasg import openapi as openapi from drf_yasg import openapi as openapi
from drf_yasg.utils import swagger_auto_schema from drf_yasg.utils import swagger_auto_schema
from rest_framework import status from rest_framework import status
@ -219,6 +221,13 @@ class UserList(APIView):
elif role == "editor": elif role == "editor":
qs = qs.filter(is_editor=True) qs = qs.filter(is_editor=True)
if settings.USERS_NEEDS_TO_BE_APPROVED:
is_approved = request.GET.get("is_approved")
if is_approved == "true":
qs = qs.filter(is_approved=True)
elif is_approved == "false":
qs = qs.filter(Q(is_approved=False) | Q(is_approved__isnull=True))
users = qs.order_by(f"{ordering}{sort_by}") users = qs.order_by(f"{ordering}{sort_by}")
paginator = pagination_class() paginator = pagination_class()

View File

@ -401,6 +401,44 @@ def clean_comment(raw_comment):
return cleaned_comment return cleaned_comment
def user_allowed_to_upload(request):
"""Any custom logic for whether a user is allowed
to upload content lives here
"""
if request.user.is_anonymous:
return False
if is_mediacms_editor(request.user):
return True
# Check if user has reached the maximum number of uploads
if hasattr(settings, 'NUMBER_OF_MEDIA_USER_CAN_UPLOAD'):
if models.Media.objects.filter(user=request.user).count() >= settings.NUMBER_OF_MEDIA_USER_CAN_UPLOAD:
return False
if settings.CAN_ADD_MEDIA == "all":
return True
elif settings.CAN_ADD_MEDIA == "email_verified":
if request.user.email_is_verified:
return True
elif settings.CAN_ADD_MEDIA == "advancedUser":
if request.user.advancedUser:
return True
return False
def can_transcribe_video(user):
"""Checks if a user can transcribe a video."""
if not getattr(settings, 'USE_WHISPER_TRANSCRIBE', False):
return False
if is_mediacms_editor(user):
return True
if getattr(settings, 'USER_CAN_TRANSCRIBE_VIDEO', False):
return True
return False
def kill_ffmpeg_process(filepath): def kill_ffmpeg_process(filepath):
"""Kill ffmpeg process that is processing a specific file """Kill ffmpeg process that is processing a specific file
@ -566,4 +604,49 @@ def handle_video_chapters(media, chapters):
else: else:
video_chapter = models.VideoChapterData.objects.create(media=media, data=chapters) video_chapter = models.VideoChapterData.objects.create(media=media, data=chapters)
return media.chapter_data return {'chapters': media.chapter_data}
def change_media_owner(media_id, new_user):
"""Change the owner of a media
Args:
media_id: ID of the media to change owner
new_user: New user object to set as owner
Returns:
Media object or None if media not found
"""
media = models.Media.objects.filter(id=media_id).first()
if not media:
return None
# Change the owner
media.user = new_user
media.save(update_fields=["user"])
# Update any related permissions
media_permissions = models.MediaPermission.objects.filter(media=media)
for permission in media_permissions:
permission.owner_user = new_user
permission.save(update_fields=["owner_user"])
return media
def copy_media(media_id):
"""Create a copy of a media
Args:
media_id: ID of the media to copy
Returns:
None
"""
pass
def is_media_allowed_type(media):
if "all" in settings.ALLOWED_MEDIA_UPLOAD_TYPES:
return True
return media.media_type in settings.ALLOWED_MEDIA_UPLOAD_TYPES

View File

@ -0,0 +1,29 @@
# Generated by Django 5.1.6 on 2025-07-08 19:15
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('files', '0010_alter_encodeprofile_resolution'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='MediaPermission',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('permission', models.CharField(choices=[('viewer', 'Viewer'), ('editor', 'Editor'), ('owner', 'Owner')], max_length=20)),
('created_at', models.DateTimeField(auto_now_add=True)),
('media', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='permissions', to='files.media')),
('owner_user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='granted_permissions', to=settings.AUTH_USER_MODEL)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
options={
'unique_together': {('user', 'media')},
},
),
]

View File

@ -0,0 +1,39 @@
# Generated by Django 5.1.6 on 2025-08-31 08:28
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('files', '0011_mediapermission'),
]
operations = [
migrations.AlterField(
model_name='language',
name='code',
field=models.CharField(help_text='language code', max_length=30),
),
migrations.AddField(
model_name='media',
name='allow_whisper_transcribe',
field=models.BooleanField(default=False, verbose_name='Transcribe auto-detected language'),
),
migrations.AddField(
model_name='media',
name='allow_whisper_transcribe_and_translate',
field=models.BooleanField(default=False, verbose_name='Transcribe auto-detected language and translate to English'),
),
migrations.CreateModel(
name='TranscriptionRequest',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('add_date', models.DateTimeField(auto_now_add=True)),
('status', models.CharField(choices=[('pending', 'Pending'), ('running', 'Running'), ('fail', 'Fail'), ('success', 'Success')], db_index=True, default='pending', max_length=20)),
('translate_to_english', models.BooleanField(default=False)),
('logs', models.TextField(blank=True, null=True)),
('media', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transcriptionrequests', to='files.media')),
],
),
]

View File

@ -0,0 +1,42 @@
# Generated by Django 5.2.6 on 2025-09-21 11:49
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('files', '0012_media_allow_whisper_transcribe_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Page',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('slug', models.SlugField(max_length=200, unique=True)),
('title', models.CharField(max_length=200)),
('description', models.TextField(blank=True)),
('add_date', models.DateTimeField(auto_now_add=True)),
('edit_date', models.DateTimeField(auto_now=True)),
],
),
migrations.CreateModel(
name='TinyMCEMedia',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('file', models.FileField(upload_to='tinymce_media/')),
('uploaded_at', models.DateTimeField(auto_now_add=True)),
('file_type', models.CharField(choices=[('image', 'Image'), ('media', 'Media')], max_length=10)),
('original_filename', models.CharField(max_length=255)),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'TinyMCE Media',
'verbose_name_plural': 'TinyMCE Media',
'ordering': ['-uploaded_at'],
},
),
]

26
files/models/__init__.py Normal file
View File

@ -0,0 +1,26 @@
# Import all models for backward compatibility
from .category import Category, Tag # noqa: F401
from .comment import Comment # noqa: F401
from .encoding import EncodeProfile, Encoding # noqa: F401
from .license import License # noqa: F401
from .media import Media, MediaPermission # noqa: F401
from .page import Page, TinyMCEMedia # noqa: F401
from .playlist import Playlist, PlaylistMedia # noqa: F401
from .rating import Rating, RatingCategory # noqa: F401
from .subtitle import Language, Subtitle, TranscriptionRequest # noqa: F401
from .utils import CODECS # noqa: F401
from .utils import ENCODE_EXTENSIONS # noqa: F401
from .utils import ENCODE_EXTENSIONS_KEYS # noqa: F401
from .utils import ENCODE_RESOLUTIONS # noqa: F401
from .utils import ENCODE_RESOLUTIONS_KEYS # noqa: F401
from .utils import MEDIA_ENCODING_STATUS # noqa: F401
from .utils import MEDIA_STATES # noqa: F401
from .utils import MEDIA_TYPES_SUPPORTED # noqa: F401
from .utils import category_thumb_path # noqa: F401
from .utils import encoding_media_file_path # noqa: F401
from .utils import generate_uid # noqa: F401
from .utils import original_media_file_path # noqa: F401
from .utils import original_thumbnail_file_path # noqa: F401
from .utils import subtitles_file_path # noqa: F401
from .utils import validate_rating # noqa: F401
from .video_data import VideoChapterData, VideoTrimRequest # noqa: F401

156
files/models/category.py Normal file
View File

@ -0,0 +1,156 @@
from django.db import models
from django.urls import reverse
from django.utils.html import strip_tags
from imagekit.models import ProcessedImageField
from imagekit.processors import ResizeToFit
from .. import helpers
from .utils import category_thumb_path, generate_uid
class Category(models.Model):
"""A Category base model"""
uid = models.CharField(unique=True, max_length=36, default=generate_uid)
add_date = models.DateTimeField(auto_now_add=True)
title = models.CharField(max_length=100, db_index=True)
description = models.TextField(blank=True)
user = models.ForeignKey("users.User", on_delete=models.CASCADE, blank=True, null=True)
is_global = models.BooleanField(default=False, help_text="global categories or user specific")
media_count = models.IntegerField(default=0, help_text="number of media")
thumbnail = ProcessedImageField(
upload_to=category_thumb_path,
processors=[ResizeToFit(width=344, height=None)],
format="JPEG",
options={"quality": 85},
blank=True,
)
listings_thumbnail = models.CharField(max_length=400, blank=True, null=True, help_text="Thumbnail to show on listings")
is_rbac_category = models.BooleanField(default=False, db_index=True, help_text='If access to Category is controlled by role based membership of Groups')
identity_provider = models.ForeignKey(
'socialaccount.SocialApp',
blank=True,
null=True,
on_delete=models.CASCADE,
related_name='categories',
help_text='If category is related with a specific Identity Provider',
verbose_name='IDP Config Name',
)
def __str__(self):
return self.title
class Meta:
ordering = ["title"]
verbose_name_plural = "Categories"
def get_absolute_url(self):
return f"{reverse('search')}?c={self.title}"
def update_category_media(self):
"""Set media_count"""
# Always set number of Category the total number of media
# Depending on how RBAC is set and Permissions etc it is
# possible that users won't see all media in a Category
# but it's worth to handle this on the UI level
# (eg through a message that says that you see only files you have permissions to see)
self.media_count = Media.objects.filter(category=self).count()
self.save(update_fields=["media_count"])
# OLD logic
# if getattr(settings, 'USE_RBAC', False) and self.is_rbac_category:
# self.media_count = Media.objects.filter(category=self).count()
# else:
# self.media_count = Media.objects.filter(listable=True, category=self).count()
self.save(update_fields=["media_count"])
return True
@property
def thumbnail_url(self):
"""Return thumbnail for category
prioritize processed value of listings_thumbnail
then thumbnail
"""
if self.thumbnail:
return helpers.url_from_path(self.thumbnail.path)
if self.listings_thumbnail:
return self.listings_thumbnail
if Media.objects.filter(category=self, state="public").exists():
media = Media.objects.filter(category=self, state="public").order_by("-views").first()
if media:
return media.thumbnail_url
return None
def save(self, *args, **kwargs):
strip_text_items = ["title", "description"]
for item in strip_text_items:
setattr(self, item, strip_tags(getattr(self, item, None)))
super(Category, self).save(*args, **kwargs)
class Tag(models.Model):
"""A Tag model"""
title = models.CharField(max_length=100, unique=True, db_index=True)
user = models.ForeignKey("users.User", on_delete=models.CASCADE, blank=True, null=True)
media_count = models.IntegerField(default=0, help_text="number of media")
listings_thumbnail = models.CharField(
max_length=400,
blank=True,
null=True,
help_text="Thumbnail to show on listings",
db_index=True,
)
def __str__(self):
return self.title
class Meta:
ordering = ["title"]
def get_absolute_url(self):
return f"{reverse('search')}?t={self.title}"
def update_tag_media(self):
self.media_count = Media.objects.filter(state="public", is_reviewed=True, tags=self).count()
self.save(update_fields=["media_count"])
return True
def save(self, *args, **kwargs):
self.title = helpers.get_alphanumeric_only(self.title)
self.title = self.title[:100]
super(Tag, self).save(*args, **kwargs)
@property
def thumbnail_url(self):
if self.listings_thumbnail:
return self.listings_thumbnail
media = Media.objects.filter(tags=self, state="public").order_by("-views").first()
if media:
return media.thumbnail_url
return None
# Import Media to avoid circular imports
from .media import Media # noqa

46
files/models/comment.py Normal file
View File

@ -0,0 +1,46 @@
import uuid
from django.conf import settings
from django.db import models
from django.urls import reverse
from django.utils.html import strip_tags
from mptt.models import MPTTModel, TreeForeignKey
class Comment(MPTTModel):
"""Comments model"""
add_date = models.DateTimeField(auto_now_add=True)
media = models.ForeignKey("Media", on_delete=models.CASCADE, db_index=True, related_name="comments")
parent = TreeForeignKey("self", on_delete=models.CASCADE, null=True, blank=True, related_name="children")
text = models.TextField(help_text="text")
uid = models.UUIDField(unique=True, default=uuid.uuid4)
user = models.ForeignKey("users.User", on_delete=models.CASCADE, db_index=True)
class MPTTMeta:
order_insertion_by = ["add_date"]
def __str__(self):
return f"On {self.media.title} by {self.user.username}"
def save(self, *args, **kwargs):
strip_text_items = ["text"]
for item in strip_text_items:
setattr(self, item, strip_tags(getattr(self, item, None)))
if self.text:
self.text = self.text[: settings.MAX_CHARS_FOR_COMMENT]
super(Comment, self).save(*args, **kwargs)
def get_absolute_url(self):
return f"{reverse('get_media')}?m={self.media.friendly_token}"
@property
def media_url(self):
return self.get_absolute_url()

303
files/models/encoding.py Normal file
View File

@ -0,0 +1,303 @@
import json
import tempfile
from django.conf import settings
from django.core.files import File
from django.db import models
from django.db.models.signals import post_delete, post_save
from django.dispatch import receiver
from django.urls import reverse
from .. import helpers
from .utils import (
CODECS,
ENCODE_EXTENSIONS,
ENCODE_RESOLUTIONS,
MEDIA_ENCODING_STATUS,
encoding_media_file_path,
)
class EncodeProfile(models.Model):
"""Encode Profile model
keeps information for each profile
"""
name = models.CharField(max_length=90)
extension = models.CharField(max_length=10, choices=ENCODE_EXTENSIONS)
resolution = models.IntegerField(choices=ENCODE_RESOLUTIONS, blank=True, null=True)
codec = models.CharField(max_length=10, choices=CODECS, blank=True, null=True)
description = models.TextField(blank=True, help_text="description")
active = models.BooleanField(default=True)
def __str__(self):
return self.name
class Meta:
ordering = ["resolution"]
class Encoding(models.Model):
"""Encoding Media Instances"""
add_date = models.DateTimeField(auto_now_add=True)
commands = models.TextField(blank=True, help_text="commands run")
chunk = models.BooleanField(default=False, db_index=True, help_text="is chunk?")
chunk_file_path = models.CharField(max_length=400, blank=True)
chunks_info = models.TextField(blank=True)
logs = models.TextField(blank=True)
md5sum = models.CharField(max_length=50, blank=True, null=True)
media = models.ForeignKey("Media", on_delete=models.CASCADE, related_name="encodings")
media_file = models.FileField("encoding file", upload_to=encoding_media_file_path, blank=True, max_length=500)
profile = models.ForeignKey(EncodeProfile, on_delete=models.CASCADE)
progress = models.PositiveSmallIntegerField(default=0)
update_date = models.DateTimeField(auto_now=True)
retries = models.IntegerField(default=0)
size = models.CharField(max_length=20, blank=True)
status = models.CharField(max_length=20, choices=MEDIA_ENCODING_STATUS, default="pending")
temp_file = models.CharField(max_length=400, blank=True)
task_id = models.CharField(max_length=100, blank=True)
total_run_time = models.IntegerField(default=0)
worker = models.CharField(max_length=100, blank=True)
@property
def media_encoding_url(self):
if self.media_file:
return helpers.url_from_path(self.media_file.path)
return None
@property
def media_chunk_url(self):
if self.chunk_file_path:
return helpers.url_from_path(self.chunk_file_path)
return None
def save(self, *args, **kwargs):
if self.media_file:
cmd = ["stat", "-c", "%s", self.media_file.path]
stdout = helpers.run_command(cmd).get("out")
if stdout:
size = int(stdout.strip())
self.size = helpers.show_file_size(size)
if self.chunk_file_path and not self.md5sum:
cmd = ["md5sum", self.chunk_file_path]
stdout = helpers.run_command(cmd).get("out")
if stdout:
md5sum = stdout.strip().split()[0]
self.md5sum = md5sum
super(Encoding, self).save(*args, **kwargs)
def update_size_without_save(self):
"""Update the size of an encoding without saving to avoid calling signals"""
if self.media_file:
cmd = ["stat", "-c", "%s", self.media_file.path]
stdout = helpers.run_command(cmd).get("out")
if stdout:
size = int(stdout.strip())
size = helpers.show_file_size(size)
Encoding.objects.filter(pk=self.pk).update(size=size)
return True
return False
def set_progress(self, progress, commit=True):
if isinstance(progress, int):
if 0 <= progress <= 100:
self.progress = progress
# save object with filter update
# to avoid calling signals
Encoding.objects.filter(pk=self.pk).update(progress=progress)
return True
return False
def __str__(self):
return f"{self.profile.name}-{self.media.title}"
def get_absolute_url(self):
return reverse("api_get_encoding", kwargs={"encoding_id": self.id})
@receiver(post_save, sender=Encoding)
def encoding_file_save(sender, instance, created, **kwargs):
"""Performs actions on encoding file delete
For example, if encoding is a chunk file, with encoding_status success,
perform a check if this is the final chunk file of a media, then
concatenate chunks, create final encoding file and delete chunks
"""
if instance.chunk and instance.status == "success":
# a chunk got completed
# check if all chunks are OK
# then concatenate to new Encoding - and remove chunks
# this should run only once!
if instance.media_file:
try:
orig_chunks = json.loads(instance.chunks_info).keys()
except BaseException:
instance.delete()
return False
chunks = Encoding.objects.filter(
media=instance.media,
profile=instance.profile,
chunks_info=instance.chunks_info,
chunk=True,
).order_by("add_date")
complete = True
# perform validation, make sure everything is there
for chunk in orig_chunks:
if not chunks.filter(chunk_file_path=chunk):
complete = False
break
for chunk in chunks:
if not (chunk.media_file and chunk.media_file.path):
complete = False
break
if complete:
# concatenate chunks and create final encoding file
chunks_paths = [f.media_file.path for f in chunks]
with tempfile.TemporaryDirectory(dir=settings.TEMP_DIRECTORY) as temp_dir:
seg_file = helpers.create_temp_file(suffix=".txt", dir=temp_dir)
tf = helpers.create_temp_file(suffix=f".{instance.profile.extension}", dir=temp_dir)
with open(seg_file, "w") as ff:
for f in chunks_paths:
ff.write(f"file {f}\n")
cmd = [
settings.FFMPEG_COMMAND,
"-y",
"-f",
"concat",
"-safe",
"0",
"-i",
seg_file,
"-c",
"copy",
"-pix_fmt",
"yuv420p",
"-movflags",
"faststart",
tf,
]
stdout = helpers.run_command(cmd)
encoding = Encoding(
media=instance.media,
profile=instance.profile,
status="success",
progress=100,
)
all_logs = "\n".join([st.logs for st in chunks])
encoding.logs = f"{chunks_paths}\n{stdout}\n{all_logs}"
workers = list(set([st.worker for st in chunks]))
encoding.worker = json.dumps({"workers": workers})
start_date = min([st.add_date for st in chunks])
end_date = max([st.update_date for st in chunks])
encoding.total_run_time = (end_date - start_date).seconds
encoding.save()
with open(tf, "rb") as f:
myfile = File(f)
output_name = f"{helpers.get_file_name(instance.media.media_file.path)}.{instance.profile.extension}"
encoding.media_file.save(content=myfile, name=output_name)
# encoding is saved, deleting chunks
# and any other encoding that might exist
# first perform one last validation
# to avoid that this is run twice
if (
len(orig_chunks)
== Encoding.objects.filter( # noqa
media=instance.media,
profile=instance.profile,
chunks_info=instance.chunks_info,
).count()
):
# if two chunks are finished at the same time, this
# will be changed
who = Encoding.objects.filter(media=encoding.media, profile=encoding.profile).exclude(id=encoding.id)
who.delete()
else:
encoding.delete()
if not Encoding.objects.filter(chunks_info=instance.chunks_info):
# TODO: in case of remote workers, files should be deleted
# example
# for worker in workers:
# for chunk in json.loads(instance.chunks_info).keys():
# remove_media_file.delay(media_file=chunk)
for chunk in json.loads(instance.chunks_info).keys():
helpers.rm_file(chunk)
instance.media.post_encode_actions(encoding=instance, action="add")
elif instance.chunk and instance.status == "fail":
encoding = Encoding(media=instance.media, profile=instance.profile, status="fail", progress=100)
chunks = Encoding.objects.filter(media=instance.media, chunks_info=instance.chunks_info, chunk=True).order_by("add_date")
chunks_paths = [f.media_file.path for f in chunks]
all_logs = "\n".join([st.logs for st in chunks])
encoding.logs = f"{chunks_paths}\n{all_logs}"
workers = list(set([st.worker for st in chunks]))
encoding.worker = json.dumps({"workers": workers})
start_date = min([st.add_date for st in chunks])
end_date = max([st.update_date for st in chunks])
encoding.total_run_time = (end_date - start_date).seconds
encoding.save()
who = Encoding.objects.filter(media=encoding.media, profile=encoding.profile).exclude(id=encoding.id)
who.delete()
# TODO: merge with above if, do not repeat code
else:
if instance.status in ["fail", "success"]:
instance.media.post_encode_actions(encoding=instance, action="add")
encodings = set([encoding.status for encoding in Encoding.objects.filter(media=instance.media)])
if ("running" in encodings) or ("pending" in encodings):
return
@receiver(post_delete, sender=Encoding)
def encoding_file_delete(sender, instance, **kwargs):
"""
Deletes file from filesystem
when corresponding `Encoding` object is deleted.
"""
if instance.media_file:
helpers.rm_file(instance.media_file.path)
if not instance.chunk:
instance.media.post_encode_actions(encoding=instance, action="delete")
# delete local chunks, and remote chunks + media file. Only when the
# last encoding of a media is complete

11
files/models/license.py Normal file
View File

@ -0,0 +1,11 @@
from django.db import models
class License(models.Model):
"""A Base license model to be used in Media"""
title = models.CharField(max_length=100, unique=True)
description = models.TextField(blank=True)
def __str__(self):
return self.title

File diff suppressed because it is too large Load Diff

42
files/models/page.py Normal file
View File

@ -0,0 +1,42 @@
from django.db import models
from django.urls import reverse
class Page(models.Model):
slug = models.SlugField(max_length=200, unique=True)
title = models.CharField(max_length=200)
description = models.TextField(blank=True)
add_date = models.DateTimeField(auto_now_add=True)
edit_date = models.DateTimeField(auto_now=True)
def __str__(self):
return self.title
def get_absolute_url(self):
return reverse("get_page", args=[str(self.slug)])
class TinyMCEMedia(models.Model):
file = models.FileField(upload_to='tinymce_media/')
uploaded_at = models.DateTimeField(auto_now_add=True)
file_type = models.CharField(
max_length=10,
choices=(
('image', 'Image'),
('media', 'Media'),
),
)
original_filename = models.CharField(max_length=255)
user = models.ForeignKey("users.User", on_delete=models.CASCADE, null=True, blank=True)
class Meta:
verbose_name = 'TinyMCE Media'
verbose_name_plural = 'TinyMCE Media'
ordering = ['-uploaded_at']
def __str__(self):
return f"{self.original_filename} ({self.file_type})"
@property
def url(self):
return self.file.url

97
files/models/playlist.py Normal file
View File

@ -0,0 +1,97 @@
import uuid
from django.db import models
from django.urls import reverse
from django.utils.html import strip_tags
from .. import helpers
class Playlist(models.Model):
"""Playlists model"""
add_date = models.DateTimeField(auto_now_add=True, db_index=True)
description = models.TextField(blank=True, help_text="description")
friendly_token = models.CharField(blank=True, max_length=12, db_index=True)
media = models.ManyToManyField("Media", through="playlistmedia", blank=True)
title = models.CharField(max_length=100, db_index=True)
uid = models.UUIDField(unique=True, default=uuid.uuid4)
user = models.ForeignKey("users.User", on_delete=models.CASCADE, db_index=True, related_name="playlists")
def __str__(self):
return self.title
@property
def media_count(self):
return self.media.filter(listable=True).count()
def get_absolute_url(self, api=False):
if api:
return reverse("api_get_playlist", kwargs={"friendly_token": self.friendly_token})
else:
return reverse("get_playlist", kwargs={"friendly_token": self.friendly_token})
@property
def url(self):
return self.get_absolute_url()
@property
def api_url(self):
return self.get_absolute_url(api=True)
def user_thumbnail_url(self):
if self.user.logo:
return helpers.url_from_path(self.user.logo.path)
return None
def set_ordering(self, media, ordering):
if media not in self.media.all():
return False
pm = PlaylistMedia.objects.filter(playlist=self, media=media).first()
if pm and isinstance(ordering, int) and 0 < ordering:
pm.ordering = ordering
pm.save()
return True
return False
def save(self, *args, **kwargs):
strip_text_items = ["title", "description"]
for item in strip_text_items:
setattr(self, item, strip_tags(getattr(self, item, None)))
self.title = self.title[:100]
if not self.friendly_token:
while True:
friendly_token = helpers.produce_friendly_token()
if not Playlist.objects.filter(friendly_token=friendly_token):
self.friendly_token = friendly_token
break
super(Playlist, self).save(*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)
return None
class PlaylistMedia(models.Model):
"""Helper model to store playlist specific media"""
action_date = models.DateTimeField(auto_now=True)
media = models.ForeignKey("Media", on_delete=models.CASCADE)
playlist = models.ForeignKey(Playlist, on_delete=models.CASCADE)
ordering = models.IntegerField(default=1)
class Meta:
ordering = ["ordering", "-action_date"]

47
files/models/rating.py Normal file
View File

@ -0,0 +1,47 @@
from django.db import models
from .utils import validate_rating
class RatingCategory(models.Model):
"""Rating Category
Facilitate user ratings.
One or more rating categories per Category can exist
will be shown to the media if they are enabled
"""
description = models.TextField(blank=True)
enabled = models.BooleanField(default=True)
title = models.CharField(max_length=200, unique=True, db_index=True)
class Meta:
verbose_name_plural = "Rating Categories"
def __str__(self):
return f"{self.title}"
class Rating(models.Model):
"""User Rating"""
add_date = models.DateTimeField(auto_now_add=True)
media = models.ForeignKey("Media", on_delete=models.CASCADE, related_name="ratings")
rating_category = models.ForeignKey(RatingCategory, on_delete=models.CASCADE)
score = models.IntegerField(validators=[validate_rating])
user = models.ForeignKey("users.User", on_delete=models.CASCADE)
class Meta:
verbose_name_plural = "Ratings"
indexes = [
models.Index(fields=["user", "media"]),
]
unique_together = ("user", "media", "rating_category")
def __str__(self):
return f"{self.user.username}, rate for {self.media.title} for category {self.rating_category.title}"

84
files/models/subtitle.py Normal file
View File

@ -0,0 +1,84 @@
import os
import tempfile
from django.conf import settings
from django.db import models
from django.urls import reverse
from .. import helpers
from .utils import MEDIA_ENCODING_STATUS, subtitles_file_path
class Language(models.Model):
"""Language model
to be used with Subtitles
"""
code = models.CharField(max_length=30, help_text="language code")
title = models.CharField(max_length=100, help_text="language code")
class Meta:
ordering = ["id"]
def __str__(self):
return f"{self.code}-{self.title}"
class Subtitle(models.Model):
"""Subtitles model"""
language = models.ForeignKey(Language, on_delete=models.CASCADE)
media = models.ForeignKey("Media", on_delete=models.CASCADE, related_name="subtitles")
subtitle_file = models.FileField(
"Subtitle/CC file",
help_text="File has to be WebVTT format",
upload_to=subtitles_file_path,
max_length=500,
)
user = models.ForeignKey("users.User", on_delete=models.CASCADE)
class Meta:
ordering = ["language__title"]
def __str__(self):
return f"{self.media.title}-{self.language.title}"
def get_absolute_url(self):
return f"{reverse('edit_subtitle')}?id={self.id}"
@property
def url(self):
return self.get_absolute_url()
def convert_to_srt(self):
input_path = self.subtitle_file.path
with tempfile.TemporaryDirectory(dir=settings.TEMP_DIRECTORY) as tmpdirname:
pysub = settings.PYSUBS_COMMAND
cmd = [pysub, input_path, "--to", "vtt", "-o", tmpdirname]
stdout = helpers.run_command(cmd)
list_of_files = os.listdir(tmpdirname)
if list_of_files:
subtitles_file = os.path.join(tmpdirname, list_of_files[0])
cmd = ["cp", subtitles_file, input_path]
stdout = helpers.run_command(cmd) # noqa
else:
raise Exception("Could not convert to srt")
return True
class TranscriptionRequest(models.Model):
# Whisper transcription request
media = models.ForeignKey("Media", on_delete=models.CASCADE, related_name="transcriptionrequests")
add_date = models.DateTimeField(auto_now_add=True)
status = models.CharField(max_length=20, choices=MEDIA_ENCODING_STATUS, default="pending", db_index=True)
translate_to_english = models.BooleanField(default=False)
logs = models.TextField(blank=True, null=True)
def __str__(self):
return f"Transcription request for {self.media.title} - {self.status}"

99
files/models/utils.py Normal file
View File

@ -0,0 +1,99 @@
from django.conf import settings
from django.core.exceptions import ValidationError
from django.utils.crypto import get_random_string
from .. import helpers
# this is used by Media and Encoding models
# reflects media encoding status for objects
MEDIA_ENCODING_STATUS = (
("pending", "Pending"),
("running", "Running"),
("fail", "Fail"),
("success", "Success"),
)
# the media state of a Media object
# this is set by default according to the portal workflow
MEDIA_STATES = (
("private", "Private"),
("public", "Public"),
("unlisted", "Unlisted"),
)
# each uploaded Media gets a media_type hint
# by helpers.get_file_type
MEDIA_TYPES_SUPPORTED = (
("video", "Video"),
("image", "Image"),
("pdf", "Pdf"),
("audio", "Audio"),
)
ENCODE_EXTENSIONS = (
("mp4", "mp4"),
("webm", "webm"),
("gif", "gif"),
)
ENCODE_RESOLUTIONS = (
(2160, "2160"),
(1440, "1440"),
(1080, "1080"),
(720, "720"),
(480, "480"),
(360, "360"),
(240, "240"),
(144, "144"),
)
CODECS = (
("h265", "h265"),
("h264", "h264"),
("vp9", "vp9"),
)
ENCODE_EXTENSIONS_KEYS = [extension for extension, name in ENCODE_EXTENSIONS]
ENCODE_RESOLUTIONS_KEYS = [resolution for resolution, name in ENCODE_RESOLUTIONS]
def generate_uid():
return get_random_string(length=16)
def original_media_file_path(instance, filename):
"""Helper function to place original media file"""
file_name = f"{instance.uid.hex}.{helpers.get_file_name(filename)}"
return settings.MEDIA_UPLOAD_DIR + f"user/{instance.user.username}/{file_name}"
def encoding_media_file_path(instance, filename):
"""Helper function to place encoded media file"""
file_name = f"{instance.media.uid.hex}.{helpers.get_file_name(filename)}"
return settings.MEDIA_ENCODING_DIR + f"{instance.profile.id}/{instance.media.user.username}/{file_name}"
def original_thumbnail_file_path(instance, filename):
"""Helper function to place original media thumbnail file"""
return settings.THUMBNAIL_UPLOAD_DIR + f"user/{instance.user.username}/{filename}"
def subtitles_file_path(instance, filename):
"""Helper function to place subtitle file"""
return settings.SUBTITLES_UPLOAD_DIR + f"user/{instance.media.user.username}/{filename}"
def category_thumb_path(instance, filename):
"""Helper function to place category thumbnail file"""
file_name = f"{instance.uid}.{helpers.get_file_name(filename)}"
return settings.MEDIA_UPLOAD_DIR + f"categories/{file_name}"
def validate_rating(value):
if -1 >= value or value > 5:
raise ValidationError("score has to be between 0 and 5")

View File

@ -0,0 +1,65 @@
from django.db import models
from django.db.models.signals import post_delete
from django.dispatch import receiver
from .. import helpers
class VideoChapterData(models.Model):
data = models.JSONField(null=False, blank=False, help_text="Chapter data")
media = models.ForeignKey('Media', on_delete=models.CASCADE, related_name='chapters')
class Meta:
unique_together = ['media']
@property
def chapter_data(self):
# ensure response is consistent
data = []
if self.data and isinstance(self.data, list):
for item in self.data:
if item.get("startTime") and item.get("endTime") and item.get("chapterTitle"):
chapter_item = {
'startTime': item.get("startTime"),
'endTime': item.get("endTime"),
'chapterTitle': item.get("chapterTitle"),
}
data.append(chapter_item)
return data
class VideoTrimRequest(models.Model):
"""Model to handle video trimming requests"""
VIDEO_TRIM_STATUS = (
("initial", "Initial"),
("running", "Running"),
("success", "Success"),
("fail", "Fail"),
)
VIDEO_ACTION_CHOICES = (
("replace", "Replace Original"),
("save_new", "Save as New"),
("create_segments", "Create Segments"),
)
TRIM_STYLE_CHOICES = (
("no_encoding", "No Encoding"),
("precise", "Precise"),
)
media = models.ForeignKey('Media', on_delete=models.CASCADE, related_name='trim_requests')
status = models.CharField(max_length=20, choices=VIDEO_TRIM_STATUS, default="initial")
add_date = models.DateTimeField(auto_now_add=True)
video_action = models.CharField(max_length=20, choices=VIDEO_ACTION_CHOICES)
media_trim_style = models.CharField(max_length=20, choices=TRIM_STYLE_CHOICES, default="no_encoding")
timestamps = models.JSONField(null=False, blank=False, help_text="Timestamps for trimming")
def __str__(self):
return f"Trim request for {self.media.title} ({self.status})"
@receiver(post_delete, sender=VideoChapterData)
def videochapterdata_delete(sender, instance, **kwargs):
helpers.rm_dir(instance.media.video_chapters_folder)

View File

@ -46,10 +46,12 @@ from .models import (
Category, Category,
EncodeProfile, EncodeProfile,
Encoding, Encoding,
Language,
Media, Media,
Rating, Rating,
Subtitle,
Tag, Tag,
VideoChapterData, TranscriptionRequest,
VideoTrimRequest, VideoTrimRequest,
) )
@ -465,6 +467,67 @@ def encode_media(
return success return success
@task(name="whisper_transcribe", queue="long_tasks", soft_time_limit=60 * 60 * 2)
def whisper_transcribe(friendly_token, translate_to_english=False):
try:
media = Media.objects.get(friendly_token=friendly_token)
except: # noqa
logger.info(f"failed to get media {friendly_token}")
return False
request = TranscriptionRequest.objects.filter(media=media, status="pending", translate_to_english=translate_to_english).first()
if not request:
logger.info(f"No pending transcription request for media {friendly_token}")
return False
if translate_to_english:
language = Language.objects.filter(code="whisper-translation").first()
if not language:
language = Language.objects.create(code="whisper-translation", title="English Translation")
else:
language = Language.objects.filter(code="whisper").first()
if not language:
language = Language.objects.create(code="whisper", title="Transcription")
cwd = os.path.dirname(os.path.realpath(media.media_file.path))
request.status = "running"
request.save(update_fields=["status"])
with tempfile.TemporaryDirectory(dir=settings.TEMP_DIRECTORY) as tmpdirname:
video_file_path = get_file_name(media.media_file.name)
video_file_path = '.'.join(video_file_path.split('.')[:-1]) # needed by whisper without the extension
subtitle_name = f"{video_file_path}.vtt"
output_name = f"{tmpdirname}/{subtitle_name}"
cmd = f"whisper /home/mediacms.io/mediacms/media_files/{media.media_file.name} --model {settings.WHISPER_MODEL} --output_dir {tmpdirname}"
if translate_to_english:
cmd += " --task translate"
logger.info(f"Whisper transcribe: ready to run command {cmd}")
start_time = datetime.now()
ret = run_command(cmd, cwd=cwd) # noqa
end_time = datetime.now()
duration = (end_time - start_time).total_seconds()
if os.path.exists(output_name):
subtitle = Subtitle.objects.create(media=media, user=media.user, language=language)
with open(output_name, 'rb') as f:
subtitle.subtitle_file.save(subtitle_name, File(f))
request.status = "success"
request.logs = f"Transcription took {duration:.2f} seconds." # noqa
request.save(update_fields=["status", "logs"])
return True
request.status = "fail"
request.logs = f"Transcription failed after {duration:.2f} seconds. Error: {ret.get('error')}" # noqa
request.save(update_fields=["status", "logs"])
return False
@task(name="produce_sprite_from_video", queue="long_tasks") @task(name="produce_sprite_from_video", queue="long_tasks")
def produce_sprite_from_video(friendly_token): def produce_sprite_from_video(friendly_token):
"""Produces a sprites file for a video, uses ffmpeg""" """Produces a sprites file for a video, uses ffmpeg"""
@ -820,7 +883,7 @@ def update_listings_thumbnails():
# Categories # Categories
used_media = [] used_media = []
saved = 0 saved = 0
qs = Category.objects.filter().order_by("-media_count") qs = Category.objects.filter()
for object in qs: for object in qs:
media = Media.objects.exclude(friendly_token__in=used_media).filter(category=object, state="public", is_reviewed=True).order_by("-views").first() media = Media.objects.exclude(friendly_token__in=used_media).filter(category=object, state="public", is_reviewed=True).order_by("-views").first()
if media: if media:
@ -833,7 +896,7 @@ def update_listings_thumbnails():
# Tags # Tags
used_media = [] used_media = []
saved = 0 saved = 0
qs = Tag.objects.filter().order_by("-media_count") qs = Tag.objects.filter()
for object in qs: for object in qs:
media = Media.objects.exclude(friendly_token__in=used_media).filter(tags=object, state="public", is_reviewed=True).order_by("-views").first() media = Media.objects.exclude(friendly_token__in=used_media).filter(tags=object, state="public", is_reviewed=True).order_by("-views").first()
if media: if media:
@ -886,45 +949,6 @@ def update_encoding_size(encoding_id):
return False return False
@task(name="produce_video_chapters", queue="short_tasks")
def produce_video_chapters(chapter_id):
# this is not used
return False
chapter_object = VideoChapterData.objects.filter(id=chapter_id).first()
if not chapter_object:
return False
media = chapter_object.media
video_path = media.media_file.path
output_folder = media.video_chapters_folder
chapters = chapter_object.data
width = 336
height = 188
if not os.path.exists(output_folder):
os.makedirs(output_folder)
results = []
for i, chapter in enumerate(chapters):
timestamp = chapter["start"]
title = chapter["title"]
output_filename = f"thumbnail_{i:02d}.jpg" # noqa
output_path = os.path.join(output_folder, output_filename)
command = [settings.FFMPEG_COMMAND, "-y", "-ss", str(timestamp), "-i", video_path, "-vframes", "1", "-q:v", "2", "-s", f"{width}x{height}", output_path]
ret = run_command(command) # noqa
if os.path.exists(output_path) and get_file_type(output_path) == "image":
results.append({"start": timestamp, "title": title, "thumbnail": output_path})
chapter_object.data = results
chapter_object.save(update_fields=["data"])
return True
@task(name="post_trim_action", queue="short_tasks", soft_time_limit=600) @task(name="post_trim_action", queue="short_tasks", soft_time_limit=600)
def post_trim_action(friendly_token): def post_trim_action(friendly_token):
"""Perform post-processing actions after video trimming """Perform post-processing actions after video trimming

20
files/tinymce_handlers.py Normal file
View File

@ -0,0 +1,20 @@
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
from .models import TinyMCEMedia
@csrf_exempt
def upload_image(request):
if not (request.user.is_authenticated and request.user.is_active and request.user.is_staff and request.user.is_superuser):
return JsonResponse({'error': 'Admin access required'}, status=403)
if request.method == "POST":
file_obj = request.FILES.get('file')
if file_obj:
# Create a new TinyMCEMedia instance for the image
media = TinyMCEMedia(file=file_obj, file_type='image', original_filename=file_obj.name, user=request.user if request.user.is_authenticated else None)
media.save()
return JsonResponse({'location': media.url})
return JsonResponse({'error': 'Invalid request'}, status=400)

View File

@ -4,9 +4,11 @@ from django.conf.urls import include
from django.conf.urls.static import static from django.conf.urls.static import static
from django.urls import path, re_path from django.urls import path, re_path
from . import management_views, views from . import management_views, tinymce_handlers, views
from .feeds import IndexRSSFeed, SearchRSSFeed from .feeds import IndexRSSFeed, SearchRSSFeed
friendly_token = r"(?P<friendly_token>[\w\-_]*)"
urlpatterns = [ urlpatterns = [
path("i18n/", include("django.conf.urls.i18n")), path("i18n/", include("django.conf.urls.i18n")),
re_path(r"^$", views.index), re_path(r"^$", views.index),
@ -28,12 +30,12 @@ urlpatterns = [
re_path(r"^latest$", views.latest_media), re_path(r"^latest$", views.latest_media),
re_path(r"^members", views.members, name="members"), re_path(r"^members", views.members, name="members"),
re_path( re_path(
r"^playlist/(?P<friendly_token>[\w]*)$", rf"^playlist/{friendly_token}$",
views.view_playlist, views.view_playlist,
name="get_playlist", name="get_playlist",
), ),
re_path( re_path(
r"^playlists/(?P<friendly_token>[\w]*)$", rf"^playlists/{friendly_token}$",
views.view_playlist, views.view_playlist,
name="get_playlist", name="get_playlist",
), ),
@ -41,6 +43,7 @@ urlpatterns = [
re_path(r"^recommended$", views.recommended_media), re_path(r"^recommended$", views.recommended_media),
path("rss/", IndexRSSFeed()), path("rss/", IndexRSSFeed()),
re_path("^rss/search", SearchRSSFeed()), re_path("^rss/search", SearchRSSFeed()),
re_path(r"^record_screen", views.record_screen, name="record_screen"),
re_path(r"^search", views.search, name="search"), re_path(r"^search", views.search, name="search"),
re_path(r"^scpublisher", views.upload_media, name="upload_media"), re_path(r"^scpublisher", views.upload_media, name="upload_media"),
re_path(r"^tags", views.tags, name="tags"), re_path(r"^tags", views.tags, name="tags"),
@ -48,10 +51,12 @@ urlpatterns = [
re_path(r"^view", views.view_media, name="get_media"), re_path(r"^view", views.view_media, name="get_media"),
re_path(r"^upload", views.upload_media, name="upload_media"), re_path(r"^upload", views.upload_media, name="upload_media"),
# API VIEWS # API VIEWS
re_path(r"^api/v1/media/user/bulk_actions$", views.MediaBulkUserActions.as_view()),
re_path(r"^api/v1/media/user/bulk_actions/$", views.MediaBulkUserActions.as_view()),
re_path(r"^api/v1/media$", views.MediaList.as_view()), re_path(r"^api/v1/media$", views.MediaList.as_view()),
re_path(r"^api/v1/media/$", views.MediaList.as_view()), re_path(r"^api/v1/media/$", views.MediaList.as_view()),
re_path( re_path(
r"^api/v1/media/(?P<friendly_token>[\w\-_]*)$", rf"^api/v1/media/{friendly_token}$",
views.MediaDetail.as_view(), views.MediaDetail.as_view(),
name="api_get_media", name="api_get_media",
), ),
@ -62,32 +67,32 @@ urlpatterns = [
), ),
re_path(r"^api/v1/search$", views.MediaSearch.as_view()), re_path(r"^api/v1/search$", views.MediaSearch.as_view()),
re_path( re_path(
r"^api/v1/media/(?P<friendly_token>[\w]*)/actions$", rf"^api/v1/media/{friendly_token}/actions$",
views.MediaActions.as_view(), views.MediaActions.as_view(),
), ),
re_path( re_path(
r"^api/v1/media/(?P<friendly_token>[\w]*)/chapters$", rf"^api/v1/media/{friendly_token}/chapters$",
views.video_chapters, views.video_chapters,
), ),
re_path( re_path(
r"^api/v1/media/(?P<friendly_token>[\w]*)/trim_video$", rf"^api/v1/media/{friendly_token}/trim_video$",
views.trim_video, views.trim_video,
), ),
re_path(r"^api/v1/categories$", views.CategoryList.as_view()), re_path(r"^api/v1/categories$", views.CategoryList.as_view()),
re_path(r"^api/v1/tags$", views.TagList.as_view()), re_path(r"^api/v1/tags$", views.TagList.as_view()),
re_path(r"^api/v1/comments$", views.CommentList.as_view()), re_path(r"^api/v1/comments$", views.CommentList.as_view()),
re_path( re_path(
r"^api/v1/media/(?P<friendly_token>[\w]*)/comments$", rf"^api/v1/media/{friendly_token}/comments$",
views.CommentDetail.as_view(), views.CommentDetail.as_view(),
), ),
re_path( re_path(
r"^api/v1/media/(?P<friendly_token>[\w]*)/comments/(?P<uid>[\w-]*)$", rf"^api/v1/media/{friendly_token}/comments/(?P<uid>[\w-]*)$",
views.CommentDetail.as_view(), views.CommentDetail.as_view(),
), ),
re_path(r"^api/v1/playlists$", views.PlaylistList.as_view()), re_path(r"^api/v1/playlists$", views.PlaylistList.as_view()),
re_path(r"^api/v1/playlists/$", views.PlaylistList.as_view()), re_path(r"^api/v1/playlists/$", views.PlaylistList.as_view()),
re_path( re_path(
r"^api/v1/playlists/(?P<friendly_token>[\w]*)$", rf"^api/v1/playlists/{friendly_token}$",
views.PlaylistDetail.as_view(), views.PlaylistDetail.as_view(),
name="api_get_playlist", name="api_get_playlist",
), ),
@ -103,8 +108,15 @@ urlpatterns = [
re_path(r"^manage/comments$", views.manage_comments, name="manage_comments"), re_path(r"^manage/comments$", views.manage_comments, name="manage_comments"),
re_path(r"^manage/media$", views.manage_media, name="manage_media"), re_path(r"^manage/media$", views.manage_media, name="manage_media"),
re_path(r"^manage/users$", views.manage_users, name="manage_users"), re_path(r"^manage/users$", views.manage_users, name="manage_users"),
# Media uploads in ADMIN created pages
re_path(r"^tinymce/upload/", tinymce_handlers.upload_image, name="tinymce_upload_image"),
re_path("^(?P<slug>[\w.-]*)$", views.get_page, name="get_page"), # noqa: W605
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
if settings.USERS_NEEDS_TO_BE_APPROVED:
urlpatterns.append(re_path(r"^approval_required/", views.approval_required, name="approval_required"))
if hasattr(settings, "USE_SAML") and settings.USE_SAML: if hasattr(settings, "USE_SAML") and settings.USE_SAML:
urlpatterns.append(re_path(r"^saml/metadata", views.saml_metadata, name="saml-metadata")) urlpatterns.append(re_path(r"^saml/metadata", views.saml_metadata, name="saml-metadata"))

File diff suppressed because it is too large Load Diff

47
files/views/__init__.py Normal file
View File

@ -0,0 +1,47 @@
# Import all views for backward compatibility
from .auth import custom_login_view, saml_metadata # noqa: F401
from .categories import CategoryList, TagList # noqa: F401
from .comments import CommentDetail, CommentList # noqa: F401
from .encoding import EncodeProfileList, EncodingDetail # noqa: F401
from .media import MediaActions # noqa: F401
from .media import MediaBulkUserActions # noqa: F401
from .media import MediaDetail # noqa: F401
from .media import MediaList # noqa: F401
from .media import MediaSearch # noqa: F401
from .pages import about # noqa: F401
from .pages import add_subtitle # noqa: F401
from .pages import approval_required # noqa: F401
from .pages import categories # noqa: F401
from .pages import contact # noqa: F401
from .pages import edit_chapters # noqa: F401
from .pages import edit_media # noqa: F401
from .pages import edit_subtitle # noqa: F401
from .pages import edit_video # noqa: F401
from .pages import embed_media # noqa: F401
from .pages import featured_media # noqa: F401
from .pages import get_page # noqa: F401
from .pages import history # noqa: F401
from .pages import index # noqa: F401
from .pages import latest_media # noqa: F401
from .pages import liked_media # noqa: F401
from .pages import manage_comments # noqa: F401
from .pages import manage_media # noqa: F401
from .pages import manage_users # noqa: F401
from .pages import members # noqa: F401
from .pages import publish_media # noqa: F401
from .pages import recommended_media # noqa: F401
from .pages import record_screen # noqa: F401
from .pages import search # noqa: F401
from .pages import setlanguage # noqa: F401
from .pages import sitemap # noqa: F401
from .pages import tags # noqa: F401
from .pages import tos # noqa: F401
from .pages import trim_video # noqa: F401
from .pages import upload_media # noqa: F401
from .pages import video_chapters # noqa: F401
from .pages import view_media # noqa: F401
from .pages import view_playlist # noqa: F401
from .playlists import PlaylistDetail, PlaylistList # noqa: F401
from .tasks import TaskDetail, TasksList # noqa: F401
from .user import UserActions # noqa: F401

42
files/views/auth.py Normal file
View File

@ -0,0 +1,42 @@
from allauth.socialaccount.models import SocialApp
from django.conf import settings
from django.http import Http404, HttpResponse
from django.shortcuts import redirect, render
from django.urls import reverse
from identity_providers.models import LoginOption
def saml_metadata(request):
if not (hasattr(settings, "USE_SAML") and settings.USE_SAML):
raise Http404
xml_parts = ['<?xml version="1.0"?>']
saml_social_apps = SocialApp.objects.filter(provider='saml')
entity_id = f"{settings.FRONTEND_HOST}/saml/metadata/"
xml_parts.append(f'<md:EntitiesDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" Name="{entity_id}">') # noqa
xml_parts.append(f' <md:EntityDescriptor entityID="{entity_id}">') # noqa
xml_parts.append(' <md:SPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">') # noqa
# Add multiple AssertionConsumerService elements with different indices
for index, app in enumerate(saml_social_apps, start=1):
xml_parts.append(
f' <md:AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" ' # noqa
f'Location="{settings.FRONTEND_HOST}/accounts/saml/{app.client_id}/acs/" index="{index}"/>' # noqa
)
xml_parts.append(' </md:SPSSODescriptor>') # noqa
xml_parts.append(' </md:EntityDescriptor>') # noqa
xml_parts.append('</md:EntitiesDescriptor>') # noqa
metadata_xml = '\n'.join(xml_parts)
return HttpResponse(metadata_xml, content_type='application/xml')
def custom_login_view(request):
if not (hasattr(settings, "USE_IDENTITY_PROVIDERS") and settings.USE_IDENTITY_PROVIDERS):
return redirect(reverse('login_system'))
login_options = []
for option in LoginOption.objects.filter(active=True):
login_options.append({'url': option.url, 'title': option.title})
return render(request, 'account/custom_login_selector.html', {'login_options': login_options})

66
files/views/categories.py Normal file
View File

@ -0,0 +1,66 @@
from django.conf import settings
from drf_yasg import openapi
from drf_yasg.utils import swagger_auto_schema
from rest_framework.response import Response
from rest_framework.settings import api_settings
from rest_framework.views import APIView
from ..methods import is_mediacms_editor
from ..models import Category, Tag
from ..serializers import CategorySerializer, TagSerializer
class CategoryList(APIView):
"""List categories"""
@swagger_auto_schema(
manual_parameters=[],
tags=['Categories'],
operation_summary='Lists Categories',
operation_description='Lists all categories',
responses={
200: openapi.Response('response description', CategorySerializer),
},
)
def get(self, request, format=None):
base_filters = {}
if not is_mediacms_editor(request.user):
base_filters = {"is_rbac_category": False}
base_queryset = Category.objects.prefetch_related("user")
categories = base_queryset.filter(**base_filters)
if not is_mediacms_editor(request.user):
if getattr(settings, 'USE_RBAC', False) and request.user.is_authenticated:
rbac_categories = request.user.get_rbac_categories_as_member()
categories = categories.union(rbac_categories)
categories = categories.order_by("title")
serializer = CategorySerializer(categories, many=True, context={"request": request})
ret = serializer.data
return Response(ret)
class TagList(APIView):
"""List tags"""
@swagger_auto_schema(
manual_parameters=[
openapi.Parameter(name='page', type=openapi.TYPE_INTEGER, in_=openapi.IN_QUERY, description='Page number'),
],
tags=['Tags'],
operation_summary='Lists Tags',
operation_description='Paginated listing of all tags',
responses={
200: openapi.Response('response description', TagSerializer),
},
)
def get(self, request, format=None):
tags = Tag.objects.filter().order_by("-media_count")
pagination_class = api_settings.DEFAULT_PAGINATION_CLASS
paginator = pagination_class()
page = paginator.paginate_queryset(tags, request)
serializer = TagSerializer(page, many=True, context={"request": request})
return paginator.get_paginated_response(serializer.data)

159
files/views/comments.py Normal file
View File

@ -0,0 +1,159 @@
from django.conf import settings
from django.shortcuts import get_object_or_404
from drf_yasg import openapi
from drf_yasg.utils import swagger_auto_schema
from rest_framework import permissions, status
from rest_framework.exceptions import PermissionDenied
from rest_framework.parsers import (
FileUploadParser,
FormParser,
JSONParser,
MultiPartParser,
)
from rest_framework.response import Response
from rest_framework.settings import api_settings
from rest_framework.views import APIView
from cms.permissions import IsAuthorizedToAdd, IsAuthorizedToAddComment
from users.models import User
from ..methods import (
check_comment_for_mention,
is_mediacms_editor,
notify_user_on_comment,
)
from ..models import Comment, Media
from ..serializers import CommentSerializer
class CommentList(APIView):
permission_classes = (permissions.IsAuthenticatedOrReadOnly, IsAuthorizedToAdd)
parser_classes = (JSONParser, MultiPartParser, FormParser, FileUploadParser)
@swagger_auto_schema(
manual_parameters=[
openapi.Parameter(name='page', type=openapi.TYPE_INTEGER, in_=openapi.IN_QUERY, description='Page number'),
openapi.Parameter(name='author', type=openapi.TYPE_STRING, in_=openapi.IN_QUERY, description='username'),
],
tags=['Comments'],
operation_summary='Lists Comments',
operation_description='Paginated listing of all comments',
responses={
200: openapi.Response('response description', CommentSerializer(many=True)),
},
)
def get(self, request, format=None):
pagination_class = api_settings.DEFAULT_PAGINATION_CLASS
paginator = pagination_class()
comments = Comment.objects.filter(media__state="public").order_by("-add_date")
comments = comments.prefetch_related("user")
comments = comments.prefetch_related("media")
params = self.request.query_params
if "author" in params:
author_param = params["author"].strip()
user_queryset = User.objects.all()
user = get_object_or_404(user_queryset, username=author_param)
comments = comments.filter(user=user)
page = paginator.paginate_queryset(comments, request)
serializer = CommentSerializer(page, many=True, context={"request": request})
return paginator.get_paginated_response(serializer.data)
class CommentDetail(APIView):
"""Comments related views
Listings of comments for a media (GET)
Create comment (POST)
Delete comment (DELETE)
"""
permission_classes = (IsAuthorizedToAddComment,)
parser_classes = (JSONParser, MultiPartParser, FormParser, FileUploadParser)
def get_object(self, friendly_token):
try:
media = Media.objects.select_related("user").get(friendly_token=friendly_token)
self.check_object_permissions(self.request, media)
if media.state == "private" and self.request.user != media.user:
return Response({"detail": "media is private"}, status=status.HTTP_400_BAD_REQUEST)
return media
except PermissionDenied:
return Response({"detail": "bad permissions"}, status=status.HTTP_400_BAD_REQUEST)
except BaseException:
return Response(
{"detail": "media file does not exist"},
status=status.HTTP_400_BAD_REQUEST,
)
@swagger_auto_schema(
manual_parameters=[],
tags=['Media'],
operation_summary='to_be_written',
operation_description='to_be_written',
)
def get(self, request, friendly_token):
# list comments for a media
media = self.get_object(friendly_token)
if isinstance(media, Response):
return media
comments = media.comments.filter().prefetch_related("user")
pagination_class = api_settings.DEFAULT_PAGINATION_CLASS
paginator = pagination_class()
page = paginator.paginate_queryset(comments, request)
serializer = CommentSerializer(page, many=True, context={"request": request})
return paginator.get_paginated_response(serializer.data)
@swagger_auto_schema(
manual_parameters=[],
tags=['Media'],
operation_summary='to_be_written',
operation_description='to_be_written',
)
def delete(self, request, friendly_token, uid=None):
"""Delete a comment
Administrators, MediaCMS editors and managers,
media owner, and comment owners, can delete a comment
"""
if uid:
try:
comment = Comment.objects.get(uid=uid)
except BaseException:
return Response(
{"detail": "comment does not exist"},
status=status.HTTP_400_BAD_REQUEST,
)
if (comment.user == self.request.user) or comment.media.user == self.request.user or is_mediacms_editor(self.request.user):
comment.delete()
else:
return Response({"detail": "bad permissions"}, status=status.HTTP_400_BAD_REQUEST)
return Response(status=status.HTTP_204_NO_CONTENT)
@swagger_auto_schema(
manual_parameters=[],
tags=['Media'],
operation_summary='to_be_written',
operation_description='to_be_written',
)
def post(self, request, friendly_token):
"""Create a comment"""
media = self.get_object(friendly_token)
if isinstance(media, Response):
return media
if not media.enable_comments:
return Response(
{"detail": "comments not allowed here"},
status=status.HTTP_400_BAD_REQUEST,
)
serializer = CommentSerializer(data=request.data, context={"request": request})
if serializer.is_valid():
serializer.save(user=request.user, media=media)
if request.user != media.user:
notify_user_on_comment(friendly_token=media.friendly_token)
# here forward the comment to check if a user was mentioned
if settings.ALLOW_MENTION_IN_COMMENTS:
check_comment_for_mention(friendly_token=media.friendly_token, comment_text=serializer.data['text'])
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

179
files/views/encoding.py Normal file
View File

@ -0,0 +1,179 @@
from django.conf import settings
from drf_yasg.utils import swagger_auto_schema
from rest_framework import permissions, status
from rest_framework.parsers import (
FileUploadParser,
FormParser,
JSONParser,
MultiPartParser,
)
from rest_framework.response import Response
from rest_framework.views import APIView
from ..helpers import produce_ffmpeg_commands
from ..models import EncodeProfile, Encoding
from ..serializers import EncodeProfileSerializer
class EncodingDetail(APIView):
"""Experimental. This View is used by remote workers
Needs heavy testing and documentation.
"""
permission_classes = (permissions.IsAdminUser,)
parser_classes = (JSONParser, MultiPartParser, FormParser, FileUploadParser)
@swagger_auto_schema(auto_schema=None)
def post(self, request, encoding_id):
ret = {}
force = request.data.get("force", False)
task_id = request.data.get("task_id", False)
action = request.data.get("action", "")
chunk = request.data.get("chunk", False)
chunk_file_path = request.data.get("chunk_file_path", "")
encoding_status = request.data.get("status", "")
progress = request.data.get("progress", "")
commands = request.data.get("commands", "")
logs = request.data.get("logs", "")
retries = request.data.get("retries", "")
worker = request.data.get("worker", "")
temp_file = request.data.get("temp_file", "")
total_run_time = request.data.get("total_run_time", "")
if action == "start":
try:
encoding = Encoding.objects.get(id=encoding_id)
media = encoding.media
profile = encoding.profile
except BaseException:
Encoding.objects.filter(id=encoding_id).delete()
return Response({"status": "fail"}, status=status.HTTP_400_BAD_REQUEST)
# TODO: break chunk True/False logic here
if (
Encoding.objects.filter(
media=media,
profile=profile,
chunk=chunk,
chunk_file_path=chunk_file_path,
).count()
> 1 # noqa
and force is False # noqa
):
Encoding.objects.filter(id=encoding_id).delete()
return Response({"status": "fail"}, status=status.HTTP_400_BAD_REQUEST)
else:
Encoding.objects.filter(
media=media,
profile=profile,
chunk=chunk,
chunk_file_path=chunk_file_path,
).exclude(id=encoding.id).delete()
encoding.status = "running"
if task_id:
encoding.task_id = task_id
encoding.save()
if chunk:
original_media_path = chunk_file_path
original_media_md5sum = encoding.md5sum
original_media_url = settings.SSL_FRONTEND_HOST + encoding.media_chunk_url
else:
original_media_path = media.media_file.path
original_media_md5sum = media.md5sum
original_media_url = settings.SSL_FRONTEND_HOST + media.original_media_url
ret["original_media_url"] = original_media_url
ret["original_media_path"] = original_media_path
ret["original_media_md5sum"] = original_media_md5sum
# generating the commands here, and will replace these with temporary
# files created on the remote server
tf = "TEMP_FILE_REPLACE"
tfpass = "TEMP_FPASS_FILE_REPLACE"
ffmpeg_commands = produce_ffmpeg_commands(
original_media_path,
media.media_info,
resolution=profile.resolution,
codec=profile.codec,
output_filename=tf,
pass_file=tfpass,
chunk=chunk,
)
if not ffmpeg_commands:
encoding.delete()
return Response({"status": "fail"}, status=status.HTTP_400_BAD_REQUEST)
ret["duration"] = media.duration
ret["ffmpeg_commands"] = ffmpeg_commands
ret["profile_extension"] = profile.extension
return Response(ret, status=status.HTTP_201_CREATED)
elif action == "update_fields":
try:
encoding = Encoding.objects.get(id=encoding_id)
except BaseException:
return Response({"status": "fail"}, status=status.HTTP_400_BAD_REQUEST)
to_update = ["size", "update_date"]
if encoding_status:
encoding.status = encoding_status
to_update.append("status")
if progress:
encoding.progress = progress
to_update.append("progress")
if logs:
encoding.logs = logs
to_update.append("logs")
if commands:
encoding.commands = commands
to_update.append("commands")
if task_id:
encoding.task_id = task_id
to_update.append("task_id")
if total_run_time:
encoding.total_run_time = total_run_time
to_update.append("total_run_time")
if worker:
encoding.worker = worker
to_update.append("worker")
if temp_file:
encoding.temp_file = temp_file
to_update.append("temp_file")
if retries:
encoding.retries = retries
to_update.append("retries")
try:
encoding.save(update_fields=to_update)
except BaseException:
return Response({"status": "fail"}, status=status.HTTP_400_BAD_REQUEST)
return Response({"status": "success"}, status=status.HTTP_201_CREATED)
@swagger_auto_schema(auto_schema=None)
def put(self, request, encoding_id, format=None):
encoding_file = request.data["file"]
encoding = Encoding.objects.filter(id=encoding_id).first()
if not encoding:
return Response(
{"detail": "encoding does not exist"},
status=status.HTTP_400_BAD_REQUEST,
)
encoding.media_file = encoding_file
encoding.save()
return Response({"detail": "ok"}, status=status.HTTP_201_CREATED)
class EncodeProfileList(APIView):
"""List encode profiles"""
@swagger_auto_schema(
manual_parameters=[],
tags=['Encoding Profiles'],
operation_summary='List Encoding Profiles',
operation_description='Lists all encoding profiles for videos',
responses={200: EncodeProfileSerializer(many=True)},
)
def get(self, request, format=None):
profiles = EncodeProfile.objects.all()
serializer = EncodeProfileSerializer(profiles, many=True, context={"request": request})
return Response(serializer.data)

760
files/views/media.py Normal file
View File

@ -0,0 +1,760 @@
from datetime import datetime, timedelta
from django.conf import settings
from django.contrib.postgres.search import SearchQuery
from django.db.models import Q
from django.shortcuts import get_object_or_404
from drf_yasg import openapi
from drf_yasg.utils import swagger_auto_schema
from rest_framework import permissions, status
from rest_framework.exceptions import PermissionDenied
from rest_framework.parsers import (
FileUploadParser,
FormParser,
JSONParser,
MultiPartParser,
)
from rest_framework.response import Response
from rest_framework.settings import api_settings
from rest_framework.views import APIView
from actions.models import MediaAction
from cms.custom_pagination import FastPaginationWithoutCount
from cms.permissions import IsAuthorizedToAdd, IsUserOrEditor
from users.models import User
from .. import helpers
from ..methods import (
change_media_owner,
copy_media,
get_user_or_session,
is_mediacms_editor,
show_recommended_media,
show_related_media,
update_user_ratings,
)
from ..models import EncodeProfile, Media, MediaPermission, Playlist, PlaylistMedia
from ..serializers import MediaSearchSerializer, MediaSerializer, SingleMediaSerializer
from ..stop_words import STOP_WORDS
from ..tasks import save_user_action
class MediaList(APIView):
"""Media listings views"""
permission_classes = (IsAuthorizedToAdd,)
parser_classes = (MultiPartParser, FormParser, FileUploadParser)
@swagger_auto_schema(
manual_parameters=[
openapi.Parameter(name='page', type=openapi.TYPE_INTEGER, in_=openapi.IN_QUERY, description='Page number'),
openapi.Parameter(name='author', type=openapi.TYPE_STRING, in_=openapi.IN_QUERY, description='username'),
openapi.Parameter(name='show', type=openapi.TYPE_STRING, in_=openapi.IN_QUERY, description='show', enum=['recommended', 'featured', 'latest']),
],
tags=['Media'],
operation_summary='List Media',
operation_description='Lists all media',
responses={200: MediaSerializer(many=True)},
)
def _get_media_queryset(self, request, user=None):
base_filters = Q(listable=True)
if user:
base_filters &= Q(user=user)
base_queryset = Media.objects.prefetch_related("user")
if not request.user.is_authenticated:
return base_queryset.filter(base_filters).order_by("-add_date")
# Build OR conditions for authenticated users
conditions = base_filters # Start with listable media
# Add user permissions
permission_filter = {'user': request.user}
if user:
permission_filter['owner_user'] = user
if MediaPermission.objects.filter(**permission_filter).exists():
perm_conditions = Q(permissions__user=request.user)
if user:
perm_conditions &= Q(user=user)
conditions |= perm_conditions
# Add RBAC conditions
if getattr(settings, 'USE_RBAC', False):
rbac_categories = request.user.get_rbac_categories_as_member()
rbac_conditions = Q(category__in=rbac_categories)
if user:
rbac_conditions &= Q(user=user)
conditions |= rbac_conditions
return base_queryset.filter(conditions).distinct().order_by("-add_date")[:1000]
def get(self, request, format=None):
# Show media
# authenticated users can see:
# All listable media (public access)
# Non-listable media they have RBAC access to
# Non-listable media they have direct permissions for
params = self.request.query_params
show_param = params.get("show", "")
author_param = params.get("author", "").strip()
pagination_class = api_settings.DEFAULT_PAGINATION_CLASS
if show_param == "recommended":
pagination_class = FastPaginationWithoutCount
media = show_recommended_media(request, limit=50)
elif show_param == "featured":
media = Media.objects.filter(listable=True, featured=True).prefetch_related("user").order_by("-add_date")
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")
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")
user_media_filters = {'permissions__user': request.user}
media = base_queryset.filter(**user_media_filters)
if getattr(settings, 'USE_RBAC', False):
rbac_categories = request.user.get_rbac_categories_as_member()
rbac_filters = {'category__in': rbac_categories}
rbac_media = base_queryset.filter(**rbac_filters)
media = media.union(rbac_media)
media = media.order_by("-add_date")[:1000] # limit to 1000 results
elif author_param:
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").order_by("-add_date")
else:
media = self._get_media_queryset(request, user)
else:
media = self._get_media_queryset(request)
paginator = pagination_class()
page = paginator.paginate_queryset(media, request)
serializer = MediaSerializer(page, many=True, context={"request": request})
return paginator.get_paginated_response(serializer.data)
@swagger_auto_schema(
manual_parameters=[
openapi.Parameter(name="media_file", in_=openapi.IN_FORM, type=openapi.TYPE_FILE, required=True, description="media_file"),
openapi.Parameter(name="description", in_=openapi.IN_FORM, type=openapi.TYPE_STRING, required=False, description="description"),
openapi.Parameter(name="title", in_=openapi.IN_FORM, type=openapi.TYPE_STRING, required=False, description="title"),
],
tags=['Media'],
operation_summary='Add new Media',
operation_description='Adds a new media, for authenticated users',
responses={201: openapi.Response('response description', MediaSerializer), 401: 'bad request'},
)
def post(self, request, format=None):
# Add new media
serializer = MediaSerializer(data=request.data, context={"request": request})
if serializer.is_valid():
media_file = request.data["media_file"]
serializer.save(user=request.user, media_file=media_file)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class MediaBulkUserActions(APIView):
"""Bulk actions on media items"""
permission_classes = (permissions.IsAuthenticated,)
parser_classes = (JSONParser,)
@swagger_auto_schema(
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
required=['media_ids', 'action'],
properties={
'media_ids': openapi.Schema(type=openapi.TYPE_ARRAY, items=openapi.Items(type=openapi.TYPE_STRING), description="List of media IDs"),
'action': openapi.Schema(
type=openapi.TYPE_STRING,
description="Action to perform",
enum=[
"enable_comments",
"disable_comments",
"delete_media",
"enable_download",
"disable_download",
"add_to_playlist",
"remove_from_playlist",
"set_state",
"change_owner",
"copy_media",
],
),
'playlist_ids': openapi.Schema(
type=openapi.TYPE_ARRAY,
items=openapi.Items(type=openapi.TYPE_INTEGER),
description="List of playlist IDs (required for add_to_playlist and remove_from_playlist actions)",
),
'state': openapi.Schema(type=openapi.TYPE_STRING, description="State to set (required for set_state action)", enum=["private", "public", "unlisted"]),
'owner': openapi.Schema(type=openapi.TYPE_STRING, description="New owner username (required for change_owner action)"),
},
),
tags=['Media'],
operation_summary='Perform bulk actions on media',
operation_description='Perform various bulk actions on multiple media items at once',
responses={
200: openapi.Response('Action performed successfully'),
400: 'Bad request',
401: 'Not authenticated',
},
)
def post(self, request, format=None):
# Check if user is authenticated
if not request.user.is_authenticated:
return Response({"detail": "Authentication required"}, status=status.HTTP_401_UNAUTHORIZED)
# Get required parameters
media_ids = request.data.get('media_ids', [])
action = request.data.get('action')
# Validate required parameters
if not media_ids:
return Response({"detail": "media_ids is required"}, status=status.HTTP_400_BAD_REQUEST)
if not action:
return Response({"detail": "action is required"}, status=status.HTTP_400_BAD_REQUEST)
# Get media objects owned by the user
media = Media.objects.filter(user=request.user, friendly_token__in=media_ids)
if not media:
return Response({"detail": "No matching media found"}, status=status.HTTP_400_BAD_REQUEST)
# Process based on action
if action == "enable_comments":
media.update(enable_comments=True)
return Response({"detail": f"Comments enabled for {media.count()} media items"})
elif action == "disable_comments":
media.update(enable_comments=False)
return Response({"detail": f"Comments disabled for {media.count()} media items"})
elif action == "delete_media":
count = media.count()
media.delete()
return Response({"detail": f"{count} media items deleted"})
elif action == "enable_download":
media.update(allow_download=True)
return Response({"detail": f"Download enabled for {media.count()} media items"})
elif action == "disable_download":
media.update(allow_download=False)
return Response({"detail": f"Download disabled for {media.count()} media items"})
elif action == "add_to_playlist":
playlist_ids = request.data.get('playlist_ids', [])
if not playlist_ids:
return Response({"detail": "playlist_ids is required for add_to_playlist action"}, status=status.HTTP_400_BAD_REQUEST)
playlists = Playlist.objects.filter(user=request.user, id__in=playlist_ids)
if not playlists:
return Response({"detail": "No matching playlists found"}, status=status.HTTP_400_BAD_REQUEST)
added_count = 0
for playlist in playlists:
for m in media:
media_in_playlist = PlaylistMedia.objects.filter(playlist=playlist).count()
if media_in_playlist < settings.MAX_MEDIA_PER_PLAYLIST:
obj, created = PlaylistMedia.objects.get_or_create(
playlist=playlist,
media=m,
ordering=media_in_playlist + 1,
)
if created:
added_count += 1
return Response({"detail": f"Added {added_count} media items to {playlists.count()} playlists"})
elif action == "remove_from_playlist":
playlist_ids = request.data.get('playlist_ids', [])
if not playlist_ids:
return Response({"detail": "playlist_ids is required for remove_from_playlist action"}, status=status.HTTP_400_BAD_REQUEST)
playlists = Playlist.objects.filter(user=request.user, id__in=playlist_ids)
if not playlists:
return Response({"detail": "No matching playlists found"}, status=status.HTTP_400_BAD_REQUEST)
removed_count = 0
for playlist in playlists:
removed = PlaylistMedia.objects.filter(playlist=playlist, media__in=media).delete()[0]
removed_count += removed
return Response({"detail": f"Removed {removed_count} media items from {playlists.count()} playlists"})
elif action == "set_state":
state = request.data.get('state')
if not state:
return Response({"detail": "state is required for set_state action"}, status=status.HTTP_400_BAD_REQUEST)
valid_states = ["private", "public", "unlisted"]
if state not in valid_states:
return Response({"detail": f"state must be one of {valid_states}"}, status=status.HTTP_400_BAD_REQUEST)
# Check if user can set public state
if not is_mediacms_editor(request.user) and settings.PORTAL_WORKFLOW != "public":
if state == "public":
return Response({"detail": "You are not allowed to set media to public state"}, status=status.HTTP_400_BAD_REQUEST)
# Update media state
for m in media:
m.state = state
if m.state == "public" and m.encoding_status == "success" and m.is_reviewed is True:
m.listable = True
else:
m.listable = False
m.save(update_fields=["state", "listable"])
return Response({"detail": f"State updated to {state} for {media.count()} media items"})
elif action == "change_owner":
owner = request.data.get('owner')
if not owner:
return Response({"detail": "owner is required for change_owner action"}, status=status.HTTP_400_BAD_REQUEST)
new_user = User.objects.filter(username=owner).first()
if not new_user:
return Response({"detail": "User not found"}, status=status.HTTP_400_BAD_REQUEST)
changed_count = 0
for m in media:
result = change_media_owner(m.id, new_user)
if result:
changed_count += 1
return Response({"detail": f"Owner changed for {changed_count} media items"})
elif action == "copy_media":
for m in media:
copy_media(m.id)
return Response({"detail": f"{media.count()} media items copied"})
else:
return Response({"detail": f"Unknown action: {action}"}, status=status.HTTP_400_BAD_REQUEST)
class MediaDetail(APIView):
"""
Retrieve, update or delete a media instance.
"""
permission_classes = (permissions.IsAuthenticatedOrReadOnly, IsUserOrEditor)
parser_classes = (MultiPartParser, FormParser, FileUploadParser)
def get_object(self, friendly_token):
try:
media = Media.objects.select_related("user").prefetch_related("encodings__profile").get(friendly_token=friendly_token)
# this need be explicitly called, and will call
# has_object_permission() after has_permission has succeeded
self.check_object_permissions(self.request, media)
if media.state == "private":
if self.request.user.has_member_access_to_media(media) or is_mediacms_editor(self.request.user):
pass
else:
return Response(
{"detail": "media is private"},
status=status.HTTP_401_UNAUTHORIZED,
)
return media
except PermissionDenied:
return Response({"detail": "bad permissions"}, status=status.HTTP_401_UNAUTHORIZED)
except BaseException:
return Response(
{"detail": "media file does not exist"},
status=status.HTTP_400_BAD_REQUEST,
)
@swagger_auto_schema(
manual_parameters=[
openapi.Parameter(name='friendly_token', type=openapi.TYPE_STRING, in_=openapi.IN_PATH, description='unique identifier', required=True),
],
tags=['Media'],
operation_summary='Get information for Media',
operation_description='Get information for a media',
responses={200: SingleMediaSerializer(), 400: 'bad request'},
)
def get(self, request, friendly_token, format=None):
# Get media details
# password = request.GET.get("password")
media = self.get_object(friendly_token)
if isinstance(media, Response):
return media
serializer = SingleMediaSerializer(media, context={"request": request})
if media.state == "private":
related_media = []
else:
related_media = show_related_media(media, request=request, limit=100)
related_media_serializer = MediaSerializer(related_media, many=True, context={"request": request})
related_media = related_media_serializer.data
ret = serializer.data
# update rattings info with user specific ratings
# eg user has already rated for this media
# this only affects user rating and only if enabled
if settings.ALLOW_RATINGS and ret.get("ratings_info") and not request.user.is_anonymous:
ret["ratings_info"] = update_user_ratings(request.user, media, ret.get("ratings_info"))
ret["related_media"] = related_media
return Response(ret)
@swagger_auto_schema(
manual_parameters=[
openapi.Parameter(name='friendly_token', type=openapi.TYPE_STRING, in_=openapi.IN_PATH, description='unique identifier', required=True),
openapi.Parameter(name='type', type=openapi.TYPE_STRING, in_=openapi.IN_FORM, description='action to perform', enum=['encode', 'review']),
openapi.Parameter(
name='encoding_profiles',
type=openapi.TYPE_ARRAY,
items=openapi.Items(type=openapi.TYPE_STRING),
in_=openapi.IN_FORM,
description='if action to perform is encode, need to specify list of ids of encoding profiles',
),
openapi.Parameter(name='result', type=openapi.TYPE_BOOLEAN, in_=openapi.IN_FORM, description='if action is review, this is the result (True for reviewed, False for not reviewed)'),
],
tags=['Media'],
operation_summary='Run action on Media',
operation_description='Actions for a media, for MediaCMS editors and managers',
responses={201: 'action created', 400: 'bad request'},
operation_id='media_manager_actions',
)
def post(self, request, friendly_token, format=None):
"""superuser actions
Available only to MediaCMS editors and managers
Action is a POST variable, review and encode are implemented
"""
media = self.get_object(friendly_token)
if isinstance(media, Response):
return media
if not is_mediacms_editor(request.user):
return Response({"detail": "not allowed"}, status=status.HTTP_400_BAD_REQUEST)
action = request.data.get("type")
profiles_list = request.data.get("encoding_profiles")
result = request.data.get("result", True)
if action == "encode":
# Create encoding tasks for specific profiles
valid_profiles = []
if profiles_list:
if isinstance(profiles_list, list):
for p in profiles_list:
p = EncodeProfile.objects.filter(id=p).first()
if p:
valid_profiles.append(p)
elif isinstance(profiles_list, str):
try:
p = EncodeProfile.objects.filter(id=int(profiles_list)).first()
valid_profiles.append(p)
except ValueError:
return Response(
{"detail": "encoding_profiles must be int or list of ints of valid encode profiles"},
status=status.HTTP_400_BAD_REQUEST,
)
media.encode(profiles=valid_profiles)
return Response({"detail": "media will be encoded"}, status=status.HTTP_201_CREATED)
elif action == "review":
if result:
media.is_reviewed = True
elif result is False:
media.is_reviewed = False
media.save(update_fields=["is_reviewed"])
return Response({"detail": "media reviewed set"}, status=status.HTTP_201_CREATED)
return Response(
{"detail": "not valid action or no action specified"},
status=status.HTTP_400_BAD_REQUEST,
)
@swagger_auto_schema(
manual_parameters=[
openapi.Parameter(name="description", in_=openapi.IN_FORM, type=openapi.TYPE_STRING, required=False, description="description"),
openapi.Parameter(name="title", in_=openapi.IN_FORM, type=openapi.TYPE_STRING, required=False, description="title"),
openapi.Parameter(name="media_file", in_=openapi.IN_FORM, type=openapi.TYPE_FILE, required=False, description="media_file"),
],
tags=['Media'],
operation_summary='Update Media',
operation_description='Update a Media, for Media uploader',
responses={201: openapi.Response('response description', MediaSerializer), 401: 'bad request'},
)
def put(self, request, friendly_token, format=None):
# Update a media object
media = self.get_object(friendly_token)
if isinstance(media, Response):
return media
if not (request.user.has_contributor_access_to_media(media) or is_mediacms_editor(request.user)):
return Response({"detail": "not allowed"}, status=status.HTTP_400_BAD_REQUEST)
serializer = MediaSerializer(media, data=request.data, context={"request": request})
if serializer.is_valid():
serializer.save(user=request.user)
# no need to update the media file itself, only the metadata
# if request.data.get('media_file'):
# media_file = request.data["media_file"]
# serializer.save(user=request.user, media_file=media_file)
# else:
# serializer.save(user=request.user)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@swagger_auto_schema(
manual_parameters=[
openapi.Parameter(name='friendly_token', type=openapi.TYPE_STRING, in_=openapi.IN_PATH, description='unique identifier', required=True),
],
tags=['Media'],
operation_summary='Delete Media',
operation_description='Delete a Media, for MediaCMS editors and managers',
responses={
204: 'no content',
},
)
def delete(self, request, friendly_token, format=None):
# Delete a media object
media = self.get_object(friendly_token)
if isinstance(media, Response):
return media
media.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
class MediaActions(APIView):
"""
Retrieve, update or delete a media action instance.
"""
permission_classes = (permissions.AllowAny,)
parser_classes = (JSONParser,)
def get_object(self, friendly_token):
try:
media = Media.objects.select_related("user").prefetch_related("encodings__profile").get(friendly_token=friendly_token)
if media.state == "private" and self.request.user != media.user:
return Response({"detail": "media is private"}, status=status.HTTP_400_BAD_REQUEST)
return media
except PermissionDenied:
return Response({"detail": "bad permissions"}, status=status.HTTP_400_BAD_REQUEST)
except BaseException:
return Response(
{"detail": "media file does not exist"},
status=status.HTTP_400_BAD_REQUEST,
)
@swagger_auto_schema(
manual_parameters=[],
tags=['Media'],
operation_summary='to_be_written',
operation_description='to_be_written',
)
def get(self, request, friendly_token, format=None):
# show date and reason for each time media was reported
media = self.get_object(friendly_token)
if not (request.user == media.user or is_mediacms_editor(request.user)):
return Response({"detail": "not allowed"}, status=status.HTTP_400_BAD_REQUEST)
if isinstance(media, Response):
return media
ret = {}
reported = MediaAction.objects.filter(media=media, action="report")
ret["reported"] = []
for rep in reported:
item = {"reported_date": rep.action_date, "reason": rep.extra_info}
ret["reported"].append(item)
return Response(ret, status=status.HTTP_200_OK)
@swagger_auto_schema(
manual_parameters=[],
tags=['Media'],
operation_summary='to_be_written',
operation_description='to_be_written',
)
def post(self, request, friendly_token, format=None):
# perform like/dislike/report actions
media = self.get_object(friendly_token)
if isinstance(media, Response):
return media
action = request.data.get("type")
extra = request.data.get("extra_info")
if request.user.is_anonymous:
# there is a list of allowed actions for
# anonymous users, specified in settings
if action not in settings.ALLOW_ANONYMOUS_ACTIONS:
return Response(
{"detail": "action allowed on logged in users only"},
status=status.HTTP_400_BAD_REQUEST,
)
if action:
user_or_session = get_user_or_session(request)
save_user_action.delay(
user_or_session,
friendly_token=media.friendly_token,
action=action,
extra_info=extra,
)
return Response({"detail": "action received"}, status=status.HTTP_201_CREATED)
else:
return Response({"detail": "no action specified"}, status=status.HTTP_400_BAD_REQUEST)
@swagger_auto_schema(
manual_parameters=[],
tags=['Media'],
operation_summary='to_be_written',
operation_description='to_be_written',
)
def delete(self, request, friendly_token, format=None):
media = self.get_object(friendly_token)
if isinstance(media, Response):
return media
if not request.user.is_superuser:
return Response({"detail": "not allowed"}, status=status.HTTP_400_BAD_REQUEST)
action = request.data.get("type")
if action:
if action == "report": # delete reported actions
MediaAction.objects.filter(media=media, action="report").delete()
media.reported_times = 0
media.save(update_fields=["reported_times"])
return Response(
{"detail": "reset reported times counter"},
status=status.HTTP_201_CREATED,
)
else:
return Response({"detail": "no action specified"}, status=status.HTTP_400_BAD_REQUEST)
class MediaSearch(APIView):
"""
Retrieve results for search
Only GET is implemented here
"""
parser_classes = (JSONParser,)
@swagger_auto_schema(
manual_parameters=[],
tags=['Search'],
operation_summary='to_be_written',
operation_description='to_be_written',
)
def get(self, request, format=None):
params = self.request.query_params
query = params.get("q", "").strip().lower()
category = params.get("c", "").strip()
tag = params.get("t", "").strip()
ordering = params.get("ordering", "").strip()
sort_by = params.get("sort_by", "").strip()
media_type = params.get("media_type", "").strip()
author = params.get("author", "").strip()
upload_date = params.get('upload_date', '').strip()
sort_by_options = ["title", "add_date", "edit_date", "views", "likes"]
if sort_by not in sort_by_options:
sort_by = "add_date"
if ordering == "asc":
ordering = ""
else:
ordering = "-"
if media_type not in ["video", "image", "audio", "pdf"]:
media_type = None
if not (query or category or tag):
ret = {}
return Response(ret, status=status.HTTP_200_OK)
if request.user.is_authenticated:
basic_query = Q(listable=True) | Q(permissions__user=request.user)
if getattr(settings, 'USE_RBAC', False):
rbac_categories = request.user.get_rbac_categories_as_member()
basic_query |= Q(category__in=rbac_categories)
else:
basic_query = Q(listable=True)
media = Media.objects.filter(basic_query).distinct()
if query:
# move this processing to a prepare_query function
query = helpers.clean_query(query)
q_parts = [q_part.rstrip("y") for q_part in query.split() if q_part not in STOP_WORDS]
if q_parts:
query = SearchQuery(q_parts[0] + ":*", search_type="raw")
for part in q_parts[1:]:
query &= SearchQuery(part + ":*", search_type="raw")
else:
query = None
if query:
media = media.filter(search=query)
if tag:
media = media.filter(tags__title=tag)
if category:
media = media.filter(category__title__contains=category)
if media_type:
media = media.filter(media_type=media_type)
if author:
media = media.filter(user__username=author)
if upload_date:
gte = None
if upload_date == 'today':
gte = datetime.now().date()
if upload_date == 'this_week':
gte = datetime.now() - timedelta(days=7)
if upload_date == 'this_month':
year = datetime.now().date().year
month = datetime.now().date().month
gte = datetime(year, month, 1)
if upload_date == 'this_year':
year = datetime.now().date().year
gte = datetime(year, 1, 1)
if gte:
media = media.filter(add_date__gte=gte)
media = media.order_by(f"{ordering}{sort_by}")
if self.request.query_params.get("show", "").strip() == "titles":
media = media.values("title")[:40]
return Response(media, status=status.HTTP_200_OK)
else:
media = media.prefetch_related("user")[:1000] # limit to 1000 results
if category or tag:
pagination_class = api_settings.DEFAULT_PAGINATION_CLASS
else:
# pagination_class = FastPaginationWithoutCount
pagination_class = api_settings.DEFAULT_PAGINATION_CLASS
paginator = pagination_class()
page = paginator.paginate_queryset(media, request)
serializer = MediaSearchSerializer(page, many=True, context={"request": request})
return paginator.get_paginated_response(serializer.data)

661
files/views/pages.py Normal file
View File

@ -0,0 +1,661 @@
import json
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.core.mail import EmailMessage
from django.http import HttpResponse, HttpResponseRedirect, JsonResponse
from django.shortcuts import render
from django.views.decorators.csrf import csrf_exempt
from cms.version import VERSION
from files.methods import user_allowed_to_upload
from users.models import User
from .. import helpers
from ..forms import (
ContactForm,
EditSubtitleForm,
MediaMetadataForm,
MediaPublishForm,
SubtitleForm,
WhisperSubtitlesForm,
)
from ..frontend_translations import translate_string
from ..helpers import get_alphanumeric_only
from ..methods import (
can_transcribe_video,
create_video_trim_request,
get_user_or_session,
handle_video_chapters,
is_media_allowed_type,
is_mediacms_editor,
)
from ..models import Category, Media, Page, Playlist, Subtitle, Tag, VideoTrimRequest
from ..tasks import save_user_action, video_trim_task
def get_page(request, slug):
context = {}
page = Page.objects.filter(slug=slug).first()
if page:
context["page"] = page
else:
return render(request, "404.html", context)
return render(request, "cms/page.html", context)
@login_required
def record_screen(request):
"""Record screen view"""
context = {}
context["can_add"] = user_allowed_to_upload(request)
can_upload_exp = settings.CANNOT_ADD_MEDIA_MESSAGE
context["can_upload_exp"] = can_upload_exp
return render(request, "cms/record_screen.html", context)
def about(request):
"""About view"""
page = Page.objects.filter(slug="about").first()
if page:
context = {}
context["page"] = page
return render(request, "cms/page.html", context)
context = {"VERSION": VERSION}
return render(request, "cms/about.html", context)
def approval_required(request):
"""User needs approval view"""
return render(request, "cms/user_needs_approval.html", {})
def setlanguage(request):
"""Set Language view"""
context = {}
return render(request, "cms/set_language.html", context)
@login_required
def add_subtitle(request):
"""Add subtitle view"""
friendly_token = request.GET.get("m", "").strip()
if not friendly_token:
return HttpResponseRedirect("/")
media = Media.objects.filter(friendly_token=friendly_token).first()
if not media:
return HttpResponseRedirect("/")
if not (request.user == media.user or is_mediacms_editor(request.user)):
return HttpResponseRedirect("/")
# Initialize variables
form = None
whisper_form = None
show_whisper_form = can_transcribe_video(request.user)
if request.method == "POST":
if 'submit' in request.POST:
form = SubtitleForm(media, request.POST, request.FILES, prefix="form")
if form.is_valid():
subtitle = form.save()
try:
subtitle.convert_to_srt()
messages.add_message(request, messages.INFO, "Caption was added!")
return HttpResponseRedirect(subtitle.media.get_absolute_url())
except Exception as e: # noqa
subtitle.delete()
error_msg = "Invalid subtitle format. Use SubRip (.srt) or WebVTT (.vtt) files."
form.add_error("subtitle_file", error_msg)
elif 'submit_whisper' in request.POST and show_whisper_form:
whisper_form = WhisperSubtitlesForm(request.user, request.POST, instance=media, prefix="whisper_form")
if whisper_form.is_valid():
whisper_form.save()
messages.add_message(request, messages.INFO, "Request for transcription was sent")
return HttpResponseRedirect(media.get_absolute_url())
# GET request or form invalid
if form is None:
form = SubtitleForm(media_item=media, prefix="form")
if show_whisper_form and whisper_form is None:
whisper_form = WhisperSubtitlesForm(request.user, instance=media, prefix="whisper_form")
subtitles = media.subtitles.all()
context = {"media_object": media, "form": form, "subtitles": subtitles, "whisper_form": whisper_form}
return render(request, "cms/add_subtitle.html", context)
@login_required
def edit_subtitle(request):
subtitle_id = request.GET.get("id", "").strip()
action = request.GET.get("action", "").strip()
if not subtitle_id:
return HttpResponseRedirect("/")
subtitle = Subtitle.objects.filter(id=subtitle_id).first()
if not subtitle:
return HttpResponseRedirect("/")
if not (request.user == subtitle.user or is_mediacms_editor(request.user)):
return HttpResponseRedirect("/")
context = {"subtitle": subtitle, "action": action}
if action == "download":
response = HttpResponse(subtitle.subtitle_file.read(), content_type="text/vtt")
filename = subtitle.subtitle_file.name.split("/")[-1]
if not filename.endswith(".vtt"):
filename = f"{filename}.vtt"
response["Content-Disposition"] = f"attachment; filename={filename}" # noqa
return response
if request.method == "GET":
form = EditSubtitleForm(subtitle)
context["form"] = form
elif request.method == "POST":
confirm = request.GET.get("confirm", "").strip()
if confirm == "true":
messages.add_message(request, messages.INFO, "Caption was deleted")
redirect_url = subtitle.media.get_absolute_url()
subtitle.delete()
return HttpResponseRedirect(redirect_url)
form = EditSubtitleForm(subtitle, request.POST)
subtitle_text = form.data["subtitle"]
with open(subtitle.subtitle_file.path, "w") as ff:
ff.write(subtitle_text)
messages.add_message(request, messages.INFO, "Caption was edited")
return HttpResponseRedirect(subtitle.media.get_absolute_url())
return render(request, "cms/edit_subtitle.html", context)
def categories(request):
"""List categories view"""
context = {}
return render(request, "cms/categories.html", context)
def contact(request):
"""Contact view"""
context = {}
if request.method == "GET":
form = ContactForm(request.user)
context["form"] = form
else:
form = ContactForm(request.user, request.POST)
if form.is_valid():
if request.user.is_authenticated:
from_email = request.user.email
name = request.user.name
else:
from_email = request.POST.get("from_email")
name = request.POST.get("name")
message = request.POST.get("message")
title = f"[{settings.PORTAL_NAME}] - Contact form message received"
msg = """
You have received a message through the contact form\n
Sender name: %s
Sender email: %s\n
\n %s
""" % (
name,
from_email,
message,
)
email = EmailMessage(
title,
msg,
settings.DEFAULT_FROM_EMAIL,
settings.ADMIN_EMAIL_LIST,
reply_to=[from_email],
)
email.send(fail_silently=True)
success_msg = "Message was sent! Thanks for contacting"
context["success_msg"] = success_msg
return render(request, "cms/contact.html", context)
def history(request):
"""Show personal history view"""
context = {}
return render(request, "cms/history.html", context)
@csrf_exempt
@login_required
def video_chapters(request, friendly_token):
if not request.method == "POST":
return HttpResponseRedirect("/")
media = Media.objects.filter(friendly_token=friendly_token).first()
if not media:
return HttpResponseRedirect("/")
if not (request.user == media.user or is_mediacms_editor(request.user)):
return HttpResponseRedirect("/")
try:
request_data = json.loads(request.body)
data = request_data.get("chapters")
if data is None:
return JsonResponse({'success': False, 'error': 'Request must contain "chapters" array'}, status=400)
chapters = []
for _, chapter_data in enumerate(data):
start_time = chapter_data.get('startTime')
end_time = chapter_data.get('endTime')
chapter_title = chapter_data.get('chapterTitle')
if start_time and end_time and chapter_title:
chapters.append(
{
'startTime': start_time,
'endTime': end_time,
'chapterTitle': chapter_title,
}
)
except Exception as e: # noqa
return JsonResponse({'success': False, 'error': 'Request data must be a list of video chapters with startTime, endTime, chapterTitle'}, status=400)
ret = handle_video_chapters(media, chapters)
return JsonResponse(ret, safe=False)
@login_required
def edit_media(request):
"""Edit a media view"""
friendly_token = request.GET.get("m", "").strip()
if not friendly_token:
return HttpResponseRedirect("/")
media = Media.objects.filter(friendly_token=friendly_token).first()
if not media:
return HttpResponseRedirect("/")
if not (request.user.has_contributor_access_to_media(media) or is_mediacms_editor(request.user)):
return HttpResponseRedirect("/")
if not is_media_allowed_type(media):
return HttpResponseRedirect(media.get_absolute_url())
if request.method == "POST":
form = MediaMetadataForm(request.user, request.POST, request.FILES, instance=media)
if form.is_valid():
media = form.save()
for tag in media.tags.all():
media.tags.remove(tag)
if form.cleaned_data.get("new_tags"):
for tag in form.cleaned_data.get("new_tags").split(","):
tag = get_alphanumeric_only(tag)
tag = tag[:99]
if tag:
try:
tag = Tag.objects.get(title=tag)
except Tag.DoesNotExist:
tag = Tag.objects.create(title=tag, user=request.user)
if tag not in media.tags.all():
media.tags.add(tag)
messages.add_message(request, messages.INFO, translate_string(request.LANGUAGE_CODE, "Media was edited"))
return HttpResponseRedirect(media.get_absolute_url())
else:
form = MediaMetadataForm(request.user, instance=media)
return render(
request,
"cms/edit_media.html",
{"form": form, "media_object": media, "add_subtitle_url": media.add_subtitle_url},
)
@login_required
def publish_media(request):
"""Publish media"""
friendly_token = request.GET.get("m", "").strip()
if not friendly_token:
return HttpResponseRedirect("/")
media = Media.objects.filter(friendly_token=friendly_token).first()
if not media:
return HttpResponseRedirect("/")
if not (request.user.has_contributor_access_to_media(media) or is_mediacms_editor(request.user)):
return HttpResponseRedirect("/")
if request.method == "POST":
form = MediaPublishForm(request.user, request.POST, request.FILES, instance=media)
if form.is_valid():
media = form.save()
messages.add_message(request, messages.INFO, translate_string(request.LANGUAGE_CODE, "Media was edited"))
return HttpResponseRedirect(media.get_absolute_url())
else:
form = MediaPublishForm(request.user, instance=media)
return render(
request,
"cms/publish_media.html",
{"form": form, "media_object": media, "add_subtitle_url": media.add_subtitle_url},
)
@login_required
def edit_chapters(request):
"""Edit chapters"""
friendly_token = request.GET.get("m", "").strip()
if not friendly_token:
return HttpResponseRedirect("/")
media = Media.objects.filter(friendly_token=friendly_token).first()
if not media:
return HttpResponseRedirect("/")
if not (request.user == media.user or is_mediacms_editor(request.user)):
return HttpResponseRedirect("/")
chapters = media.chapter_data
return render(
request,
"cms/edit_chapters.html",
{"media_object": media, "add_subtitle_url": media.add_subtitle_url, "media_file_path": helpers.url_from_path(media.media_file.path), "media_id": media.friendly_token, "chapters": chapters},
)
@csrf_exempt
@login_required
def trim_video(request, friendly_token):
if not settings.ALLOW_VIDEO_TRIMMER:
return JsonResponse({"success": False, "error": "Video trimming is not allowed"}, status=400)
if not request.method == "POST":
return HttpResponseRedirect("/")
media = Media.objects.filter(friendly_token=friendly_token).first()
if not media:
return HttpResponseRedirect("/")
if not (request.user == media.user or is_mediacms_editor(request.user)):
return HttpResponseRedirect("/")
existing_requests = VideoTrimRequest.objects.filter(media=media, status__in=["initial", "running"]).exists()
if existing_requests:
return JsonResponse({"success": False, "error": "A trim request is already in progress for this video"}, status=400)
try:
data = json.loads(request.body)
video_trim_request = create_video_trim_request(media, data)
video_trim_task.delay(video_trim_request.id)
ret = {"success": True, "request_id": video_trim_request.id}
return JsonResponse(ret, safe=False, status=200)
except Exception as e: # noqa
ret = {"success": False, "error": "Incorrect request data"}
return JsonResponse(ret, safe=False, status=400)
@login_required
def edit_video(request):
"""Edit video"""
friendly_token = request.GET.get("m", "").strip()
if not friendly_token:
return HttpResponseRedirect("/")
media = Media.objects.filter(friendly_token=friendly_token).first()
if not media:
return HttpResponseRedirect("/")
if not (request.user == media.user or is_mediacms_editor(request.user)):
return HttpResponseRedirect("/")
if media.media_type not in ["video", "audio"]:
messages.add_message(request, messages.INFO, "Media is not video")
return HttpResponseRedirect(media.get_absolute_url())
if not settings.ALLOW_VIDEO_TRIMMER:
messages.add_message(request, messages.INFO, "Video Trimmer is not enabled")
return HttpResponseRedirect(media.get_absolute_url())
# Check if there's a running trim request
running_trim_request = VideoTrimRequest.objects.filter(media=media, status__in=["initial", "running"]).exists()
if running_trim_request:
messages.add_message(request, messages.INFO, "Video trim request is already running")
return HttpResponseRedirect(media.get_absolute_url())
media_file_path = media.trim_video_url
if not media_file_path:
messages.add_message(request, messages.INFO, "Media processing has not finished yet")
return HttpResponseRedirect(media.get_absolute_url())
if media.encoding_status in ["pending", "running"]:
video_msg = "Media encoding hasn't finished yet. Attempting to show the original video file"
messages.add_message(request, messages.INFO, video_msg)
return render(
request,
"cms/edit_video.html",
{"media_object": media, "add_subtitle_url": media.add_subtitle_url, "media_file_path": media_file_path},
)
def embed_media(request):
"""Embed media view"""
friendly_token = request.GET.get("m", "").strip()
if not friendly_token:
return HttpResponseRedirect("/")
media = Media.objects.values("title").filter(friendly_token=friendly_token).first()
if not media:
return HttpResponseRedirect("/")
context = {}
context["media"] = friendly_token
return render(request, "cms/embed.html", context)
def featured_media(request):
"""List featured media view"""
context = {}
return render(request, "cms/featured-media.html", context)
def index(request):
"""Index view"""
context = {}
return render(request, "cms/index.html", context)
def latest_media(request):
"""List latest media view"""
context = {}
return render(request, "cms/latest-media.html", context)
def liked_media(request):
"""List user's liked media view"""
context = {}
return render(request, "cms/liked_media.html", context)
@login_required
def manage_users(request):
"""List users management view"""
if not is_mediacms_editor(request.user):
return HttpResponseRedirect("/")
context = {}
return render(request, "cms/manage_users.html", context)
@login_required
def manage_media(request):
"""List media management view"""
if not is_mediacms_editor(request.user):
return HttpResponseRedirect("/")
categories = Category.objects.all().order_by('title').values_list('title', flat=True)
context = {'categories': list(categories)}
return render(request, "cms/manage_media.html", context)
@login_required
def manage_comments(request):
"""List comments management view"""
if not is_mediacms_editor(request.user):
return HttpResponseRedirect("/")
context = {}
return render(request, "cms/manage_comments.html", context)
def members(request):
"""List members view"""
if settings.CAN_SEE_MEMBERS_PAGE == "editors" and not is_mediacms_editor(request.user):
return HttpResponseRedirect("/")
if settings.CAN_SEE_MEMBERS_PAGE == "admins" and not request.user.is_superuser:
return HttpResponseRedirect("/")
context = {}
return render(request, "cms/members.html", context)
def recommended_media(request):
"""List recommended media view"""
context = {}
return render(request, "cms/recommended-media.html", context)
def search(request):
"""Search view"""
context = {}
RSS_URL = f"/rss{request.environ.get('REQUEST_URI')}"
context["RSS_URL"] = RSS_URL
return render(request, "cms/search.html", context)
def sitemap(request):
"""Sitemap"""
context = {}
context["media"] = list(Media.objects.filter(listable=True).order_by("-add_date"))
context["playlists"] = list(Playlist.objects.filter().order_by("-add_date"))
context["users"] = list(User.objects.filter())
return render(request, "sitemap.xml", context, content_type="application/xml")
def tags(request):
"""List tags view"""
context = {}
return render(request, "cms/tags.html", context)
def tos(request):
"""Terms of service view"""
context = {}
return render(request, "cms/tos.html", context)
@login_required
def upload_media(request):
"""Upload media view"""
from allauth.account.forms import LoginForm
form = LoginForm()
context = {}
context["form"] = form
context["can_add"] = user_allowed_to_upload(request)
can_upload_exp = settings.CANNOT_ADD_MEDIA_MESSAGE
context["can_upload_exp"] = can_upload_exp
return render(request, "cms/add-media.html", context)
def view_media(request):
"""View media view"""
friendly_token = request.GET.get("m", "").strip()
context = {}
media = Media.objects.filter(friendly_token=friendly_token).first()
if not media:
context["media"] = None
return render(request, "cms/media.html", context)
user_or_session = get_user_or_session(request)
save_user_action.delay(user_or_session, friendly_token=friendly_token, action="watch")
context = {}
context["media"] = friendly_token
context["media_object"] = media
context["CAN_DELETE_MEDIA"] = False
context["CAN_EDIT_MEDIA"] = False
context["CAN_DELETE_COMMENTS"] = False
if request.user.is_authenticated:
if request.user.has_contributor_access_to_media(media) or is_mediacms_editor(request.user):
context["CAN_DELETE_MEDIA"] = True
context["CAN_EDIT_MEDIA"] = True
context["CAN_DELETE_COMMENTS"] = True
# in case media is video and is processing (eg the case a video was just uploaded)
# attempt to show it (rather than showing a blank video player)
if media.media_type == 'video':
video_msg = None
if media.encoding_status == "pending":
video_msg = "Media encoding hasn't started yet. Attempting to show the original video file"
if media.encoding_status == "running":
video_msg = "Media encoding is under processing. Attempting to show the original video file"
if video_msg and media.user == request.user:
messages.add_message(request, messages.INFO, video_msg)
context["is_media_allowed_type"] = is_media_allowed_type(media)
return render(request, "cms/media.html", context)
def view_playlist(request, friendly_token):
"""View playlist view"""
try:
playlist = Playlist.objects.get(friendly_token=friendly_token)
except BaseException:
playlist = None
context = {}
context["playlist"] = playlist
return render(request, "cms/playlist.html", context)

195
files/views/playlists.py Normal file
View File

@ -0,0 +1,195 @@
from django.conf import settings
from drf_yasg import openapi
from drf_yasg.utils import swagger_auto_schema
from rest_framework import permissions, status
from rest_framework.exceptions import PermissionDenied
from rest_framework.parsers import (
FileUploadParser,
FormParser,
JSONParser,
MultiPartParser,
)
from rest_framework.response import Response
from rest_framework.settings import api_settings
from rest_framework.views import APIView
from cms.permissions import IsAuthorizedToAdd, IsUserOrEditor
from ..models import Media, Playlist, PlaylistMedia
from ..serializers import MediaSerializer, PlaylistDetailSerializer, PlaylistSerializer
class PlaylistList(APIView):
"""Playlists listings and creation views"""
permission_classes = (permissions.IsAuthenticatedOrReadOnly, IsAuthorizedToAdd)
parser_classes = (JSONParser, MultiPartParser, FormParser, FileUploadParser)
@swagger_auto_schema(
manual_parameters=[],
tags=['Playlists'],
operation_summary='to_be_written',
operation_description='to_be_written',
responses={
200: openapi.Response('response description', PlaylistSerializer(many=True)),
},
)
def get(self, request, format=None):
pagination_class = api_settings.DEFAULT_PAGINATION_CLASS
paginator = pagination_class()
playlists = Playlist.objects.filter().prefetch_related("user")
if "author" in self.request.query_params:
author = self.request.query_params["author"].strip()
playlists = playlists.filter(user__username=author)
page = paginator.paginate_queryset(playlists, request)
serializer = PlaylistSerializer(page, many=True, context={"request": request})
return paginator.get_paginated_response(serializer.data)
@swagger_auto_schema(
manual_parameters=[],
tags=['Playlists'],
operation_summary='to_be_written',
operation_description='to_be_written',
)
def post(self, request, format=None):
serializer = PlaylistSerializer(data=request.data, context={"request": request})
if serializer.is_valid():
serializer.save(user=request.user)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class PlaylistDetail(APIView):
"""Playlist related views"""
permission_classes = (permissions.IsAuthenticatedOrReadOnly, IsUserOrEditor)
parser_classes = (JSONParser, MultiPartParser, FormParser, FileUploadParser)
def get_playlist(self, friendly_token):
try:
playlist = Playlist.objects.get(friendly_token=friendly_token)
self.check_object_permissions(self.request, playlist)
return playlist
except PermissionDenied:
return Response({"detail": "not enough permissions"}, status=status.HTTP_400_BAD_REQUEST)
except BaseException:
return Response(
{"detail": "Playlist does not exist"},
status=status.HTTP_400_BAD_REQUEST,
)
@swagger_auto_schema(
manual_parameters=[],
tags=['Playlists'],
operation_summary='to_be_written',
operation_description='to_be_written',
)
def get(self, request, friendly_token, format=None):
playlist = self.get_playlist(friendly_token)
if isinstance(playlist, Response):
return playlist
serializer = PlaylistDetailSerializer(playlist, context={"request": request})
playlist_media = PlaylistMedia.objects.filter(playlist=playlist, media__state="public").prefetch_related("media__user")
playlist_media = [c.media for c in playlist_media]
playlist_media_serializer = MediaSerializer(playlist_media, many=True, context={"request": request})
ret = serializer.data
ret["playlist_media"] = playlist_media_serializer.data
return Response(ret)
@swagger_auto_schema(
manual_parameters=[],
tags=['Playlists'],
operation_summary='to_be_written',
operation_description='to_be_written',
)
def post(self, request, friendly_token, format=None):
playlist = self.get_playlist(friendly_token)
if isinstance(playlist, Response):
return playlist
serializer = PlaylistDetailSerializer(playlist, data=request.data, context={"request": request})
if serializer.is_valid():
serializer.save(user=request.user)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@swagger_auto_schema(
manual_parameters=[],
tags=['Playlists'],
operation_summary='to_be_written',
operation_description='to_be_written',
)
def put(self, request, friendly_token, format=None):
playlist = self.get_playlist(friendly_token)
if isinstance(playlist, Response):
return playlist
action = request.data.get("type")
media_friendly_token = request.data.get("media_friendly_token")
ordering = 0
if request.data.get("ordering"):
try:
ordering = int(request.data.get("ordering"))
except ValueError:
pass
if action in ["add", "remove", "ordering"]:
media = Media.objects.filter(friendly_token=media_friendly_token).first()
if media:
if action == "add":
media_in_playlist = PlaylistMedia.objects.filter(playlist=playlist).count()
if media_in_playlist >= settings.MAX_MEDIA_PER_PLAYLIST:
return Response(
{"detail": "max number of media for a Playlist reached"},
status=status.HTTP_400_BAD_REQUEST,
)
else:
obj, created = PlaylistMedia.objects.get_or_create(
playlist=playlist,
media=media,
ordering=media_in_playlist + 1,
)
obj.save()
return Response(
{"detail": "media added to Playlist"},
status=status.HTTP_201_CREATED,
)
elif action == "remove":
PlaylistMedia.objects.filter(playlist=playlist, media=media).delete()
return Response(
{"detail": "media removed from Playlist"},
status=status.HTTP_201_CREATED,
)
elif action == "ordering":
if ordering:
playlist.set_ordering(media, ordering)
return Response(
{"detail": "new ordering set"},
status=status.HTTP_201_CREATED,
)
else:
return Response({"detail": "media is not valid"}, status=status.HTTP_400_BAD_REQUEST)
return Response(
{"detail": "invalid or not specified action"},
status=status.HTTP_400_BAD_REQUEST,
)
@swagger_auto_schema(
manual_parameters=[],
tags=['Playlists'],
operation_summary='to_be_written',
operation_description='to_be_written',
)
def delete(self, request, friendly_token, format=None):
playlist = self.get_playlist(friendly_token)
if isinstance(playlist, Response):
return playlist
playlist.delete()
return Response(status=status.HTTP_204_NO_CONTENT)

30
files/views/tasks.py Normal file
View File

@ -0,0 +1,30 @@
from rest_framework import permissions, status
from rest_framework.response import Response
from rest_framework.views import APIView
from ..methods import list_tasks
class TasksList(APIView):
"""List tasks"""
swagger_schema = None
permission_classes = (permissions.IsAdminUser,)
def get(self, request, format=None):
ret = list_tasks()
return Response(ret)
class TaskDetail(APIView):
"""Cancel a task"""
swagger_schema = None
permission_classes = (permissions.IsAdminUser,)
def delete(self, request, uid, format=None):
# This is not imported!
# revoke(uid, terminate=True)
return Response(status=status.HTTP_204_NO_CONTENT)

45
files/views/user.py Normal file
View File

@ -0,0 +1,45 @@
from drf_yasg import openapi
from drf_yasg.utils import swagger_auto_schema
from rest_framework.parsers import JSONParser
from rest_framework.settings import api_settings
from rest_framework.views import APIView
from actions.models import USER_MEDIA_ACTIONS
from ..models import Media
from ..serializers import MediaSerializer
VALID_USER_ACTIONS = [action for action, name in USER_MEDIA_ACTIONS]
class UserActions(APIView):
parser_classes = (JSONParser,)
@swagger_auto_schema(
manual_parameters=[
openapi.Parameter(name='action', type=openapi.TYPE_STRING, in_=openapi.IN_PATH, description='action', required=True, enum=VALID_USER_ACTIONS),
],
tags=['Users'],
operation_summary='List user actions',
operation_description='Lists user actions',
)
def get(self, request, action):
media = []
if action in VALID_USER_ACTIONS:
if request.user.is_authenticated:
media = Media.objects.select_related("user").filter(mediaactions__user=request.user, mediaactions__action=action).order_by("-mediaactions__action_date")
elif request.session.session_key:
media = (
Media.objects.select_related("user")
.filter(
mediaactions__session_key=request.session.session_key,
mediaactions__action=action,
)
.order_by("-mediaactions__action_date")
)
pagination_class = api_settings.DEFAULT_PAGINATION_CLASS
paginator = pagination_class()
page = paginator.paginate_queryset(media, request)
serializer = MediaSerializer(page, many=True, context={"request": request})
return paginator.get_paginated_response(serializer.data)

BIN
fixtures/test_image2.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

View File

@ -0,0 +1,15 @@
node_modules
dist
.DS_Store
server/public
vite.config.ts.*
*.tar.gz
yt.readme.md
client/public/videos/sample-video.mp4
client/public/videos/sample-video-30s.mp4
client/public/videos/sample-video-37s.mp4
videos/sample-video-37s.mp4
client/public/videos/sample-video-30s.mp4
client/public/videos/sample-video-1.mp4
client/public/videos/sample-video-10m.mp4
client/public/videos/sample-video-10s.mp4

View File

@ -0,0 +1,5 @@
{
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"prettier.configPath": ".prettierrc"
}

View File

@ -0,0 +1,255 @@
# MediaCMS Chapters Editor
A modern browser-based chapter editing tool built with React and TypeScript that integrates with MediaCMS. The Chapters Editor allows users to create, manage, and edit video chapters with precise timing controls and an intuitive timeline interface.
## Features
- 📑 Create and manage video chapters with custom titles
- ⏱️ Precise timestamp controls for chapter start and end points
- ✂️ Split chapters and reorganize content
- 👁️ Preview chapters with jump-to navigation
- 🔄 Undo/redo support for all editing operations
- 🏷️ Chapter metadata editing (titles, descriptions)
- 💾 Save chapter data directly to MediaCMS
- 🎯 Timeline-based chapter visualization
- 📱 Responsive design for desktop and mobile
## Use Cases
- **Educational Content**: Add chapters to lectures and tutorials for better navigation
- **Entertainment**: Create chapters for movies, shows, or long-form content
- **Documentation**: Organize training videos and documentation with logical sections
- **Accessibility**: Improve content accessibility with structured navigation
## Tech Stack
- React 18
- TypeScript
- Vite
## Installation
### Prerequisites
- Node.js (v20+) - Use `nvm use 20` if you have nvm installed
- Yarn or npm package manager
### Setup
```bash
# Navigate to the Chapters Editor directory
cd frontend-tools/chapters-editor
# Install dependencies with Yarn
yarn install
# Or with npm
npm install
```
## Development
The Chapters Editor can be run in two modes:
### Standalone Development Mode
This starts a local development server with hot reloading:
```bash
# Start the development server with Yarn
yarn dev
# Or with npm
npm run dev
```
### Frontend-only Development Mode
If you want to work only on the frontend with MediaCMS backend:
```bash
# Start frontend-only development with Yarn
yarn dev:frontend
# Or with npm
npm run dev:frontend
```
## Building
### For MediaCMS Integration
To build the Chapters Editor for integration with MediaCMS:
```bash
# Build for Django integration with Yarn
yarn build:django
# Or with npm
npm run build:django
```
This will compile the editor and place the output in the MediaCMS static directory.
### Standalone Build
To build the editor as a standalone application:
```bash
# Build for production with Yarn
yarn build
# Or with npm
npm run build
```
## Deployment
To deploy the Chapters Editor, you can use the build and deploy script (recommended):
```bash
# Run the deployment script
sh deploy/scripts/build_and_deploy.sh
```
The build script handles all necessary steps for compiling and deploying the editor to MediaCMS.
You can also deploy manually after building:
```bash
# With Yarn
yarn deploy
# Or with npm
npm run deploy
```
## Project Structure
- `/client` - Frontend React application
- `/src` - Source code
- `/components` - React components for chapter editing
- `/hooks` - Custom React hooks for chapter management
- `/lib` - Utility functions and helpers
- `/services` - API services for MediaCMS integration
- `/styles` - CSS and style definitions
- `/shared` - Shared TypeScript types and utilities
## API Integration
The Chapters Editor interfaces with MediaCMS through a set of API endpoints for:
- Retrieving video metadata and existing chapters
- Saving chapter data (timestamps, titles, descriptions)
- Validating chapter structure and timing
- Integration with MediaCMS user permissions
### Chapter Data Format
Chapters are stored in the following format:
```json
{
"chapters": [
{
"id": "chapter-1",
"title": "Introduction",
"startTime": 0,
"endTime": 120,
"description": "Opening remarks and overview"
},
{
"id": "chapter-2",
"title": "Main Content",
"startTime": 120,
"endTime": 600,
"description": "Core educational material"
}
]
}
```
## Code Formatting
To automatically format all source files using [Prettier](https://prettier.io):
```bash
# Format all code in the src directory
npx prettier --write client/src/
# Or format specific file types
npx prettier --write "client/src/**/*.{js,jsx,ts,tsx,json,css,scss,md}"
```
You can also add this as a script in `package.json`:
```json
"scripts": {
"format": "prettier --write client/src/"
}
```
Then run:
```bash
yarn format
# or
npm run format
```
## Testing
Run the test suite to ensure Chapters Editor functionality:
```bash
# Run tests with Yarn
yarn test
# Or with npm
npm test
# Run tests in watch mode
yarn test:watch
npm run test:watch
```
## Contributing
1. Fork the repository
2. Create a feature branch: `git checkout -b feature/chapter-enhancement`
3. Make your changes and add tests
4. Run the formatter: `yarn format`
5. Run tests: `yarn test`
6. Commit your changes: `git commit -m "Add chapter enhancement"`
7. Push to the branch: `git push origin feature/chapter-enhancement`
8. Submit a pull request
## Troubleshooting
### Common Issues
**Chapter timestamps not saving**: Ensure the MediaCMS backend API is accessible and user has proper permissions.
**Timeline not displaying correctly**: Check browser console for JavaScript errors and ensure video file is properly loaded.
**Performance issues with long videos**: The editor is optimized for videos up to 2 hours. For longer content, consider splitting into multiple files.
### Debug Mode
Enable debug mode for detailed logging:
```bash
# Start with debug logging
DEBUG=true yarn dev
```
## Browser Support
- Chrome/Chromium 90+
- Firefox 88+
- Safari 14+
- Edge 90+
## License
This project is licensed under the same license as MediaCMS. See the main MediaCMS repository for license details.

View File

@ -0,0 +1,34 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
/>
<title>Chapters Editor</title>
<!-- Add meta tag to help iOS devices render as desktop -->
<script>
// Try to detect iOS
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
if (isIOS) {
// Replace viewport meta tag with one optimized for desktop view
const viewportMeta = document.querySelector('meta[name="viewport"]');
if (viewportMeta) {
viewportMeta.setAttribute(
'content',
'width=1024, initial-scale=1.0, maximum-scale=1.0, user-scalable=no'
);
}
// Add a class to the HTML element for iOS-specific styles
document.documentElement.classList.add('ios-device');
}
</script>
</head>
<body>
<div id="chapters-editor-root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 695 KiB

View File

@ -0,0 +1,186 @@
import { formatDetailedTime } from './lib/timeUtils';
import logger from './lib/logger';
import VideoPlayer from '@/components/VideoPlayer';
import TimelineControls from '@/components/TimelineControls';
import EditingTools from '@/components/EditingTools';
import ClipSegments from '@/components/ClipSegments';
import MobilePlayPrompt from '@/components/IOSPlayPrompt';
import useVideoChapters from '@/hooks/useVideoChapters';
import { useEffect } from 'react';
const App = () => {
const {
videoRef,
currentTime,
duration,
isPlaying,
setIsPlaying,
isMuted,
trimStart,
trimEnd,
splitPoints,
zoomLevel,
clipSegments,
selectedSegmentId,
hasUnsavedChanges,
historyPosition,
history,
handleTrimStartChange,
handleTrimEndChange,
handleZoomChange,
handleMobileSafeSeek,
handleSplit,
handleReset,
handleUndo,
handleRedo,
toggleMute,
handleSegmentUpdate,
handleChapterSave,
handleSelectedSegmentChange,
isMobile,
videoInitialized,
setVideoInitialized,
initializeSafariIfNeeded,
} = useVideoChapters();
const handlePlay = async () => {
if (!videoRef.current) return;
const video = videoRef.current;
// If already playing, just pause the video
if (isPlaying) {
video.pause();
setIsPlaying(false);
logger.debug('Video paused');
return;
}
// Safari: Try to initialize if needed before playing
if (duration === 0) {
const initialized = await initializeSafariIfNeeded();
if (initialized) {
// Wait a moment for initialization to complete
setTimeout(() => handlePlay(), 200);
return;
}
}
// Start playing - no boundary checking, play through entire timeline
video
.play()
.then(() => {
setIsPlaying(true);
setVideoInitialized(true);
logger.debug('Continuous playback started from:', formatDetailedTime(video.currentTime));
})
.catch((err) => {
console.error('Error playing video:', err);
});
};
// Handle keyboard shortcuts
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
// Don't handle keyboard shortcuts if user is typing in an input field
const target = event.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
return;
}
switch (event.code) {
case 'Space':
event.preventDefault(); // Prevent default spacebar behavior (scrolling, button activation)
handlePlay();
break;
case 'ArrowLeft':
event.preventDefault();
if (videoRef.current) {
// Use the video element's current time directly to avoid stale state
const newTime = Math.max(videoRef.current.currentTime - 10, 0);
handleMobileSafeSeek(newTime);
logger.debug('Jumped backward 10 seconds to:', formatDetailedTime(newTime));
}
break;
case 'ArrowRight':
event.preventDefault();
if (videoRef.current) {
// Use the video element's current time directly to avoid stale state
const newTime = Math.min(videoRef.current.currentTime + 10, duration);
handleMobileSafeSeek(newTime);
logger.debug('Jumped forward 10 seconds to:', formatDetailedTime(newTime));
}
break;
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [handlePlay, handleMobileSafeSeek, duration, videoRef]);
return (
<div className="bg-background min-h-screen">
<MobilePlayPrompt videoRef={videoRef} onPlay={handlePlay} />
<div className="container mx-auto px-4 py-6 max-w-6xl">
{/* Video Player */}
<VideoPlayer
videoRef={videoRef}
currentTime={currentTime}
duration={duration}
isPlaying={isPlaying}
isMuted={isMuted}
onPlayPause={handlePlay}
onSeek={handleMobileSafeSeek}
onToggleMute={toggleMute}
/>
{/* Editing Tools */}
<EditingTools
onSplit={handleSplit}
onReset={handleReset}
onUndo={handleUndo}
onRedo={handleRedo}
onPlay={handlePlay}
isPlaying={isPlaying}
canUndo={historyPosition > 0}
canRedo={historyPosition < history.length - 1}
/>
{/* Timeline Controls */}
<TimelineControls
currentTime={currentTime}
duration={duration}
thumbnails={[]}
trimStart={trimStart}
trimEnd={trimEnd}
splitPoints={splitPoints}
zoomLevel={zoomLevel}
clipSegments={clipSegments}
selectedSegmentId={selectedSegmentId}
onSelectedSegmentChange={handleSelectedSegmentChange}
onSegmentUpdate={handleSegmentUpdate}
onChapterSave={handleChapterSave}
onTrimStartChange={handleTrimStartChange}
onTrimEndChange={handleTrimEndChange}
onZoomChange={handleZoomChange}
onSeek={handleMobileSafeSeek}
videoRef={videoRef}
hasUnsavedChanges={hasUnsavedChanges}
isIOSUninitialized={isMobile && !videoInitialized}
isPlaying={isPlaying}
setIsPlaying={setIsPlaying}
onPlayPause={handlePlay}
/>
{/* Clip Segments */}
<ClipSegments segments={clipSegments} selectedSegmentId={selectedSegmentId} />
</div>
</div>
);
};
export default App;

View File

@ -0,0 +1,6 @@
// Import the audio poster image as a module
// Vite will handle this and provide the correct URL
import audioPosterJpg from '../../public/audio-poster.jpg';
export const AUDIO_POSTER_URL = audioPosterJpg;

View File

@ -0,0 +1 @@
<?xml version="1.0" ?><svg style="enable-background:new 0 0 512 512;" version="1.1" viewBox="0 0 512 512" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><style type="text/css">.st0{fill:#333333;}.st1{fill:none;stroke:#333333;stroke-width:32;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;}</style><g id="Layer_1"/><g id="Layer_2"><g><g><path class="st0" d="M208.15,380.19h-91.19c-5.7,0-10.32-4.62-10.32-10.32V142.13c0-5.7,4.62-10.32,10.32-10.32h91.19 c5.7,0,10.32,4.62,10.32,10.32v227.74C218.47,375.57,213.85,380.19,208.15,380.19z"/></g><g><path class="st0" d="M395.04,380.19h-91.19c-5.7,0-10.32-4.62-10.32-10.32V142.13c0-5.7,4.62-10.32,10.32-10.32h91.19 c5.7,0,10.32,4.62,10.32,10.32v227.74C405.36,375.57,400.74,380.19,395.04,380.19z"/></g></g></g></svg>

After

Width:  |  Height:  |  Size: 832 B

View File

@ -0,0 +1 @@
<?xml version="1.0" ?><svg style="enable-background:new 0 0 512 512;" version="1.1" viewBox="0 0 512 512" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><style type="text/css">.st0{fill:#333333;}.st1{fill:none;stroke:#333333;stroke-width:32;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;}</style><g id="Layer_1"/><g id="Layer_2"><g><g><path class="st0" d="M85.26,277.5l164.08,94.73c16.55,9.56,37.24-2.39,37.24-21.5V161.27c0-19.11-20.69-31.06-37.24-21.5 L85.26,234.5C68.71,244.06,68.71,267.94,85.26,277.5z"/></g><g><path class="st0" d="M377.47,375.59h41.42c11.19,0,20.26-9.07,20.26-20.26V156.67c0-11.19-9.07-20.26-20.26-20.26h-41.42 c-11.19,0-20.26,9.07-20.26,20.26v198.67C357.21,366.52,366.28,375.59,377.47,375.59z"/></g></g></g></svg>

After

Width:  |  Height:  |  Size: 813 B

View File

@ -0,0 +1,4 @@
<?xml version="1.0" ?><svg style="enable-background:new 0 0 512 512;" version="1.1" viewBox="0 0 512 512" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><style type="text/css">
.st0{fill:#333333;}
.st1{fill:none;stroke:#333333;stroke-width:32;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;}
</style><g id="Layer_1"/><g id="Layer_2"><g><g><path class="st0" d="M85.26,277.5l164.08,94.73c16.55,9.56,37.24-2.39,37.24-21.5V161.27c0-19.11-20.69-31.06-37.24-21.5 L85.26,234.5C68.71,244.06,68.71,267.94,85.26,277.5z"/></g><g><path class="st0" d="M377.47,375.59h41.42c11.19,0,20.26-9.07,20.26-20.26V156.67c0-11.19-9.07-20.26-20.26-20.26h-41.42 c-11.19,0-20.26,9.07-20.26,20.26v198.67C357.21,366.52,366.28,375.59,377.47,375.59z"/></g></g></g></svg>

After

Width:  |  Height:  |  Size: 818 B

View File

@ -0,0 +1,4 @@
<?xml version="1.0" ?><svg style="enable-background:new 0 0 512 512;" version="1.1" viewBox="0 0 512 512" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><style type="text/css">
.st0{fill:#333333;}
.st1{fill:none;stroke:#333333;stroke-width:32;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;}
</style><g id="Layer_1"/><g id="Layer_2"><g><path class="st0" d="M350.45,277.5l-164.08,94.73c-16.55,9.56-37.24-2.39-37.24-21.5V161.27c0-19.11,20.69-31.06,37.24-21.5 l164.08,94.73C367,244.06,367,267.94,350.45,277.5z"/></g></g></svg>

After

Width:  |  Height:  |  Size: 597 B

View File

@ -0,0 +1,4 @@
<?xml version="1.0" ?><svg style="enable-background:new 0 0 512 512;" version="1.1" viewBox="0 0 512 512" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><style type="text/css">
.st0{fill:#333333;}
.st1{fill:none;stroke:#333333;stroke-width:32;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;}
</style><g id="Layer_1"/><g id="Layer_2"><g><path class="st0" d="M350.45,277.5l-164.08,94.73c-16.55,9.56-37.24-2.39-37.24-21.5V161.27c0-19.11,20.69-31.06,37.24-21.5 l164.08,94.73C367,244.06,367,267.94,350.45,277.5z"/></g></g></svg>

After

Width:  |  Height:  |  Size: 611 B

View File

@ -0,0 +1,10 @@
<?xml version="1.0" ?>
<svg height="32" viewBox="0 0 32 32" width="32" xmlns="http://www.w3.org/2000/svg">
<title/>
<g data-name="1" id="_1">
<path d="M27,3V29a1,1,0,0,1-1,1H6a1,1,0,0,1-1-1V27H7v1H25V4H7V7H5V3A1,1,0,0,1,6,2H26A1,1,0,0,1,27,3Z"/>
<g transform="translate(2, 0)">
<path d="M10.71,20.29,7.41,17H18V15H7.41l3.3-3.29L9.29,10.29l-5,5a1,1,0,0,0,0,1.42l5,5Z" id="logout_account_exit_door"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 439 B

View File

@ -0,0 +1,10 @@
<?xml version="1.0" ?>
<svg height="32" viewBox="0 0 32 32" width="32" xmlns="http://www.w3.org/2000/svg">
<title/>
<g data-name="1" id="_1">
<path d="M27,3V29a1,1,0,0,1-1,1H6a1,1,0,0,1-1-1V27H7v1H25V4H7V7H5V3A1,1,0,0,1,6,2H26A1,1,0,0,1,27,3Z"/>
<g transform="translate(2, 0)">
<path d="M10.71,20.29,7.41,17H18V15H7.41l3.3-3.29L9.29,10.29l-5,5a1,1,0,0,0,0,1.42l5,5Z" id="logout_account_exit_door"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 439 B

View File

@ -0,0 +1 @@
<?xml version="1.0" ?><svg height="32" viewBox="0 0 32 32" width="32" xmlns="http://www.w3.org/2000/svg"><title/><g data-name="1" id="_1"><path d="M27,3V29a1,1,0,0,1-1,1H6a1,1,0,0,1-1-1V27H7v1H25V4H7V7H5V3A1,1,0,0,1,6,2H26A1,1,0,0,1,27,3ZM10.71,20.29,7.41,17H18V15H7.41l3.3-3.29L9.29,10.29l-5,5a1,1,0,0,0,0,1.42l5,5Z" id="logout_account_exit_door"/></g></svg>

After

Width:  |  Height:  |  Size: 359 B

View File

@ -0,0 +1,9 @@
<?xml version="1.0" ?>
<svg height="32" viewBox="0 0 32 32" width="32" xmlns="http://www.w3.org/2000/svg">
<g data-name="1" id="_1">
<path d="M5,3V29a1,1,0,0,0,1,1H26a1,1,0,0,0,1-1V25H25v3H7V4H25V7h2V3a1,1,0,0,0-1-1H6A1,1,0,0,0,5,3Z"/>
<g transform="translate(30, 0) scale(-1, 1)">
<path d="M10.71,20.29,7.41,17H18V15H7.41l3.3-3.29L9.29,10.29l-5,5a1,1,0,0,0,0,1.42l5,5Z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 412 B

View File

@ -0,0 +1,9 @@
<?xml version="1.0" ?>
<svg height="32" viewBox="0 0 32 32" width="32" xmlns="http://www.w3.org/2000/svg">
<g data-name="1" id="_1">
<path d="M5,3V29a1,1,0,0,0,1,1H26a1,1,0,0,0,1-1V25H25v3H7V4H25V7h2V3a1,1,0,0,0-1-1H6A1,1,0,0,0,5,3Z"/>
<g transform="translate(28, 0) scale(-1, 1)">
<path d="M10.71,20.29,7.41,17H18V15H7.41l3.3-3.29L9.29,10.29l-5,5a1,1,0,0,0,0,1.42l5,5Z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 411 B

View File

@ -0,0 +1 @@
<?xml version="1.0" ?><svg height="32" viewBox="0 0 32 32" width="32" xmlns="http://www.w3.org/2000/svg"><title/><g data-name="1" id="_1"><path d="M27,3V29a1,1,0,0,1-1,1H6a1,1,0,0,1-1-1V27H7v1H25V4H7V7H5V3A1,1,0,0,1,6,2H26A1,1,0,0,1,27,3ZM12.29,20.29l1.42,1.42,5-5a1,1,0,0,0,0-1.42l-5-5-1.42,1.42L15.59,15H5v2H15.59Z" id="login_account_enter_door"/></g></svg>

After

Width:  |  Height:  |  Size: 359 B

Some files were not shown because too many files have changed in this diff Show More