Compare commits

...

16 Commits

Author SHA1 Message Date
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
Markos Gogoulos
df98b65704 feat: pass version on static files (#1318) 2025-07-07 11:54:02 +03:00
Markos Gogoulos
a607996bfa feat: adds minimum resolution of 144p 2025-07-07 11:34:02 +03:00
Markos Gogoulos
79f2e2bb11 feat: replace format with fstrings 2025-07-07 11:26:08 +03:00
Markos Gogoulos
d54732040a feat: add DB connection pooling 2025-07-07 11:18:40 +03:00
Andy
e8520bc7cd fix: date picker in edit media (#1297)
By default, Django uses type=text for the date input, which respects
the locale settings. However, when changing the input type to date,
it should only accept YYYY-MM-DD format so the input field can be
properly handled.
2025-07-06 11:44:44 +03:00
Markos Gogoulos
b6e46e7b62 feat: replace login middleware (#1314) 2025-07-06 11:25:50 +03:00
Adam Stradovnik
36eab954bd feat: Adds support for Slovenian frontend translations (#1306) 2025-07-06 11:05:07 +03:00
Markos Gogoulos
610716533b fix formatting 2025-07-01 15:46:34 +03:00
Yiannis Christodoulou
4f1c4a2b4c fix: Disable Segment Tools and Reset Preview State During Playback (#1305)
* fix: Disable Segment Tools and Reset Preview State During Playback

* chore: remove some unnecessary comments

* chore: build assets

* fix: do not display the handles (left/right) on preview mode

* fix: Disable all tools on preview mode (undo, redo, reset, etc.)

* Update README.md

* feat: Prettier configuration for video editor

* Update README.md

* Update .prettierrc

* style: Format entire codebase (video-editor) with Prettier

* fix: During segments playback mode, disable button interactions but keep hover working

* feat: Add yarn format

* prettier format

* Update package.json

* feat: Install prettier and improve formatting

* build assets

* Update version.py 6.2.0
2025-07-01 15:33:39 +03:00
Markos Gogoulos
83f3eec940 feat: enable editing of media slug, show categories on manage media 2025-06-24 11:13:33 +03:00
Markos Gogoulos
a5acce4ab1 fix: single server install issues 2025-06-15 11:19:29 +03:00
Ofek
a4e9309350 (FEAT) Add Hebrew Translation (#1295) 2025-06-14 11:08:17 +03:00
Casper Tollund
6beaf0bbe2 Add support for Danish translation (#1293)
Co-authored-by: Casper Tollund <casto@mac.ait.clients.local>
2025-06-12 17:35:37 +03:00
131 changed files with 7401 additions and 5144 deletions

4
.gitignore vendored
View File

@@ -25,3 +25,7 @@ yt.readme.md
frontend-tools/.DS_Store
static/video_editor/videos/sample-video-30s.mp4
static/video_editor/videos/sample-video-37s.mp4
/frontend-tools/video-editor-v2
.DS_Store
static/video_editor/videos/sample-video-10m.mp4
static/video_editor/videos/sample-video-10s.mp4

View File

@@ -1,4 +1,4 @@
FROM python:3.13-bookworm AS build-image
FROM python:3.13.5-bookworm AS build-image
# Install system dependencies needed for downloading and extracting
RUN apt-get update -y && \
@@ -7,14 +7,14 @@ RUN apt-get update -y && \
apt-get purge --auto-remove && \
apt-get clean
# Install ffmpeg
RUN wget -q https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz && \
mkdir -p ffmpeg-tmp && \
RUN wget -q https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz
RUN mkdir -p 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 && \
rm -rf ffmpeg-tmp ffmpeg-release-amd64-static.tar.xz
# Install Bento4 in the specified location
# Install Bento4 in the specified location
RUN mkdir -p /home/mediacms.io/bento4 && \
wget -q http://zebulon.bok.net/Bento4/binaries/Bento4-SDK-1-6-0-637.x86_64-unknown-linux.zip && \
unzip Bento4-SDK-1-6-0-637.x86_64-unknown-linux.zip -d /home/mediacms.io/bento4 && \
@@ -24,7 +24,7 @@ RUN mkdir -p /home/mediacms.io/bento4 && \
rm Bento4-SDK-1-6-0-637.x86_64-unknown-linux.zip
############ RUNTIME IMAGE ############
FROM python:3.13-bookworm AS runtime_image
FROM python:3.13.5-bookworm AS runtime_image
SHELL ["/bin/bash", "-c"]
@@ -37,7 +37,7 @@ ENV PATH="$VIRTUAL_ENV/bin:$PATH"
# Install runtime system dependencies
RUN apt-get update -y && \
apt-get -y upgrade && \
apt-get install --no-install-recommends supervisor nginx imagemagick procps libxml2-dev libxmlsec1-dev libxmlsec1-openssl -y && \
apt-get install --no-install-recommends supervisor nginx imagemagick procps pkg-config libxml2-dev libxmlsec1-dev libxmlsec1-openssl -y && \
rm -rf /var/lib/apt/lists/* && \
apt-get purge --auto-remove && \
apt-get clean
@@ -58,7 +58,7 @@ COPY requirements.txt requirements-dev.txt ./
ARG DEVELOPMENT_MODE=False
RUN pip install --no-cache-dir -r requirements.txt && \
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; \
@@ -85,4 +85,4 @@ EXPOSE 9000 80
RUN chmod +x ./deploy/docker/entrypoint.sh
ENTRYPOINT ["./deploy/docker/entrypoint.sh"]
CMD ["./deploy/docker/start.sh"]
CMD ["./deploy/docker/start.sh"]

View File

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

View File

@@ -38,7 +38,7 @@ A demo is available at https://demo.mediacms.io
- **Configurable actions**: allow download, add comments, add likes, dislikes, report media
- **Configuration options**: change logos, fonts, styling, add more pages
- **Enhanced video player**: customized video.js player with multiple resolution and playback speed options
- **Multiple transcoding profiles**: sane defaults for multiple dimensions (240p, 360p, 480p, 720p, 1080p) and multiple profiles (h264, h265, vp9)
- **Multiple transcoding profiles**: sane defaults for multiple dimensions (144p, 240p, 360p, 480p, 720p, 1080p) and multiple profiles (h264, h265, vp9)
- **Adaptive video streaming**: possible through HLS protocol
- **Subtitles/CC**: support for multilingual subtitle files
- **Scalable transcoding**: transcoding through priorities. Experimental support for remote workers
@@ -93,20 +93,15 @@ There are two ways to run MediaCMS, through Docker Compose and through installin
A complete guide can be found on the blog post [How to self-host and share your videos in 2021](https://medium.com/@MediaCMS.io/how-to-self-host-and-share-your-videos-in-2021-14067e3b291b).
## Configuration
Visit [Configuration](docs/admins_docs.md#5-configuration) page.
## Information for developers
Check out the new section on the [Developer Experience](docs/dev_exp.md) page
## Documentation
* [Users documentation](docs/user_docs.md) page
* [Administrators documentation](docs/admins_docs.md) page
* [Developers documentation](docs/developers_docs.md) page
* [Configuration](docs/admins_docs.md#5-configuration) page
* [Transcoding](docs/transcoding.md) page
* [Developer Experience](docs/dev_exp.md) page
* [Media Permissions](docs/media_permissions.md) page
## Technology

View File

@@ -186,7 +186,7 @@ CHUNKIZE_VIDEO_DURATION = 60 * 5
VIDEO_CHUNKS_DURATION = 60 * 4
# always get these two, even if upscaling
MINIMUM_RESOLUTIONS_TO_ENCODE = [240, 360]
MINIMUM_RESOLUTIONS_TO_ENCODE = [144, 240]
# default settings for notifications
# not all of them are implemented
@@ -376,16 +376,7 @@ LOGGING = {
},
}
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": "mediacms",
"HOST": "127.0.0.1",
"PORT": "5432",
"USER": "mediacms",
"PASSWORD": "mediacms",
}
}
DATABASES = {"default": {"ENGINE": "django.db.backends.postgresql", "NAME": "mediacms", "HOST": "127.0.0.1", "PORT": "5432", "USER": "mediacms", "PASSWORD": "mediacms", "OPTIONS": {'pool': True}}}
REDIS_LOCATION = "redis://127.0.0.1:6379/1"
@@ -453,6 +444,7 @@ DEFAULT_AUTO_FIELD = 'django.db.models.AutoField'
LANGUAGES = [
('ar', _('Arabic')),
('bn', _('Bengali')),
('da', _('Danish')),
('nl', _('Dutch')),
('en', _('English')),
('fr', _('French')),
@@ -465,11 +457,13 @@ LANGUAGES = [
('pt', _('Portuguese')),
('ru', _('Russian')),
('zh-hans', _('Simplified Chinese')),
('sl', _('Slovenian')),
('zh-hant', _('Traditional Chinese')),
('es', _('Spanish')),
('tr', _('Turkish')),
('el', _('Greek')),
('ur', _('Urdu')),
('he', _('Hebrew')),
]
LANGUAGE_CODE = 'en' # default language
@@ -501,6 +495,15 @@ JAZZMIN_UI_TWEAKS = {"theme": "flatly"}
USE_ROUNDED_CORNERS = True
ALLOW_VIDEO_TRIMMER = True
ALLOW_CUSTOM_MEDIA_URLS = False
# Whether to allow anonymous users to list all users
ALLOW_ANONYMOUS_USER_LISTING = True
# ffmpeg options
FFMPEG_DEFAULT_PRESET = "medium" # see https://trac.ffmpeg.org/wiki/Encode/H.264
try:
# keep a local_settings.py file for local overrides
from .local_settings import * # noqa
@@ -540,13 +543,5 @@ except ImportError:
if GLOBAL_LOGIN_REQUIRED:
# this should go after the AuthenticationMiddleware middleware
MIDDLEWARE.insert(6, "login_required.middleware.LoginRequiredMiddleware")
LOGIN_REQUIRED_IGNORE_PATHS = [
r'/accounts/login/$',
r'/accounts/logout/$',
r'/accounts/signup/$',
r'/accounts/password/.*/$',
r'/accounts/confirm-email/.*/$',
# r'/api/v[0-9]+/',
]
auth_index = MIDDLEWARE.index("django.contrib.auth.middleware.AuthenticationMiddleware")
MIDDLEWARE.insert(auth_index + 1, "django.contrib.auth.middleware.LoginRequiredMiddleware")

View File

@@ -1 +1 @@
VERSION = "6.0.0"
VERSION = "6.4.0"

View File

@@ -13,6 +13,7 @@ DATABASES = {
"PORT": os.getenv('POSTGRES_PORT', '5432'),
"USER": os.getenv('POSTGRES_USER', 'mediacms'),
"PASSWORD": os.getenv('POSTGRES_PASSWORD', 'mediacms'),
"OPTIONS": {'pool': True},
}
}

View File

@@ -72,7 +72,7 @@ services:
POSTGRES_DB: mediacms
TZ: Europe/London
healthcheck:
test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}", "--host=db", "--dbname=$POSTGRES_DB", "--username=$POSTGRES_USER"]
test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"]
interval: 10s
timeout: 5s
retries: 5
@@ -81,6 +81,6 @@ services:
restart: always
healthcheck:
test: ["CMD", "redis-cli","ping"]
interval: 30s
timeout: 10s
interval: 10s
timeout: 5s
retries: 3

View File

@@ -2,7 +2,7 @@
## Table of contents
- [1. Welcome](#1-welcome)
- [2. Server Installaton](#2-server-installation)
- [2. Single Server Installaton](#2-single-server-installation)
- [3. Docker Installation](#3-docker-installation)
- [4. Docker Deployment options](#4-docker-deployment-options)
- [5. Configuration](#5-configuration)
@@ -25,20 +25,20 @@
- [22. Role-Based Access Control](#22-role-based-access-control)
- [23. SAML setup](#23-saml-setup)
- [24. Identity Providers setup](#24-identity-providers-setup)
- [25. Custom urls](#25-custom-urls)
## 1. Welcome
This page is created for MediaCMS administrators that are responsible for setting up the software, maintaining it and making modifications.
## 2. Server Installation
## 2. Single Server Installation
The core dependencies are Python3, Django3, Celery, PostgreSQL, Redis, ffmpeg. Any system that can have these dependencies installed, can run MediaCMS. But we strongly suggest installing on Linux Ubuntu (tested on versions 20, 22).
The core dependencies are python3, Django, celery, PostgreSQL, redis, ffmpeg. Any system that can have these dependencies installed, can run MediaCMS. But the install.sh is only tested in Linux Ubuntu 24 and 22 versions.
Installation on an Ubuntu system with git utility installed should be completed in a few minutes with the following steps.
Installation on an Ubuntu 22/24 system with git utility installed should be completed in a few minutes with the following steps.
Make sure you run it as user root, on a clear system, since the automatic script will install and configure the following services: Celery/PostgreSQL/Redis/Nginx and will override any existing settings.
Automated script - tested on Ubuntu 20, Ubuntu 22 and Debian Buster
```bash
mkdir /home/mediacms.io && cd /home/mediacms.io/
@@ -89,13 +89,11 @@ Database can be backed up with pg_dump and media_files on /home/mediacms.io/medi
## Installation
Install a recent version of [Docker](https://docs.docker.com/get-docker/), and [Docker Compose](https://docs.docker.com/compose/install/).
For Ubuntu 20/22 systems this is:
For Ubuntu systems this is:
```bash
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh
sudo curl -L "https://github.com/docker/compose/releases/download/1.29.2/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
sudo chmod +x /usr/local/bin/docker-compose
```
Then run as root
@@ -111,7 +109,7 @@ If you want to explore more options (including setup of https with letsencrypt c
Run
```bash
docker-compose up
docker compose up
```
This will download all MediaCMS related Docker images and start all containers. Once it finishes, MediaCMS will be installed and available on http://localhost or http://ip
@@ -131,8 +129,8 @@ Get latest MediaCMS image and stop/start containers
```bash
cd /path/to/mediacms/installation
docker pull mediacms/mediacms
docker-compose down
docker-compose up
docker compose down
docker compose up
```
### Update from version 2 to version 3
@@ -170,9 +168,7 @@ 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.
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
@@ -189,7 +185,7 @@ Edit this file and set `VIRTUAL_HOST` as my_domain.com, `LETSENCRYPT_HOST` as my
Edit `deploy/docker/local_settings.py` and set https://my_domain.com as `FRONTEND_HOST`
Now run docker-compose -f docker-compose-letsencrypt.yaml up, when installation finishes you will be able to access https://my_domain.com using a valid Letsencrypt certificate!
Now run `docker compose -f docker-compose-letsencrypt.yaml up`, when installation finishes you will be able to access https://my_domain.com using a valid Letsencrypt certificate!
### Advanced Deployment, accessed as http://localhost:8000
@@ -230,7 +226,7 @@ Single server installation: edit `cms/local_settings.py`, make a change and rest
Docker Compose installation: edit `deploy/docker/local_settings.py`, make a change and restart MediaCMS containers
```bash
#docker-compose restart web celery_worker celery_beat
#docker compose restart web celery_worker celery_beat
```
### 5.1 Change portal logo
@@ -504,6 +500,16 @@ 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.
### 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.
## 6. Manage pages
to be written
@@ -967,3 +973,6 @@ USE_IDENTITY_PROVIDERS = True
Visiting the admin, you will see the Identity Providers tab and you can add one.
## 25. Custom urls
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.

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
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
@@ -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
```
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
1. Edit frontend/src/ files
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/`
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
### Helper commands
@@ -81,7 +81,7 @@ Build the 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-scripts rimraf ./dist && mediacms-scripts build --config=./config/mediacms.config.js --env=dist

View File

@@ -17,7 +17,7 @@ to be written
## 3. API documentation
API is documented using Swagger - checkout ot http://your_installation/swagger - example https://demo.mediacms.io/swagger/
This page allows you to login to perform authenticated actions - it will also use your session if logged in.
This page allows you to login to perform authenticated actions - it will also use your session if logged in.
An example of working with Python requests library:
@@ -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)
```
docker-compose -f docker-compose-dev.yaml build
docker-compose -f docker-compose-dev.yaml up
docker compose -f docker-compose-dev.yaml build
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`:
@@ -65,16 +65,16 @@ 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
```
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,
```
cp -r frontend/dist/static/* static/
```
POST calls: cannot be performed through the dev server, you have to make through the normal application (port 80) and then see changes on the dev application on port 8088.
POST calls: cannot be performed through the dev server, you have to make through the normal application (port 80) and then see changes on the dev application on port 8088.
Make sure the urls are set on `frontend/.env` if different than localhost
@@ -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
```
docker-compose -f docker-compose-dev.yaml restart web
docker compose -f docker-compose-dev.yaml restart web
```
## How video is transcoded
@@ -113,7 +113,7 @@ there is also an experimental small service (not commited to the repo currently)
When the Encode object is marked as success and chunk=False, and thus is available for download/stream, there is a task that gets started and saves an HLS version of the file (1 mp4-->x number of small .ts chunks). This would be FILES_C
This mechanism allows for workers that have access on the same filesystem (either localhost, or through a shared network filesystem, eg NFS/EFS) to work on the same time and produce results.
This mechanism allows for workers that have access on the same filesystem (either localhost, or through a shared network filesystem, eg NFS/EFS) to work on the same time and produce results.
## 6. Working with the automated tests
@@ -122,19 +122,19 @@ This instructions assume that you're using the docker installation
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)
```
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
```
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)
@@ -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
```
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
```
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 ;)

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.

50
docs/transcoding.md Normal file
View File

@@ -0,0 +1,50 @@
# Transcoding in MediaCMS
MediaCMS uses FFmpeg for transcoding media files. Most of the transcoding settings and configurations are defined in `files/helpers.py`.
## Configuration Options
Several transcoding parameters can be customized in `cms/settings.py`:
### FFmpeg Preset
The default FFmpeg preset is set to "medium". This setting controls the encoding speed and compression efficiency trade-off.
```python
# ffmpeg options
FFMPEG_DEFAULT_PRESET = "medium" # see https://trac.ffmpeg.org/wiki/Encode/H.264
```
Available presets include:
- ultrafast
- superfast
- veryfast
- faster
- fast
- medium (default)
- slow
- slower
- veryslow
Faster presets result in larger file sizes for the same quality, while slower presets provide better compression but take longer to encode.
### Other Transcoding Settings
Additional transcoding settings in `settings.py` include:
- `FFMPEG_COMMAND`: Path to the FFmpeg executable
- `FFPROBE_COMMAND`: Path to the FFprobe executable
- `DO_NOT_TRANSCODE_VIDEO`: If set to True, only the original video is shown without transcoding
- `CHUNKIZE_VIDEO_DURATION`: For videos longer than this duration (in seconds), they get split into chunks and encoded independently
- `VIDEO_CHUNKS_DURATION`: Duration of each chunk (must be smaller than CHUNKIZE_VIDEO_DURATION)
- `MINIMUM_RESOLUTIONS_TO_ENCODE`: Always encode these resolutions, even if upscaling is required
## Advanced Configuration
For more advanced transcoding settings, you may need to modify the following in `files/helpers.py`:
- Video bitrates for different codecs and resolutions
- Audio encoders and bitrates
- CRF (Constant Rate Factor) values
- Keyframe settings
- Encoding parameters for different codecs (H.264, H.265, VP9)

View File

@@ -1,5 +1,7 @@
from django.conf import settings
from cms.version import VERSION
from .frontend_translations import get_translation, get_translation_strings
from .methods import is_mediacms_editor, is_mediacms_manager
@@ -37,6 +39,7 @@ def stuff(request):
ret["USE_SAML"] = settings.USE_SAML
ret["USE_RBAC"] = settings.USE_RBAC
ret["USE_ROUNDED_CORNERS"] = settings.USE_ROUNDED_CORNERS
ret["VERSION"] = VERSION
if request.user.is_superuser:
ret["DJANGO_ADMIN_URL"] = settings.DJANGO_ADMIN_URL

View File

@@ -83,7 +83,7 @@ class IndexRSSFeed(Feed):
return item.edit_date
def item_link(self, item):
return reverse("get_media") + "?m={0}".format(item.friendly_token)
return f"{reverse('get_media')}?m={item.friendly_token}"
def item_extra_kwargs(self, item):
item = {
@@ -151,7 +151,7 @@ class SearchRSSFeed(Feed):
return item.edit_date
def item_link(self, item):
return reverse("get_media") + "?m={0}".format(item.friendly_token)
return f"{reverse('get_media')}?m={item.friendly_token}"
def item_extra_kwargs(self, item):
item = {

View File

@@ -22,6 +22,7 @@ class MediaMetadataForm(forms.ModelForm):
class Meta:
model = Media
fields = (
"friendly_token",
"title",
"new_tags",
"add_date",
@@ -34,15 +35,17 @@ class MediaMetadataForm(forms.ModelForm):
widgets = {
"new_tags": MultipleSelect(),
"description": forms.Textarea(attrs={'rows': 4}),
"add_date": forms.DateInput(attrs={'type': 'date'}),
"add_date": forms.DateInput(attrs={'type': 'date'}, format='%Y-%m-%d'),
"thumbnail_time": forms.NumberInput(attrs={'min': 0, 'step': 0.1}),
}
labels = {
"friendly_token": "Slug",
"uploaded_poster": "Poster Image",
"thumbnail_time": "Thumbnail Time (seconds)",
}
help_texts = {
"title": "",
"friendly_token": "Media URL slug",
"thumbnail_time": "Select the time in seconds for the video thumbnail",
"uploaded_poster": "Maximum file size: 5MB",
}
@@ -50,6 +53,8 @@ class MediaMetadataForm(forms.ModelForm):
def __init__(self, user, *args, **kwargs):
self.user = user
super(MediaMetadataForm, self).__init__(*args, **kwargs)
if not getattr(settings, 'ALLOW_CUSTOM_MEDIA_URLS', False):
self.fields.pop("friendly_token")
if self.instance.media_type != "video":
self.fields.pop("thumbnail_time")
if self.instance.media_type == "image":
@@ -63,20 +68,37 @@ class MediaMetadataForm(forms.ModelForm):
self.helper.form_method = 'post'
self.helper.form_enctype = "multipart/form-data"
self.helper.form_show_errors = False
self.helper.layout = Layout(
layout_fields = [
CustomField('title'),
CustomField('new_tags'),
CustomField('add_date'),
CustomField('description'),
CustomField('uploaded_poster'),
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":
self.helper.layout.append(CustomField('thumbnail_time'))
if getattr(settings, 'ALLOW_CUSTOM_MEDIA_URLS', False):
self.helper.layout.insert(0, CustomField('friendly_token'))
self.helper.layout.append(FormActions(Submit('submit', 'Update Media', css_class='primaryAction')))
def clean_friendly_token(self):
token = self.cleaned_data.get("friendly_token", "").strip()
if token:
if not all(c.isalnum() or c in "-_" for c in token):
raise forms.ValidationError("Slug can only contain alphanumeric characters, underscores, or hyphens.")
if Media.objects.filter(friendly_token=token).exclude(pk=self.instance.pk).exists():
raise forms.ValidationError("This slug is already in use. Please choose a different one.")
return token
def clean_uploaded_poster(self):
image = self.cleaned_data.get("uploaded_poster", False)
if image:

View File

@@ -0,0 +1,104 @@
translation_strings = {
"ABOUT": "OM",
"AUTOPLAY": "Automatisk afspilning",
"About": "Om",
"Add a ": "Tilføj en ",
"COMMENT": "KOMMENTAR",
"Categories": "Kategorier",
"Category": "Kategori",
"Change Language": "Skift sprog",
"Change password": "Skift adgangskode",
"Comment": "Kommentar",
"Comments": "Kommentarer",
"Comments are disabled": "Kommentarer er slået fra",
"Contact": "Kontakt",
"DELETE MEDIA": "SLET MEDIE",
"DOWNLOAD": "HENT",
"EDIT MEDIA": "REDIGER MEDIE",
"EDIT PROFILE": "REDIGER PROFIL",
"EDIT SUBTITLE": "REDIGER UNDERTEKSTER",
"Edit media": "Rediger medie",
"Edit profile": "Rediger profil",
"Edit subtitle": "Rediger undertekster",
"Featured": "Fremhævede",
"Go": "Vælg",
"History": "Historik",
"Home": "Hjem",
"Language": "Sprog",
"Latest": "Nyeste",
"Liked media": "Medier du har liket",
"Manage comments": "Administrer kommentarer",
"Manage media": "Administrer medier",
"Manage users": "Administrer brugere",
"Media": "Medier",
"Media was edited": "Mediet er blevet redigeret",
"Members": "Medlemmer",
"My media": "Mine medier",
"My playlists": "Mine playlister",
"No": "Nej",
"No comment yet": "Ingen kommentar endnu",
"No comments yet": "Ingen komentarer endnu",
"No results for": "Ingen resultater for",
"PLAYLISTS": "PLAYLISTER",
"Playlists": "Playlister",
"Powered by": "Drevet af",
"Published on": "Udgivet på",
"Recommended": "Anbefalet",
"Register": "Registrer",
"SAVE": "GEM",
"SEARCH": "SØG",
"SHARE": "DEL",
"SHOW MORE": "VIS MERE",
"SUBMIT": "INDSEND",
"Search": "Søg",
"Select": "Vælg",
"Sign in": "Log ind",
"Sign out": "Log ud",
"Subtitle was added": "Undertekster tilføjet",
"Tags": "Tags",
"Terms": "Vilkår",
"UPLOAD": "UPLOAD",
"Up next": "Næste",
"Upload": "Upload",
"Upload media": "Upload medie",
"Uploads": "Uploads",
"VIEW ALL": "SE ALLE",
"View all": "Se alle",
"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.",
"media in category": "medier i kategori",
"media in tag": "medier i tag",
"view": "visning",
"views": "visninger",
"yet": "endnu",
}
replacement_strings = {
"Apr": "Apr",
"Aug": "Aug",
"Dec": "Dec",
"Feb": "Feb",
"Jan": "Jan",
"Jul": "Jul",
"Jun": "Jun",
"Mar": "Mar",
"May": "Maj",
"Nov": "Nov",
"Oct": "Okt",
"Sep": "Sep",
"day ago": "dag siden",
"days ago": "dage siden",
"hour ago": "time siden",
"hours ago": "timer siden",
"just now": "lige nu",
"minute ago": "minut siden",
"minutes ago": "minutter siden",
"month ago": "måned siden",
"months ago": "måneder siden",
"second ago": "sekund siden",
"seconds ago": "sekunder siden",
"week ago": "uge siden",
"weeks ago": "uger siden",
"year ago": "år siden",
"years ago": "år siden",
}

View File

@@ -0,0 +1,104 @@
translation_strings = {
'ABOUT': 'על אודות',
'AUTOPLAY': 'ניגון אוטומטי',
'About': 'על אודות',
'Add a ': 'הוסף',
'COMMENT': 'תגובה',
'Categories': 'קטגוריות',
'Category': 'קטגוריה',
'Change Language': 'שנה שפה',
'Change password': 'שנה סיסמה',
'Comment': 'תגובה',
'Comments': 'תגובות',
'Comments are disabled': 'התגובות מושבתות',
'Contact': 'צור קשר',
'DELETE MEDIA': 'מחק מדיה',
'DOWNLOAD': 'הורד',
'EDIT MEDIA': 'ערוך מדיה',
'EDIT PROFILE': 'ערוך פרופיל',
'EDIT SUBTITLE': 'ערוך כתוביות',
'Edit media': 'ערוך מדיה',
'Edit profile': 'ערוך פרופיל',
'Edit subtitle': 'ערוך כתוביות',
'Featured': 'מומלצים',
'Go': 'בצע', # in context of "execution"
'History': 'היסטוריה',
'Home': 'דף הבית',
'Language': 'שפה',
'Latest': 'העדכונים האחרונים',
'Liked media': 'מדיה שאהבתי',
'Manage comments': 'ניהול תגובות',
'Manage media': 'ניהול מדיה',
'Manage users': 'ניהול משתמשים',
'Media': 'מדיה',
'Media was edited': 'המדיה נערכה',
'Members': 'משתמשים',
'My media': 'המדיה שלי',
'My playlists': 'הפלייליסטים שלי',
'No': 'לא', # in context of "no comments", etc.
'No comment yet': 'עדיין אין תגובות',
'No comments yet': 'עדיין אין תגובות',
'No results for': 'אין תוצאות עבור',
'PLAYLISTS': 'פלייליסטים',
'Playlists': 'פלייליסטים',
'Powered by': 'מופעל על ידי',
'Published on': 'פורסם בתאריך',
'Recommended': 'מומלץ',
'Register': 'הרשמה',
'SAVE': 'שמור',
'SEARCH': 'חפש',
'SHARE': 'שתף',
'SHOW MORE': 'הצג עוד',
'SUBMIT': 'שלח',
'Search': 'חפש',
'Select': 'בחר',
'Sign in': 'התחבר',
'Sign out': 'התנתק',
'Subtitle was added': 'הכתובית נוספה',
'Tags': 'תגיות',
'Terms': 'תנאים',
'UPLOAD': 'העלה',
'Up next': 'הבא בתור',
'Upload': 'העלה',
'Upload media': 'העלה מדיה',
'Uploads': 'העלאות',
'VIEW ALL': 'הצג הכל',
'View all': 'הצג הכל',
'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': 'מדיה בתגית',
'view': 'צפיות',
'views': 'צפיות',
'yet': 'עדיין',
}
replacement_strings = {
'Apr': 'אפריל',
'Aug': 'אוגוסט',
'Dec': 'דצמבר',
'Feb': 'פברואר',
'Jan': 'ינואר',
'Jul': 'יולי',
'Jun': 'יוני',
'Mar': 'מרץ',
'May': 'מאי',
'Nov': 'נובמבר',
'Oct': 'אוקטובר',
'Sep': 'ספטמבר',
'day ago': 'לפני יום',
'days ago': 'לפני ימים',
'hour ago': 'לפני שעה',
'hours ago': 'לפני שעות',
'just now': 'הרגע',
'minute ago': 'לפני דקה',
'minutes ago': 'לפני דקות',
'month ago': 'לפני חודש',
'months ago': 'לפני חודשים',
'second ago': 'לפני שנייה',
'seconds ago': 'לפני שניות',
'week ago': 'לפני שבוע',
'weeks ago': 'לפני שבועות',
'year ago': 'לפני שנה',
'years ago': 'לפני שנים',
}

View File

@@ -0,0 +1,104 @@
translation_strings = {
"ABOUT": "O NAS",
"AUTOPLAY": "SAMODEJNO PREDVAJANJE",
"Add a ": "Dodaj ",
"COMMENT": "KOMENTAR",
"Categories": "Kategorije",
"Category": "Kategorija",
"Change Language": "Spremeni jezik",
"Change password": "Spremeni geslo",
"About": "O nas",
"Comment": "Komentar",
"Comments": "Komentarji",
"Comments are disabled": "Komentarji so onemogočeni",
"Contact": "Kontakt",
"DELETE MEDIA": "IZBRIŠI MEDIJ",
"DOWNLOAD": "PRENESI",
"EDIT MEDIA": "UREDI MEDIJ",
"EDIT PROFILE": "UREDI PROFIL",
"EDIT SUBTITLE": "UREDI PODNAPISE",
"Edit media": "Uredi medij",
"Edit profile": "Uredi profil",
"Edit subtitle": "Uredi podnapise",
"Featured": "Izbrani",
"Go": "Pojdi",
"History": "Zgodovina",
"Home": "Domov",
"Language": "Jezik",
"Latest": "Najnovejši",
"Liked media": "Všečkani mediji",
"Manage comments": "Upravljaj komentarje",
"Manage media": "Upravljaj medije",
"Manage users": "Upravljaj uporabnike",
"Media": "Mediji",
"Media was edited": "Medij je bil urejen",
"Members": "Člani",
"My media": "Moji mediji",
"My playlists": "Moji seznami predvajanja",
"No": "Ne",
"No comment yet": "Brez komentarja",
"No comments yet": "Brez komentarjev",
"No results for": "Ni rezultatov za",
"PLAYLISTS": "SEZNAMI PREDVAJANJA",
"Playlists": "Seznami predvajanja",
"Powered by": "Poganja",
"Published on": "Objavljeno",
"Recommended": "Priporočeno",
"Register": "Registracija",
"SAVE": "SHRANI",
"SEARCH": "ISKANJE",
"SHARE": "DELI",
"SHOW MORE": "PRIKAŽI VEČ",
"SUBMIT": "POŠLJI",
"Search": "Iskanje",
"Select": "Izberi",
"Sign in": "Prijava",
"Sign out": "Odjava",
"Subtitle was added": "Podnapisi so bili dodani",
"Tags": "Oznake",
"Terms": "Pogoji",
"UPLOAD": "NALOŽI",
"Up next": "Naslednji",
"Upload": "Naloži",
"Upload media": "Naloži medij",
"Uploads": "Naloženi",
"VIEW ALL": "PRIKAŽI VSE",
"View all": "Prikaži vse",
"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",
"media in category": "mediji v kategoriji",
"media in tag": "mediji z oznako",
"view": "ogled",
"views": "ogledi",
"yet": "še",
}
replacement_strings = {
"Apr": "Apr",
"Aug": "Avg",
"Dec": "Dec",
"Feb": "Feb",
"Jan": "Jan",
"Jul": "Jul",
"Jun": "Jun",
"Mar": "Mar",
"May": "Maj",
"Nov": "Nov",
"Oct": "Okt",
"Sep": "Sep",
"day ago": "dan nazaj",
"days ago": "dni nazaj",
"hour ago": "ura nazaj",
"hours ago": "ur nazaj",
"just now": "pravkar",
"minute ago": "minuta nazaj",
"minutes ago": "minut nazaj",
"month ago": "mesec nazaj",
"months ago": "mesecev nazaj",
"second ago": "sekunda nazaj",
"seconds ago": "sekund nazaj",
"week ago": "teden nazaj",
"weeks ago": "tednov nazaj",
"year ago": "leto nazaj",
"years ago": "let nazaj",
}

View File

@@ -34,12 +34,6 @@ BUF_SIZE_MULTIPLIER = 1.5
KEYFRAME_DISTANCE = 4
KEYFRAME_DISTANCE_MIN = 2
# speed presets
# see https://trac.ffmpeg.org/wiki/Encode/H.264
X26x_PRESET = "medium" # "medium"
X265_PRESET = "medium"
X26x_PRESET_BIG_HEIGHT = "faster"
# VP9_SPEED = 1 # between 0 and 4, lower is slower
VP9_SPEED = 2
@@ -55,6 +49,7 @@ VIDEO_CRFS = {
VIDEO_BITRATES = {
"h264": {
25: {
144: 150,
240: 300,
360: 500,
480: 1000,
@@ -67,6 +62,7 @@ VIDEO_BITRATES = {
},
"h265": {
25: {
144: 75,
240: 150,
360: 275,
480: 500,
@@ -79,6 +75,7 @@ VIDEO_BITRATES = {
},
"vp9": {
25: {
144: 75,
240: 150,
360: 275,
480: 500,
@@ -173,7 +170,7 @@ def rm_dir(directory):
def url_from_path(filename):
# TODO: find a way to preserver http - https ...
return "{0}{1}".format(settings.MEDIA_URL, filename.replace(settings.MEDIA_ROOT, ""))
return f"{settings.MEDIA_URL}{filename.replace(settings.MEDIA_ROOT, '')}"
def create_temp_file(suffix=None, dir=settings.TEMP_DIRECTORY):
@@ -488,7 +485,7 @@ def show_file_size(size):
if size:
size = size / 1000000
size = round(size, 1)
size = "{0}MB".format(str(size))
size = f"{str(size)}MB"
return size
@@ -596,17 +593,13 @@ def get_base_ffmpeg_command(
cmd = base_cmd[:]
# preset settings
preset = getattr(settings, "FFMPEG_DEFAULT_PRESET", "medium")
if encoder == "libvpx-vp9":
if pass_number == 1:
speed = 4
else:
speed = VP9_SPEED
elif encoder in ["libx264"]:
preset = X26x_PRESET
elif encoder in ["libx265"]:
preset = X265_PRESET
if target_height >= 720:
preset = X26x_PRESET_BIG_HEIGHT
if encoder == "libx264":
level = "4.2" if target_height <= 1080 else "5.2"
@@ -730,7 +723,7 @@ def produce_ffmpeg_commands(media_file, media_info, resolution, codec, output_fi
return False
if media_info.get("video_height") < resolution:
if resolution not in [240, 360]: # always get these two
if resolution not in settings.MINIMUM_RESOLUTIONS_TO_ENCODE:
return False
# if codec == "h264_baseline":

View File

@@ -46,6 +46,7 @@ class MediaList(APIView):
featured = params.get("featured", "").strip()
is_reviewed = params.get("is_reviewed", "").strip()
category = params.get("category", "").strip()
sort_by_options = [
"title",
@@ -98,6 +99,9 @@ class MediaList(APIView):
if is_reviewed != "all":
qs = qs.filter(is_reviewed=is_reviewed)
if category:
qs = qs.filter(category__title__contains=category)
media = qs.order_by(f"{ordering}{sort_by}")
paginator = pagination_class()

View File

@@ -166,14 +166,14 @@ Media becomes private if it gets reported %s times\n
)
if settings.ADMINS_NOTIFICATIONS.get("MEDIA_REPORTED", False):
title = "[{}] - Media was reported".format(settings.PORTAL_NAME)
title = f"[{settings.PORTAL_NAME}] - Media was reported"
d = {}
d["title"] = title
d["msg"] = msg
d["to"] = settings.ADMIN_EMAIL_LIST
notify_items.append(d)
if settings.USERS_NOTIFICATIONS.get("MEDIA_REPORTED", False):
title = "[{}] - Media was reported".format(settings.PORTAL_NAME)
title = f"[{settings.PORTAL_NAME}] - Media was reported"
d = {}
d["title"] = title
d["msg"] = msg
@@ -182,7 +182,7 @@ Media becomes private if it gets reported %s times\n
if action == "media_added" and media:
if settings.ADMINS_NOTIFICATIONS.get("MEDIA_ADDED", False):
title = "[{}] - Media was added".format(settings.PORTAL_NAME)
title = f"[{settings.PORTAL_NAME}] - Media was added"
msg = """
Media %s was added by user %s.
""" % (
@@ -195,7 +195,7 @@ Media %s was added by user %s.
d["to"] = settings.ADMIN_EMAIL_LIST
notify_items.append(d)
if settings.USERS_NOTIFICATIONS.get("MEDIA_ADDED", False):
title = "[{}] - Your media was added".format(settings.PORTAL_NAME)
title = f"[{settings.PORTAL_NAME}] - Your media was added"
msg = """
Your media has been added! It will be encoded and will be available soon.
URL: %s
@@ -339,7 +339,7 @@ def notify_user_on_comment(friendly_token):
media_url = settings.SSL_FRONTEND_HOST + media.get_absolute_url()
if user.notification_on_comments:
title = "[{}] - A comment was added".format(settings.PORTAL_NAME)
title = f"[{settings.PORTAL_NAME}] - A comment was added"
msg = """
A comment has been added to your media %s .
View it on %s
@@ -363,7 +363,7 @@ def notify_user_on_mention(friendly_token, user_mentioned, cleaned_comment):
media_url = settings.SSL_FRONTEND_HOST + media.get_absolute_url()
if user.notification_on_comments:
title = "[{}] - You were mentioned in a comment".format(settings.PORTAL_NAME)
title = f"[{settings.PORTAL_NAME}] - You were mentioned in a comment"
msg = """
You were mentioned in a comment on %s .
View it on %s
@@ -567,3 +567,42 @@ def handle_video_chapters(media, chapters):
video_chapter = models.VideoChapterData.objects.create(media=media, data=chapters)
return 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

View File

@@ -0,0 +1,17 @@
# Generated by Django 5.1.6 on 2025-06-20 08:13
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('files', '0008_alter_media_state_videotrimrequest'),
]
operations = [
migrations.AlterField(
model_name='media',
name='friendly_token',
field=models.CharField(blank=True, db_index=True, help_text='Identifier for the Media', max_length=150, unique=True),
),
]

View File

@@ -0,0 +1,17 @@
# Generated by Django 5.1.6 on 2025-07-05 11:49
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('files', '0009_alter_media_friendly_token'),
]
operations = [
migrations.AlterField(
model_name='encodeprofile',
name='resolution',
field=models.IntegerField(blank=True, choices=[(2160, '2160'), (1440, '1440'), (1080, '1080'), (720, '720'), (480, '480'), (360, '360'), (240, '240'), (144, '144')], null=True),
),
]

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')},
},
),
]

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

@@ -0,0 +1,25 @@
# 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 .playlist import Playlist, PlaylistMedia # noqa: F401
from .rating import Rating, RatingCategory # noqa: F401
from .subtitle import Language, Subtitle # 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[:99]
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

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[:99]
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}"

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

@@ -0,0 +1,72 @@
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 subtitles_file_path
class Language(models.Model):
"""Language model
to be used with Subtitles
"""
code = models.CharField(max_length=12, 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

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,86 @@
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']
def save(self, *args, **kwargs):
from .. import tasks
is_new = self.pk is None
if is_new or (not is_new and self._check_data_changed()):
super().save(*args, **kwargs)
tasks.produce_video_chapters.delay(self.pk)
else:
super().save(*args, **kwargs)
def _check_data_changed(self):
if self.pk:
old_instance = VideoChapterData.objects.get(pk=self.pk)
return old_instance.data != self.data
return False
@property
def chapter_data(self):
# ensure response is consistent
data = []
for item in self.data:
if item.get("start") and item.get("title"):
thumbnail = item.get("thumbnail")
if thumbnail:
thumbnail = helpers.url_from_path(thumbnail)
else:
thumbnail = "static/images/chapter_default.jpg"
data.append(
{
"start": item.get("start"),
"title": item.get("title"),
"thumbnail": thumbnail,
}
)
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

@@ -136,8 +136,8 @@ def chunkize_media(self, friendly_token, profiles, force=True):
cwd = os.path.dirname(os.path.realpath(media.media_file.path))
file_name = media.media_file.path.split("/")[-1]
random_prefix = produce_friendly_token()
file_format = "{0}_{1}".format(random_prefix, file_name)
chunks_file_name = "%02d_{0}".format(file_format)
file_format = f"{random_prefix}_{file_name}"
chunks_file_name = f"%02d_{file_format}"
chunks_file_name += ".mkv"
cmd = [
settings.FFMPEG_COMMAND,
@@ -162,7 +162,7 @@ def chunkize_media(self, friendly_token, profiles, force=True):
chunks.append(ch[0])
if not chunks:
# command completely failed to segment file.putting to normal encode
logger.info("Failed to break file {0} in chunks." " Putting to normal encode queue".format(friendly_token))
logger.info(f"Failed to break file {friendly_token} in chunks. Putting to normal encode queue")
for profile in profiles:
if media.video_height and media.video_height < profile.resolution:
if profile.resolution not in settings.MINIMUM_RESOLUTIONS_TO_ENCODE:
@@ -211,7 +211,7 @@ def chunkize_media(self, friendly_token, profiles, force=True):
priority=priority,
)
logger.info("got {0} chunks and will encode to {1} profiles".format(len(chunks), to_profiles))
logger.info(f"got {len(chunks)} chunks and will encode to {to_profiles} profiles")
return True
@@ -355,8 +355,8 @@ def encode_media(
# return False
with tempfile.TemporaryDirectory(dir=settings.TEMP_DIRECTORY) as temp_dir:
tf = create_temp_file(suffix=".{0}".format(profile.extension), dir=temp_dir)
tfpass = create_temp_file(suffix=".{0}".format(profile.extension), dir=temp_dir)
tf = create_temp_file(suffix=f".{profile.extension}", dir=temp_dir)
tfpass = create_temp_file(suffix=f".{profile.extension}", dir=temp_dir)
ffmpeg_commands = produce_ffmpeg_commands(
original_media_path,
media.media_info,
@@ -398,7 +398,7 @@ def encode_media(
if n_times % 60 == 0:
encoding.progress = percent
encoding.save(update_fields=["progress", "update_date"])
logger.info("Saved {0}".format(round(percent, 2)))
logger.info(f"Saved {round(percent, 2)}")
n_times += 1
except DatabaseError:
# primary reason for this is that the encoding has been deleted, because
@@ -451,7 +451,7 @@ def encode_media(
with open(tf, "rb") as f:
myfile = File(f)
output_name = "{0}.{1}".format(get_file_name(original_media_path), profile.extension)
output_name = f"{get_file_name(original_media_path)}.{profile.extension}"
encoding.media_file.save(content=myfile, name=output_name)
encoding.total_run_time = (encoding.update_date - encoding.add_date).seconds
@@ -472,7 +472,7 @@ def produce_sprite_from_video(friendly_token):
try:
media = Media.objects.get(friendly_token=friendly_token)
except BaseException:
logger.info("failed to get media with friendly_token %s" % friendly_token)
logger.info(f"failed to get media with friendly_token {friendly_token}")
return False
with tempfile.TemporaryDirectory(dir=settings.TEMP_DIRECTORY) as tmpdirname:
@@ -516,7 +516,7 @@ def create_hls(friendly_token):
try:
media = Media.objects.get(friendly_token=friendly_token)
except BaseException:
logger.info("failed to get media with friendly_token %s" % friendly_token)
logger.info(f"failed to get media with friendly_token {friendly_token}")
return False
p = media.uid.hex
@@ -558,7 +558,7 @@ def check_running_states():
encodings = Encoding.objects.filter(status="running")
logger.info("got {0} encodings that are in state running".format(encodings.count()))
logger.info(f"got {encodings.count()} encodings that are in state running")
changed = 0
for encoding in encodings:
now = datetime.now(encoding.update_date.tzinfo)
@@ -575,7 +575,7 @@ def check_running_states():
# TODO: allign with new code + chunksize...
changed += 1
if changed:
logger.info("changed from running to pending on {0} items".format(changed))
logger.info(f"changed from running to pending on {changed} items")
return True
@@ -585,7 +585,7 @@ def check_media_states():
# check encoding status of not success media
media = Media.objects.filter(Q(encoding_status="running") | Q(encoding_status="fail") | Q(encoding_status="pending"))
logger.info("got {0} media that are not in state success".format(media.count()))
logger.info(f"got {media.count()} media that are not in state success")
changed = 0
for m in media:
@@ -593,7 +593,7 @@ def check_media_states():
m.save(update_fields=["encoding_status"])
changed += 1
if changed:
logger.info("changed encoding status to {0} media items".format(changed))
logger.info(f"changed encoding status to {changed} media items")
return True
@@ -628,7 +628,7 @@ def check_pending_states():
media.encode(profiles=[profile], force=False)
changed += 1
if changed:
logger.info("set to the encode queue {0} encodings that were on pending state".format(changed))
logger.info(f"set to the encode queue {changed} encodings that were on pending state")
return True
@@ -652,7 +652,7 @@ def check_missing_profiles():
# if they appear on the meanwhile (eg on a big queue)
changed += 1
if changed:
logger.info("set to the encode queue {0} profiles".format(changed))
logger.info(f"set to the encode queue {changed} profiles")
return True
@@ -820,7 +820,7 @@ def update_listings_thumbnails():
# Categories
used_media = []
saved = 0
qs = Category.objects.filter().order_by("-media_count")
qs = Category.objects.filter()
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()
if media:
@@ -828,12 +828,12 @@ def update_listings_thumbnails():
object.save(update_fields=["listings_thumbnail"])
used_media.append(media.friendly_token)
saved += 1
logger.info("updated {} categories".format(saved))
logger.info(f"updated {saved} categories")
# Tags
used_media = []
saved = 0
qs = Tag.objects.filter().order_by("-media_count")
qs = Tag.objects.filter()
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()
if media:
@@ -841,7 +841,7 @@ def update_listings_thumbnails():
object.save(update_fields=["listings_thumbnail"])
used_media.append(media.friendly_token)
saved += 1
logger.info("updated {} tags".format(saved))
logger.info(f"updated {saved} tags")
return True

View File

@@ -48,10 +48,12 @@ urlpatterns = [
re_path(r"^view", views.view_media, name="get_media"),
re_path(r"^upload", views.upload_media, name="upload_media"),
# 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/(?P<friendly_token>[\w]*)$",
r"^api/v1/media/(?P<friendly_token>[\w\-_]*)$",
views.MediaDetail.as_view(),
name="api_get_media",
),

File diff suppressed because it is too large Load Diff

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

@@ -0,0 +1,43 @@
# 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 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 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 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)

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

@@ -0,0 +1,763 @@
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:
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(
manual_parameters=[
openapi.Parameter(name='media_ids', in_=openapi.IN_FORM, type=openapi.TYPE_ARRAY, items=openapi.Items(type=openapi.TYPE_STRING), required=True, description="List of media IDs"),
openapi.Parameter(
name='action',
in_=openapi.IN_FORM,
type=openapi.TYPE_STRING,
required=True,
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",
],
),
openapi.Parameter(
name='playlist_ids',
in_=openapi.IN_FORM,
type=openapi.TYPE_ARRAY,
items=openapi.Items(type=openapi.TYPE_INTEGER),
required=False,
description="List of playlist IDs (required for add_to_playlist and remove_from_playlist actions)",
),
openapi.Parameter(
name='state', in_=openapi.IN_FORM, type=openapi.TYPE_STRING, required=False, description="State to set (required for set_state action)", enum=["private", "public", "unlisted"]
),
openapi.Parameter(name='owner', in_=openapi.IN_FORM, type=openapi.TYPE_STRING, required=False, 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)

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

@@ -0,0 +1,593 @@
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.permissions import user_allowed_to_upload
from cms.version import VERSION
from users.models import User
from .. import helpers
from ..forms import (
ContactForm,
EditSubtitleForm,
MediaMetadataForm,
MediaPublishForm,
SubtitleForm,
)
from ..frontend_translations import translate_string
from ..helpers import get_alphanumeric_only
from ..methods import (
create_video_trim_request,
get_user_or_session,
handle_video_chapters,
is_mediacms_editor,
)
from ..models import Category, Media, Playlist, Subtitle, Tag, VideoTrimRequest
from ..tasks import save_user_action, video_trim_task
def about(request):
"""About view"""
context = {"VERSION": VERSION}
return render(request, "cms/about.html", context)
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("/")
if request.method == "POST":
form = SubtitleForm(media, request.POST, request.FILES)
if form.is_valid():
subtitle = form.save()
new_subtitle = Subtitle.objects.filter(id=subtitle.id).first()
try:
new_subtitle.convert_to_srt()
messages.add_message(request, messages.INFO, "Subtitle was added!")
return HttpResponseRedirect(subtitle.media.get_absolute_url())
except: # noqa: E722
new_subtitle.delete()
error_msg = "Invalid subtitle format. Use SubRip (.srt) or WebVTT (.vtt) files."
form.add_error("subtitle_file", error_msg)
else:
form = SubtitleForm(media_item=media)
subtitles = media.subtitles.all()
context = {"media": media, "form": form, "subtitles": subtitles}
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, "Subtitle 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, "Subtitle 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):
# this is not ready...
return False
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:
data = json.loads(request.body)["chapters"]
chapters = []
for _, chapter_data in enumerate(data):
start_time = chapter_data.get('start')
title = chapter_data.get('title')
if start_time and title:
chapters.append(
{
'start': start_time,
'title': title,
}
)
except Exception as e: # noqa
return JsonResponse({'success': False, 'error': 'Request data must be a list of video chapters with start and title'}, 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 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"""
# not implemented yet
return False
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("/")
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},
)
@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 not media.media_type == "video":
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"""
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:
messages.add_message(request, messages.INFO, video_msg)
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)

View File

@@ -1 +1 @@
[{"model": "files.encodeprofile", "pk": 19, "fields": {"name": "h264-2160", "extension": "mp4", "resolution": 2160, "codec": "h264", "description": "", "active": false}}, {"model": "files.encodeprofile", "pk": 22, "fields": {"name": "vp9-2160", "extension": "webm", "resolution": 2160, "codec": "vp9", "description": "", "active": false}}, {"model": "files.encodeprofile", "pk": 16, "fields": {"name": "h265-2160", "extension": "mp4", "resolution": 2160, "codec": "h265", "description": "", "active": false}}, {"model": "files.encodeprofile", "pk": 4, "fields": {"name": "h264-1440", "extension": "mp4", "resolution": 1440, "codec": "h264", "description": "", "active": false}}, {"model": "files.encodeprofile", "pk": 5, "fields": {"name": "vp9-1440", "extension": "webm", "resolution": 1440, "codec": "vp9", "description": "", "active": false}}, {"model": "files.encodeprofile", "pk": 6, "fields": {"name": "h265-1440", "extension": "mp4", "resolution": 1440, "codec": "h265", "description": "", "active": false}}, {"model": "files.encodeprofile", "pk": 7, "fields": {"name": "h264-1080", "extension": "mp4", "resolution": 1080, "codec": "h264", "description": "", "active": true}}, {"model": "files.encodeprofile", "pk": 8, "fields": {"name": "vp9-1080", "extension": "webm", "resolution": 1080, "codec": "vp9", "description": "", "active": false}}, {"model": "files.encodeprofile", "pk": 9, "fields": {"name": "h265-1080", "extension": "mp4", "resolution": 1080, "codec": "h265", "description": "", "active": false}}, {"model": "files.encodeprofile", "pk": 10, "fields": {"name": "h264-720", "extension": "mp4", "resolution": 720, "codec": "h264", "description": "", "active": true}}, {"model": "files.encodeprofile", "pk": 11, "fields": {"name": "vp9-720", "extension": "webm", "resolution": 720, "codec": "vp9", "description": "", "active": false}}, {"model": "files.encodeprofile", "pk": 12, "fields": {"name": "h265-720", "extension": "mp4", "resolution": 720, "codec": "h265", "description": "", "active": false}}, {"model": "files.encodeprofile", "pk": 13, "fields": {"name": "h264-480", "extension": "mp4", "resolution": 480, "codec": "h264", "description": "", "active": true}}, {"model": "files.encodeprofile", "pk": 14, "fields": {"name": "vp9-480", "extension": "webm", "resolution": 480, "codec": "vp9", "description": "", "active": false}}, {"model": "files.encodeprofile", "pk": 15, "fields": {"name": "h265-480", "extension": "mp4", "resolution": 480, "codec": "h265", "description": "", "active": false}}, {"model": "files.encodeprofile", "pk": 3, "fields": {"name": "h264-360", "extension": "mp4", "resolution": 360, "codec": "h264", "description": "", "active": true}}, {"model": "files.encodeprofile", "pk": 17, "fields": {"name": "vp9-360", "extension": "webm", "resolution": 360, "codec": "vp9", "description": "", "active": false}}, {"model": "files.encodeprofile", "pk": 18, "fields": {"name": "h265-360", "extension": "mp4", "resolution": 360, "codec": "h265", "description": "", "active": false}}, {"model": "files.encodeprofile", "pk": 2, "fields": {"name": "h264-240", "extension": "mp4", "resolution": 240, "codec": "h264", "description": "", "active": true}}, {"model": "files.encodeprofile", "pk": 20, "fields": {"name": "vp9-240", "extension": "webm", "resolution": 240, "codec": "vp9", "description": "", "active": false}}, {"model": "files.encodeprofile", "pk": 21, "fields": {"name": "h265-240", "extension": "mp4", "resolution": 240, "codec": "h265", "description": "", "active": false}}, {"model": "files.encodeprofile", "pk": 1, "fields": {"name": "preview", "extension": "gif", "resolution": null, "codec": null, "description": "", "active": true}}]
[{"model": "files.encodeprofile", "pk": 19, "fields": {"name": "h264-2160", "extension": "mp4", "resolution": 2160, "codec": "h264", "description": "", "active": false}}, {"model": "files.encodeprofile", "pk": 22, "fields": {"name": "vp9-2160", "extension": "webm", "resolution": 2160, "codec": "vp9", "description": "", "active": false}}, {"model": "files.encodeprofile", "pk": 23, "fields": {"name": "h264-144", "extension": "mp4", "resolution": 144, "codec": "h264", "description": "", "active": true}}, {"model": "files.encodeprofile", "pk": 16, "fields": {"name": "h265-2160", "extension": "mp4", "resolution": 2160, "codec": "h265", "description": "", "active": false}}, {"model": "files.encodeprofile", "pk": 4, "fields": {"name": "h264-1440", "extension": "mp4", "resolution": 1440, "codec": "h264", "description": "", "active": false}}, {"model": "files.encodeprofile", "pk": 5, "fields": {"name": "vp9-1440", "extension": "webm", "resolution": 1440, "codec": "vp9", "description": "", "active": false}}, {"model": "files.encodeprofile", "pk": 6, "fields": {"name": "h265-1440", "extension": "mp4", "resolution": 1440, "codec": "h265", "description": "", "active": false}}, {"model": "files.encodeprofile", "pk": 7, "fields": {"name": "h264-1080", "extension": "mp4", "resolution": 1080, "codec": "h264", "description": "", "active": true}}, {"model": "files.encodeprofile", "pk": 8, "fields": {"name": "vp9-1080", "extension": "webm", "resolution": 1080, "codec": "vp9", "description": "", "active": false}}, {"model": "files.encodeprofile", "pk": 9, "fields": {"name": "h265-1080", "extension": "mp4", "resolution": 1080, "codec": "h265", "description": "", "active": false}}, {"model": "files.encodeprofile", "pk": 10, "fields": {"name": "h264-720", "extension": "mp4", "resolution": 720, "codec": "h264", "description": "", "active": true}}, {"model": "files.encodeprofile", "pk": 11, "fields": {"name": "vp9-720", "extension": "webm", "resolution": 720, "codec": "vp9", "description": "", "active": false}}, {"model": "files.encodeprofile", "pk": 12, "fields": {"name": "h265-720", "extension": "mp4", "resolution": 720, "codec": "h265", "description": "", "active": false}}, {"model": "files.encodeprofile", "pk": 13, "fields": {"name": "h264-480", "extension": "mp4", "resolution": 480, "codec": "h264", "description": "", "active": true}}, {"model": "files.encodeprofile", "pk": 14, "fields": {"name": "vp9-480", "extension": "webm", "resolution": 480, "codec": "vp9", "description": "", "active": false}}, {"model": "files.encodeprofile", "pk": 15, "fields": {"name": "h265-480", "extension": "mp4", "resolution": 480, "codec": "h265", "description": "", "active": false}}, {"model": "files.encodeprofile", "pk": 3, "fields": {"name": "h264-360", "extension": "mp4", "resolution": 360, "codec": "h264", "description": "", "active": true}}, {"model": "files.encodeprofile", "pk": 17, "fields": {"name": "vp9-360", "extension": "webm", "resolution": 360, "codec": "vp9", "description": "", "active": false}}, {"model": "files.encodeprofile", "pk": 18, "fields": {"name": "h265-360", "extension": "mp4", "resolution": 360, "codec": "h265", "description": "", "active": false}}, {"model": "files.encodeprofile", "pk": 2, "fields": {"name": "h264-240", "extension": "mp4", "resolution": 240, "codec": "h264", "description": "", "active": true}}, {"model": "files.encodeprofile", "pk": 20, "fields": {"name": "vp9-240", "extension": "webm", "resolution": 240, "codec": "vp9", "description": "", "active": false}}, {"model": "files.encodeprofile", "pk": 21, "fields": {"name": "h265-240", "extension": "mp4", "resolution": 240, "codec": "h265", "description": "", "active": false}}, {"model": "files.encodeprofile", "pk": 1, "fields": {"name": "preview", "extension": "gif", "resolution": null, "codec": null, "description": "", "active": true}}]

BIN
fixtures/test_image2.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

View File

@@ -10,3 +10,6 @@ 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

@@ -1 +0,0 @@
*

View File

@@ -0,0 +1,22 @@
{
"printWidth": 100,
"tabWidth": 2,
"useTabs": false,
"semi": true,
"singleQuote": false,
"quoteProps": "as-needed",
"bracketSpacing": true,
"bracketSameLine": false,
"arrowParens": "always",
"trailingComma": "none",
"endOfLine": "lf",
"embeddedLanguageFormatting": "auto",
"overrides": [
{
"files": ["*.css", "*.scss"],
"options": {
"singleQuote": false
}
}
]
}

View File

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

View File

@@ -128,4 +128,44 @@ npm run deploy
## API Integration
The video editor interfaces with MediaCMS through a set of API endpoints for retrieving and saving video edits.
The video editor interfaces with MediaCMS through a set of API endpoints for retrieving and saving video edits.
Sure! Here's your updated `README.md` section with a new **"Code Formatting"** section using Prettier. I placed it after the "Development" section to keep the flow logical:
---
## Code Formatting
To automatically format all source files using [Prettier](https://prettier.io):
```bash
# Format all code in the src directory
npx prettier --write src/
```
Or for specific file types:
```bash
cd frontend-tools/video-editor/
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
```
---
Let me know if you'd like to auto-format on commit using `lint-staged` + `husky`.

View File

@@ -16,7 +16,6 @@ const App = () => {
isPlaying,
setIsPlaying,
isMuted,
isPreviewMode,
thumbnails,
trimStart,
trimEnd,
@@ -34,7 +33,6 @@ const App = () => {
handleReset,
handleUndo,
handleRedo,
handlePreview,
toggleMute,
handleSave,
handleSaveACopy,
@@ -43,7 +41,7 @@ const App = () => {
videoInitialized,
setVideoInitialized,
isPlayingSegments,
handlePlaySegments,
handlePlaySegments
} = useVideoTrimmer();
// Function to play from the beginning
@@ -71,31 +69,31 @@ const App = () => {
const handlePlay = () => {
if (!videoRef.current) return;
const video = videoRef.current;
// If already playing, just pause the video
if (isPlaying) {
video.pause();
setIsPlaying(false);
return;
}
const currentPosition = Number(video.currentTime.toFixed(6)); // Fix to microsecond precision
// Find the next stopping point based on current position
let stopTime = duration;
let currentSegment = null;
let nextSegment = null;
// Sort segments by start time to ensure correct order
const sortedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
// First, check if we're inside a segment or exactly at its start/end
currentSegment = sortedSegments.find(seg => {
currentSegment = sortedSegments.find((seg) => {
const segStartTime = Number(seg.startTime.toFixed(6));
const segEndTime = Number(seg.endTime.toFixed(6));
// Check if we're inside the segment
if (currentPosition > segStartTime && currentPosition < segEndTime) {
return true;
@@ -111,15 +109,15 @@ const App = () => {
}
return false;
});
// If we're not in a segment, find the next segment
if (!currentSegment) {
nextSegment = sortedSegments.find(seg => {
nextSegment = sortedSegments.find((seg) => {
const segStartTime = Number(seg.startTime.toFixed(6));
return segStartTime > currentPosition;
});
}
// Determine where to stop based on position
if (currentSegment) {
// If we're in a segment, stop at its end
@@ -128,113 +126,123 @@ const App = () => {
// If we're in a cutaway and there's a next segment, stop at its start
stopTime = Number(nextSegment.startTime.toFixed(6));
}
// Create a boundary checker function with high precision
const checkBoundary = () => {
if (!video) return;
const currentPosition = Number(video.currentTime.toFixed(6));
const timeLeft = Number((stopTime - currentPosition).toFixed(6));
// If we've reached or passed the boundary
if (timeLeft <= 0 || currentPosition >= stopTime) {
// First pause playback
video.pause();
// Force exact position with multiple verification attempts
const setExactPosition = () => {
if (!video) return;
// Set to exact boundary time
video.currentTime = stopTime;
handleMobileSafeSeek(stopTime);
const actualPosition = Number(video.currentTime.toFixed(6));
const difference = Number(Math.abs(actualPosition - stopTime).toFixed(6));
logger.debug("Position verification:", {
target: formatDetailedTime(stopTime),
actual: formatDetailedTime(actualPosition),
difference: difference
});
// If we're not exactly at the target position, try one more time
if (difference > 0) {
video.currentTime = stopTime;
handleMobileSafeSeek(stopTime);
}
};
// Multiple attempts to ensure precision, with increasing delays
setExactPosition();
setTimeout(setExactPosition, 5); // Quick first retry
setTimeout(setExactPosition, 5); // Quick first retry
setTimeout(setExactPosition, 10); // Second retry
setTimeout(setExactPosition, 20); // Third retry if needed
setTimeout(setExactPosition, 50); // Final verification
// Remove our boundary checker
video.removeEventListener('timeupdate', checkBoundary);
video.removeEventListener("timeupdate", checkBoundary);
setIsPlaying(false);
// Log the final position for debugging
logger.debug("Stopped at position:", {
target: formatDetailedTime(stopTime),
actual: formatDetailedTime(video.currentTime),
type: currentSegment ? "segment end" : (nextSegment ? "next segment start" : "end of video"),
segment: currentSegment ? {
id: currentSegment.id,
start: formatDetailedTime(currentSegment.startTime),
end: formatDetailedTime(currentSegment.endTime)
} : null,
nextSegment: nextSegment ? {
id: nextSegment.id,
start: formatDetailedTime(nextSegment.startTime),
end: formatDetailedTime(nextSegment.endTime)
} : null
type: currentSegment
? "segment end"
: nextSegment
? "next segment start"
: "end of video",
segment: currentSegment
? {
id: currentSegment.id,
start: formatDetailedTime(currentSegment.startTime),
end: formatDetailedTime(currentSegment.endTime)
}
: null,
nextSegment: nextSegment
? {
id: nextSegment.id,
start: formatDetailedTime(nextSegment.startTime),
end: formatDetailedTime(nextSegment.endTime)
}
: null
});
return;
}
};
// Start our boundary checker
video.addEventListener('timeupdate', checkBoundary);
video.addEventListener("timeupdate", checkBoundary);
// Start playing
video.play()
video
.play()
.then(() => {
setIsPlaying(true);
setVideoInitialized(true);
logger.debug("Playback started:", {
from: formatDetailedTime(currentPosition),
to: formatDetailedTime(stopTime),
currentSegment: currentSegment ? {
id: currentSegment.id,
start: formatDetailedTime(currentSegment.startTime),
end: formatDetailedTime(currentSegment.endTime)
} : 'None',
nextSegment: nextSegment ? {
id: nextSegment.id,
start: formatDetailedTime(nextSegment.startTime),
end: formatDetailedTime(nextSegment.endTime)
} : 'None'
currentSegment: currentSegment
? {
id: currentSegment.id,
start: formatDetailedTime(currentSegment.startTime),
end: formatDetailedTime(currentSegment.endTime)
}
: "None",
nextSegment: nextSegment
? {
id: nextSegment.id,
start: formatDetailedTime(nextSegment.startTime),
end: formatDetailedTime(nextSegment.endTime)
}
: "None"
});
})
.catch(err => {
.catch((err) => {
console.error("Error playing video:", err);
});
};
return (
<div className="bg-background min-h-screen">
<MobilePlayPrompt
videoRef={videoRef}
onPlay={handlePlay}
/>
<MobilePlayPrompt videoRef={videoRef} onPlay={handlePlay} />
<div className="container mx-auto px-4 py-6 max-w-6xl">
{/* Video Player */}
<VideoPlayer
<VideoPlayer
videoRef={videoRef}
currentTime={currentTime}
duration={duration}
@@ -246,15 +254,13 @@ const App = () => {
/>
{/* Editing Tools */}
<EditingTools
<EditingTools
onSplit={handleSplit}
onReset={handleReset}
onUndo={handleUndo}
onRedo={handleRedo}
onPreview={handlePreview}
onPlaySegments={handlePlaySegments}
onPlay={handlePlay}
isPreviewMode={isPreviewMode}
isPlaying={isPlaying}
isPlayingSegments={isPlayingSegments}
canUndo={historyPosition > 0}
@@ -262,7 +268,7 @@ const App = () => {
/>
{/* Timeline Controls */}
<TimelineControls
<TimelineControls
currentTime={currentTime}
duration={duration}
thumbnails={thumbnails}
@@ -279,7 +285,6 @@ const App = () => {
onSave={handleSave}
onSaveACopy={handleSaveACopy}
onSaveSegments={handleSaveSegments}
isPreviewMode={isPreviewMode}
hasUnsavedChanges={hasUnsavedChanges}
isIOSUninitialized={isMobile && !videoInitialized}
isPlaying={isPlaying}

View File

@@ -1,5 +1,5 @@
import { formatTime, formatLongTime } from "@/lib/timeUtils";
import '../styles/ClipSegments.css';
import "../styles/ClipSegments.css";
export interface Segment {
id: number;
@@ -16,41 +16,36 @@ interface ClipSegmentsProps {
const ClipSegments = ({ segments }: ClipSegmentsProps) => {
// Sort segments by startTime
const sortedSegments = [...segments].sort((a, b) => a.startTime - b.startTime);
// Handle delete segment click
const handleDeleteSegment = (segmentId: number) => {
// Create and dispatch the delete event
const deleteEvent = new CustomEvent('delete-segment', {
detail: { segmentId }
const deleteEvent = new CustomEvent("delete-segment", {
detail: { segmentId }
});
document.dispatchEvent(deleteEvent);
};
// Generate the same color background for a segment as shown in the timeline
const getSegmentColorClass = (index: number) => {
// Return CSS class based on index modulo 8
// Return CSS class based on index modulo 8
// This matches the CSS nth-child selectors in the timeline
return `segment-default-color segment-color-${(index % 8) + 1}`;
};
return (
<div className="clip-segments-container">
<h3 className="clip-segments-title">Clip Segments</h3>
{sortedSegments.map((segment, index) => (
<div
key={segment.id}
className={`segment-item ${getSegmentColorClass(index)}`}
>
<div key={segment.id} className={`segment-item ${getSegmentColorClass(index)}`}>
<div className="segment-content">
<div
className="segment-thumbnail"
<div
className="segment-thumbnail"
style={{ backgroundImage: `url(${segment.thumbnail})` }}
></div>
<div className="segment-info">
<div className="segment-title">
Segment {index + 1}
</div>
<div className="segment-title">Segment {index + 1}</div>
<div className="segment-time">
{formatTime(segment.startTime)} - {formatTime(segment.endTime)}
</div>
@@ -60,20 +55,24 @@ const ClipSegments = ({ segments }: ClipSegmentsProps) => {
</div>
</div>
<div className="segment-actions">
<button
className="delete-button"
<button
className="delete-button"
aria-label="Delete Segment"
data-tooltip="Delete this segment"
onClick={() => handleDeleteSegment(segment.id)}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clipRule="evenodd" />
<path
fillRule="evenodd"
d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z"
clipRule="evenodd"
/>
</svg>
</button>
</div>
</div>
))}
{sortedSegments.length === 0 && (
<div className="empty-message">
No segments created yet. Use the split button to create segments.

View File

@@ -1,17 +1,15 @@
import '../styles/EditingTools.css';
import { useEffect, useState } from 'react';
import "../styles/EditingTools.css";
import { useEffect, useState } from "react";
interface EditingToolsProps {
onSplit: () => void;
onReset: () => void;
onUndo: () => void;
onRedo: () => void;
onPreview: () => void;
onPlaySegments: () => void;
onPlay: () => void;
canUndo: boolean;
canRedo: boolean;
isPreviewMode?: boolean;
isPlaying?: boolean;
isPlayingSegments?: boolean;
}
@@ -21,14 +19,12 @@ const EditingTools = ({
onReset,
onUndo,
onRedo,
onPreview,
onPlaySegments,
onPlay,
canUndo,
canRedo,
isPreviewMode = false,
isPlaying = false,
isPlayingSegments = false,
isPlayingSegments = false
}: EditingToolsProps) => {
const [isSmallScreen, setIsSmallScreen] = useState(false);
@@ -38,17 +34,17 @@ const EditingTools = ({
};
checkScreenSize();
window.addEventListener('resize', checkScreenSize);
return () => window.removeEventListener('resize', checkScreenSize);
window.addEventListener("resize", checkScreenSize);
return () => window.removeEventListener("resize", checkScreenSize);
}, []);
// Handle play button click with iOS fix
const handlePlay = () => {
// Ensure lastSeekedPosition is used when play is clicked
if (typeof window !== 'undefined') {
if (typeof window !== "undefined") {
console.log("Play button clicked, current lastSeekedPosition:", window.lastSeekedPosition);
}
// Call the original handler
onPlay();
};
@@ -59,15 +55,25 @@ const EditingTools = ({
{/* Left side - Play buttons group */}
<div className="button-group play-buttons-group">
{/* Play Segments button */}
<button
<button
className={`button segments-button`}
onClick={onPlaySegments}
data-tooltip={isPlayingSegments ? "Stop segments playback" : "Play segments in one continuous flow"}
style={{ fontSize: '0.875rem' }}
data-tooltip={
isPlayingSegments ? "Stop segments playback" : "Play segments in one continuous flow"
}
style={{ fontSize: "0.875rem" }}
>
{isPlayingSegments ? (
<>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="10" />
<line x1="10" y1="15" x2="10" y2="9" />
<line x1="14" y1="15" x2="14" y2="9" />
@@ -77,7 +83,15 @@ const EditingTools = ({
</>
) : (
<>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="10" />
<polygon points="10 8 16 12 10 16 10 8" />
</svg>
@@ -116,18 +130,26 @@ const EditingTools = ({
)}
</button> */}
{/* Standard Play button (only shown when not in preview mode or segments playback) */}
{!isPreviewMode && (!isPlayingSegments || !isSmallScreen) && (
<button
className={`button play-button ${isPlayingSegments ? 'greyed-out' : ''}`}
{/* Standard Play button (only shown when not in segments playback on small screens) */}
{(!isPlayingSegments || !isSmallScreen) && (
<button
className={`button play-button ${isPlayingSegments ? "greyed-out" : ""}`}
onClick={handlePlay}
data-tooltip={isPlaying ? "Pause video" : "Play full video"}
style={{ fontSize: '0.875rem' }}
style={{ fontSize: "0.875rem" }}
disabled={isPlayingSegments}
>
{isPlaying ? (
{isPlaying && !isPlayingSegments ? (
<>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="10" />
<line x1="10" y1="15" x2="10" y2="9" />
<line x1="14" y1="15" x2="14" y2="9" />
@@ -137,7 +159,15 @@ const EditingTools = ({
</>
) : (
<>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="10" />
<polygon points="10 8 16 12 10 16 10 8" />
</svg>
@@ -147,7 +177,7 @@ const EditingTools = ({
)}
</button>
)}
{/* Segments Playback message (replaces play button during segments playback) */}
{/* {isPlayingSegments && !isSmallScreen && (
<div className="segments-playback-message">
@@ -159,7 +189,7 @@ const EditingTools = ({
Preview Mode
</div>
)} */}
{/* Preview mode message (replaces play button) */}
{/* {isPreviewMode && (
<div className="preview-mode-message">
@@ -172,43 +202,64 @@ const EditingTools = ({
</div>
)} */}
</div>
{/* Right side - Editing tools */}
<div className="button-group secondary">
<button
<button
className="button"
aria-label="Undo"
data-tooltip="Undo last action"
disabled={!canUndo}
aria-label="Undo"
data-tooltip={isPlayingSegments ? "Disabled during preview" : "Undo last action"}
disabled={!canUndo || isPlayingSegments}
onClick={onUndo}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 14 4 9l5-5"/>
<path d="M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5v0a5.5 5.5 0 0 1-5.5 5.5H11"/>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M9 14 4 9l5-5" />
<path d="M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5v0a5.5 5.5 0 0 1-5.5 5.5H11" />
</svg>
<span className="button-text">Undo</span>
</button>
<button
<button
className="button"
aria-label="Redo"
data-tooltip="Redo last undone action"
disabled={!canRedo}
aria-label="Redo"
data-tooltip={isPlayingSegments ? "Disabled during preview" : "Redo last undone action"}
disabled={!canRedo || isPlayingSegments}
onClick={onRedo}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m15 14 5-5-5-5"/>
<path d="M20 9H9.5A5.5 5.5 0 0 0 4 14.5v0A5.5 5.5 0 0 0 9.5 20H13"/>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="m15 14 5-5-5-5" />
<path d="M20 9H9.5A5.5 5.5 0 0 0 4 14.5v0A5.5 5.5 0 0 0 9.5 20H13" />
</svg>
<span className="button-text">Redo</span>
</button>
<div className="divider"></div>
<button
<button
className="button"
onClick={onReset}
data-tooltip="Reset to full video"
data-tooltip={isPlayingSegments ? "Disabled during preview" : "Reset to full video"}
disabled={isPlayingSegments}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clipRule="evenodd" />
<path
fillRule="evenodd"
d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z"
clipRule="evenodd"
/>
</svg>
<span className="reset-text">Reset</span>
</button>

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react';
import '../styles/IOSPlayPrompt.css';
import React, { useState, useEffect } from "react";
import "../styles/IOSPlayPrompt.css";
interface MobilePlayPromptProps {
videoRef: React.RefObject<HTMLVideoElement>;
@@ -13,7 +13,9 @@ const MobilePlayPrompt: React.FC<MobilePlayPromptProps> = ({ videoRef, onPlay })
useEffect(() => {
const checkIsMobile = () => {
// More comprehensive check for mobile/tablet devices
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile|mobile|CriOS/i.test(navigator.userAgent);
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile|mobile|CriOS/i.test(
navigator.userAgent
);
};
// Always show for mobile devices on each visit
@@ -31,9 +33,9 @@ const MobilePlayPrompt: React.FC<MobilePlayPromptProps> = ({ videoRef, onPlay })
setIsVisible(false);
};
video.addEventListener('play', handlePlay);
video.addEventListener("play", handlePlay);
return () => {
video.removeEventListener('play', handlePlay);
video.removeEventListener("play", handlePlay);
};
}, [videoRef]);
@@ -62,11 +64,8 @@ const MobilePlayPrompt: React.FC<MobilePlayPromptProps> = ({ videoRef, onPlay })
<li>Then you'll be able to use all timeline controls</li>
</ol>
</div> */}
<button
className="mobile-play-button"
onClick={handlePlayClick}
>
<button className="mobile-play-button" onClick={handlePlayClick}>
Click to start editing...
</button>
</div>
@@ -74,4 +73,4 @@ const MobilePlayPrompt: React.FC<MobilePlayPromptProps> = ({ videoRef, onPlay })
);
};
export default MobilePlayPrompt;
export default MobilePlayPrompt;

View File

@@ -1,6 +1,6 @@
import { useEffect, useState, useRef } from "react";
import { formatTime } from "@/lib/timeUtils";
import '../styles/IOSVideoPlayer.css';
import "../styles/IOSVideoPlayer.css";
interface IOSVideoPlayerProps {
videoRef: React.RefObject<HTMLVideoElement>;
@@ -8,14 +8,10 @@ interface IOSVideoPlayerProps {
duration: number;
}
const IOSVideoPlayer = ({
videoRef,
currentTime,
duration,
}: IOSVideoPlayerProps) => {
const IOSVideoPlayer = ({ videoRef, currentTime, duration }: IOSVideoPlayerProps) => {
const [videoUrl, setVideoUrl] = useState<string>("");
const [iosVideoRef, setIosVideoRef] = useState<HTMLVideoElement | null>(null);
// Refs for hold-to-continue functionality
const incrementIntervalRef = useRef<NodeJS.Timeout | null>(null);
const decrementIntervalRef = useRef<NodeJS.Timeout | null>(null);
@@ -27,17 +23,17 @@ const IOSVideoPlayer = ({
if (decrementIntervalRef.current) clearInterval(decrementIntervalRef.current);
};
}, []);
// Get the video source URL from the main player
useEffect(() => {
if (videoRef.current && videoRef.current.querySelector('source')) {
const source = videoRef.current.querySelector('source') as HTMLSourceElement;
if (videoRef.current && videoRef.current.querySelector("source")) {
const source = videoRef.current.querySelector("source") as HTMLSourceElement;
if (source && source.src) {
setVideoUrl(source.src);
}
} else {
// Fallback to sample video if needed
setVideoUrl("/videos/sample-video-37s.mp4");
setVideoUrl("/videos/sample-video-10m.mp4");
}
}, [videoRef]);
@@ -61,13 +57,13 @@ const IOSVideoPlayer = ({
const startIncrement = (e: React.MouseEvent | React.TouchEvent) => {
// Prevent default to avoid text selection
e.preventDefault();
if (!iosVideoRef) return;
if (incrementIntervalRef.current) clearInterval(incrementIntervalRef.current);
// First immediate adjustment
iosVideoRef.currentTime = Math.min(iosVideoRef.duration, iosVideoRef.currentTime + 0.05);
// Setup continuous adjustment
incrementIntervalRef.current = setInterval(() => {
if (iosVideoRef) {
@@ -88,13 +84,13 @@ const IOSVideoPlayer = ({
const startDecrement = (e: React.MouseEvent | React.TouchEvent) => {
// Prevent default to avoid text selection
e.preventDefault();
if (!iosVideoRef) return;
if (decrementIntervalRef.current) clearInterval(decrementIntervalRef.current);
// First immediate adjustment
iosVideoRef.currentTime = Math.max(0, iosVideoRef.currentTime - 0.05);
// Setup continuous adjustment
decrementIntervalRef.current = setInterval(() => {
if (iosVideoRef) {
@@ -115,12 +111,14 @@ const IOSVideoPlayer = ({
<div className="ios-video-player-container">
{/* Current Time / Duration Display */}
<div className="ios-time-display mb-2">
<span className="text-sm">{formatTime(currentTime)} / {formatTime(duration)}</span>
<span className="text-sm">
{formatTime(currentTime)} / {formatTime(duration)}
</span>
</div>
{/* iOS-optimized Video Element with Native Controls */}
<video
ref={ref => setIosVideoRef(ref)}
ref={(ref) => setIosVideoRef(ref)}
className="w-full rounded-md"
src={videoUrl}
controls
@@ -133,26 +131,26 @@ const IOSVideoPlayer = ({
<source src={videoUrl} type="video/mp4" />
<p>Your browser doesn't support HTML5 video.</p>
</video>
{/* iOS Video Skip Controls */}
<div className="ios-skip-controls mt-3 flex justify-center gap-4">
<button
<button
onClick={jumpBackward15}
className="ios-control-btn flex items-center justify-center bg-purple-500 text-white py-2 px-4 rounded-md"
>
-15s
</button>
<button
<button
onClick={jumpForward15}
className="ios-control-btn flex items-center justify-center bg-purple-500 text-white py-2 px-4 rounded-md"
>
+15s
</button>
</div>
{/* iOS Fine Control Buttons */}
<div className="ios-fine-controls mt-2 flex justify-center gap-4">
<button
<button
onMouseDown={startDecrement}
onTouchStart={startDecrement}
onMouseUp={stopDecrement}
@@ -163,7 +161,7 @@ const IOSVideoPlayer = ({
>
-50ms
</button>
<button
<button
onMouseDown={startIncrement}
onTouchStart={startIncrement}
onMouseUp={stopIncrement}
@@ -175,7 +173,7 @@ const IOSVideoPlayer = ({
+50ms
</button>
</div>
<div className="ios-note mt-2 text-xs text-gray-500">
<p>This player uses native iOS controls for better compatibility with iOS devices.</p>
</div>
@@ -183,4 +181,4 @@ const IOSVideoPlayer = ({
);
};
export default IOSVideoPlayer;
export default IOSVideoPlayer;

View File

@@ -1,5 +1,5 @@
import React, { useEffect } from 'react';
import '../styles/Modal.css';
import React, { useEffect } from "react";
import "../styles/Modal.css";
interface ModalProps {
isOpen: boolean;
@@ -9,36 +9,30 @@ interface ModalProps {
actions?: React.ReactNode;
}
const Modal: React.FC<ModalProps> = ({
isOpen,
onClose,
title,
children,
actions
}) => {
const Modal: React.FC<ModalProps> = ({ isOpen, onClose, title, children, actions }) => {
// Close modal when Escape key is pressed
useEffect(() => {
const handleEscapeKey = (event: KeyboardEvent) => {
if (event.key === 'Escape' && isOpen) {
if (event.key === "Escape" && isOpen) {
onClose();
}
};
document.addEventListener('keydown', handleEscapeKey);
document.addEventListener("keydown", handleEscapeKey);
// Disable body scrolling when modal is open
if (isOpen) {
document.body.style.overflow = 'hidden';
document.body.style.overflow = "hidden";
}
return () => {
document.removeEventListener('keydown', handleEscapeKey);
document.body.style.overflow = '';
document.removeEventListener("keydown", handleEscapeKey);
document.body.style.overflow = "";
};
}, [isOpen, onClose]);
if (!isOpen) return null;
// Handle click outside the modal content to close it
const handleClickOutside = (event: React.MouseEvent) => {
if (event.target === event.currentTarget) {
@@ -48,23 +42,19 @@ const Modal: React.FC<ModalProps> = ({
return (
<div className="modal-overlay" onClick={handleClickOutside}>
<div className="modal-container" onClick={e => e.stopPropagation()}>
<div className="modal-container" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h2 className="modal-title">{title}</h2>
<button
className="modal-close-button"
onClick={onClose}
aria-label="Close modal"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
<button className="modal-close-button" onClick={onClose} aria-label="Close modal">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18"></line>
@@ -72,19 +62,13 @@ const Modal: React.FC<ModalProps> = ({
</svg>
</button>
</div>
<div className="modal-content">
{children}
</div>
{actions && (
<div className="modal-actions">
{actions}
</div>
)}
<div className="modal-content">{children}</div>
{actions && <div className="modal-actions">{actions}</div>}
</div>
</div>
);
};
export default Modal;
export default Modal;

View File

@@ -1,7 +1,7 @@
import React, { useRef, useEffect, useState } from "react";
import { formatTime, formatDetailedTime } from "@/lib/timeUtils";
import logger from '../lib/logger';
import '../styles/VideoPlayer.css';
import logger from "../lib/logger";
import "../styles/VideoPlayer.css";
interface VideoPlayerProps {
videoRef: React.RefObject<HTMLVideoElement>;
@@ -32,37 +32,37 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
const isDraggingProgressRef = useRef(false);
const [tooltipPosition, setTooltipPosition] = useState({ x: 0 });
const [tooltipTime, setTooltipTime] = useState(0);
const sampleVideoUrl = typeof window !== 'undefined' &&
(window as any).MEDIA_DATA?.videoUrl ||
"/videos/sample-video-37s.mp4";
const sampleVideoUrl =
(typeof window !== "undefined" && (window as any).MEDIA_DATA?.videoUrl) ||
"/videos/sample-video-10m.mp4";
// Detect iOS device
useEffect(() => {
const checkIOS = () => {
const userAgent = navigator.userAgent || navigator.vendor || (window as any).opera;
return /iPad|iPhone|iPod/.test(userAgent) && !(window as any).MSStream;
};
setIsIOS(checkIOS());
// Check if video was previously initialized
if (typeof window !== 'undefined') {
const wasInitialized = localStorage.getItem('video_initialized') === 'true';
if (typeof window !== "undefined") {
const wasInitialized = localStorage.getItem("video_initialized") === "true";
setHasInitialized(wasInitialized);
}
}, []);
// Update initialized state when video plays
useEffect(() => {
if (isPlaying && !hasInitialized) {
setHasInitialized(true);
if (typeof window !== 'undefined') {
localStorage.setItem('video_initialized', 'true');
if (typeof window !== "undefined") {
localStorage.setItem("video_initialized", "true");
}
}
}, [isPlaying, hasInitialized]);
// Add iOS-specific attributes to prevent fullscreen playback
useEffect(() => {
const video = videoRef.current;
@@ -70,15 +70,15 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
// These attributes need to be set directly on the DOM element
// for iOS Safari to respect inline playback
video.setAttribute('playsinline', 'true');
video.setAttribute('webkit-playsinline', 'true');
video.setAttribute('x-webkit-airplay', 'allow');
video.setAttribute("playsinline", "true");
video.setAttribute("webkit-playsinline", "true");
video.setAttribute("x-webkit-airplay", "allow");
// Store the last known good position for iOS
const handleTimeUpdate = () => {
if (!isDraggingProgressRef.current) {
setLastPosition(video.currentTime);
if (typeof window !== 'undefined') {
if (typeof window !== "undefined") {
window.lastSeekedPosition = video.currentTime;
}
}
@@ -86,33 +86,33 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
// Handle iOS-specific play/pause state
const handlePlay = () => {
logger.debug('Video play event fired');
logger.debug("Video play event fired");
if (isIOS) {
setHasInitialized(true);
localStorage.setItem('video_initialized', 'true');
localStorage.setItem("video_initialized", "true");
}
};
const handlePause = () => {
logger.debug('Video pause event fired');
logger.debug("Video pause event fired");
};
video.addEventListener('timeupdate', handleTimeUpdate);
video.addEventListener('play', handlePlay);
video.addEventListener('pause', handlePause);
video.addEventListener("timeupdate", handleTimeUpdate);
video.addEventListener("play", handlePlay);
video.addEventListener("pause", handlePause);
return () => {
video.removeEventListener('timeupdate', handleTimeUpdate);
video.removeEventListener('play', handlePlay);
video.removeEventListener('pause', handlePause);
video.removeEventListener("timeupdate", handleTimeUpdate);
video.removeEventListener("play", handlePlay);
video.removeEventListener("pause", handlePause);
};
}, [videoRef, isIOS, isDraggingProgressRef]);
// Save current time to lastPosition when it changes (from external seeking)
useEffect(() => {
setLastPosition(currentTime);
}, [currentTime]);
// Jump 10 seconds forward
const handleForward = () => {
const newTime = Math.min(currentTime + 10, duration);
@@ -126,58 +126,58 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
onSeek(newTime);
setLastPosition(newTime);
};
// Calculate progress percentage
const progressPercentage = duration > 0 ? (currentTime / duration) * 100 : 0;
// Handle start of progress bar dragging
const handleProgressDragStart = (e: React.MouseEvent) => {
e.preventDefault();
setIsDraggingProgress(true);
isDraggingProgressRef.current = true;
// Get initial position
handleProgressDrag(e);
// Set up document-level event listeners for mouse movement and release
const handleMouseMove = (moveEvent: MouseEvent) => {
if (isDraggingProgressRef.current) {
handleProgressDrag(moveEvent);
}
};
const handleMouseUp = () => {
setIsDraggingProgress(false);
isDraggingProgressRef.current = false;
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
};
// Handle progress dragging for both mouse and touch events
const handleProgressDrag = (e: MouseEvent | React.MouseEvent) => {
if (!progressRef.current) return;
const rect = progressRef.current.getBoundingClientRect();
const clickPosition = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
const seekTime = duration * clickPosition;
// Update tooltip position and time
setTooltipPosition({ x: e.clientX });
setTooltipTime(seekTime);
// Store position locally for iOS Safari - critical for timeline seeking
setLastPosition(seekTime);
// Also store globally for integration with other components
if (typeof window !== 'undefined') {
if (typeof window !== "undefined") {
(window as any).lastSeekedPosition = seekTime;
}
onSeek(seekTime);
};
@@ -185,59 +185,59 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
const handleProgressTouchStart = (e: React.TouchEvent) => {
if (!progressRef.current || !e.touches[0]) return;
e.preventDefault();
setIsDraggingProgress(true);
isDraggingProgressRef.current = true;
// Get initial position using touch
handleProgressTouchMove(e);
// Set up document-level event listeners for touch movement and release
const handleTouchMove = (moveEvent: TouchEvent) => {
if (isDraggingProgressRef.current) {
handleProgressTouchMove(moveEvent);
}
};
const handleTouchEnd = () => {
setIsDraggingProgress(false);
isDraggingProgressRef.current = false;
document.removeEventListener('touchmove', handleTouchMove);
document.removeEventListener('touchend', handleTouchEnd);
document.removeEventListener('touchcancel', handleTouchEnd);
document.removeEventListener("touchmove", handleTouchMove);
document.removeEventListener("touchend", handleTouchEnd);
document.removeEventListener("touchcancel", handleTouchEnd);
};
document.addEventListener('touchmove', handleTouchMove, { passive: false });
document.addEventListener('touchend', handleTouchEnd);
document.addEventListener('touchcancel', handleTouchEnd);
document.addEventListener("touchmove", handleTouchMove, { passive: false });
document.addEventListener("touchend", handleTouchEnd);
document.addEventListener("touchcancel", handleTouchEnd);
};
// Handle touch dragging on progress bar
const handleProgressTouchMove = (e: TouchEvent | React.TouchEvent) => {
if (!progressRef.current) return;
// Get the touch coordinates
const touch = 'touches' in e ? e.touches[0] : null;
const touch = "touches" in e ? e.touches[0] : null;
if (!touch) return;
e.preventDefault(); // Prevent scrolling while dragging
const rect = progressRef.current.getBoundingClientRect();
const touchPosition = Math.max(0, Math.min(1, (touch.clientX - rect.left) / rect.width));
const seekTime = duration * touchPosition;
// Update tooltip position and time
setTooltipPosition({ x: touch.clientX });
setTooltipTime(seekTime);
// Store position for iOS Safari
setLastPosition(seekTime);
// Also store globally for integration with other components
if (typeof window !== 'undefined') {
if (typeof window !== "undefined") {
(window as any).lastSeekedPosition = seekTime;
}
onSeek(seekTime);
};
@@ -245,20 +245,20 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
// If we're already dragging, don't handle the click
if (isDraggingProgress) return;
if (progressRef.current) {
const rect = progressRef.current.getBoundingClientRect();
const clickPosition = (e.clientX - rect.left) / rect.width;
const seekTime = duration * clickPosition;
// Store position locally for iOS Safari - critical for timeline seeking
setLastPosition(seekTime);
// Also store globally for integration with other components
if (typeof window !== 'undefined') {
if (typeof window !== "undefined") {
(window as any).lastSeekedPosition = seekTime;
}
onSeek(seekTime);
}
};
@@ -278,38 +278,43 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
const handleVideoClick = () => {
const video = videoRef.current;
if (!video) return;
// If the video is paused, we want to play it
if (video.paused) {
// For iOS Safari: Before playing, explicitly seek to the remembered position
if (isIOS && lastPosition !== null && lastPosition > 0) {
logger.debug("iOS: Explicitly setting position before play:", lastPosition);
// First, seek to the position
video.currentTime = lastPosition;
// Use a small timeout to ensure seeking is complete before play
setTimeout(() => {
if (videoRef.current) {
// Try to play with proper promise handling
videoRef.current.play()
videoRef.current
.play()
.then(() => {
logger.debug("iOS: Play started successfully at position:", videoRef.current?.currentTime);
logger.debug(
"iOS: Play started successfully at position:",
videoRef.current?.currentTime
);
onPlayPause(); // Update parent state after successful play
})
.catch(err => {
.catch((err) => {
console.error("iOS: Error playing video:", err);
});
}
}, 50);
} else {
// Normal play (non-iOS or no remembered position)
video.play()
video
.play()
.then(() => {
logger.debug("Normal: Play started successfully");
onPlayPause(); // Update parent state after successful play
})
.catch(err => {
.catch((err) => {
console.error("Error playing video:", err);
});
}
@@ -336,19 +341,17 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
<source src={sampleVideoUrl} type="video/mp4" />
<p>Your browser doesn't support HTML5 video.</p>
</video>
{/* iOS First-play indicator - only shown on first visit for iOS devices when not initialized */}
{isIOS && !hasInitialized && !isPlaying && (
<div className="ios-first-play-indicator">
<div className="ios-play-message">
Tap Play to initialize video controls
</div>
<div className="ios-play-message">Tap Play to initialize video controls</div>
</div>
)}
{/* Play/Pause Indicator (shows based on current state) */}
<div className={`play-pause-indicator ${isPlaying ? 'pause-icon' : 'play-icon'}`}></div>
<div className={`play-pause-indicator ${isPlaying ? "pause-icon" : "play-icon"}`}></div>
{/* Video Controls Overlay */}
<div className="video-controls">
{/* Time and Duration */}
@@ -356,47 +359,52 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
<span className="video-current-time">{formatTime(currentTime)}</span>
<span className="video-duration">/ {formatTime(duration)}</span>
</div>
{/* Progress Bar with enhanced dragging */}
<div
<div
ref={progressRef}
className={`video-progress ${isDraggingProgress ? 'dragging' : ''}`}
className={`video-progress ${isDraggingProgress ? "dragging" : ""}`}
onClick={handleProgressClick}
onMouseDown={handleProgressDragStart}
onTouchStart={handleProgressTouchStart}
>
<div
className="video-progress-fill"
style={{ width: `${progressPercentage}%` }}
></div>
<div
className="video-scrubber"
style={{ left: `${progressPercentage}%` }}
></div>
<div className="video-progress-fill" style={{ width: `${progressPercentage}%` }}></div>
<div className="video-scrubber" style={{ left: `${progressPercentage}%` }}></div>
{/* Floating time tooltip when dragging */}
{isDraggingProgress && (
<div className="video-time-tooltip" style={{
left: `${tooltipPosition.x}px`,
transform: 'translateX(-50%)'
}}>
<div
className="video-time-tooltip"
style={{
left: `${tooltipPosition.x}px`,
transform: "translateX(-50%)"
}}
>
{formatDetailedTime(tooltipTime)}
</div>
)}
</div>
{/* Controls - Mute and Fullscreen buttons */}
<div className="video-controls-buttons">
{/* Mute/Unmute Button */}
{onToggleMute && (
<button
className="mute-button"
<button
className="mute-button"
aria-label={isMuted ? "Unmute" : "Mute"}
onClick={onToggleMute}
data-tooltip={isMuted ? "Unmute" : "Mute"}
>
{isMuted ? (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="1" y1="1" x2="23" y2="23"></line>
<path d="M9 9v3a3 3 0 0 0 5.12 2.12M15 9.34V4a3 3 0 0 0-5.94-.6"></path>
<path d="M17 16.95A7 7 0 0 1 5 12v-2m14 0v2a7 7 0 0 1-.11 1.23"></path>
@@ -404,23 +412,35 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
<line x1="8" y1="23" x2="16" y2="23"></line>
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon>
<path d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07"></path>
</svg>
)}
</button>
)}
{/* Fullscreen Button */}
<button
className="fullscreen-button"
<button
className="fullscreen-button"
aria-label="Fullscreen"
onClick={handleFullscreen}
data-tooltip="Toggle fullscreen"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M3 4a1 1 0 011-1h4a1 1 0 010 2H6.414l2.293 2.293a1 1 0 01-1.414 1.414L5 6.414V8a1 1 0 01-2 0V4zm9 1a1 1 0 010-2h4a1 1 0 011 1v4a1 1 0 01-2 0V6.414l-2.293 2.293a1 1 0 11-1.414-1.414L13.586 5H12zm-9 7a1 1 0 012 0v1.586l2.293-2.293a1 1 0 011.414 1.414L6.414 15H8a1 1 0 010 2H4a1 1 0 01-1-1v-4zm13-1a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 010-2h1.586l-2.293-2.293a1 1 0 011.414-1.414L15 13.586V12a1 1 0 011-1z" clipRule="evenodd" />
<path
fillRule="evenodd"
d="M3 4a1 1 0 011-1h4a1 1 0 010 2H6.414l2.293 2.293a1 1 0 01-1.414 1.414L5 6.414V8a1 1 0 01-2 0V4zm9 1a1 1 0 010-2h4a1 1 0 011 1v4a1 1 0 01-2 0V6.414l-2.293 2.293a1 1 0 11-1.414-1.414L13.586 5H12zm-9 7a1 1 0 012 0v1.586l2.293-2.293a1 1 0 011.414 1.414L6.414 15H8a1 1 0 010 2H4a1 1 0 01-1-1v-4zm13-1a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 010-2h1.586l-2.293-2.293a1 1 0 011.414-1.414L15 13.586V12a1 1 0 011-1z"
clipRule="evenodd"
/>
</svg>
</button>
</div>

View File

@@ -125,13 +125,13 @@
overflow-x: auto;
overflow-y: hidden;
margin-bottom: 0.75rem;
background-color: #EEE; /* Very light gray background */
background-color: #eee; /* Very light gray background */
position: relative;
}
.timeline-container {
position: relative;
background-color: #EEE; /* Very light gray background */
background-color: #eee; /* Very light gray background */
height: 6rem;
width: 100%;
cursor: pointer;
@@ -208,17 +208,27 @@
overflow: hidden;
cursor: grab;
user-select: none;
transition: box-shadow 0.2s, transform 0.1s;
transition:
box-shadow 0.2s,
transform 0.1s;
/* Original z-index for stacking order based on segment ID */
z-index: 15;
}
/* No background colors for segments, just borders with 2-color scheme */
.clip-segment:nth-child(odd), .segment-color-1, .segment-color-3, .segment-color-5, .segment-color-7 {
.clip-segment:nth-child(odd),
.segment-color-1,
.segment-color-3,
.segment-color-5,
.segment-color-7 {
background-color: transparent;
border: 2px solid rgba(0, 123, 255, 0.9); /* Blue border */
}
.clip-segment:nth-child(even), .segment-color-2, .segment-color-4, .segment-color-6, .segment-color-8 {
.clip-segment:nth-child(even),
.segment-color-2,
.segment-color-4,
.segment-color-6,
.segment-color-8 {
background-color: transparent;
border: 2px solid rgba(108, 117, 125, 0.9); /* Gray border */
}
@@ -315,7 +325,7 @@
input[type="range"] {
-webkit-appearance: none;
height: 6px;
background: #E0E0E0;
background: #e0e0e0;
border-radius: 3px;
}
@@ -350,12 +360,14 @@ input[type="range"]::-webkit-slider-thumb {
z-index: 1000;
opacity: 0;
visibility: hidden;
transition: opacity 0.2s, visibility 0.2s;
transition:
opacity 0.2s,
visibility 0.2s;
pointer-events: none;
}
[data-tooltip]::after {
content: '';
content: "";
position: absolute;
bottom: 100%;
left: 50%;
@@ -366,7 +378,9 @@ input[type="range"]::-webkit-slider-thumb {
margin-bottom: 0px;
opacity: 0;
visibility: hidden;
transition: opacity 0.2s, visibility 0.2s;
transition:
opacity 0.2s,
visibility 0.2s;
pointer-events: none;
}
@@ -464,7 +478,7 @@ button[disabled][data-tooltip]::after {
}
.segment-tooltip::after {
content: '';
content: "";
position: absolute;
bottom: -6px;
left: 50%;
@@ -539,7 +553,7 @@ button[disabled][data-tooltip]::after {
}
.empty-space-tooltip::after {
content: '';
content: "";
position: absolute;
bottom: -8px;
left: 50%;
@@ -617,7 +631,9 @@ button[disabled][data-tooltip]::after {
}
/* Save buttons styling */
.save-button, .save-copy-button, .save-segments-button {
.save-button,
.save-copy-button,
.save-segments-button {
background-color: rgba(0, 123, 255, 0.8);
color: white;
border: none;
@@ -628,7 +644,8 @@ button[disabled][data-tooltip]::after {
transition: background-color 0.2s;
}
.save-button:hover, .save-copy-button:hover {
.save-button:hover,
.save-copy-button:hover {
background-color: rgba(0, 123, 255, 1);
}
@@ -735,7 +752,8 @@ button[disabled][data-tooltip]::after {
font-size: 1.1rem;
}
.current-time, .duration-time {
.current-time,
.duration-time {
white-space: nowrap;
}
@@ -770,7 +788,8 @@ button[disabled][data-tooltip]::after {
gap: 8px;
}
.save-button, .save-copy-button {
.save-button,
.save-copy-button {
margin-top: 8px;
width: 100%;
}

View File

@@ -7,25 +7,25 @@ const logger = {
* Logs debug messages only in development environment
*/
debug: (...args: any[]) => {
if (process.env.NODE_ENV === 'development') {
if (process.env.NODE_ENV === "development") {
console.debug(...args);
}
},
/**
* Always logs error messages
*/
error: (...args: any[]) => console.error(...args),
/**
* Always logs warning messages
*/
warn: (...args: any[]) => console.warn(...args),
/**
* Always logs info messages
*/
info: (...args: any[]) => console.info(...args)
};
export default logger;
export default logger;

View File

@@ -10,13 +10,13 @@ async function throwIfResNotOk(res: Response) {
export async function apiRequest(
method: string,
url: string,
data?: unknown | undefined,
data?: unknown | undefined
): Promise<Response> {
const res = await fetch(url, {
method,
headers: data ? { "Content-Type": "application/json" } : {},
body: data ? JSON.stringify(data) : undefined,
credentials: "include",
credentials: "include"
});
await throwIfResNotOk(res);
@@ -24,13 +24,11 @@ export async function apiRequest(
}
type UnauthorizedBehavior = "returnNull" | "throw";
export const getQueryFn: <T>(options: {
on401: UnauthorizedBehavior;
}) => QueryFunction<T> =
export const getQueryFn: <T>(options: { on401: UnauthorizedBehavior }) => QueryFunction<T> =
({ on401: unauthorizedBehavior }) =>
async ({ queryKey }) => {
const res = await fetch(queryKey[0] as string, {
credentials: "include",
credentials: "include"
});
if (unauthorizedBehavior === "returnNull" && res.status === 401) {
@@ -48,10 +46,10 @@ export const queryClient = new QueryClient({
refetchInterval: false,
refetchOnWindowFocus: false,
staleTime: Infinity,
retry: false,
retry: false
},
mutations: {
retry: false,
},
},
retry: false
}
}
});

View File

@@ -3,17 +3,17 @@
*/
export const formatDetailedTime = (seconds: number): string => {
if (isNaN(seconds)) return "00:00:00.000";
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const remainingSeconds = Math.floor(seconds % 60);
const milliseconds = Math.round((seconds % 1) * 1000);
const formattedHours = String(hours).padStart(2, "0");
const formattedMinutes = String(minutes).padStart(2, "0");
const formattedSeconds = String(remainingSeconds).padStart(2, "0");
const formattedMilliseconds = String(milliseconds).padStart(3, "0");
return `${formattedHours}:${formattedMinutes}:${formattedSeconds}.${formattedMilliseconds}`;
};

View File

@@ -1,6 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
return twMerge(clsx(inputs));
}

View File

@@ -2,20 +2,17 @@
* Generate a solid color background for a segment
* Returns a CSS color based on the segment position
*/
export const generateSolidColor = (
time: number,
duration: number
): string => {
export const generateSolidColor = (time: number, duration: number): string => {
// Use the time position to create different colors
// This gives each segment a different color without needing an image
const position = Math.min(Math.max(time / (duration || 1), 0), 1);
// Calculate color based on position
// Use an extremely light blue-based color palette
const hue = 210; // Blue base
const saturation = 40 + Math.floor(position * 20); // 40-60% (less saturated)
const lightness = 85 + Math.floor(position * 8); // 85-93% (extremely light)
return `hsl(${hue}, ${saturation}%, ${lightness}%)`;
};
@@ -24,27 +21,27 @@ export const generateSolidColor = (
* Now returns a data URL for a solid color square instead of a video thumbnail
*/
export const generateThumbnail = async (
videoElement: HTMLVideoElement,
videoElement: HTMLVideoElement,
time: number
): Promise<string> => {
return new Promise((resolve) => {
// Create a small canvas for the solid color
const canvas = document.createElement('canvas');
const canvas = document.createElement("canvas");
canvas.width = 10; // Much smaller - we only need a color
canvas.height = 10;
const ctx = canvas.getContext('2d');
const ctx = canvas.getContext("2d");
if (ctx) {
// Get the solid color based on time
const color = generateSolidColor(time, videoElement.duration);
// Fill with solid color
ctx.fillStyle = color;
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
// Convert to data URL (much smaller now)
const dataUrl = canvas.toDataURL('image/png', 0.5);
const dataUrl = canvas.toDataURL("image/png", 0.5);
resolve(dataUrl);
});
};

View File

@@ -2,7 +2,7 @@ import { createRoot } from "react-dom/client";
import App from "./App";
import "./index.css";
if (typeof window !== 'undefined') {
if (typeof window !== "undefined") {
window.MEDIA_DATA = {
videoUrl: "",
mediaId: ""
@@ -30,8 +30,8 @@ const mountComponents = () => {
}
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', mountComponents);
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", mountComponents);
} else {
mountComponents();
}
}

View File

@@ -4,36 +4,36 @@ interface TrimVideoRequest {
segments: {
startTime: string;
endTime: string;
name?: string;
name?: string;
}[];
saveAsCopy?: boolean;
saveIndividualSegments?: boolean;
saveIndividualSegments?: boolean;
}
interface TrimVideoResponse {
msg: string;
url_redirect: string;
status?: number; // HTTP status code for success/error
error?: string; // Error message if status is not 200
status?: number; // HTTP status code for success/error
error?: string; // Error message if status is not 200
}
// Helper function to simulate delay
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
// For now, we'll use a mock API that returns a promise
// This can be replaced with actual API calls later
export const trimVideo = async (
mediaId: string,
mediaId: string,
data: TrimVideoRequest
): Promise<TrimVideoResponse> => {
try {
// Attempt the real API call
const response = await fetch(`/api/v1/media/${mediaId}/trim_video`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data)
});
if (!response.ok) {
// For error responses, return with error status and message
if (response.status === 400) {
@@ -86,7 +86,7 @@ export const trimVideo = async (
};
}
}
// Successful response
const jsonResponse = await response.json();
return {
@@ -104,7 +104,7 @@ export const trimVideo = async (
url_redirect: `./view?m=${mediaId}`
};
}
/* Mock implementation that simulates network latency
return new Promise((resolve) => {
setTimeout(() => {
@@ -115,4 +115,4 @@ export const trimVideo = async (
}, 1500); // Simulate 1.5 second server delay
});
*/
};
};

View File

@@ -4,7 +4,7 @@
[data-tooltip] {
position: relative;
}
[data-tooltip]:before {
content: attr(data-tooltip);
position: absolute;
@@ -21,13 +21,15 @@
white-space: nowrap;
opacity: 0;
visibility: hidden;
transition: opacity 0.2s, visibility 0.2s;
transition:
opacity 0.2s,
visibility 0.2s;
z-index: 1000;
pointer-events: none;
}
[data-tooltip]:after {
content: '';
content: "";
position: absolute;
bottom: 100%;
left: 50%;
@@ -37,17 +39,19 @@
border-color: rgba(0, 0, 0, 0.8) transparent transparent transparent;
opacity: 0;
visibility: hidden;
transition: opacity 0.2s, visibility 0.2s;
transition:
opacity 0.2s,
visibility 0.2s;
pointer-events: none;
}
[data-tooltip]:hover:before,
[data-tooltip]:hover:after {
opacity: 1;
visibility: visible;
}
}
/* Hide button tooltips on touch devices */
@media (pointer: coarse) {
[data-tooltip]:before,
@@ -143,7 +147,9 @@
border-radius: 9999px;
border: none;
cursor: pointer;
transition: background-color 0.2s, color 0.2s;
transition:
background-color 0.2s,
color 0.2s;
min-width: auto;
&:hover {
@@ -163,12 +169,28 @@
color: rgba(51, 51, 51, 0.7);
}
.segment-color-1 { background-color: rgba(59, 130, 246, 0.15); }
.segment-color-2 { background-color: rgba(16, 185, 129, 0.15); }
.segment-color-3 { background-color: rgba(245, 158, 11, 0.15); }
.segment-color-4 { background-color: rgba(239, 68, 68, 0.15); }
.segment-color-5 { background-color: rgba(139, 92, 246, 0.15); }
.segment-color-6 { background-color: rgba(236, 72, 153, 0.15); }
.segment-color-7 { background-color: rgba(6, 182, 212, 0.15); }
.segment-color-8 { background-color: rgba(250, 204, 21, 0.15); }
}
.segment-color-1 {
background-color: rgba(59, 130, 246, 0.15);
}
.segment-color-2 {
background-color: rgba(16, 185, 129, 0.15);
}
.segment-color-3 {
background-color: rgba(245, 158, 11, 0.15);
}
.segment-color-4 {
background-color: rgba(239, 68, 68, 0.15);
}
.segment-color-5 {
background-color: rgba(139, 92, 246, 0.15);
}
.segment-color-6 {
background-color: rgba(236, 72, 153, 0.15);
}
.segment-color-7 {
background-color: rgba(6, 182, 212, 0.15);
}
.segment-color-8 {
background-color: rgba(250, 204, 21, 0.15);
}
}

View File

@@ -1,11 +1,10 @@
#video-editor-trim-root {
/* Tooltip styles - only on desktop where hover is available */
@media (hover: hover) and (pointer: fine) {
[data-tooltip] {
position: relative;
}
[data-tooltip]:before {
content: attr(data-tooltip);
position: absolute;
@@ -22,13 +21,15 @@
white-space: nowrap;
opacity: 0;
visibility: hidden;
transition: opacity 0.2s, visibility 0.2s;
transition:
opacity 0.2s,
visibility 0.2s;
z-index: 1000;
pointer-events: none;
}
[data-tooltip]:after {
content: '';
content: "";
position: absolute;
bottom: 100%;
left: 50%;
@@ -38,17 +39,19 @@
border-color: rgba(0, 0, 0, 0.8) transparent transparent transparent;
opacity: 0;
visibility: hidden;
transition: opacity 0.2s, visibility 0.2s;
transition:
opacity 0.2s,
visibility 0.2s;
pointer-events: none;
}
[data-tooltip]:hover:before,
[data-tooltip]:hover:after {
opacity: 1;
visibility: visible;
}
}
/* Hide button tooltips on touch devices */
@media (pointer: coarse) {
[data-tooltip]:before,
@@ -86,7 +89,7 @@
.full-text {
display: inline;
}
.short-text {
display: none;
}
@@ -99,20 +102,20 @@
.button-group {
display: flex;
align-items: center;
&.play-buttons-group {
gap: 0.75rem;
justify-content: flex-start;
flex: 0 0 auto; /* Don't expand to fill space */
}
&.secondary {
gap: 0.75rem;
align-items: center;
justify-content: flex-end;
margin-left: auto; /* Push to right edge */
}
button {
display: flex;
align-items: center;
@@ -121,17 +124,16 @@
border: none;
cursor: pointer;
min-width: auto;
/* Disabled hover effect as requested */
&:hover:not(:disabled) {
color: inherit;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
svg {
height: 1.25rem;
width: 1.25rem;
@@ -144,10 +146,11 @@
border-right: 1px solid #d1d5db;
height: 1.5rem;
margin: 0 0.5rem;
}
}
/* Style for play buttons with highlight effect */
.play-button, .preview-button {
.play-button,
.preview-button {
font-weight: 600;
display: flex;
align-items: center;
@@ -157,13 +160,13 @@
justify-content: center;
font-size: 0.875rem !important;
}
/* Greyed out play button when segments are playing */
.play-button.greyed-out {
opacity: 0.5;
cursor: not-allowed;
}
/* Highlighted stop button with blue pulse on small screens */
.segments-button.highlighted-stop {
background-color: rgba(59, 130, 246, 0.1);
@@ -171,7 +174,7 @@
border: 1px solid #3b82f6;
animation: bluePulse 2s infinite;
}
@keyframes bluePulse {
0% {
box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.4);
@@ -183,9 +186,10 @@
box-shadow: 0 0 0 0 rgba(59, 130, 246, 0);
}
}
/* Completely disable ALL hover effects for play buttons */
.play-button:hover:not(:disabled), .preview-button:hover:not(:disabled) {
.play-button:hover:not(:disabled),
.preview-button:hover:not(:disabled) {
/* Reset everything to prevent any changes */
color: inherit !important;
transform: none !important;
@@ -193,27 +197,15 @@
width: auto !important;
background: none !important;
}
.play-button svg, .preview-button svg {
.play-button svg,
.preview-button svg {
height: 1.5rem;
width: 1.5rem;
/* Make sure SVG scales with the button but doesn't change layout */
flex-shrink: 0;
}
/* Style for the preview mode message that replaces the play button */
.preview-mode-message {
display: flex;
align-items: center;
background-color: rgba(59, 130, 246, 0.1);
color: #3b82f6;
padding: 6px 12px;
border-radius: 4px;
font-weight: 600;
font-size: 0.875rem;
animation: pulse 2s infinite;
}
@keyframes pulse {
0% {
opacity: 0.8;
@@ -225,19 +217,12 @@
opacity: 0.8;
}
}
.preview-mode-message svg {
height: 1.25rem;
width: 1.25rem;
margin-right: 0.5rem;
color: #3b82f6;
}
/* Add responsive button text class */
.button-text {
margin-left: 0.25rem;
}
/* Media queries for the editing tools */
@media (max-width: 992px) {
/* Hide text for undo/redo buttons on medium screens */
@@ -245,76 +230,77 @@
display: none;
}
}
@media (max-width: 768px) {
/* Keep all buttons in a single row, make them more compact */
.flex-container.single-row {
justify-content: space-between;
}
.button-group {
gap: 0.5rem;
}
/* Keep font size consistent regardless of screen size */
.preview-button, .play-button {
.preview-button,
.play-button {
font-size: 0.875rem !important;
}
}
@media (max-width: 640px) {
/* Prevent container overflow on mobile */
.editing-tools-container {
padding: 0.75rem;
overflow-x: hidden;
}
/* At this breakpoint, make preview button text shorter */
.preview-button {
min-width: auto;
}
/* Switch to short text versions */
.full-text {
display: none;
}
.short-text {
display: inline;
margin-left: 0.15rem;
}
/* Hide reset text */
.reset-text {
display: none;
}
/* Ensure buttons stay in correct position */
.button-group.play-buttons-group {
flex: initial;
justify-content: flex-start;
flex-shrink: 0;
}
.button-group.secondary {
flex: initial;
justify-content: flex-end;
flex-shrink: 0;
}
/* Reduce button sizes on mobile */
.button-group button {
padding: 0.375rem;
min-width: auto;
}
.button-group button svg {
height: 1.125rem;
width: 1.125rem;
margin-right: 0.125rem;
}
}
@media (max-width: 576px) {
/* Keep single row, left-align play buttons, right-align controls */
.flex-container.single-row {
@@ -322,94 +308,88 @@
flex-wrap: nowrap;
gap: 10px;
}
/* Fix left-align for play buttons */
.button-group.play-buttons-group {
justify-content: flex-start;
flex: 0 0 auto;
}
/* Fix right-align for editing controls */
.button-group.secondary {
justify-content: flex-end;
margin-left: auto;
}
/* Reduce button padding to fit more easily */
.button-group button {
padding: 0.25rem;
}
/* Smaller preview mode message */
.preview-mode-message {
font-size: 0.8rem;
padding: 4px 8px;
}
.divider {
margin: 0 0.25rem;
}
}
/* Very small screens - maintain layout but reduce further */
@media (max-width: 480px) {
.editing-tools-container {
padding: 0.5rem;
}
.flex-container.single-row {
gap: 8px;
}
.button-group.play-buttons-group,
.button-group.secondary {
gap: 0.25rem;
}
.divider {
display: none; /* Hide divider on very small screens */
}
/* Even smaller buttons on very small screens */
.button-group button {
padding: 0.125rem;
}
.button-group button svg {
height: 1rem;
width: 1rem;
margin-right: 0;
}
/* Hide all button text on very small screens */
.button-text,
.reset-text {
display: none;
}
}
/* Portrait orientation specific fixes */
@media (max-width: 640px) and (orientation: portrait) {
.editing-tools-container {
width: 100%;
box-sizing: border-box;
}
.flex-container.single-row {
width: 100%;
padding: 0;
margin: 0;
}
/* Ensure button groups don't overflow */
.button-group {
max-width: 50%;
}
.button-group.play-buttons-group {
max-width: 60%;
}
.button-group.secondary {
max-width: 40%;
}

View File

@@ -132,7 +132,7 @@
.ios-notification {
padding-top: env(safe-area-inset-top);
}
.ios-notification-close {
padding: 10px;
}
@@ -143,11 +143,11 @@
.ios-notification-content {
padding: 5px;
}
.ios-notification-message h3 {
font-size: 15px;
}
.ios-notification-message p,
.ios-notification-message ol {
font-size: 13px;
@@ -164,4 +164,4 @@ html.ios-device {
html.ios-device .ios-control-btn {
/* Make buttons easier to tap in desktop mode */
min-height: 44px;
}
}

View File

@@ -93,4 +93,4 @@
/* Extra spacing for mobile */
padding: 14px 25px;
}
}
}

View File

@@ -36,13 +36,13 @@
.ios-video-player-container video {
max-height: 50vh; /* Use viewport height on iOS */
}
/* Improve controls visibility on iOS */
video::-webkit-media-controls {
opacity: 1 !important;
visibility: visible !important;
}
/* Ensure controls don't disappear too quickly */
video::-webkit-media-controls-panel {
transition-duration: 3s !important;
@@ -76,19 +76,19 @@
/* Prevent text selection on buttons */
.no-select {
-webkit-touch-callout: none; /* iOS Safari */
-webkit-user-select: none; /* Safari */
-khtml-user-select: none; /* Konqueror HTML */
-moz-user-select: none; /* Firefox */
-ms-user-select: none; /* Internet Explorer/Edge */
user-select: none; /* Non-prefixed version, supported by Chrome and Opera */
-webkit-user-select: none; /* Safari */
-khtml-user-select: none; /* Konqueror HTML */
-moz-user-select: none; /* Firefox */
-ms-user-select: none; /* Internet Explorer/Edge */
user-select: none; /* Non-prefixed version, supported by Chrome and Opera */
cursor: default;
}
/* Specifically prevent default behavior on fine controls */
.ios-fine-controls button,
.ios-fine-controls button,
.ios-external-controls .no-select {
touch-action: manipulation;
-webkit-touch-callout: none;
-webkit-user-select: none;
pointer-events: auto;
}
}

View File

@@ -1,302 +1,306 @@
#video-editor-trim-root {
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-container {
background-color: white;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
width: 90%;
max-width: 500px;
max-height: 90vh;
overflow-y: auto;
animation: modal-fade-in 0.3s ease-out;
}
@keyframes modal-fade-in {
from {
opacity: 0;
transform: translateY(-20px);
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
to {
opacity: 1;
transform: translateY(0);
}
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid #eee;
}
.modal-title {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: #333;
}
.modal-close-button {
background: none;
border: none;
cursor: pointer;
color: #666;
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.2s;
}
.modal-close-button:hover {
color: #000;
}
.modal-content {
padding: 20px;
color: #333;
font-size: 1rem;
line-height: 1.5;
max-height: 400px;
overflow-y: auto;
}
.modal-actions {
display: flex;
justify-content: flex-end;
padding: 16px 20px;
border-top: 1px solid #eee;
gap: 12px;
}
.modal-button {
padding: 8px 16px;
border-radius: 4px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
border: none;
}
.modal-button-primary {
background-color: #0066cc;
color: white;
}
.modal-button-primary:hover {
background-color: #0055aa;
}
.modal-button-secondary {
background-color: #f0f0f0;
color: #333;
}
.modal-button-secondary:hover {
background-color: #e0e0e0;
}
.modal-button-danger {
background-color: #dc3545;
color: white;
}
.modal-button-danger:hover {
background-color: #bd2130;
}
/* Modal content styles */
.modal-message {
margin-bottom: 16px;
font-size: 1rem;
}
.text-center {
text-align: center;
}
.modal-spinner {
display: flex;
align-items: center;
justify-content: center;
margin: 20px 0;
}
.spinner {
border: 4px solid rgba(0, 0, 0, 0.1);
border-radius: 50%;
border-top: 4px solid #0066cc;
width: 30px;
height: 30px;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.modal-success-icon {
display: flex;
justify-content: center;
margin-bottom: 16px;
color: #28a745;
font-size: 2rem;
}
.modal-success-icon svg {
width: 60px;
height: 60px;
color: #4CAF50;
animation: success-pop 0.5s ease-out;
}
@keyframes success-pop {
0% {
transform: scale(0);
opacity: 0;
}
70% {
transform: scale(1.1);
opacity: 1;
}
100% {
transform: scale(1);
opacity: 1;
}
}
.modal-error-icon {
display: flex;
justify-content: center;
margin-bottom: 16px;
color: #dc3545;
font-size: 2rem;
}
.modal-error-icon svg {
width: 60px;
height: 60px;
color: #F44336;
animation: error-pop 0.5s ease-out;
}
@keyframes error-pop {
0% {
transform: scale(0);
opacity: 0;
}
70% {
transform: scale(1.1);
opacity: 1;
}
100% {
transform: scale(1);
opacity: 1;
}
}
.modal-choices {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 20px;
}
.modal-choice-button {
padding: 12px 16px;
border: none;
border-radius: 4px;
background-color: #0066cc;
text-align: center;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
font-weight: 500;
text-decoration: none;
color: white;
}
.modal-choice-button:hover {
background-color: #0055aa;
transform: translateY(-1px);
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
}
.modal-choice-button svg {
margin-right: 8px;
}
.success-link {
background-color: #4CAF50;
}
.success-link:hover {
background-color: #3d8b40;
}
.centered-choice {
margin: 0 auto;
width: auto;
min-width: 220px;
background-color: #0066cc;
color: white;
}
.centered-choice:hover {
background-color: #0055aa;
}
@media (max-width: 480px) {
.modal-container {
width: 95%;
background-color: white;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
width: 90%;
max-width: 500px;
max-height: 90vh;
overflow-y: auto;
animation: modal-fade-in 0.3s ease-out;
}
@keyframes modal-fade-in {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid #eee;
}
.modal-title {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: #333;
}
.modal-close-button {
background: none;
border: none;
cursor: pointer;
color: #666;
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.2s;
}
.modal-close-button:hover {
color: #000;
}
.modal-content {
padding: 20px;
color: #333;
font-size: 1rem;
line-height: 1.5;
max-height: 400px;
overflow-y: auto;
}
.modal-actions {
flex-direction: column;
display: flex;
justify-content: flex-end;
padding: 16px 20px;
border-top: 1px solid #eee;
gap: 12px;
}
.modal-button {
width: 100%;
padding: 8px 16px;
border-radius: 4px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
border: none;
}
.modal-button-primary {
background-color: #0066cc;
color: white;
}
.modal-button-primary:hover {
background-color: #0055aa;
}
.modal-button-secondary {
background-color: #f0f0f0;
color: #333;
}
.modal-button-secondary:hover {
background-color: #e0e0e0;
}
.modal-button-danger {
background-color: #dc3545;
color: white;
}
.modal-button-danger:hover {
background-color: #bd2130;
}
/* Modal content styles */
.modal-message {
margin-bottom: 16px;
font-size: 1rem;
}
.text-center {
text-align: center;
}
.modal-spinner {
display: flex;
align-items: center;
justify-content: center;
margin: 20px 0;
}
.spinner {
border: 4px solid rgba(0, 0, 0, 0.1);
border-radius: 50%;
border-top: 4px solid #0066cc;
width: 30px;
height: 30px;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.modal-success-icon {
display: flex;
justify-content: center;
margin-bottom: 16px;
color: #28a745;
font-size: 2rem;
}
.modal-success-icon svg {
width: 60px;
height: 60px;
color: #4caf50;
animation: success-pop 0.5s ease-out;
}
@keyframes success-pop {
0% {
transform: scale(0);
opacity: 0;
}
70% {
transform: scale(1.1);
opacity: 1;
}
100% {
transform: scale(1);
opacity: 1;
}
}
.modal-error-icon {
display: flex;
justify-content: center;
margin-bottom: 16px;
color: #dc3545;
font-size: 2rem;
}
.modal-error-icon svg {
width: 60px;
height: 60px;
color: #f44336;
animation: error-pop 0.5s ease-out;
}
@keyframes error-pop {
0% {
transform: scale(0);
opacity: 0;
}
70% {
transform: scale(1.1);
opacity: 1;
}
100% {
transform: scale(1);
opacity: 1;
}
}
.modal-choices {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 20px;
}
.modal-choice-button {
padding: 12px 16px;
border: none;
border-radius: 4px;
background-color: #0066cc;
text-align: center;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
font-weight: 500;
text-decoration: none;
color: white;
}
.modal-choice-button:hover {
background-color: #0055aa;
transform: translateY(-1px);
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
}
.modal-choice-button svg {
margin-right: 8px;
}
.success-link {
background-color: #4caf50;
}
.success-link:hover {
background-color: #3d8b40;
}
.centered-choice {
margin: 0 auto;
width: auto;
min-width: 220px;
background-color: #0066cc;
color: white;
}
.centered-choice:hover {
background-color: #0055aa;
}
@media (max-width: 480px) {
.modal-container {
width: 95%;
}
.modal-actions {
flex-direction: column;
}
.modal-button {
width: 100%;
}
}
.error-message {
color: #f44336;
font-weight: 500;
background-color: rgba(244, 67, 54, 0.1);
padding: 10px;
border-radius: 4px;
border-left: 4px solid #f44336;
margin-top: 10px;
}
.redirect-message {
margin-top: 20px;
color: #555;
font-size: 0.95rem;
padding: 0;
margin: 0;
}
.countdown {
font-weight: bold;
color: #0066cc;
font-size: 1.1rem;
}
}
.error-message {
color: #F44336;
font-weight: 500;
background-color: rgba(244, 67, 54, 0.1);
padding: 10px;
border-radius: 4px;
border-left: 4px solid #F44336;
margin-top: 10px;
}
.redirect-message {
margin-top: 20px;
color: #555;
font-size: 0.95rem;
padding: 0;
margin: 0;
}
.countdown {
font-weight: bold;
color: #0066cc;
font-size: 1.1rem;
}
}

View File

@@ -56,7 +56,7 @@
.timeline-marker {
position: absolute;
height: 82px; /* Increased height to extend below timeline */
height: 82px; /* Increased height to extend below timeline */
width: 2px;
background-color: #000;
transform: translateX(-50%);
@@ -83,7 +83,7 @@
.timeline-marker-drag {
position: absolute;
bottom: -12px; /* Changed from -6px to -12px to move it further down */
bottom: -12px; /* Changed from -6px to -12px to move it further down */
left: 50%;
transform: translateX(-50%);
width: 16px;
@@ -248,14 +248,14 @@
right: 0;
border-radius: 0 2px 2px 0;
}
/* Enhanced handles for touch devices */
@media (pointer: coarse) {
.clip-segment-handle {
width: 14px; /* Wider target for touch devices */
background-color: rgba(0, 0, 0, 0.4); /* Darker by default for better visibility */
}
.clip-segment-handle:after {
content: "";
position: absolute;
@@ -267,15 +267,15 @@
background-color: rgba(255, 255, 255, 0.8);
border-radius: 1px;
}
.clip-segment-handle.left:after {
box-shadow: -2px 0 0 rgba(0, 0, 0, 0.5);
}
.clip-segment-handle.right:after {
box-shadow: 2px 0 0 rgba(0, 0, 0, 0.5);
}
/* Active state for touch feedback */
.clip-segment-handle:active {
background-color: rgba(0, 0, 0, 0.6);
@@ -284,19 +284,19 @@
.timeline-marker {
height: 85px;
}
.timeline-marker-head {
width: 24px;
height: 24px;
top: -13px;
}
.timeline-marker-drag {
width: 24px;
height: 24px;
bottom: -18px;
}
.timeline-marker-head.dragging {
width: 28px;
height: 28px;
@@ -321,7 +321,7 @@
.segment-tooltip:after,
.empty-space-tooltip:after {
content: '';
content: "";
position: absolute;
bottom: -5px;
left: 50%;
@@ -335,7 +335,7 @@
.segment-tooltip:before,
.empty-space-tooltip:before {
content: '';
content: "";
position: absolute;
bottom: -6px;
left: 50%;
@@ -438,7 +438,7 @@
font-size: 0.875rem;
border: none;
cursor: pointer;
margin-right: 0.50rem;
margin-right: 0.5rem;
}
.time-button:hover {
@@ -532,8 +532,8 @@
}
/* General styles for all save buttons */
.save-button,
.save-copy-button,
.save-button,
.save-copy-button,
.save-segments-button {
color: #ffffff;
background: #0066cc;
@@ -548,8 +548,8 @@
}
/* Shared hover effect */
.save-button:hover,
.save-copy-button:hover,
.save-button:hover,
.save-copy-button:hover,
.save-segments-button:hover {
background-color: #0056b3;
}
@@ -561,30 +561,30 @@
justify-content: space-between;
gap: 0.5rem;
}
.save-button,
.save-copy-button,
.save-button,
.save-copy-button,
.save-segments-button {
flex: 1;
font-size: 0.7rem;
padding: 0.25rem 0.35rem;
}
}
/* Very small screens - adjust save buttons */
@media (max-width: 480px) {
.save-button,
.save-copy-button,
.save-button,
.save-copy-button,
.save-segments-button {
font-size: 0.675rem;
padding: 0.25rem;
}
/* Remove margins for controls-right buttons */
.controls-right {
margin: 0;
}
.controls-right button {
margin: 0;
}
@@ -595,7 +595,7 @@
[data-tooltip] {
position: relative;
}
[data-tooltip]:before {
content: attr(data-tooltip);
position: absolute;
@@ -612,13 +612,15 @@
white-space: nowrap;
opacity: 0;
visibility: hidden;
transition: opacity 0.2s, visibility 0.2s;
transition:
opacity 0.2s,
visibility 0.2s;
z-index: 1000;
pointer-events: none;
}
[data-tooltip]:after {
content: '';
content: "";
position: absolute;
bottom: 100%;
left: 50%;
@@ -628,17 +630,19 @@
border-color: rgba(0, 0, 0, 0.8) transparent transparent transparent;
opacity: 0;
visibility: hidden;
transition: opacity 0.2s, visibility 0.2s;
transition:
opacity 0.2s,
visibility 0.2s;
pointer-events: none;
}
[data-tooltip]:hover:before,
[data-tooltip]:hover:after {
opacity: 1;
visibility: visible;
}
}
/* Hide button tooltips on touch devices */
@media (pointer: coarse) {
[data-tooltip]:before,
@@ -669,27 +673,27 @@
}
.modal-success-icon svg {
color: #4CAF50;
color: #4caf50;
animation: fadeIn 0.5s ease-in-out;
}
.modal-error-icon svg {
color: #F44336;
color: #f44336;
animation: fadeIn 0.5s ease-in-out;
}
.success-link {
background-color: #4CAF50;
background-color: #4caf50;
color: white;
transition: background-color 0.3s;
}
.success-link:hover {
background-color: #388E3C;
background-color: #388e3c;
}
.error-message {
color: #F44336;
color: #f44336;
font-weight: 500;
}
@@ -809,47 +813,18 @@
}
@keyframes pulse {
0% { opacity: 0.7; transform: scale(1); }
50% { opacity: 1; transform: scale(1.05); }
100% { opacity: 0.7; transform: scale(1); }
}
/* Preview mode styles */
.preview-mode .tooltip-action-btn {
opacity: 0.5;
pointer-events: none;
cursor: not-allowed;
}
.preview-mode .tooltip-time-btn {
opacity: 0.5;
pointer-events: none;
cursor: not-allowed;
}
/* Timeline preview mode styles */
.timeline-container-card.preview-mode {
pointer-events: none;
}
.timeline-container-card.preview-mode .timeline-marker-head,
.timeline-container-card.preview-mode .timeline-marker-drag,
.timeline-container-card.preview-mode .clip-segment,
.timeline-container-card.preview-mode .clip-segment-handle,
.timeline-container-card.preview-mode .time-button,
.timeline-container-card.preview-mode .zoom-button,
.timeline-container-card.preview-mode .save-button,
.timeline-container-card.preview-mode .save-copy-button,
.timeline-container-card.preview-mode .save-segments-button {
opacity: 0.5;
pointer-events: none;
cursor: not-allowed;
}
.timeline-container-card.preview-mode .clip-segment:hover {
box-shadow: none;
border-color: rgba(0, 0, 0, 0.15);
background-color: inherit !important;
0% {
opacity: 0.7;
transform: scale(1);
}
50% {
opacity: 1;
transform: scale(1.05);
}
100% {
opacity: 0.7;
transform: scale(1);
}
}
/* Segments playback mode styles - minimal functional styling */
@@ -858,19 +833,26 @@
cursor: pointer;
}
.segments-playback-mode .tooltip-action-btn.set-in,
.segments-playback-mode .tooltip-action-btn.set-out,
.segments-playback-mode .tooltip-action-btn.play-from-start {
opacity: 0.5;
pointer-events: none;
}
.segments-playback-mode .tooltip-action-btn.play,
.segments-playback-mode .tooltip-action-btn.pause {
opacity: 1;
cursor: pointer;
}
/* During segments playback mode, disable button interactions but keep hover working */
.segments-playback-mode .tooltip-time-btn[disabled],
.segments-playback-mode .tooltip-action-btn[disabled] {
opacity: 0.5 !important;
cursor: not-allowed !important;
}
/* Ensure disabled buttons still show tooltips on hover */
.segments-playback-mode [data-tooltip][disabled]:hover:before,
.segments-playback-mode [data-tooltip][disabled]:hover:after {
opacity: 1 !important;
visibility: visible !important;
}
/* Show segments playback message */
.segments-playback-message {
display: flex;
@@ -889,4 +871,4 @@
width: 1.25rem;
margin-right: 0.5rem;
color: #3b82f6;
}
}

View File

@@ -23,7 +23,7 @@
}
.tooltip-row:first-child {
margin-bottom: 6px;
margin-bottom: 6px;
}
.tooltip-time-btn {
@@ -56,6 +56,26 @@
overflow: hidden !important;
}
/* Disabled state for time display */
.tooltip-time-display.disabled {
pointer-events: none !important;
cursor: not-allowed !important;
opacity: 0.6 !important;
user-select: none !important;
-webkit-user-select: none !important;
-moz-user-select: none !important;
-ms-user-select: none !important;
}
/* Force disabled tooltips to show on hover for better user feedback */
.tooltip-time-btn.disabled[data-tooltip]:hover:before,
.tooltip-time-btn.disabled[data-tooltip]:hover:after,
.tooltip-action-btn.disabled[data-tooltip]:hover:before,
.tooltip-action-btn.disabled[data-tooltip]:hover:after {
opacity: 1 !important;
visibility: visible !important;
}
.tooltip-actions {
display: flex;
justify-content: space-between;
@@ -69,13 +89,13 @@
background-color: #f3f4f6;
border: none;
border-radius: 4px;
padding: 5px;
padding: 5px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: #4b5563;
width: 26px;
width: 26px;
height: 26px;
min-width: 20px !important;
position: relative; /* Add relative positioning for tooltips */
@@ -100,14 +120,16 @@
white-space: nowrap;
opacity: 0;
visibility: hidden;
transition: opacity 0.2s, visibility 0.2s;
transition:
opacity 0.2s,
visibility 0.2s;
z-index: 2500; /* High z-index */
pointer-events: none;
}
/* Triangle arrow pointing up to the button */
.tooltip-action-btn[data-tooltip]:after {
content: '';
content: "";
position: absolute;
top: 35px; /* Match the before element */
left: 50%; /* Center horizontally */
@@ -119,7 +141,9 @@
margin-left: 0; /* Reset margin */
opacity: 0;
visibility: hidden;
transition: opacity 0.2s, visibility 0.2s;
transition:
opacity 0.2s,
visibility 0.2s;
z-index: 2500; /* High z-index */
pointer-events: none;
}
@@ -175,7 +199,7 @@
}
.tooltip-action-btn.play-from-start {
color: #4f46e5;
color: #4f46e5;
}
.tooltip-action-btn.play-from-start:hover {
@@ -194,7 +218,7 @@
padding: 6px 10px;
display: flex;
flex-direction: row;
color: #10b981;
color: #10b981;
}
.tooltip-action-btn.new-segment:hover {
@@ -227,43 +251,80 @@
color: #9ca3af;
}
/* Ensure pause button is properly styled when disabled */
.tooltip-action-btn.pause.disabled {
color: #9ca3af !important;
opacity: 0.5;
cursor: not-allowed;
}
.tooltip-action-btn.pause.disabled:hover {
background-color: #f3f4f6 !important;
color: #9ca3af !important;
}
/* Ensure play button is properly styled when disabled */
.tooltip-action-btn.play.disabled {
color: #9ca3af !important;
opacity: 0.5;
cursor: not-allowed;
}
.tooltip-action-btn.play.disabled:hover {
background-color: #f3f4f6 !important;
color: #9ca3af !important;
}
/* Ensure time adjustment buttons are properly styled when disabled */
.tooltip-time-btn.disabled {
opacity: 0.5 !important;
cursor: not-allowed !important;
background-color: #f3f4f6 !important;
color: #9ca3af !important;
}
.tooltip-time-btn.disabled:hover {
background-color: #f3f4f6 !important;
color: #9ca3af !important;
}
/* Additional mobile optimizations */
@media (max-width: 768px) {
.two-row-tooltip {
padding: 4px;
padding: 4px;
}
.tooltip-row:first-child {
margin-bottom: 4px;
margin-bottom: 4px;
}
.tooltip-time-btn {
min-width: 20px !important;
font-size: 0.7rem !important;
padding: 3px 6px !important;
}
.tooltip-time-display {
font-size: 0.8rem !important;
padding: 3px 4px !important;
min-width: 90px !important;
}
.tooltip-action-btn {
width: 24px;
height: 24px;
padding: 4px;
}
.tooltip-action-btn.new-segment {
padding: 4px 8px;
}
.tooltip-action-btn svg {
width: 14px;
height: 14px;
}
/* Adjust tooltip position for small screens - maintain the same position but adjust size */
.tooltip-action-btn[data-tooltip]:before {
min-width: 100px;
@@ -272,7 +333,7 @@
height: 24px;
top: 33px; /* Maintain the same relative distance on mobile */
}
.tooltip-action-btn[data-tooltip]:after {
top: 33px; /* Match the tooltip position */
}

View File

@@ -4,7 +4,7 @@
[data-tooltip] {
position: relative;
}
[data-tooltip]:before {
content: attr(data-tooltip);
position: absolute;
@@ -21,13 +21,15 @@
white-space: nowrap;
opacity: 0;
visibility: hidden;
transition: opacity 0.2s, visibility 0.2s;
transition:
opacity 0.2s,
visibility 0.2s;
z-index: 1000;
pointer-events: none;
}
[data-tooltip]:after {
content: '';
content: "";
position: absolute;
bottom: 100%;
left: 50%;
@@ -37,17 +39,19 @@
border-color: rgba(0, 0, 0, 0.8) transparent transparent transparent;
opacity: 0;
visibility: hidden;
transition: opacity 0.2s, visibility 0.2s;
transition:
opacity 0.2s,
visibility 0.2s;
pointer-events: none;
}
[data-tooltip]:hover:before,
[data-tooltip]:hover:after {
opacity: 1;
visibility: visible;
}
}
/* Hide button tooltips on touch devices */
@media (pointer: coarse) {
[data-tooltip]:before,
@@ -71,7 +75,7 @@
-webkit-user-select: none;
user-select: none;
}
.video-player-container video {
width: 100%;
height: 100%;
@@ -83,7 +87,7 @@
-webkit-user-select: none;
user-select: none;
}
/* iOS-specific styles */
@supports (-webkit-touch-callout: none) {
.video-player-container video {
@@ -92,7 +96,7 @@
-webkit-touch-callout: none;
}
}
.play-pause-indicator {
position: absolute;
top: 50%;
@@ -106,19 +110,19 @@
transition: opacity 0.3s;
pointer-events: none;
}
.video-player-container:hover .play-pause-indicator {
opacity: 1;
}
.play-pause-indicator::before {
content: '';
content: "";
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.play-pause-indicator.play-icon::before {
width: 0;
height: 0;
@@ -127,14 +131,14 @@
border-left: 25px solid white;
margin-left: 3px;
}
.play-pause-indicator.pause-icon::before {
width: 20px;
height: 25px;
border-left: 6px solid white;
border-right: 6px solid white;
}
/* iOS First-play indicator */
.ios-first-play-indicator {
position: absolute;
@@ -148,7 +152,7 @@
justify-content: center;
z-index: 10;
}
.ios-play-message {
color: white;
font-size: 1.2rem;
@@ -158,13 +162,22 @@
border-radius: 0.5rem;
animation: pulse 2s infinite;
}
@keyframes pulse {
0% { opacity: 0.7; transform: scale(1); }
50% { opacity: 1; transform: scale(1.05); }
100% { opacity: 0.7; transform: scale(1); }
0% {
opacity: 0.7;
transform: scale(1);
}
50% {
opacity: 1;
transform: scale(1.05);
}
100% {
opacity: 0.7;
transform: scale(1);
}
}
.video-controls {
position: absolute;
bottom: 0;
@@ -175,21 +188,21 @@
opacity: 0;
transition: opacity 0.3s;
}
.video-player-container:hover .video-controls {
opacity: 1;
}
.video-current-time {
color: white;
font-size: 0.875rem;
}
.video-duration {
color: white;
font-size: 0.875rem;
}
.video-time-display {
display: flex;
justify-content: space-between;
@@ -197,7 +210,7 @@
color: white;
font-size: 0.875rem;
}
.video-progress {
position: relative;
height: 6px;
@@ -208,11 +221,11 @@
touch-action: none; /* Prevent browser handling of drag gestures */
flex-grow: 1;
}
.video-progress.dragging {
height: 8px;
}
.video-progress-fill {
position: absolute;
top: 0;
@@ -222,7 +235,7 @@
border-radius: 3px;
pointer-events: none;
}
.video-scrubber {
position: absolute;
top: 50%;
@@ -232,9 +245,12 @@
background-color: #ff0000;
border-radius: 50%;
cursor: grab;
transition: transform 0.1s ease, width 0.1s ease, height 0.1s ease;
transition:
transform 0.1s ease,
width 0.1s ease,
height 0.1s ease;
}
/* Make the scrubber larger when dragging for better control */
.video-progress.dragging .video-scrubber {
transform: translate(-50%, -50%) scale(1.2);
@@ -243,22 +259,22 @@
cursor: grabbing;
box-shadow: 0 0 8px rgba(255, 0, 0, 0.6);
}
/* Enhance for touch devices */
@media (pointer: coarse) {
.video-scrubber {
width: 20px;
height: 20px;
}
.video-progress.dragging .video-scrubber {
width: 24px;
height: 24px;
}
/* Create a larger invisible touch target */
.video-scrubber:before {
content: '';
content: "";
position: absolute;
top: -10px;
left: -10px;
@@ -266,14 +282,14 @@
bottom: -10px;
}
}
.video-controls-buttons {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.75rem;
}
.mute-button,
.fullscreen-button {
min-width: auto;
@@ -283,17 +299,17 @@
cursor: pointer;
padding: 0.25rem;
transition: transform 0.2s;
&:hover {
transform: scale(1.1);
}
svg {
width: 1.25rem;
height: 1.25rem;
}
}
/* Time tooltip that appears when dragging */
.video-time-tooltip {
position: absolute;
@@ -309,10 +325,10 @@
white-space: nowrap;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
/* Add a small arrow to the tooltip */
.video-time-tooltip:after {
content: '';
content: "";
position: absolute;
bottom: -4px;
left: 50%;
@@ -323,4 +339,4 @@
border-right: 4px solid transparent;
border-top: 4px solid rgba(0, 0, 0, 0.7);
}
}
}

View File

@@ -7,7 +7,8 @@
"dev": "vite",
"start": "NODE_ENV=production node dist/index.js",
"check": "tsc",
"build:django": "vite build --config vite.video-editor.config.ts --outDir ../../../static/video_editor"
"build:django": "vite build --config vite.video-editor.config.ts --outDir ../../../static/video_editor",
"format": "npx prettier --write client/src/**/*.{ts,tsx,css}"
},
"dependencies": {
"@tanstack/react-query": "^5.74.4",
@@ -35,6 +36,7 @@
"autoprefixer": "^10.4.20",
"esbuild": "^0.25.0",
"postcss": "^8.4.47",
"prettier": "^3.6.0",
"tailwindcss": "^3.4.17",
"typescript": "^5.8.3",
"vite": "^5.4.18"

View File

@@ -1834,6 +1834,11 @@ postcss@^8.4.43, postcss@^8.4.47:
picocolors "^1.1.1"
source-map-js "^1.2.1"
prettier@^3.6.0:
version "3.6.0"
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.6.0.tgz#18ec98d62cb0757a5d4eab40253ff3e6d0fc8dea"
integrity sha512-ujSB9uXHJKzM/2GBuE0hBOUgC77CN3Bnpqa+g80bkv3T3A93wL/xlzDATHhnhkzifz/UE2SNOvmbTz5hSkDlHw==
proxy-addr@~2.0.7:
version "2.0.7"
resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025"
@@ -2087,6 +2092,7 @@ statuses@2.0.1:
integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==
"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0:
name string-width-cjs
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@@ -2105,6 +2111,7 @@ string-width@^5.0.1, string-width@^5.1.2:
strip-ansi "^7.0.1"
"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
name strip-ansi-cjs
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==

View File

@@ -66,7 +66,7 @@
}
@media (min-width: 1024px) {
width: 20%;
width: 10%;
&:nth-child(3n + 1),
&:nth-child(3n + 2),

View File

@@ -5,6 +5,11 @@ import { FilterOptions } from '../_shared';
import './ManageItemList-filters.scss';
// Get categories from window if available
const categories = window.CATEGORIES ?
[{ id: 'all', title: 'All' }].concat(window.CATEGORIES.map(cat => ({ id: cat, title: cat }))) :
[{ id: 'all', title: 'All' }];
const filters = {
state: [
{ id: 'all', title: 'All' },
@@ -46,6 +51,7 @@ export function ManageMediaFilters(props) {
const [encodingStatus, setEncodingStatus] = useState('all');
const [isFeatured, setIsFeatured] = useState('all');
const [isReviewed, setIsReviewed] = useState('all');
const [category, setCategory] = useState('all');
const containerRef = useRef(null);
const innerContainerRef = useRef(null);
@@ -63,6 +69,7 @@ export function ManageMediaFilters(props) {
encoding_status: encodingStatus,
featured: isFeatured,
is_reviewed: isReviewed,
category: category,
};
switch (ev.currentTarget.getAttribute('filter')) {
@@ -91,6 +98,11 @@ export function ManageMediaFilters(props) {
props.onFiltersUpdate(args);
setIsReviewed(args.is_reviewed);
break;
case 'category':
args.category = ev.currentTarget.getAttribute('value');
props.onFiltersUpdate(args);
setCategory(args.category);
break;
}
}
@@ -151,6 +163,13 @@ export function ManageMediaFilters(props) {
<FilterOptions id={'featured'} options={filters.featured} selected={isFeatured} onSelect={onFilterSelect} />
</div>
</div>
<div className="mi-filter">
<div className="mi-filter-title">CATEGORY</div>
<div className="mi-filter-options">
<FilterOptions id={'category'} options={categories} selected={category} onSelect={onFilterSelect} />
</div>
</div>
</div>
</div>
);

View File

@@ -10,8 +10,9 @@ fi
while true; do
read -p "
This script will attempt to perform a system update, install required dependencies, install and configure PostgreSQL, NGINX, Redis and a few other utilities.
It is expected to run on a new system **with no running instances of any these services**. Make sure you check the script before you continue. Then enter yes or no
This script will attempt to perform a system update and install services including PostgreSQL, nginx and Django.
It is expected to run on a new system **with no running instances of any these services**.
This has been tested only in Ubuntu Linux 22 and 24. Make sure you check the script before you continue. Then enter yes or no
" yn
case $yn in
[Yy]* ) echo "OK!"; break;;
@@ -20,15 +21,7 @@ It is expected to run on a new system **with no running instances of any these s
esac
done
osVersion=$(lsb_release -d)
if [[ $osVersion == *"Ubuntu 20"* ]] || [[ $osVersion == *"Ubuntu 22"* ]] || [[ $osVersion == *"buster"* ]] || [[ $osVersion == *"bullseye"* ]]; then
echo 'Performing system update and dependency installation, this will take a few minutes'
apt-get update && apt-get -y upgrade && apt-get install python3-venv python3-dev virtualenv redis-server postgresql nginx git gcc vim unzip imagemagick python3-certbot-nginx certbot wget xz-utils -y
else
echo "This script is tested for Ubuntu 20/22 versions only, if you want to try MediaCMS on another system you have to perform the manual installation"
exit
fi
apt-get update && apt-get -y upgrade && apt-get install pkg-config python3-venv python3-dev virtualenv redis-server postgresql nginx git gcc vim unzip imagemagick procps libxml2-dev libxmlsec1-dev libxmlsec1-openssl python3-certbot-nginx certbot wget xz-utils -y
# install ffmpeg
echo "Downloading and installing ffmpeg"
@@ -50,6 +43,7 @@ echo 'Creating database to be used in MediaCMS'
su -c "psql -c \"CREATE DATABASE mediacms\"" postgres
su -c "psql -c \"CREATE USER mediacms WITH ENCRYPTED PASSWORD 'mediacms'\"" postgres
su -c "psql -c \"GRANT ALL PRIVILEGES ON DATABASE mediacms TO mediacms\"" postgres
su -c "psql -d mediacms -c \"GRANT CREATE, USAGE ON SCHEMA public TO mediacms\"" postgres
echo 'Creating python virtualenv on /home/mediacms.io'
@@ -57,7 +51,7 @@ cd /home/mediacms.io
virtualenv . --python=python3
source /home/mediacms.io/bin/activate
cd mediacms
pip install -r requirements.txt
pip install --no-binary lxml,xmlsec -r requirements.txt
SECRET_KEY=`python -c 'from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())'`

View File

@@ -1,9 +1,8 @@
Django==5.1.6
djangorestframework==3.15.2
lxml==5.0.0 # dont use later version, as theres a strange error "lxml & xmlsec libxml2 library version mismatch"
python3-saml==1.16.0
django-allauth==65.4.1
psycopg==3.2.4
psycopg[pool]==3.2.4
uwsgi==2.0.28
django-redis==5.4.0
celery==5.4.0
@@ -19,7 +18,6 @@ requests==2.32.3
django-celery-email==3.0.0
m3u8==6.0.0
django-debug-toolbar==5.0.1
django-login-required-middleware==0.9.0
pre-commit==4.1.0
django-jazzmin==3.0.1
pysubs2==1.8.0

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -19,4 +19,4 @@
{% include "components/footer.html" %}
{% endblock %}
{% block bottomimports %}<script src="{% static "js/base.js" %}"></script>{% endblock bottomimports %}
{% block bottomimports %}<script src="{% static "js/base.js" %}?v={{ VERSION }}"></script>{% endblock bottomimports %}

View File

@@ -129,7 +129,7 @@
{% endblock innercontent %}
{% block bottomimports %}
<script src="{% static "js/add-media.js" %}"></script>
<script src="{% static "js/add-media.js" %}?v={{ VERSION }}"></script>
<script>
document.addEventListener("DOMContentLoaded", function(event) {
function getCSRFToken() {

View File

@@ -41,5 +41,5 @@
{% block content %}<div id="page-categories"></div>{% endblock %}
{% block bottomimports %}
<script src="{% static "js/categories.js" %}"></script>
<script src="{% static "js/categories.js" %}?v={{ VERSION }}"></script>
{% endblock bottomimports %}

View File

@@ -9,5 +9,5 @@
{% block content %}<div id="page-embed"></div>{% endblock content %}
{% block bottomimports %}
<script src="{% static "js/embed.js" %}"></script>
<script src="{% static "js/embed.js" %}?v={{ VERSION }}"></script>
{% endblock bottomimports %}

View File

@@ -43,5 +43,5 @@
{% endblock %}
{% block bottomimports %}
<script src="{% static "js/featured.js" %}"></script>
<script src="{% static "js/featured.js" %}?v={{ VERSION }}"></script>
{% endblock bottomimports %}

View File

@@ -13,5 +13,5 @@
{% endblock %}
{% block bottomimports %}
<script src="{% static "js/history.js" %}"></script>
<script src="{% static "js/history.js" %}?v={{ VERSION }}"></script>
{% endblock bottomimports %}

View File

@@ -48,5 +48,5 @@
{% block content %}<div id="page-home"></div>{% endblock %}
{% block bottomimports %}
<script src="{% static "js/index.js" %}"></script>
<script src="{% static "js/index.js" %}?v={{ VERSION }}"></script>
{% endblock bottomimports %}

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