Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e790795bfd | ||
|
|
de99d84c18 | ||
|
|
8aa89c0958 | ||
|
|
df98b65704 | ||
|
|
a607996bfa | ||
|
|
79f2e2bb11 | ||
|
|
d54732040a | ||
|
|
e8520bc7cd | ||
|
|
b6e46e7b62 | ||
|
|
36eab954bd | ||
|
|
610716533b | ||
|
|
4f1c4a2b4c | ||
|
|
83f3eec940 | ||
|
|
a5acce4ab1 | ||
|
|
a4e9309350 | ||
|
|
6beaf0bbe2 | ||
|
|
70168299ba | ||
|
|
b28c2d8271 | ||
|
|
d34fc328bf | ||
|
|
ab4d9d67df | ||
|
|
f7a2f049bd |
11
.gitignore
vendored
@@ -18,3 +18,14 @@ static/drf-yasg
|
||||
cms/local_settings.py
|
||||
deploy/docker/local_settings.py
|
||||
yt.readme.md
|
||||
/frontend-tools/video-editor/node_modules
|
||||
/frontend-tools/video-editor/client/node_modules
|
||||
/static_collected
|
||||
/frontend-tools/video-editor-v1
|
||||
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
|
||||
|
||||
1
.prettierignore
Normal file
@@ -0,0 +1 @@
|
||||
*
|
||||
18
Dockerfile
@@ -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"]
|
||||
2
Makefile
@@ -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; \
|
||||
|
||||
20
README.md
@@ -28,8 +28,9 @@ A demo is available at https://demo.mediacms.io
|
||||
- **Multiple media types support**: video, audio, image, pdf
|
||||
- **Multiple media classification options**: categories, tags and custom
|
||||
- **Multiple media sharing options**: social media share, videos embed code generation
|
||||
- **Role-Based Access Control (RBAC)
|
||||
- **SAML support
|
||||
- **Video Trimmer**: trim video, replace, save as new or create segments
|
||||
- **Role-Based Access Control (RBAC)**: create RBAC categories and connect users to groups with view/edit access on their media
|
||||
- **SAML support**: with ability to add mappings to system roles and groups
|
||||
- **Easy media searching**: enriched with live search functionality
|
||||
- **Playlists for audio and video content**: create playlists, add and reorder content
|
||||
- **Responsive design**: including light and dark themes
|
||||
@@ -37,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
|
||||
@@ -92,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
|
||||
|
||||
@@ -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"
|
||||
@@ -445,19 +436,6 @@ CELERY_TASK_ALWAYS_EAGER = False
|
||||
if os.environ.get("TESTING"):
|
||||
CELERY_TASK_ALWAYS_EAGER = True
|
||||
|
||||
|
||||
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]+/',
|
||||
]
|
||||
|
||||
# if True, only show original, don't perform any action on videos
|
||||
DO_NOT_TRANSCODE_VIDEO = False
|
||||
|
||||
@@ -466,21 +444,26 @@ DEFAULT_AUTO_FIELD = 'django.db.models.AutoField'
|
||||
LANGUAGES = [
|
||||
('ar', _('Arabic')),
|
||||
('bn', _('Bengali')),
|
||||
('da', _('Danish')),
|
||||
('nl', _('Dutch')),
|
||||
('en', _('English')),
|
||||
('fr', _('French')),
|
||||
('de', _('German')),
|
||||
('hi', _('Hindi')),
|
||||
('id', _('Indonesian')),
|
||||
('it', _('Italian')),
|
||||
('ja', _('Japanese')),
|
||||
('ko', _('Korean')),
|
||||
('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
|
||||
@@ -509,6 +492,17 @@ USE_RBAC = False
|
||||
USE_IDENTITY_PROVIDERS = False
|
||||
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
|
||||
@@ -520,6 +514,8 @@ except ImportError:
|
||||
# local_settings not in use
|
||||
pass
|
||||
|
||||
# Don't add new settings below that could be overridden in local_settings.py!!!
|
||||
|
||||
if "http" not in FRONTEND_HOST:
|
||||
# FRONTEND_HOST needs a http:// preffix
|
||||
FRONTEND_HOST = f"http://{FRONTEND_HOST}" # noqa
|
||||
@@ -544,3 +540,8 @@ try:
|
||||
from .dev_settings import * # noqa
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
|
||||
if GLOBAL_LOGIN_REQUIRED:
|
||||
auth_index = MIDDLEWARE.index("django.contrib.auth.middleware.AuthenticationMiddleware")
|
||||
MIDDLEWARE.insert(auth_index + 1, "django.contrib.auth.middleware.LoginRequiredMiddleware")
|
||||
|
||||
@@ -1 +1 @@
|
||||
VERSION = "5.0.0"
|
||||
VERSION = "6.4.0"
|
||||
|
||||
@@ -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},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
27
deploy/scripts/build_and_deploy.sh
Normal file
@@ -0,0 +1,27 @@
|
||||
#!/bin/bash
|
||||
# This script builds the video editor package and deploys the frontend assets to the static directory.
|
||||
|
||||
# Exit on any error
|
||||
set -e
|
||||
|
||||
echo "Starting build process..."
|
||||
|
||||
# Build video editor package
|
||||
echo "Building video editor package..."
|
||||
cd frontend-tools/video-editor
|
||||
yarn build:django
|
||||
cd ../../
|
||||
|
||||
# Run npm build in the frontend container
|
||||
echo "Building frontend assets..."
|
||||
docker compose -f docker-compose-dev.yaml exec frontend npm run dist
|
||||
|
||||
# Copy static assets to the static directory
|
||||
echo "Copying static assets..."
|
||||
cp -r frontend/dist/static/* static/
|
||||
|
||||
# Restart the web service
|
||||
echo "Restarting web service..."
|
||||
docker compose -f docker-compose-dev.yaml restart web
|
||||
|
||||
echo "Build and deployment completed successfully!"
|
||||
@@ -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
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
name: mediacms-dev
|
||||
services:
|
||||
migrations:
|
||||
platform: linux/amd64
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./Dockerfile
|
||||
context: ..
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
- DEVELOPMENT_MODE=True
|
||||
image: mediacms/mediacms:latest
|
||||
volumes:
|
||||
- ./:/home/mediacms.io/mediacms/
|
||||
command: "./deploy/docker/prestart.sh"
|
||||
- ../:/home/mediacms.io/mediacms/
|
||||
command: "/home/mediacms.io/mediacms/deploy/docker/prestart.sh"
|
||||
environment:
|
||||
DEVELOPMENT_MODE: True
|
||||
ENABLE_UWSGI: 'no'
|
||||
@@ -95,13 +96,13 @@ services:
|
||||
ports:
|
||||
- "80:80"
|
||||
volumes:
|
||||
- ./:/home/mediacms.io/mediacms/
|
||||
- ../:/home/mediacms.io/mediacms/
|
||||
depends_on:
|
||||
- migrations
|
||||
db:
|
||||
image: postgres:17.2-alpine
|
||||
volumes:
|
||||
- ./postgres_data:/var/lib/postgresql/data/
|
||||
- ../postgres_data:/var/lib/postgresql/data/
|
||||
restart: always
|
||||
environment:
|
||||
POSTGRES_USER: mediacms
|
||||
@@ -127,7 +128,7 @@ services:
|
||||
deploy:
|
||||
replicas: 1
|
||||
volumes:
|
||||
- ./:/home/mediacms.io/mediacms/
|
||||
- ../:/home/mediacms.io/mediacms/
|
||||
environment:
|
||||
ENABLE_UWSGI: 'no'
|
||||
ENABLE_NGINX: 'no'
|
||||
|
||||
@@ -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
|
||||
@@ -809,14 +815,8 @@ This will disable the transcoding process and only the original file will be sho
|
||||
|
||||
## 19. Rounded corners on videos
|
||||
|
||||
By default the video player and media items are now having rounded corners, on larger screens (not in mobile). If you don't like this change, remove the `border-radius` added on the following files:
|
||||
By default the video player and media items are now having rounded corners, on larger screens (not in mobile). If you don't like this change, set `USE_ROUNDED_CORNERS = False` in `local_settings.py`.
|
||||
|
||||
```
|
||||
frontend/src/static/css/_extra.css
|
||||
frontend/src/static/js/components/list-item/Item.scss
|
||||
frontend/src/static/js/components/media-page/MediaPage.scss
|
||||
```
|
||||
you now have to re-run the frontend build in order to see the changes (check docs/dev_exp.md)
|
||||
|
||||
|
||||
## 20. Translations
|
||||
@@ -879,7 +879,7 @@ By default there are 3 statuses for any Media that lives on the system, public,
|
||||
|
||||
Now user can view the Media even if it is in private state. User also sees all media in Category page
|
||||
|
||||
When user is added to group, they can be set as Member, Contributor, Manager.
|
||||
When user is added to group, they can be set as Member, Contributor, Manager.
|
||||
|
||||
- Member: user can view media that are published on one or more categories that this group is associated with
|
||||
- Contributor: besides viewing, user can also edit the Media in a category associated with this Group. They can also publish Media to this category
|
||||
@@ -891,17 +891,17 @@ Use cases facilitated with RBAC:
|
||||
- viewing all media of a category: if RBAC is enabled, and user visits a Category, they are able to see the listing of all media that are published in this category, independent of their state, provided that the category is associated with a group that the user is member of
|
||||
- viewing all categories associated with groups the user is member of: if RBAC is enabled, and user visits the listing of categories, they can view all categories that are associated with a group the user is member
|
||||
|
||||
How to enable RBAC support:
|
||||
How to enable RBAC support:
|
||||
|
||||
```
|
||||
USE_RBAC = True
|
||||
```
|
||||
|
||||
on `local_settings.py` and restart the instance.
|
||||
on `local_settings.py` and restart the instance.
|
||||
|
||||
|
||||
## 23. SAML setup
|
||||
SAML authentication is supported along with the option to utilize the SAML response and do useful things as setting up the user role in MediaCMS or participation in groups.
|
||||
SAML authentication is supported along with the option to utilize the SAML response and do useful things as setting up the user role in MediaCMS or participation in groups.
|
||||
|
||||
To enable SAML support, edit local_settings.py and set the following options:
|
||||
|
||||
@@ -942,8 +942,8 @@ Select the SAML Configurations tab, create a new one and set:
|
||||
|
||||
1. **IDP ID**: Must be a URL
|
||||
2. **IDP Certificate**: x509cert from your SAML provider
|
||||
3. **SSO URL**:
|
||||
4. **SLO URL**:
|
||||
3. **SSO URL**:
|
||||
4. **SLO URL**:
|
||||
5. **SP Metadata URL**: The metadata URL that the IDP will utilize. This can be https://{portal}/saml/metadata and is autogenerated by MediaCMS
|
||||
|
||||
- Step 3: Set other Options
|
||||
@@ -971,5 +971,8 @@ to enable the identity providers, set the following setting on `local_settings.p
|
||||
USE_IDENTITY_PROVIDERS = True
|
||||
```
|
||||
|
||||
Visiting the admin, you will see the Identity Providers tab and you can add one.
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
@@ -11,6 +11,7 @@
|
||||
- [Share media](#share-media)
|
||||
- [Embed media](#embed-media)
|
||||
- [Customize my profile options](#customize-my-profile-options)
|
||||
- [Trim videos](#trim-videos)
|
||||
|
||||
## Uploading media
|
||||
|
||||
@@ -198,7 +199,7 @@ You can now watch the captions/subtitles play back in the video player - and tog
|
||||
<img src="./images/CC-display.png"/>
|
||||
</p>
|
||||
|
||||
## Using Timestamps for sharing
|
||||
## Using Timestamps for sharing
|
||||
|
||||
### Using Timestamp in the URL
|
||||
|
||||
@@ -240,7 +241,7 @@ Comments send with mentions will contain a link to the user page, and can be set
|
||||
When enabled, comments including a timestamp will also be displayed in the current video Timebar as a little colorful dot. The comment can be previewed by hovering the dot (left image) and it will be displayed on top of the video when reaching the correct time (right image).
|
||||
|
||||
Only comments with correct timestamps formats (HH:MM:SS or MM:SS) will be picked up and appear in the Timebar.
|
||||
|
||||
|
||||
<p align="left">
|
||||
<img src="./images/TimebarComments_Hover.png" height="180" alt="Comment preview on hover"/>
|
||||
<img src="./images/TimebarComments_Hit.png" height="180" alt="Comment shown when the timestamp is reached "/>
|
||||
@@ -257,3 +258,7 @@ How to use the embed media option
|
||||
|
||||
## Customize my profile options
|
||||
Customize profile and channel
|
||||
|
||||
## Trim videos
|
||||
Once a video is uploaded, you can trim it to create a new video or to replace the original one. You can also create segments of the video, which will be available as separate videos. Edit the video and click on the "Trime Video" option. If the original video has finished processing (encodings are created for all resolutions), then this is an action that runs instantly. If the original video hasn't processed, which is the case when you upload a video and edit it right away, then the trim action will trigger processing of the video and will take some time to finish. In all cases, you get to see the original video (or the trimmed versions) immediately, so you are sure of what you have uploaded or trimmed, with a message that the video is being processed.
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ from .models import (
|
||||
Media,
|
||||
Subtitle,
|
||||
Tag,
|
||||
VideoTrimRequest,
|
||||
)
|
||||
|
||||
|
||||
@@ -199,6 +200,10 @@ class SubtitleAdmin(admin.ModelAdmin):
|
||||
pass
|
||||
|
||||
|
||||
class VideoTrimRequestAdmin(admin.ModelAdmin):
|
||||
pass
|
||||
|
||||
|
||||
class EncodingAdmin(admin.ModelAdmin):
|
||||
list_display = ["get_title", "chunk", "profile", "progress", "status", "has_file"]
|
||||
list_filter = ["chunk", "profile", "status"]
|
||||
@@ -222,5 +227,6 @@ admin.site.register(Category, CategoryAdmin)
|
||||
admin.site.register(Tag, TagAdmin)
|
||||
admin.site.register(Subtitle, SubtitleAdmin)
|
||||
admin.site.register(Language, LanguageAdmin)
|
||||
admin.site.register(VideoTrimRequest, VideoTrimRequestAdmin)
|
||||
|
||||
Media._meta.app_config.verbose_name = "Media"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -35,6 +37,10 @@ def stuff(request):
|
||||
ret["TRANSLATION"] = get_translation(request.LANGUAGE_CODE)
|
||||
ret["REPLACEMENTS"] = get_translation_strings(request.LANGUAGE_CODE)
|
||||
ret["USE_SAML"] = settings.USE_SAML
|
||||
ret["USE_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
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
188
files/forms.py
@@ -1,49 +1,150 @@
|
||||
from crispy_forms.bootstrap import FormActions
|
||||
from crispy_forms.helper import FormHelper
|
||||
from crispy_forms.layout import Field, Layout, Submit
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
|
||||
from .methods import get_next_state, is_mediacms_editor
|
||||
from .models import Category, Media, Subtitle
|
||||
from .models import MEDIA_STATES, Category, Media, Subtitle
|
||||
|
||||
|
||||
class CustomField(Field):
|
||||
template = 'cms/crispy_custom_field.html'
|
||||
|
||||
|
||||
class MultipleSelect(forms.CheckboxSelectMultiple):
|
||||
input_type = "checkbox"
|
||||
|
||||
|
||||
class MediaForm(forms.ModelForm):
|
||||
new_tags = forms.CharField(label="Tags", help_text="a comma separated list of new tags.", required=False)
|
||||
class MediaMetadataForm(forms.ModelForm):
|
||||
new_tags = forms.CharField(label="Tags", help_text="a comma separated list of tags.", required=False)
|
||||
|
||||
class Meta:
|
||||
model = Media
|
||||
fields = (
|
||||
"friendly_token",
|
||||
"title",
|
||||
"category",
|
||||
"new_tags",
|
||||
"add_date",
|
||||
"uploaded_poster",
|
||||
"description",
|
||||
"state",
|
||||
"enable_comments",
|
||||
"featured",
|
||||
"thumbnail_time",
|
||||
"reported_times",
|
||||
"is_reviewed",
|
||||
"allow_download",
|
||||
)
|
||||
|
||||
widgets = {
|
||||
"tags": MultipleSelect(),
|
||||
"new_tags": MultipleSelect(),
|
||||
"description": forms.Textarea(attrs={'rows': 4}),
|
||||
"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",
|
||||
}
|
||||
|
||||
def __init__(self, user, *args, **kwargs):
|
||||
self.user = user
|
||||
super(MediaForm, self).__init__(*args, **kwargs)
|
||||
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":
|
||||
self.fields.pop("uploaded_poster")
|
||||
|
||||
self.fields["new_tags"].initial = ", ".join([tag.title for tag in self.instance.tags.all()])
|
||||
|
||||
self.helper = FormHelper()
|
||||
self.helper.form_tag = True
|
||||
self.helper.form_class = 'post-form'
|
||||
self.helper.form_method = 'post'
|
||||
self.helper.form_enctype = "multipart/form-data"
|
||||
self.helper.form_show_errors = False
|
||||
|
||||
layout_fields = [
|
||||
CustomField('title'),
|
||||
CustomField('new_tags'),
|
||||
CustomField('add_date'),
|
||||
CustomField('description'),
|
||||
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:
|
||||
if image.size > 5 * 1024 * 1024:
|
||||
raise forms.ValidationError("Image file too large ( > 5mb )")
|
||||
return image
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
data = self.cleaned_data # noqa
|
||||
|
||||
media = super(MediaMetadataForm, self).save(*args, **kwargs)
|
||||
return media
|
||||
|
||||
|
||||
class MediaPublishForm(forms.ModelForm):
|
||||
confirm_state = forms.BooleanField(required=False, initial=False, label="Acknowledge sharing status", help_text="")
|
||||
|
||||
class Meta:
|
||||
model = Media
|
||||
fields = (
|
||||
"category",
|
||||
"state",
|
||||
"featured",
|
||||
"reported_times",
|
||||
"is_reviewed",
|
||||
"allow_download",
|
||||
)
|
||||
|
||||
widgets = {
|
||||
"category": MultipleSelect(),
|
||||
}
|
||||
|
||||
def __init__(self, user, *args, **kwargs):
|
||||
self.user = user
|
||||
super(MediaPublishForm, self).__init__(*args, **kwargs)
|
||||
if not is_mediacms_editor(user):
|
||||
self.fields.pop("featured")
|
||||
self.fields.pop("reported_times")
|
||||
self.fields.pop("is_reviewed")
|
||||
# if settings.PORTAL_WORKFLOW == 'private':
|
||||
# self.fields.pop("state")
|
||||
for field in ["featured", "reported_times", "is_reviewed"]:
|
||||
self.fields[field].disabled = True
|
||||
self.fields[field].widget.attrs['class'] = 'read-only-field'
|
||||
self.fields[field].widget.attrs['title'] = "This field can only be modified by MediaCMS admins or editors"
|
||||
|
||||
if settings.PORTAL_WORKFLOW not in ["public"]:
|
||||
valid_states = ["unlisted", "private"]
|
||||
if self.instance.state and self.instance.state not in valid_states:
|
||||
valid_states.append(self.instance.state)
|
||||
self.fields["state"].choices = [(state, dict(MEDIA_STATES).get(state, state)) for state in valid_states]
|
||||
|
||||
if getattr(settings, 'USE_RBAC', False) and 'category' in self.fields:
|
||||
if is_mediacms_editor(user):
|
||||
@@ -61,14 +162,52 @@ class MediaForm(forms.ModelForm):
|
||||
|
||||
self.fields['category'].queryset = Category.objects.filter(id__in=combined_category_ids).order_by('title')
|
||||
|
||||
self.fields["new_tags"].initial = ", ".join([tag.title for tag in self.instance.tags.all()])
|
||||
self.helper = FormHelper()
|
||||
self.helper.form_tag = True
|
||||
self.helper.form_class = 'post-form'
|
||||
self.helper.form_method = 'post'
|
||||
self.helper.form_enctype = "multipart/form-data"
|
||||
self.helper.form_show_errors = False
|
||||
self.helper.layout = Layout(
|
||||
CustomField('category'),
|
||||
CustomField('state'),
|
||||
CustomField('featured'),
|
||||
CustomField('reported_times'),
|
||||
CustomField('is_reviewed'),
|
||||
CustomField('allow_download'),
|
||||
)
|
||||
|
||||
def clean_uploaded_poster(self):
|
||||
image = self.cleaned_data.get("uploaded_poster", False)
|
||||
if image:
|
||||
if image.size > 5 * 1024 * 1024:
|
||||
raise forms.ValidationError("Image file too large ( > 5mb )")
|
||||
return image
|
||||
self.helper.layout.append(FormActions(Submit('submit', 'Publish Media', css_class='primaryAction')))
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
state = cleaned_data.get("state")
|
||||
categories = cleaned_data.get("category")
|
||||
|
||||
if getattr(settings, 'USE_RBAC', False) and 'category' in self.fields:
|
||||
rbac_categories = categories.filter(is_rbac_category=True).values_list('title', flat=True)
|
||||
|
||||
if rbac_categories and state in ['private', 'unlisted']:
|
||||
# Make the confirm_state field visible and add it to the layout
|
||||
self.fields['confirm_state'].widget = forms.CheckboxInput()
|
||||
|
||||
# add it after the state field
|
||||
state_index = None
|
||||
for i, layout_item in enumerate(self.helper.layout):
|
||||
if isinstance(layout_item, CustomField) and layout_item.fields[0] == 'state':
|
||||
state_index = i
|
||||
break
|
||||
|
||||
if state_index:
|
||||
layout_items = list(self.helper.layout)
|
||||
layout_items.insert(state_index + 1, CustomField('confirm_state'))
|
||||
self.helper.layout = Layout(*layout_items)
|
||||
|
||||
if not cleaned_data.get('confirm_state'):
|
||||
error_message = f"I understand that although media state is {state}, the media is also shared with users that have access to the following categories: {', '.join(rbac_categories)}"
|
||||
self.add_error('confirm_state', error_message)
|
||||
|
||||
return cleaned_data
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
data = self.cleaned_data
|
||||
@@ -76,7 +215,8 @@ class MediaForm(forms.ModelForm):
|
||||
if state != self.initial["state"]:
|
||||
self.instance.state = get_next_state(self.user, self.initial["state"], self.instance.state)
|
||||
|
||||
media = super(MediaForm, self).save(*args, **kwargs)
|
||||
media = super(MediaPublishForm, self).save(*args, **kwargs)
|
||||
|
||||
return media
|
||||
|
||||
|
||||
|
||||
104
files/frontend_translations/da.py
Normal 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",
|
||||
}
|
||||
104
files/frontend_translations/he.py
Normal 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': 'לפני שנים',
|
||||
}
|
||||
105
files/frontend_translations/it.py
Normal file
@@ -0,0 +1,105 @@
|
||||
translation_strings = {
|
||||
"ABOUT": "SU DI NOI",
|
||||
"AUTOPLAY": "RIPRODUZIONE AUTOMATICA",
|
||||
"About": "Su di noi",
|
||||
"Add a": "Aggiungi un",
|
||||
"Add a ": "Aggiungi un ",
|
||||
"COMMENT": "COMMENTA",
|
||||
"Categories": "Categorie",
|
||||
"Category": "Categoria",
|
||||
"Change Language": "Cambia lingua",
|
||||
"Change password": "Cambia password",
|
||||
"Comment": "Commento",
|
||||
"Comments": "Commenti",
|
||||
"Comments are disabled": "I commenti sono disabilitati",
|
||||
"Contact": "Contatti",
|
||||
"DELETE MEDIA": "ELIMINA MEDIA",
|
||||
"DOWNLOAD": "SCARICA",
|
||||
"EDIT MEDIA": "MODIFICA IL MEDIA",
|
||||
"EDIT PROFILE": "MODIFICA IL PROFILO",
|
||||
"EDIT SUBTITLE": "MODIFICA I SOTTOTITOLI",
|
||||
"Edit media": "Modifica il media",
|
||||
"Edit profile": "Modifica il profilo",
|
||||
"Edit subtitle": "Modifica i sottotitoli",
|
||||
"Featured": "In evidenza",
|
||||
"Go": "Vai",
|
||||
"History": "Cronologia",
|
||||
"Home": "Home",
|
||||
"Language": "Lingua",
|
||||
"Latest": "Ultimi",
|
||||
"Liked media": "Piaciuti",
|
||||
"Manage comments": "Gestisci i commenti",
|
||||
"Manage media": "Gestisci i media",
|
||||
"Manage users": "Gestisci gli utenti",
|
||||
"Media": "Media",
|
||||
"Media was edited": "Il media è stato modificato",
|
||||
"Members": "Membri",
|
||||
"My media": "I miei media",
|
||||
"My playlists": "Le mie playlist",
|
||||
"No": "No",
|
||||
"No comment yet": "Ancora nessun commento",
|
||||
"No comments yet": "Ancora nessun commento",
|
||||
"No results for": "Nessun risultato per",
|
||||
"PLAYLISTS": "PLAYLIST",
|
||||
"Playlists": "Playlist",
|
||||
"Powered by": "Powered by",
|
||||
"Published on": "Pubblicato il",
|
||||
"Recommended": "Raccomandati",
|
||||
"Register": "Registrati",
|
||||
"SAVE": "SALVA",
|
||||
"SEARCH": "CERCA",
|
||||
"SHARE": "CONDIVIDI",
|
||||
"SHOW MORE": "MOSTRA DI PIÙ",
|
||||
"SUBMIT": "INVIA",
|
||||
"Search": "Cerca",
|
||||
"Select": "Seleziona",
|
||||
"Sign in": "Login",
|
||||
"Sign out": "Logout",
|
||||
"Subtitle was added": "I sottotitoli sono stati aggiunti",
|
||||
"Tags": "Tag",
|
||||
"Terms": "Termini e condizioni",
|
||||
"UPLOAD": "CARICA",
|
||||
"Up next": "A seguire",
|
||||
"Upload": "Carica",
|
||||
"Upload media": "Carica i media",
|
||||
"Uploads": "Caricamenti",
|
||||
"VIEW ALL": "MOSTRA TUTTI",
|
||||
"View all": "Mostra tutti",
|
||||
"comment": "commento",
|
||||
"is a modern, fully featured open source video and media CMS. It is developed to meet the needs of modern web platforms for viewing and sharing media": "è un CMS per media open source moderno e completo. È stato sviluppato per rispondere per venire incontro alle esigenze delle moderne piattaforme web di visualizzazione e condivisione media",
|
||||
"media in category": "media nella categoria",
|
||||
"media in tag": "media con tag",
|
||||
"view": "visualizzazione",
|
||||
"views": "visualizzazioni",
|
||||
"yet": "ancora",
|
||||
}
|
||||
|
||||
replacement_strings = {
|
||||
"Apr": "Apr",
|
||||
"Aug": "Ago",
|
||||
"Dec": "Dic",
|
||||
"Feb": "Feb",
|
||||
"Jan": "Gen",
|
||||
"Jul": "Lug",
|
||||
"Jun": "Giu",
|
||||
"Mar": "Mar",
|
||||
"May": "Mag",
|
||||
"Nov": "Nov",
|
||||
"Oct": "Ott",
|
||||
"Sep": "Set",
|
||||
"day ago": "giorno fa",
|
||||
"days ago": "giorni fa",
|
||||
"hour ago": "ora fa",
|
||||
"hours ago": "ore fa",
|
||||
"just now": "adesso",
|
||||
"minute ago": "minuto fa",
|
||||
"minutes ago": "minuti fa",
|
||||
"month ago": "mese fa",
|
||||
"months ago": "mesi fa",
|
||||
"second ago": "secondo fa",
|
||||
"seconds ago": "secondi fa",
|
||||
"week ago": "settimana fa",
|
||||
"weeks ago": "settimane fa",
|
||||
"year ago": "anno fa",
|
||||
"years ago": "anni fa",
|
||||
}
|
||||
104
files/frontend_translations/sl.py
Normal 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",
|
||||
}
|
||||
104
files/frontend_translations/zh_hant.py
Normal 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': ' ', # no such usage in this language,
|
||||
}
|
||||
|
||||
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': '年前',
|
||||
}
|
||||
200
files/helpers.py
@@ -3,6 +3,7 @@
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import shutil
|
||||
@@ -15,6 +16,9 @@ from django.conf import settings
|
||||
|
||||
CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
CRF_ENCODING_NUM_SECONDS = 2 # 0 * 60 # videos with greater duration will get
|
||||
# CRF encoding and not two-pass
|
||||
# Encoding individual chunks may yield quality variations if you use a
|
||||
@@ -30,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
|
||||
|
||||
@@ -51,6 +49,7 @@ VIDEO_CRFS = {
|
||||
VIDEO_BITRATES = {
|
||||
"h264": {
|
||||
25: {
|
||||
144: 150,
|
||||
240: 300,
|
||||
360: 500,
|
||||
480: 1000,
|
||||
@@ -63,6 +62,7 @@ VIDEO_BITRATES = {
|
||||
},
|
||||
"h265": {
|
||||
25: {
|
||||
144: 75,
|
||||
240: 150,
|
||||
360: 275,
|
||||
480: 500,
|
||||
@@ -75,6 +75,7 @@ VIDEO_BITRATES = {
|
||||
},
|
||||
"vp9": {
|
||||
25: {
|
||||
144: 75,
|
||||
240: 150,
|
||||
360: 275,
|
||||
480: 500,
|
||||
@@ -169,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):
|
||||
@@ -484,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
|
||||
|
||||
|
||||
@@ -592,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"
|
||||
@@ -726,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":
|
||||
@@ -787,6 +784,179 @@ def clean_query(query):
|
||||
return query.lower()
|
||||
|
||||
|
||||
def timestamp_to_seconds(timestamp):
|
||||
"""Convert a timestamp in format HH:MM:SS.mmm to seconds
|
||||
|
||||
Args:
|
||||
timestamp (str): Timestamp in format HH:MM:SS.mmm
|
||||
|
||||
Returns:
|
||||
float: Timestamp in seconds
|
||||
"""
|
||||
h, m, s = timestamp.split(':')
|
||||
s, ms = s.split('.')
|
||||
return int(h) * 3600 + int(m) * 60 + int(s) + float('0.' + ms)
|
||||
|
||||
|
||||
def seconds_to_timestamp(seconds):
|
||||
"""Convert seconds to timestamp in format HH:MM:SS.mmm
|
||||
|
||||
Args:
|
||||
seconds (float): Time in seconds
|
||||
|
||||
Returns:
|
||||
str: Timestamp in format HH:MM:SS.mmm
|
||||
"""
|
||||
hours = int(seconds // 3600)
|
||||
minutes = int((seconds % 3600) // 60)
|
||||
seconds_remainder = seconds % 60
|
||||
seconds_int = int(seconds_remainder)
|
||||
milliseconds = int((seconds_remainder - seconds_int) * 1000)
|
||||
|
||||
return f"{hours:02d}:{minutes:02d}:{seconds_int:02d}.{milliseconds:03d}" # noqa
|
||||
|
||||
|
||||
def get_trim_timestamps(media_file_path, timestamps_list, run_ffprobe=False):
|
||||
"""Process a list of timestamps to align start times with I-frames for better video trimming
|
||||
|
||||
Args:
|
||||
media_file_path (str): Path to the media file
|
||||
timestamps_list (list): List of dictionaries with startTime and endTime
|
||||
|
||||
Returns:
|
||||
list: Processed timestamps with adjusted startTime values
|
||||
"""
|
||||
if not isinstance(timestamps_list, list):
|
||||
return []
|
||||
|
||||
timestamps_results = []
|
||||
timestamps_to_process = []
|
||||
|
||||
for item in timestamps_list:
|
||||
if isinstance(item, dict) and 'startTime' in item and 'endTime' in item:
|
||||
timestamps_to_process.append(item)
|
||||
|
||||
if not timestamps_to_process:
|
||||
return []
|
||||
|
||||
# just a single timestamp with no startTime, no need to process
|
||||
if len(timestamps_to_process) == 1 and timestamps_to_process[0]['startTime'] == "00:00:00.000":
|
||||
return timestamps_list
|
||||
|
||||
# Process each timestamp
|
||||
for item in timestamps_to_process:
|
||||
startTime = item['startTime']
|
||||
endTime = item['endTime']
|
||||
|
||||
# with ffmpeg -ss -i that is getting run, there is no need to call ffprobe to find the I-frame,
|
||||
# as ffmpeg will do that. Keeping this for now in case it is needed
|
||||
|
||||
i_frames = []
|
||||
if run_ffprobe:
|
||||
SEC_TO_SUBTRACT = 10
|
||||
start_seconds = timestamp_to_seconds(startTime)
|
||||
search_start = max(0, start_seconds - SEC_TO_SUBTRACT)
|
||||
|
||||
# Create ffprobe command to find nearest I-frame
|
||||
cmd = [
|
||||
settings.FFPROBE_COMMAND,
|
||||
"-v",
|
||||
"error",
|
||||
"-select_streams",
|
||||
"v:0",
|
||||
"-show_entries",
|
||||
"frame=pts_time,pict_type",
|
||||
"-of",
|
||||
"csv=p=0",
|
||||
"-read_intervals",
|
||||
f"{search_start}%{startTime}",
|
||||
media_file_path,
|
||||
]
|
||||
cmd = [str(s) for s in cmd]
|
||||
logger.info(f"trim cmd: {cmd}")
|
||||
|
||||
stdout = run_command(cmd).get("out")
|
||||
|
||||
if stdout:
|
||||
for line in stdout.strip().split('\n'):
|
||||
if line and line.endswith(',I'):
|
||||
i_frames.append(line.replace(',I', ''))
|
||||
|
||||
if i_frames:
|
||||
adjusted_startTime = seconds_to_timestamp(float(i_frames[-1]))
|
||||
|
||||
if not i_frames:
|
||||
adjusted_startTime = startTime
|
||||
|
||||
timestamps_results.append({'startTime': adjusted_startTime, 'endTime': endTime})
|
||||
|
||||
return timestamps_results
|
||||
|
||||
|
||||
def trim_video_method(media_file_path, timestamps_list):
|
||||
"""Trim a video file based on a list of timestamps
|
||||
|
||||
Args:
|
||||
media_file_path (str): Path to the media file
|
||||
timestamps_list (list): List of dictionaries with startTime and endTime
|
||||
|
||||
Returns:
|
||||
bool: True if successful, False otherwise
|
||||
"""
|
||||
if not isinstance(timestamps_list, list) or not timestamps_list:
|
||||
return False
|
||||
|
||||
if not os.path.exists(media_file_path):
|
||||
return False
|
||||
|
||||
with tempfile.TemporaryDirectory(dir=settings.TEMP_DIRECTORY) as temp_dir:
|
||||
output_file = os.path.join(temp_dir, "output.mp4")
|
||||
|
||||
segment_files = []
|
||||
for i, item in enumerate(timestamps_list):
|
||||
start_time = timestamp_to_seconds(item['startTime'])
|
||||
end_time = timestamp_to_seconds(item['endTime'])
|
||||
duration = end_time - start_time
|
||||
|
||||
# For single timestamp, we can use the output file directly
|
||||
# For multiple timestamps, we need to create segment files
|
||||
segment_file = output_file if len(timestamps_list) == 1 else os.path.join(temp_dir, f"segment_{i}.mp4")
|
||||
|
||||
cmd = [settings.FFMPEG_COMMAND, "-y", "-ss", str(item['startTime']), "-i", media_file_path, "-t", str(duration), "-c", "copy", "-avoid_negative_ts", "1", segment_file]
|
||||
|
||||
result = run_command(cmd) # noqa
|
||||
|
||||
if os.path.exists(segment_file) and os.path.getsize(segment_file) > 0:
|
||||
if len(timestamps_list) > 1:
|
||||
segment_files.append(segment_file)
|
||||
else:
|
||||
return False
|
||||
|
||||
if len(timestamps_list) > 1:
|
||||
if not segment_files:
|
||||
return False
|
||||
|
||||
concat_list_path = os.path.join(temp_dir, "concat_list.txt")
|
||||
with open(concat_list_path, "w") as f:
|
||||
for segment in segment_files:
|
||||
f.write(f"file '{segment}'\n")
|
||||
concat_cmd = [settings.FFMPEG_COMMAND, "-y", "-f", "concat", "-safe", "0", "-i", concat_list_path, "-c", "copy", output_file]
|
||||
|
||||
concat_result = run_command(concat_cmd) # noqa
|
||||
|
||||
if not os.path.exists(output_file) or os.path.getsize(output_file) == 0:
|
||||
return False
|
||||
|
||||
# Replace the original file with the trimmed version
|
||||
try:
|
||||
rm_file(media_file_path)
|
||||
shutil.copy2(output_file, media_file_path)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.info(f"Failed to replace original file: {str(e)}")
|
||||
return False
|
||||
|
||||
|
||||
def get_alphanumeric_only(string):
|
||||
"""Returns a query that contains only alphanumeric characters
|
||||
This include characters other than the English alphabet too
|
||||
|
||||
@@ -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()
|
||||
|
||||
174
files/methods.py
@@ -5,16 +5,19 @@ import itertools
|
||||
import logging
|
||||
import random
|
||||
import re
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.core.files import File
|
||||
from django.core.mail import EmailMessage
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
|
||||
from cms import celery_app
|
||||
|
||||
from . import models
|
||||
from . import helpers, models
|
||||
from .helpers import mask_ip
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -163,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
|
||||
@@ -179,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.
|
||||
""" % (
|
||||
@@ -192,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
|
||||
@@ -262,7 +265,7 @@ def show_related_media_content(media, request, limit):
|
||||
"user_featured",
|
||||
"-user_featured",
|
||||
]
|
||||
# TODO: MAke this mess more readable, and add TAGS support - aka related
|
||||
# TODO: Make this mess more readable, and add TAGS support - aka related
|
||||
# tags rather than random media
|
||||
if len(m) < limit:
|
||||
category = media.category.first()
|
||||
@@ -336,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
|
||||
@@ -360,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
|
||||
@@ -398,6 +401,111 @@ def clean_comment(raw_comment):
|
||||
return cleaned_comment
|
||||
|
||||
|
||||
def kill_ffmpeg_process(filepath):
|
||||
"""Kill ffmpeg process that is processing a specific file
|
||||
|
||||
Args:
|
||||
filepath: Path to the file being processed by ffmpeg
|
||||
|
||||
Returns:
|
||||
subprocess.CompletedProcess: Result of the kill command
|
||||
"""
|
||||
if not filepath:
|
||||
return False
|
||||
cmd = "ps aux|grep 'ffmpeg'|grep %s|grep -v grep |awk '{print $2}'" % filepath
|
||||
result = subprocess.run(cmd, stdout=subprocess.PIPE, shell=True)
|
||||
pid = result.stdout.decode("utf-8").strip()
|
||||
if pid:
|
||||
cmd = "kill -9 %s" % pid
|
||||
result = subprocess.run(cmd, stdout=subprocess.PIPE, shell=True)
|
||||
return result
|
||||
|
||||
|
||||
def copy_video(original_media, copy_encodings=True, title_suffix="(Trimmed)"):
|
||||
"""Create a copy of a media object
|
||||
|
||||
Args:
|
||||
original_media: Original Media object to copy
|
||||
copy_encodings: Whether to copy the encodings too
|
||||
|
||||
Returns:
|
||||
New Media object
|
||||
"""
|
||||
|
||||
with open(original_media.media_file.path, "rb") as f:
|
||||
myfile = File(f)
|
||||
new_media = models.Media(
|
||||
media_file=myfile,
|
||||
title=f"{original_media.title} {title_suffix}",
|
||||
description=original_media.description,
|
||||
user=original_media.user,
|
||||
media_type="video",
|
||||
enable_comments=original_media.enable_comments,
|
||||
allow_download=original_media.allow_download,
|
||||
state=original_media.state,
|
||||
is_reviewed=original_media.is_reviewed,
|
||||
encoding_status=original_media.encoding_status,
|
||||
listable=original_media.listable,
|
||||
add_date=timezone.now(),
|
||||
video_height=original_media.video_height,
|
||||
media_info=original_media.media_info,
|
||||
)
|
||||
models.Media.objects.bulk_create([new_media])
|
||||
# avoids calling signals since signals will call media_init and we don't want that
|
||||
|
||||
if copy_encodings:
|
||||
for encoding in original_media.encodings.filter(chunk=False, status="success"):
|
||||
if encoding.media_file:
|
||||
with open(encoding.media_file.path, "rb") as f:
|
||||
myfile = File(f)
|
||||
new_encoding = models.Encoding(
|
||||
media_file=myfile, media=new_media, profile=encoding.profile, status="success", progress=100, chunk=False, logs=f"Copied from encoding {encoding.id}"
|
||||
)
|
||||
models.Encoding.objects.bulk_create([new_encoding])
|
||||
# avoids calling signals as this is still not ready
|
||||
|
||||
# Copy categories and tags
|
||||
for category in original_media.category.all():
|
||||
new_media.category.add(category)
|
||||
|
||||
for tag in original_media.tags.all():
|
||||
new_media.tags.add(tag)
|
||||
|
||||
if original_media.thumbnail:
|
||||
with open(original_media.thumbnail.path, 'rb') as f:
|
||||
thumbnail_name = helpers.get_file_name(original_media.thumbnail.path)
|
||||
new_media.thumbnail.save(thumbnail_name, File(f))
|
||||
|
||||
if original_media.poster:
|
||||
with open(original_media.poster.path, 'rb') as f:
|
||||
poster_name = helpers.get_file_name(original_media.poster.path)
|
||||
new_media.poster.save(poster_name, File(f))
|
||||
|
||||
return new_media
|
||||
|
||||
|
||||
def create_video_trim_request(media, data):
|
||||
"""Create a video trim request for a media
|
||||
|
||||
Args:
|
||||
media: Media object
|
||||
data: Dictionary with trim request data
|
||||
|
||||
Returns:
|
||||
VideoTrimRequest object
|
||||
"""
|
||||
|
||||
video_action = "replace"
|
||||
if data.get('saveIndividualSegments'):
|
||||
video_action = "create_segments"
|
||||
elif data.get('saveAsCopy'):
|
||||
video_action = "save_new"
|
||||
|
||||
video_trim_request = models.VideoTrimRequest.objects.create(media=media, status="initial", video_action=video_action, media_trim_style='no_encoding', timestamps=data.get('segments', {}))
|
||||
|
||||
return video_trim_request
|
||||
|
||||
|
||||
def list_tasks():
|
||||
"""Lists celery tasks
|
||||
To be used in an admin dashboard
|
||||
@@ -448,3 +556,53 @@ def list_tasks():
|
||||
ret["task_ids"] = task_ids
|
||||
ret["media_profile_pairs"] = media_profile_pairs
|
||||
return ret
|
||||
|
||||
|
||||
def handle_video_chapters(media, chapters):
|
||||
video_chapter = models.VideoChapterData.objects.filter(media=media).first()
|
||||
if video_chapter:
|
||||
video_chapter.data = chapters
|
||||
video_chapter.save()
|
||||
else:
|
||||
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
|
||||
|
||||
24
files/migrations/0007_alter_media_state_videochapterdata.py
Normal file
@@ -0,0 +1,24 @@
|
||||
# Generated by Django 5.1.6 on 2025-04-15 07:26
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('files', '0006_alter_category_title'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='VideoChapterData',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('data', models.JSONField(help_text='Chapter data')),
|
||||
('media', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='chapters', to='files.media')),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('media',)},
|
||||
},
|
||||
),
|
||||
]
|
||||
30
files/migrations/0008_alter_media_state_videotrimrequest.py
Normal file
@@ -0,0 +1,30 @@
|
||||
# Generated by Django 5.1.6 on 2025-05-02 14:23
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('files', '0007_alter_media_state_videochapterdata'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='media',
|
||||
name='state',
|
||||
field=models.CharField(choices=[('private', 'Private'), ('public', 'Public'), ('unlisted', 'Unlisted')], db_index=True, default='public', help_text='state of Media', max_length=20),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='VideoTrimRequest',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('status', models.CharField(choices=[('initial', 'Initial'), ('running', 'Running'), ('success', 'Success'), ('fail', 'Fail')], default='initial', max_length=20)),
|
||||
('add_date', models.DateTimeField(auto_now_add=True)),
|
||||
('video_action', models.CharField(choices=[('replace', 'Replace Original'), ('save_new', 'Save as New'), ('create_segments', 'Create Segments')], max_length=20)),
|
||||
('media_trim_style', models.CharField(choices=[('no_encoding', 'No Encoding'), ('precise', 'Precise')], default='no_encoding', max_length=20)),
|
||||
('timestamps', models.JSONField(help_text='Timestamps for trimming')),
|
||||
('media', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='trim_requests', to='files.media')),
|
||||
],
|
||||
),
|
||||
]
|
||||
17
files/migrations/0009_alter_media_friendly_token.py
Normal 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),
|
||||
),
|
||||
]
|
||||
17
files/migrations/0010_alter_encodeprofile_resolution.py
Normal 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),
|
||||
),
|
||||
]
|
||||
29
files/migrations/0011_mediapermission.py
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
97
files/models/playlist.py
Normal 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
@@ -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
@@ -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
@@ -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")
|
||||
86
files/models/video_data.py
Normal 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)
|
||||
@@ -161,6 +161,7 @@ class SingleMediaSerializer(serializers.ModelSerializer):
|
||||
"hls_info",
|
||||
"license",
|
||||
"subtitles_info",
|
||||
"chapter_data",
|
||||
"ratings_info",
|
||||
"add_subtitle_url",
|
||||
"allow_download",
|
||||
|
||||
358
files/tasks.py
@@ -2,13 +2,11 @@ import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from celery import Task
|
||||
from celery import shared_task as task
|
||||
from celery.exceptions import SoftTimeLimitExceeded
|
||||
from celery.signals import task_revoked
|
||||
|
||||
# from celery.task.control import revoke
|
||||
@@ -16,6 +14,7 @@ from celery.utils.log import get_task_logger
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.core.files import File
|
||||
from django.db import DatabaseError
|
||||
from django.db.models import Q
|
||||
|
||||
from actions.models import USER_MEDIA_ACTIONS, MediaAction
|
||||
@@ -28,14 +27,31 @@ from .helpers import (
|
||||
create_temp_file,
|
||||
get_file_name,
|
||||
get_file_type,
|
||||
get_trim_timestamps,
|
||||
media_file_info,
|
||||
produce_ffmpeg_commands,
|
||||
produce_friendly_token,
|
||||
rm_file,
|
||||
run_command,
|
||||
trim_video_method,
|
||||
)
|
||||
from .methods import (
|
||||
copy_video,
|
||||
kill_ffmpeg_process,
|
||||
list_tasks,
|
||||
notify_users,
|
||||
pre_save_action,
|
||||
)
|
||||
from .models import (
|
||||
Category,
|
||||
EncodeProfile,
|
||||
Encoding,
|
||||
Media,
|
||||
Rating,
|
||||
Tag,
|
||||
VideoChapterData,
|
||||
VideoTrimRequest,
|
||||
)
|
||||
from .methods import list_tasks, notify_users, pre_save_action
|
||||
from .models import Category, EncodeProfile, Encoding, Media, Rating, Tag
|
||||
|
||||
logger = get_task_logger(__name__)
|
||||
|
||||
@@ -48,6 +64,69 @@ ERRORS_LIST = [
|
||||
]
|
||||
|
||||
|
||||
def handle_pending_running_encodings(media):
|
||||
"""Handle pending and running encodings for a media object.
|
||||
|
||||
we are trimming the original file. If there are encodings in success state, this means that the encoding has run
|
||||
and has succeeded, so we can keep them (they will be trimmed) or if we dont keep them we dont have to delete them
|
||||
here
|
||||
|
||||
However for encodings that are in pending or running phase, just delete them
|
||||
|
||||
Args:
|
||||
media: The media object to handle encodings for
|
||||
|
||||
Returns:
|
||||
bool: True if any encodings were deleted, False otherwise
|
||||
"""
|
||||
encodings = media.encodings.exclude(status="success")
|
||||
deleted = False
|
||||
for encoding in encodings:
|
||||
if encoding.temp_file:
|
||||
kill_ffmpeg_process(encoding.temp_file)
|
||||
if encoding.chunk_file_path:
|
||||
kill_ffmpeg_process(encoding.chunk_file_path)
|
||||
deleted = True
|
||||
encoding.delete()
|
||||
|
||||
return deleted
|
||||
|
||||
|
||||
def pre_trim_video_actions(media):
|
||||
# the reason for this function is to perform tasks before trimming a video
|
||||
|
||||
# avoid re-running unnecessary encodings (or chunkize_media, which is the first step for them)
|
||||
# if the video is already completed
|
||||
# however if it is a new video (user uploded the video and starts trimming
|
||||
# before the video is processed), this is necessary, so encode has to be called to give it a chance to encode
|
||||
|
||||
# if a video is fully processed (all encodings are success), or if a video is new, then things are clear
|
||||
|
||||
# HOWEVER there is a race condition and this is that some encodings are success and some are pending/running
|
||||
# Since we are making speed cutting, we will perform an ffmpeg -c copy on all of them and the result will be
|
||||
# that they will end up differently cut, because ffmpeg checks for I-frames
|
||||
# The result is fine if playing the video but is bad in case of HLS
|
||||
# So we need to delete all encodings inevitably to produce same results, if there are some that are success and some that
|
||||
# are still not finished.
|
||||
|
||||
profiles = EncodeProfile.objects.filter(active=True, extension='mp4', resolution__lte=media.video_height)
|
||||
media_encodings = EncodeProfile.objects.filter(encoding__in=media.encodings.filter(status="success", chunk=False), extension='mp4').distinct()
|
||||
|
||||
picked = []
|
||||
for profile in profiles:
|
||||
if profile in media_encodings:
|
||||
continue
|
||||
else:
|
||||
picked.append(profile)
|
||||
|
||||
if picked:
|
||||
# by calling encode will re-encode all. The logic is explained above...
|
||||
logger.info(f"Encoding media {media.friendly_token} will have to be performed for all profiles")
|
||||
media.encode()
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@task(name="chunkize_media", bind=True, queue="short_tasks", soft_time_limit=60 * 30 * 4)
|
||||
def chunkize_media(self, friendly_token, profiles, force=True):
|
||||
"""Break media in chunks and start encoding tasks"""
|
||||
@@ -57,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,
|
||||
@@ -83,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:
|
||||
@@ -132,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
|
||||
|
||||
|
||||
@@ -145,6 +224,7 @@ class EncodingTask(Task):
|
||||
self.encoding.status = "fail"
|
||||
self.encoding.save(update_fields=["status"])
|
||||
kill_ffmpeg_process(self.encoding.temp_file)
|
||||
kill_ffmpeg_process(self.encoding.chunk_file_path)
|
||||
if hasattr(self.encoding, "media"):
|
||||
self.encoding.media.post_encode_actions()
|
||||
except BaseException:
|
||||
@@ -171,7 +251,13 @@ def encode_media(
|
||||
):
|
||||
"""Encode a media to given profile, using ffmpeg, storing progress"""
|
||||
|
||||
logger.info("Encode Media started, friendly token {0}, profile id {1}, force {2}".format(friendly_token, profile_id, force))
|
||||
logger.info(f"encode_media for {friendly_token}/{profile_id}/{encoding_id}/{force}/{chunk}")
|
||||
# TODO: this is new behavior, check whether it performs well. Before that check it would end up saving the Encoding
|
||||
# at some point below. Now it exits the task. Could it be that before it would give it a chance to re-run? Or it was
|
||||
# not being used at all?
|
||||
if not Encoding.objects.filter(id=encoding_id).exists():
|
||||
logger.info(f"Exiting for {friendly_token}/{profile_id}/{encoding_id}/{force} since encoding id not found")
|
||||
return False
|
||||
|
||||
if self.request.id:
|
||||
task_id = self.request.id
|
||||
@@ -269,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,
|
||||
@@ -311,28 +397,37 @@ def encode_media(
|
||||
percent = duration * 100 / media.duration
|
||||
if n_times % 60 == 0:
|
||||
encoding.progress = percent
|
||||
try:
|
||||
encoding.save(update_fields=["progress", "update_date"])
|
||||
logger.info("Saved {0}".format(round(percent, 2)))
|
||||
except BaseException:
|
||||
pass
|
||||
encoding.save(update_fields=["progress", "update_date"])
|
||||
logger.info(f"Saved {round(percent, 2)}")
|
||||
n_times += 1
|
||||
except DatabaseError:
|
||||
# primary reason for this is that the encoding has been deleted, because
|
||||
# the media file was deleted, or also that there was a trim video request
|
||||
# so it would be redundant to let it complete the encoding
|
||||
kill_ffmpeg_process(encoding.temp_file)
|
||||
kill_ffmpeg_process(encoding.chunk_file_path)
|
||||
return False
|
||||
|
||||
except StopIteration:
|
||||
break
|
||||
except VideoEncodingError:
|
||||
# ffmpeg error, or ffmpeg was killed
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
try:
|
||||
# output is empty, fail message is on the exception
|
||||
output = e.message
|
||||
except AttributeError:
|
||||
output = ""
|
||||
if isinstance(e, SoftTimeLimitExceeded):
|
||||
kill_ffmpeg_process(encoding.temp_file)
|
||||
kill_ffmpeg_process(encoding.temp_file)
|
||||
kill_ffmpeg_process(encoding.chunk_file_path)
|
||||
encoding.logs = output
|
||||
encoding.status = "fail"
|
||||
encoding.save(update_fields=["status", "logs"])
|
||||
try:
|
||||
encoding.save(update_fields=["status", "logs"])
|
||||
except DatabaseError:
|
||||
return False
|
||||
raise_exception = True
|
||||
# if this is an ffmpeg's valid error
|
||||
# no need for the task to be re-run
|
||||
@@ -356,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
|
||||
|
||||
@@ -377,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:
|
||||
@@ -397,10 +492,10 @@ def produce_sprite_from_video(friendly_token):
|
||||
if os.path.exists(output_name) and get_file_type(output_name) == "image":
|
||||
with open(output_name, "rb") as f:
|
||||
myfile = File(f)
|
||||
media.sprites.save(
|
||||
content=myfile,
|
||||
name=get_file_name(media.media_file.path) + "sprites.jpg",
|
||||
)
|
||||
# SOS: avoid race condition, since this runs for a long time and will replace any other media changes on the meanwhile!!!
|
||||
media.sprites.save(content=myfile, name=get_file_name(media.media_file.path) + "sprites.jpg", save=False)
|
||||
media.save(update_fields=["sprites"])
|
||||
|
||||
except Exception as e:
|
||||
print(e)
|
||||
return True
|
||||
@@ -421,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
|
||||
@@ -452,8 +547,7 @@ def create_hls(friendly_token):
|
||||
pp = os.path.join(output_dir, "master.m3u8")
|
||||
if os.path.exists(pp):
|
||||
if media.hls_file != pp:
|
||||
media.hls_file = pp
|
||||
media.save(update_fields=["hls_file"])
|
||||
Media.objects.filter(pk=media.pk).update(hls_file=pp)
|
||||
return True
|
||||
|
||||
|
||||
@@ -464,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)
|
||||
@@ -481,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
|
||||
|
||||
|
||||
@@ -491,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:
|
||||
@@ -499,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
|
||||
|
||||
|
||||
@@ -534,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
|
||||
|
||||
|
||||
@@ -558,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
|
||||
|
||||
|
||||
@@ -726,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:
|
||||
@@ -734,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:
|
||||
@@ -747,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
|
||||
|
||||
@@ -776,23 +870,189 @@ def task_sent_handler(sender=None, headers=None, body=None, **kwargs):
|
||||
return True
|
||||
|
||||
|
||||
def kill_ffmpeg_process(filepath):
|
||||
# this is not ideal, ffmpeg pid could be linked to the Encoding object
|
||||
cmd = "ps aux|grep 'ffmpeg'|grep %s|grep -v grep |awk '{print $2}'" % filepath
|
||||
result = subprocess.run(cmd, stdout=subprocess.PIPE, shell=True)
|
||||
pid = result.stdout.decode("utf-8").strip()
|
||||
if pid:
|
||||
cmd = "kill -9 %s" % pid
|
||||
result = subprocess.run(cmd, stdout=subprocess.PIPE, shell=True)
|
||||
return result
|
||||
|
||||
|
||||
@task(name="remove_media_file", base=Task, queue="long_tasks")
|
||||
def remove_media_file(media_file=None):
|
||||
rm_file(media_file)
|
||||
return True
|
||||
|
||||
|
||||
@task(name="update_encoding_size", queue="short_tasks")
|
||||
def update_encoding_size(encoding_id):
|
||||
"""Update the size of an encoding without saving to avoid calling signals"""
|
||||
encoding = Encoding.objects.filter(id=encoding_id).first()
|
||||
if encoding:
|
||||
encoding.update_size_without_save()
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@task(name="produce_video_chapters", queue="short_tasks")
|
||||
def produce_video_chapters(chapter_id):
|
||||
# this is not used
|
||||
return False
|
||||
chapter_object = VideoChapterData.objects.filter(id=chapter_id).first()
|
||||
if not chapter_object:
|
||||
return False
|
||||
|
||||
media = chapter_object.media
|
||||
video_path = media.media_file.path
|
||||
output_folder = media.video_chapters_folder
|
||||
|
||||
chapters = chapter_object.data
|
||||
|
||||
width = 336
|
||||
height = 188
|
||||
|
||||
if not os.path.exists(output_folder):
|
||||
os.makedirs(output_folder)
|
||||
|
||||
results = []
|
||||
|
||||
for i, chapter in enumerate(chapters):
|
||||
timestamp = chapter["start"]
|
||||
title = chapter["title"]
|
||||
|
||||
output_filename = f"thumbnail_{i:02d}.jpg" # noqa
|
||||
output_path = os.path.join(output_folder, output_filename)
|
||||
|
||||
command = [settings.FFMPEG_COMMAND, "-y", "-ss", str(timestamp), "-i", video_path, "-vframes", "1", "-q:v", "2", "-s", f"{width}x{height}", output_path]
|
||||
ret = run_command(command) # noqa
|
||||
if os.path.exists(output_path) and get_file_type(output_path) == "image":
|
||||
results.append({"start": timestamp, "title": title, "thumbnail": output_path})
|
||||
|
||||
chapter_object.data = results
|
||||
chapter_object.save(update_fields=["data"])
|
||||
return True
|
||||
|
||||
|
||||
@task(name="post_trim_action", queue="short_tasks", soft_time_limit=600)
|
||||
def post_trim_action(friendly_token):
|
||||
"""Perform post-processing actions after video trimming
|
||||
|
||||
Args:
|
||||
friendly_token: The friendly token of the media
|
||||
|
||||
Returns:
|
||||
bool: True if successful, False otherwise
|
||||
"""
|
||||
logger.info(f"Post trim action for {friendly_token}")
|
||||
try:
|
||||
media = Media.objects.get(friendly_token=friendly_token)
|
||||
except Media.DoesNotExist:
|
||||
logger.info(f"Media with friendly token {friendly_token} not found")
|
||||
return False
|
||||
|
||||
media.set_media_type()
|
||||
encodings = media.encodings.filter(status="success", profile__extension='mp4', chunk=False)
|
||||
# if they are still not encoded, when the first one will be encoded, it will have the chance to
|
||||
# call post_trim_action again
|
||||
if encodings:
|
||||
for encoding in encodings:
|
||||
# update encoding size, in case they don't have one, due to the
|
||||
# way the copy_video took place
|
||||
update_encoding_size(encoding.id)
|
||||
|
||||
media.produce_thumbnails_from_video()
|
||||
produce_sprite_from_video.delay(friendly_token)
|
||||
create_hls.delay(friendly_token)
|
||||
|
||||
vt_request = VideoTrimRequest.objects.filter(media=media, status="running").first()
|
||||
if vt_request:
|
||||
vt_request.status = "success"
|
||||
vt_request.save(update_fields=["status"])
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@task(name="video_trim_task", bind=True, queue="short_tasks", soft_time_limit=600)
|
||||
def video_trim_task(self, trim_request_id):
|
||||
# SOS: if at some point we move from ffmpeg copy, then this need be changed
|
||||
# to long_tasks
|
||||
try:
|
||||
trim_request = VideoTrimRequest.objects.get(id=trim_request_id)
|
||||
except VideoTrimRequest.DoesNotExist:
|
||||
logger.info(f"VideoTrimRequest with ID {trim_request_id} not found")
|
||||
return False
|
||||
|
||||
trim_request.status = "running"
|
||||
trim_request.save(update_fields=["status"])
|
||||
|
||||
timestamps_encodings = get_trim_timestamps(trim_request.media.trim_video_path, trim_request.timestamps)
|
||||
timestamps_original = get_trim_timestamps(trim_request.media.media_file.path, trim_request.timestamps)
|
||||
|
||||
if not timestamps_encodings:
|
||||
trim_request.status = "fail"
|
||||
trim_request.save(update_fields=["status"])
|
||||
return False
|
||||
|
||||
target_media = trim_request.media
|
||||
original_media = trim_request.media
|
||||
|
||||
# splitting the logic for single file and multiple files
|
||||
if trim_request.video_action in ["save_new", "replace"]:
|
||||
proceed_with_single_file = True
|
||||
if trim_request.video_action == "create_segments":
|
||||
if len(timestamps_encodings) == 1:
|
||||
proceed_with_single_file = True
|
||||
else:
|
||||
proceed_with_single_file = False
|
||||
|
||||
if proceed_with_single_file:
|
||||
if trim_request.video_action == "save_new" or trim_request.video_action == "create_segments" and len(timestamps_encodings) == 1:
|
||||
new_media = copy_video(original_media, copy_encodings=True)
|
||||
|
||||
target_media = new_media
|
||||
trim_request.media = new_media
|
||||
trim_request.save(update_fields=["media"])
|
||||
|
||||
# processing timestamps differently on encodings and original file, in case we do accuracy trimming (currently not)
|
||||
# these have different I-frames and the cut is made based on the I-frames
|
||||
|
||||
original_trim_result = trim_video_method(target_media.media_file.path, timestamps_original)
|
||||
if not original_trim_result:
|
||||
logger.info(f"Failed to trim original file for media {target_media.friendly_token}")
|
||||
|
||||
deleted_encodings = handle_pending_running_encodings(target_media)
|
||||
# the following could be un-necessary, read commend in pre_trim_video_actions to see why
|
||||
encodings = target_media.encodings.filter(status="success", profile__extension='mp4', chunk=False)
|
||||
for encoding in encodings:
|
||||
trim_result = trim_video_method(encoding.media_file.path, timestamps_encodings)
|
||||
if not trim_result:
|
||||
logger.info(f"Failed to trim encoding {encoding.id} for media {target_media.friendly_token}")
|
||||
encoding.delete()
|
||||
|
||||
pre_trim_video_actions(target_media)
|
||||
post_trim_action.delay(target_media.friendly_token)
|
||||
|
||||
else:
|
||||
for i, timestamp in enumerate(timestamps_encodings, start=1):
|
||||
# copy the original file for each of the segments. This could be optimized to avoid the overhead but
|
||||
# for now is necessary because the ffmpeg trim command will be run towards the original
|
||||
# file on different times.
|
||||
target_media = copy_video(original_media, title_suffix=f"(Trimmed) {i}", copy_encodings=True)
|
||||
|
||||
video_trim_request = VideoTrimRequest.objects.create(media=target_media, status="running", video_action="create_segments", media_trim_style='no_encoding', timestamps=[timestamp]) # noqa
|
||||
|
||||
original_trim_result = trim_video_method(target_media.media_file.path, [timestamp])
|
||||
deleted_encodings = handle_pending_running_encodings(target_media) # noqa
|
||||
# the following could be un-necessary, read commend in pre_trim_video_actions to see why
|
||||
encodings = target_media.encodings.filter(status="success", profile__extension='mp4', chunk=False)
|
||||
for encoding in encodings:
|
||||
trim_result = trim_video_method(encoding.media_file.path, [timestamp])
|
||||
if not trim_result:
|
||||
logger.info(f"Failed to trim encoding {encoding.id} for media {target_media.friendly_token}")
|
||||
encoding.delete()
|
||||
|
||||
pre_trim_video_actions(target_media)
|
||||
post_trim_action.delay(target_media.friendly_token)
|
||||
|
||||
# set as completed the initial trim_request
|
||||
trim_request.status = "success"
|
||||
trim_request.save(update_fields=["status"])
|
||||
|
||||
return True
|
||||
|
||||
|
||||
# TODO LIST
|
||||
# 1 chunks are deleted from original server when file is fully encoded.
|
||||
# however need to enter this logic in cases of fail as well
|
||||
|
||||
@@ -16,6 +16,9 @@ urlpatterns = [
|
||||
re_path(r"^edit_subtitle", views.edit_subtitle, name="edit_subtitle"),
|
||||
re_path(r"^categories$", views.categories, name="categories"),
|
||||
re_path(r"^contact$", views.contact, name="contact"),
|
||||
re_path(r"^publish", views.publish_media, name="publish_media"),
|
||||
re_path(r"^edit_chapters", views.edit_chapters, name="edit_chapters"),
|
||||
re_path(r"^edit_video", views.edit_video, name="edit_video"),
|
||||
re_path(r"^edit", views.edit_media, name="edit_media"),
|
||||
re_path(r"^embed", views.embed_media, name="get_embed"),
|
||||
re_path(r"^featured$", views.featured_media),
|
||||
@@ -45,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",
|
||||
),
|
||||
@@ -62,6 +67,14 @@ urlpatterns = [
|
||||
r"^api/v1/media/(?P<friendly_token>[\w]*)/actions$",
|
||||
views.MediaActions.as_view(),
|
||||
),
|
||||
re_path(
|
||||
r"^api/v1/media/(?P<friendly_token>[\w]*)/chapters$",
|
||||
views.video_chapters,
|
||||
),
|
||||
re_path(
|
||||
r"^api/v1/media/(?P<friendly_token>[\w]*)/trim_video$",
|
||||
views.trim_video,
|
||||
),
|
||||
re_path(r"^api/v1/categories$", views.CategoryList.as_view()),
|
||||
re_path(r"^api/v1/tags$", views.TagList.as_view()),
|
||||
re_path(r"^api/v1/comments$", views.CommentList.as_view()),
|
||||
|
||||
1542
files/views.py
43
files/views/__init__.py
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
@@ -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
|
After Width: | Height: | Size: 44 KiB |
1
frontend-tools/static/video_editor/video-editor.css
Normal file
203
frontend-tools/static/video_editor/video-editor.js
Normal file
1
frontend-tools/static/video_editor/video-editor.js.map
Normal file
15
frontend-tools/video-editor/.gitignore
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
node_modules
|
||||
dist
|
||||
.DS_Store
|
||||
server/public
|
||||
vite.config.ts.*
|
||||
*.tar.gz
|
||||
yt.readme.md
|
||||
client/public/videos/sample-video.mp4
|
||||
client/public/videos/sample-video-30s.mp4
|
||||
client/public/videos/sample-video-37s.mp4
|
||||
videos/sample-video-37s.mp4
|
||||
client/public/videos/sample-video-30s.mp4
|
||||
client/public/videos/sample-video-1.mp4
|
||||
client/public/videos/sample-video-10m.mp4
|
||||
client/public/videos/sample-video-10s.mp4
|
||||
0
frontend-tools/video-editor/.prettierignore
Normal file
22
frontend-tools/video-editor/.prettierrc
Normal 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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
5
frontend-tools/video-editor/.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true,
|
||||
"prettier.configPath": ".prettierrc"
|
||||
}
|
||||
171
frontend-tools/video-editor/README.md
Normal file
@@ -0,0 +1,171 @@
|
||||
# MediaCMS Video Editor
|
||||
|
||||
A modern browser-based video editing tool built with React and TypeScript that integrates with MediaCMS. The editor allows users to trim, split, and manage video segments with a timeline interface.
|
||||
|
||||
## Features
|
||||
|
||||
- ⏱️ Trim video start and end points
|
||||
- ✂️ Split videos into multiple segments
|
||||
- 👁️ Preview individual segments or the full edited video
|
||||
- 🔄 Undo/redo support for all editing operations
|
||||
- 🔊 Audio mute controls
|
||||
- 💾 Save edits directly to MediaCMS
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- React 18
|
||||
- TypeScript
|
||||
- Vite
|
||||
- Tailwind CSS
|
||||
- Express (for development server)
|
||||
- Drizzle ORM
|
||||
|
||||
## Installation
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js (v20+) - Use `nvm use 20` if you have nvm installed
|
||||
- Yarn or npm package manager
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
# Navigate to the video editor directory
|
||||
cd frontend-tools/video-editor
|
||||
|
||||
# Install dependencies with Yarn
|
||||
yarn install
|
||||
|
||||
# Or with npm
|
||||
npm install
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
The video editor can be run in two modes:
|
||||
|
||||
### Standalone Development Mode
|
||||
|
||||
This starts a local development server with hot reloading:
|
||||
|
||||
```bash
|
||||
# Start the development server with Yarn
|
||||
yarn dev
|
||||
|
||||
# Or with npm
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Frontend-only Development Mode
|
||||
|
||||
If you want to work only on the frontend with MediaCMS backend:
|
||||
|
||||
```bash
|
||||
# Start frontend-only development with Yarn
|
||||
yarn dev:frontend
|
||||
|
||||
# Or with npm
|
||||
npm run dev:frontend
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
### For MediaCMS Integration
|
||||
|
||||
To build the video editor for integration with MediaCMS:
|
||||
|
||||
```bash
|
||||
# Build for Django integration with Yarn
|
||||
yarn build:django
|
||||
|
||||
# Or with npm
|
||||
npm run build:django
|
||||
```
|
||||
|
||||
This will compile the editor and place the output in the MediaCMS static directory.
|
||||
|
||||
### Standalone Build
|
||||
|
||||
To build the editor as a standalone application:
|
||||
|
||||
```bash
|
||||
# Build for production with Yarn
|
||||
yarn build
|
||||
|
||||
# Or with npm
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
To deploy the video editor, you can use the build and deploy script (recommended):
|
||||
|
||||
```bash
|
||||
# Run the deployment script
|
||||
sh deploy/scripts/build_and_deploy.sh
|
||||
```
|
||||
|
||||
The build script handles all necessary steps for compiling and deploying the editor to MediaCMS.
|
||||
|
||||
You can also deploy manually after building:
|
||||
|
||||
```bash
|
||||
# With Yarn
|
||||
yarn deploy
|
||||
|
||||
# Or with npm
|
||||
npm run deploy
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
- `/src` - Source code
|
||||
- `/components` - React components
|
||||
- `/hooks` - Custom React hooks
|
||||
- `/lib` - Utility functions and helpers
|
||||
- `/services` - API services
|
||||
- `/styles` - CSS and style definitions
|
||||
|
||||
## API Integration
|
||||
|
||||
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`.
|
||||
29
frontend-tools/video-editor/client/index.html
Normal file
@@ -0,0 +1,29 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||
<title>Video Editor</title>
|
||||
<!-- Add meta tag to help iOS devices render as desktop -->
|
||||
<script>
|
||||
// Try to detect iOS
|
||||
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
|
||||
|
||||
if (isIOS) {
|
||||
// Replace viewport meta tag with one optimized for desktop view
|
||||
const viewportMeta = document.querySelector('meta[name="viewport"]');
|
||||
if (viewportMeta) {
|
||||
viewportMeta.setAttribute('content', 'width=1024, initial-scale=1.0, maximum-scale=1.0, user-scalable=no');
|
||||
}
|
||||
|
||||
// Add a class to the HTML element for iOS-specific styles
|
||||
document.documentElement.classList.add('ios-device');
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="video-editor-trim-root"></div>
|
||||
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
303
frontend-tools/video-editor/client/src/App.tsx
Normal file
@@ -0,0 +1,303 @@
|
||||
import { useRef, useEffect, useState } from "react";
|
||||
import { formatTime, formatDetailedTime } from "./lib/timeUtils";
|
||||
import logger from "./lib/logger";
|
||||
import VideoPlayer from "@/components/VideoPlayer";
|
||||
import TimelineControls from "@/components/TimelineControls";
|
||||
import EditingTools from "@/components/EditingTools";
|
||||
import ClipSegments from "@/components/ClipSegments";
|
||||
import MobilePlayPrompt from "@/components/IOSPlayPrompt";
|
||||
import useVideoTrimmer from "@/hooks/useVideoTrimmer";
|
||||
|
||||
const App = () => {
|
||||
const {
|
||||
videoRef,
|
||||
currentTime,
|
||||
duration,
|
||||
isPlaying,
|
||||
setIsPlaying,
|
||||
isMuted,
|
||||
thumbnails,
|
||||
trimStart,
|
||||
trimEnd,
|
||||
splitPoints,
|
||||
zoomLevel,
|
||||
clipSegments,
|
||||
hasUnsavedChanges,
|
||||
historyPosition,
|
||||
history,
|
||||
handleTrimStartChange,
|
||||
handleTrimEndChange,
|
||||
handleZoomChange,
|
||||
handleMobileSafeSeek,
|
||||
handleSplit,
|
||||
handleReset,
|
||||
handleUndo,
|
||||
handleRedo,
|
||||
toggleMute,
|
||||
handleSave,
|
||||
handleSaveACopy,
|
||||
handleSaveSegments,
|
||||
isMobile,
|
||||
videoInitialized,
|
||||
setVideoInitialized,
|
||||
isPlayingSegments,
|
||||
handlePlaySegments
|
||||
} = useVideoTrimmer();
|
||||
|
||||
// Function to play from the beginning
|
||||
const playFromBeginning = () => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.currentTime = 0;
|
||||
handleMobileSafeSeek(0);
|
||||
if (!isPlaying) {
|
||||
handlePlay();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Function to jump 15 seconds backward
|
||||
const jumpBackward15 = () => {
|
||||
const newTime = Math.max(0, currentTime - 15);
|
||||
handleMobileSafeSeek(newTime);
|
||||
};
|
||||
|
||||
// Function to jump 15 seconds forward
|
||||
const jumpForward15 = () => {
|
||||
const newTime = Math.min(duration, currentTime + 15);
|
||||
handleMobileSafeSeek(newTime);
|
||||
};
|
||||
|
||||
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) => {
|
||||
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;
|
||||
}
|
||||
// Check if we're exactly at the start
|
||||
if (currentPosition === segStartTime) {
|
||||
return true;
|
||||
}
|
||||
// Check if we're exactly at the end
|
||||
if (currentPosition === segEndTime) {
|
||||
// If we're at the end of a segment, we should look for the next one
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
// If we're not in a segment, find the next segment
|
||||
if (!currentSegment) {
|
||||
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
|
||||
stopTime = Number(currentSegment.endTime.toFixed(6));
|
||||
} else if (nextSegment) {
|
||||
// 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, 10); // Second retry
|
||||
setTimeout(setExactPosition, 20); // Third retry if needed
|
||||
setTimeout(setExactPosition, 50); // Final verification
|
||||
|
||||
// Remove our boundary checker
|
||||
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
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Start our boundary checker
|
||||
video.addEventListener("timeupdate", checkBoundary);
|
||||
|
||||
// Start playing
|
||||
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"
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Error playing video:", err);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-background min-h-screen">
|
||||
<MobilePlayPrompt videoRef={videoRef} onPlay={handlePlay} />
|
||||
|
||||
<div className="container mx-auto px-4 py-6 max-w-6xl">
|
||||
{/* Video Player */}
|
||||
<VideoPlayer
|
||||
videoRef={videoRef}
|
||||
currentTime={currentTime}
|
||||
duration={duration}
|
||||
isPlaying={isPlaying}
|
||||
isMuted={isMuted}
|
||||
onPlayPause={handlePlay}
|
||||
onSeek={handleMobileSafeSeek}
|
||||
onToggleMute={toggleMute}
|
||||
/>
|
||||
|
||||
{/* Editing Tools */}
|
||||
<EditingTools
|
||||
onSplit={handleSplit}
|
||||
onReset={handleReset}
|
||||
onUndo={handleUndo}
|
||||
onRedo={handleRedo}
|
||||
onPlaySegments={handlePlaySegments}
|
||||
onPlay={handlePlay}
|
||||
isPlaying={isPlaying}
|
||||
isPlayingSegments={isPlayingSegments}
|
||||
canUndo={historyPosition > 0}
|
||||
canRedo={historyPosition < history.length - 1}
|
||||
/>
|
||||
|
||||
{/* Timeline Controls */}
|
||||
<TimelineControls
|
||||
currentTime={currentTime}
|
||||
duration={duration}
|
||||
thumbnails={thumbnails}
|
||||
trimStart={trimStart}
|
||||
trimEnd={trimEnd}
|
||||
splitPoints={splitPoints}
|
||||
zoomLevel={zoomLevel}
|
||||
clipSegments={clipSegments}
|
||||
onTrimStartChange={handleTrimStartChange}
|
||||
onTrimEndChange={handleTrimEndChange}
|
||||
onZoomChange={handleZoomChange}
|
||||
onSeek={handleMobileSafeSeek}
|
||||
videoRef={videoRef}
|
||||
onSave={handleSave}
|
||||
onSaveACopy={handleSaveACopy}
|
||||
onSaveSegments={handleSaveSegments}
|
||||
hasUnsavedChanges={hasUnsavedChanges}
|
||||
isIOSUninitialized={isMobile && !videoInitialized}
|
||||
isPlaying={isPlaying}
|
||||
setIsPlaying={setIsPlaying}
|
||||
onPlayPause={handlePlay}
|
||||
isPlayingSegments={isPlayingSegments}
|
||||
/>
|
||||
|
||||
{/* Clip Segments */}
|
||||
<ClipSegments segments={clipSegments} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" ?><svg style="enable-background:new 0 0 512 512;" version="1.1" viewBox="0 0 512 512" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><style type="text/css">.st0{fill:#333333;}.st1{fill:none;stroke:#333333;stroke-width:32;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;}</style><g id="Layer_1"/><g id="Layer_2"><g><g><path class="st0" d="M208.15,380.19h-91.19c-5.7,0-10.32-4.62-10.32-10.32V142.13c0-5.7,4.62-10.32,10.32-10.32h91.19 c5.7,0,10.32,4.62,10.32,10.32v227.74C218.47,375.57,213.85,380.19,208.15,380.19z"/></g><g><path class="st0" d="M395.04,380.19h-91.19c-5.7,0-10.32-4.62-10.32-10.32V142.13c0-5.7,4.62-10.32,10.32-10.32h91.19 c5.7,0,10.32,4.62,10.32,10.32v227.74C405.36,375.57,400.74,380.19,395.04,380.19z"/></g></g></g></svg>
|
||||
|
After Width: | Height: | Size: 832 B |
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" ?><svg style="enable-background:new 0 0 512 512;" version="1.1" viewBox="0 0 512 512" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><style type="text/css">.st0{fill:#333333;}.st1{fill:none;stroke:#333333;stroke-width:32;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;}</style><g id="Layer_1"/><g id="Layer_2"><g><g><path class="st0" d="M85.26,277.5l164.08,94.73c16.55,9.56,37.24-2.39,37.24-21.5V161.27c0-19.11-20.69-31.06-37.24-21.5 L85.26,234.5C68.71,244.06,68.71,267.94,85.26,277.5z"/></g><g><path class="st0" d="M377.47,375.59h41.42c11.19,0,20.26-9.07,20.26-20.26V156.67c0-11.19-9.07-20.26-20.26-20.26h-41.42 c-11.19,0-20.26,9.07-20.26,20.26v198.67C357.21,366.52,366.28,375.59,377.47,375.59z"/></g></g></g></svg>
|
||||
|
After Width: | Height: | Size: 813 B |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" ?><svg style="enable-background:new 0 0 512 512;" version="1.1" viewBox="0 0 512 512" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><style type="text/css">
|
||||
.st0{fill:#333333;}
|
||||
.st1{fill:none;stroke:#333333;stroke-width:32;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;}
|
||||
</style><g id="Layer_1"/><g id="Layer_2"><g><g><path class="st0" d="M85.26,277.5l164.08,94.73c16.55,9.56,37.24-2.39,37.24-21.5V161.27c0-19.11-20.69-31.06-37.24-21.5 L85.26,234.5C68.71,244.06,68.71,267.94,85.26,277.5z"/></g><g><path class="st0" d="M377.47,375.59h41.42c11.19,0,20.26-9.07,20.26-20.26V156.67c0-11.19-9.07-20.26-20.26-20.26h-41.42 c-11.19,0-20.26,9.07-20.26,20.26v198.67C357.21,366.52,366.28,375.59,377.47,375.59z"/></g></g></g></svg>
|
||||
|
After Width: | Height: | Size: 818 B |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" ?><svg style="enable-background:new 0 0 512 512;" version="1.1" viewBox="0 0 512 512" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><style type="text/css">
|
||||
.st0{fill:#333333;}
|
||||
.st1{fill:none;stroke:#333333;stroke-width:32;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;}
|
||||
</style><g id="Layer_1"/><g id="Layer_2"><g><path class="st0" d="M350.45,277.5l-164.08,94.73c-16.55,9.56-37.24-2.39-37.24-21.5V161.27c0-19.11,20.69-31.06,37.24-21.5 l164.08,94.73C367,244.06,367,267.94,350.45,277.5z"/></g></g></svg>
|
||||
|
After Width: | Height: | Size: 597 B |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" ?><svg style="enable-background:new 0 0 512 512;" version="1.1" viewBox="0 0 512 512" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><style type="text/css">
|
||||
.st0{fill:#333333;}
|
||||
.st1{fill:none;stroke:#333333;stroke-width:32;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;}
|
||||
</style><g id="Layer_1"/><g id="Layer_2"><g><path class="st0" d="M350.45,277.5l-164.08,94.73c-16.55,9.56-37.24-2.39-37.24-21.5V161.27c0-19.11,20.69-31.06,37.24-21.5 l164.08,94.73C367,244.06,367,267.94,350.45,277.5z"/></g></g></svg>
|
||||
|
After Width: | Height: | Size: 611 B |
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" ?>
|
||||
<svg height="32" viewBox="0 0 32 32" width="32" xmlns="http://www.w3.org/2000/svg">
|
||||
<title/>
|
||||
<g data-name="1" id="_1">
|
||||
<path d="M27,3V29a1,1,0,0,1-1,1H6a1,1,0,0,1-1-1V27H7v1H25V4H7V7H5V3A1,1,0,0,1,6,2H26A1,1,0,0,1,27,3Z"/>
|
||||
<g transform="translate(2, 0)">
|
||||
<path d="M10.71,20.29,7.41,17H18V15H7.41l3.3-3.29L9.29,10.29l-5,5a1,1,0,0,0,0,1.42l5,5Z" id="logout_account_exit_door"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 439 B |
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" ?>
|
||||
<svg height="32" viewBox="0 0 32 32" width="32" xmlns="http://www.w3.org/2000/svg">
|
||||
<title/>
|
||||
<g data-name="1" id="_1">
|
||||
<path d="M27,3V29a1,1,0,0,1-1,1H6a1,1,0,0,1-1-1V27H7v1H25V4H7V7H5V3A1,1,0,0,1,6,2H26A1,1,0,0,1,27,3Z"/>
|
||||
<g transform="translate(2, 0)">
|
||||
<path d="M10.71,20.29,7.41,17H18V15H7.41l3.3-3.29L9.29,10.29l-5,5a1,1,0,0,0,0,1.42l5,5Z" id="logout_account_exit_door"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 439 B |
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" ?><svg height="32" viewBox="0 0 32 32" width="32" xmlns="http://www.w3.org/2000/svg"><title/><g data-name="1" id="_1"><path d="M27,3V29a1,1,0,0,1-1,1H6a1,1,0,0,1-1-1V27H7v1H25V4H7V7H5V3A1,1,0,0,1,6,2H26A1,1,0,0,1,27,3ZM10.71,20.29,7.41,17H18V15H7.41l3.3-3.29L9.29,10.29l-5,5a1,1,0,0,0,0,1.42l5,5Z" id="logout_account_exit_door"/></g></svg>
|
||||
|
After Width: | Height: | Size: 359 B |
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" ?>
|
||||
<svg height="32" viewBox="0 0 32 32" width="32" xmlns="http://www.w3.org/2000/svg">
|
||||
<g data-name="1" id="_1">
|
||||
<path d="M5,3V29a1,1,0,0,0,1,1H26a1,1,0,0,0,1-1V25H25v3H7V4H25V7h2V3a1,1,0,0,0-1-1H6A1,1,0,0,0,5,3Z"/>
|
||||
<g transform="translate(30, 0) scale(-1, 1)">
|
||||
<path d="M10.71,20.29,7.41,17H18V15H7.41l3.3-3.29L9.29,10.29l-5,5a1,1,0,0,0,0,1.42l5,5Z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 412 B |
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" ?>
|
||||
<svg height="32" viewBox="0 0 32 32" width="32" xmlns="http://www.w3.org/2000/svg">
|
||||
<g data-name="1" id="_1">
|
||||
<path d="M5,3V29a1,1,0,0,0,1,1H26a1,1,0,0,0,1-1V25H25v3H7V4H25V7h2V3a1,1,0,0,0-1-1H6A1,1,0,0,0,5,3Z"/>
|
||||
<g transform="translate(28, 0) scale(-1, 1)">
|
||||
<path d="M10.71,20.29,7.41,17H18V15H7.41l3.3-3.29L9.29,10.29l-5,5a1,1,0,0,0,0,1.42l5,5Z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 411 B |
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" ?><svg height="32" viewBox="0 0 32 32" width="32" xmlns="http://www.w3.org/2000/svg"><title/><g data-name="1" id="_1"><path d="M27,3V29a1,1,0,0,1-1,1H6a1,1,0,0,1-1-1V27H7v1H25V4H7V7H5V3A1,1,0,0,1,6,2H26A1,1,0,0,1,27,3ZM12.29,20.29l1.42,1.42,5-5a1,1,0,0,0,0-1.42l-5-5-1.42,1.42L15.59,15H5v2H15.59Z" id="login_account_enter_door"/></g></svg>
|
||||
|
After Width: | Height: | Size: 359 B |
@@ -0,0 +1,85 @@
|
||||
import { formatTime, formatLongTime } from "@/lib/timeUtils";
|
||||
import "../styles/ClipSegments.css";
|
||||
|
||||
export interface Segment {
|
||||
id: number;
|
||||
name: string;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
thumbnail: string;
|
||||
}
|
||||
|
||||
interface ClipSegmentsProps {
|
||||
segments: Segment[];
|
||||
}
|
||||
|
||||
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 }
|
||||
});
|
||||
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
|
||||
// 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 className="segment-content">
|
||||
<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-time">
|
||||
{formatTime(segment.startTime)} - {formatTime(segment.endTime)}
|
||||
</div>
|
||||
<div className="segment-duration">
|
||||
Duration: {formatLongTime(segment.endTime - segment.startTime)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="segment-actions">
|
||||
<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"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{sortedSegments.length === 0 && (
|
||||
<div className="empty-message">
|
||||
No segments created yet. Use the split button to create segments.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClipSegments;
|
||||
@@ -0,0 +1,272 @@
|
||||
import "../styles/EditingTools.css";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface EditingToolsProps {
|
||||
onSplit: () => void;
|
||||
onReset: () => void;
|
||||
onUndo: () => void;
|
||||
onRedo: () => void;
|
||||
onPlaySegments: () => void;
|
||||
onPlay: () => void;
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
isPlaying?: boolean;
|
||||
isPlayingSegments?: boolean;
|
||||
}
|
||||
|
||||
const EditingTools = ({
|
||||
onSplit,
|
||||
onReset,
|
||||
onUndo,
|
||||
onRedo,
|
||||
onPlaySegments,
|
||||
onPlay,
|
||||
canUndo,
|
||||
canRedo,
|
||||
isPlaying = false,
|
||||
isPlayingSegments = false
|
||||
}: EditingToolsProps) => {
|
||||
const [isSmallScreen, setIsSmallScreen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const checkScreenSize = () => {
|
||||
setIsSmallScreen(window.innerWidth <= 640);
|
||||
};
|
||||
|
||||
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") {
|
||||
console.log("Play button clicked, current lastSeekedPosition:", window.lastSeekedPosition);
|
||||
}
|
||||
|
||||
// Call the original handler
|
||||
onPlay();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="editing-tools-container">
|
||||
<div className="flex-container single-row">
|
||||
{/* Left side - Play buttons group */}
|
||||
<div className="button-group play-buttons-group">
|
||||
{/* Play Segments button */}
|
||||
<button
|
||||
className={`button segments-button`}
|
||||
onClick={onPlaySegments}
|
||||
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"
|
||||
>
|
||||
<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" />
|
||||
</svg>
|
||||
<span className="full-text">Stop Preview</span>
|
||||
<span className="short-text">Stop Preview</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<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>
|
||||
<span className="full-text">Play Preview</span>
|
||||
<span className="short-text">Play Preview</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Play Preview button */}
|
||||
{/* <button
|
||||
className="button preview-button"
|
||||
onClick={onPreview}
|
||||
data-tooltip={isPreviewMode ? "Stop preview playback" : "Play only segments (skips gaps between segments)"}
|
||||
style={{ fontSize: '0.875rem' }}
|
||||
>
|
||||
{isPreviewMode ? (
|
||||
<>
|
||||
<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" />
|
||||
</svg>
|
||||
<span className="full-text">Stop Preview</span>
|
||||
<span className="short-text">Stop</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<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>
|
||||
<span className="full-text">Play Preview</span>
|
||||
<span className="short-text">Preview</span>
|
||||
</>
|
||||
)}
|
||||
</button> */}
|
||||
|
||||
{/* 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" }}
|
||||
disabled={isPlayingSegments}
|
||||
>
|
||||
{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"
|
||||
>
|
||||
<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" />
|
||||
</svg>
|
||||
<span className="full-text">Pause</span>
|
||||
<span className="short-text">Pause</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<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>
|
||||
<span className="full-text">Play</span>
|
||||
<span className="short-text">Play</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Segments Playback message (replaces play button during segments playback) */}
|
||||
{/* {isPlayingSegments && !isSmallScreen && (
|
||||
<div className="segments-playback-message">
|
||||
<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="12" y1="16" x2="12" y2="12" />
|
||||
<line x1="12" y1="8" x2="12" y2="8" />
|
||||
</svg>
|
||||
Preview Mode
|
||||
</div>
|
||||
)} */}
|
||||
|
||||
{/* Preview mode message (replaces play button) */}
|
||||
{/* {isPreviewMode && (
|
||||
<div className="preview-mode-message">
|
||||
<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="12" y1="16" x2="12" y2="12" />
|
||||
<line x1="12" y1="8" x2="12" y2="8" />
|
||||
</svg>
|
||||
Preview Mode
|
||||
</div>
|
||||
)} */}
|
||||
</div>
|
||||
|
||||
{/* Right side - Editing tools */}
|
||||
<div className="button-group secondary">
|
||||
<button
|
||||
className="button"
|
||||
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>
|
||||
<span className="button-text">Undo</span>
|
||||
</button>
|
||||
<button
|
||||
className="button"
|
||||
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>
|
||||
<span className="button-text">Redo</span>
|
||||
</button>
|
||||
<div className="divider"></div>
|
||||
<button
|
||||
className="button"
|
||||
onClick={onReset}
|
||||
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"
|
||||
/>
|
||||
</svg>
|
||||
<span className="reset-text">Reset</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditingTools;
|
||||
@@ -0,0 +1,76 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import "../styles/IOSPlayPrompt.css";
|
||||
|
||||
interface MobilePlayPromptProps {
|
||||
videoRef: React.RefObject<HTMLVideoElement>;
|
||||
onPlay: () => void;
|
||||
}
|
||||
|
||||
const MobilePlayPrompt: React.FC<MobilePlayPromptProps> = ({ videoRef, onPlay }) => {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
// Check if the device is mobile
|
||||
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
|
||||
);
|
||||
};
|
||||
|
||||
// Always show for mobile devices on each visit
|
||||
const isMobile = checkIsMobile();
|
||||
setIsVisible(isMobile);
|
||||
}, []);
|
||||
|
||||
// Close the prompt when video plays
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
const handlePlay = () => {
|
||||
// Just close the prompt when video plays
|
||||
setIsVisible(false);
|
||||
};
|
||||
|
||||
video.addEventListener("play", handlePlay);
|
||||
return () => {
|
||||
video.removeEventListener("play", handlePlay);
|
||||
};
|
||||
}, [videoRef]);
|
||||
|
||||
const handlePlayClick = () => {
|
||||
onPlay();
|
||||
// Prompt will be closed by the play event handler
|
||||
};
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
return (
|
||||
<div className="mobile-play-prompt-overlay">
|
||||
<div className="mobile-play-prompt">
|
||||
{/* <h3>Mobile Device Notice</h3>
|
||||
|
||||
<p>
|
||||
For the best video editing experience on mobile devices, you need to <strong>play the video first</strong> before
|
||||
using the timeline controls.
|
||||
</p>
|
||||
|
||||
<div className="mobile-prompt-instructions">
|
||||
<p>Please follow these steps:</p>
|
||||
<ol>
|
||||
<li>Tap the button below to start the video</li>
|
||||
<li>After the video starts, you can pause it</li>
|
||||
<li>Then you'll be able to use all timeline controls</li>
|
||||
</ol>
|
||||
</div> */}
|
||||
|
||||
<button className="mobile-play-button" onClick={handlePlayClick}>
|
||||
Click to start editing...
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MobilePlayPrompt;
|
||||
@@ -0,0 +1,184 @@
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { formatTime } from "@/lib/timeUtils";
|
||||
import "../styles/IOSVideoPlayer.css";
|
||||
|
||||
interface IOSVideoPlayerProps {
|
||||
videoRef: React.RefObject<HTMLVideoElement>;
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
// Clean up intervals on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (incrementIntervalRef.current) clearInterval(incrementIntervalRef.current);
|
||||
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 (source && source.src) {
|
||||
setVideoUrl(source.src);
|
||||
}
|
||||
} else {
|
||||
// Fallback to sample video if needed
|
||||
setVideoUrl("/videos/sample-video-10m.mp4");
|
||||
}
|
||||
}, [videoRef]);
|
||||
|
||||
// Function to jump 15 seconds backward
|
||||
const jumpBackward15 = () => {
|
||||
if (iosVideoRef) {
|
||||
const newTime = Math.max(0, iosVideoRef.currentTime - 15);
|
||||
iosVideoRef.currentTime = newTime;
|
||||
}
|
||||
};
|
||||
|
||||
// Function to jump 15 seconds forward
|
||||
const jumpForward15 = () => {
|
||||
if (iosVideoRef) {
|
||||
const newTime = Math.min(iosVideoRef.duration, iosVideoRef.currentTime + 15);
|
||||
iosVideoRef.currentTime = newTime;
|
||||
}
|
||||
};
|
||||
|
||||
// Start continuous 50ms increment when button is held
|
||||
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) {
|
||||
iosVideoRef.currentTime = Math.min(iosVideoRef.duration, iosVideoRef.currentTime + 0.05);
|
||||
}
|
||||
}, 100);
|
||||
};
|
||||
|
||||
// Stop continuous increment
|
||||
const stopIncrement = () => {
|
||||
if (incrementIntervalRef.current) {
|
||||
clearInterval(incrementIntervalRef.current);
|
||||
incrementIntervalRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
// Start continuous 50ms decrement when button is held
|
||||
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) {
|
||||
iosVideoRef.currentTime = Math.max(0, iosVideoRef.currentTime - 0.05);
|
||||
}
|
||||
}, 100);
|
||||
};
|
||||
|
||||
// Stop continuous decrement
|
||||
const stopDecrement = () => {
|
||||
if (decrementIntervalRef.current) {
|
||||
clearInterval(decrementIntervalRef.current);
|
||||
decrementIntervalRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{/* iOS-optimized Video Element with Native Controls */}
|
||||
<video
|
||||
ref={(ref) => setIosVideoRef(ref)}
|
||||
className="w-full rounded-md"
|
||||
src={videoUrl}
|
||||
controls
|
||||
playsInline
|
||||
webkit-playsinline="true"
|
||||
x-webkit-airplay="allow"
|
||||
preload="auto"
|
||||
crossOrigin="anonymous"
|
||||
>
|
||||
<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
|
||||
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
|
||||
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
|
||||
onMouseDown={startDecrement}
|
||||
onTouchStart={startDecrement}
|
||||
onMouseUp={stopDecrement}
|
||||
onMouseLeave={stopDecrement}
|
||||
onTouchEnd={stopDecrement}
|
||||
onTouchCancel={stopDecrement}
|
||||
className="ios-control-btn flex items-center justify-center bg-indigo-600 text-white py-2 px-4 rounded-md no-select"
|
||||
>
|
||||
-50ms
|
||||
</button>
|
||||
<button
|
||||
onMouseDown={startIncrement}
|
||||
onTouchStart={startIncrement}
|
||||
onMouseUp={stopIncrement}
|
||||
onMouseLeave={stopIncrement}
|
||||
onTouchEnd={stopIncrement}
|
||||
onTouchCancel={stopIncrement}
|
||||
className="ios-control-btn flex items-center justify-center bg-indigo-600 text-white py-2 px-4 rounded-md no-select"
|
||||
>
|
||||
+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>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default IOSVideoPlayer;
|
||||
74
frontend-tools/video-editor/client/src/components/Modal.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import React, { useEffect } from "react";
|
||||
import "../styles/Modal.css";
|
||||
|
||||
interface ModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
actions?: React.ReactNode;
|
||||
}
|
||||
|
||||
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) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleEscapeKey);
|
||||
|
||||
// Disable body scrolling when modal is open
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = "hidden";
|
||||
}
|
||||
|
||||
return () => {
|
||||
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) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={handleClickOutside}>
|
||||
<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"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="modal-content">{children}</div>
|
||||
|
||||
{actions && <div className="modal-actions">{actions}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Modal;
|
||||
@@ -0,0 +1,452 @@
|
||||
import React, { useRef, useEffect, useState } from "react";
|
||||
import { formatTime, formatDetailedTime } from "@/lib/timeUtils";
|
||||
import logger from "../lib/logger";
|
||||
import "../styles/VideoPlayer.css";
|
||||
|
||||
interface VideoPlayerProps {
|
||||
videoRef: React.RefObject<HTMLVideoElement>;
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
isPlaying: boolean;
|
||||
isMuted?: boolean;
|
||||
onPlayPause: () => void;
|
||||
onSeek: (time: number) => void;
|
||||
onToggleMute?: () => void;
|
||||
}
|
||||
|
||||
const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
videoRef,
|
||||
currentTime,
|
||||
duration,
|
||||
isPlaying,
|
||||
isMuted = false,
|
||||
onPlayPause,
|
||||
onSeek,
|
||||
onToggleMute
|
||||
}) => {
|
||||
const progressRef = useRef<HTMLDivElement>(null);
|
||||
const [isIOS, setIsIOS] = useState(false);
|
||||
const [hasInitialized, setHasInitialized] = useState(false);
|
||||
const [lastPosition, setLastPosition] = useState<number | null>(null);
|
||||
const [isDraggingProgress, setIsDraggingProgress] = useState(false);
|
||||
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-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";
|
||||
setHasInitialized(wasInitialized);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Update initialized state when video plays
|
||||
useEffect(() => {
|
||||
if (isPlaying && !hasInitialized) {
|
||||
setHasInitialized(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;
|
||||
if (!video) return;
|
||||
|
||||
// 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");
|
||||
|
||||
// Store the last known good position for iOS
|
||||
const handleTimeUpdate = () => {
|
||||
if (!isDraggingProgressRef.current) {
|
||||
setLastPosition(video.currentTime);
|
||||
if (typeof window !== "undefined") {
|
||||
window.lastSeekedPosition = video.currentTime;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Handle iOS-specific play/pause state
|
||||
const handlePlay = () => {
|
||||
logger.debug("Video play event fired");
|
||||
if (isIOS) {
|
||||
setHasInitialized(true);
|
||||
localStorage.setItem("video_initialized", "true");
|
||||
}
|
||||
};
|
||||
|
||||
const handlePause = () => {
|
||||
logger.debug("Video pause event fired");
|
||||
};
|
||||
|
||||
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);
|
||||
};
|
||||
}, [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);
|
||||
onSeek(newTime);
|
||||
setLastPosition(newTime);
|
||||
};
|
||||
|
||||
// Jump 10 seconds backward
|
||||
const handleBackward = () => {
|
||||
const newTime = Math.max(currentTime - 10, 0);
|
||||
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.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") {
|
||||
(window as any).lastSeekedPosition = seekTime;
|
||||
}
|
||||
|
||||
onSeek(seekTime);
|
||||
};
|
||||
|
||||
// Handle touch events for progress bar
|
||||
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.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;
|
||||
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") {
|
||||
(window as any).lastSeekedPosition = seekTime;
|
||||
}
|
||||
|
||||
onSeek(seekTime);
|
||||
};
|
||||
|
||||
// Handle click on progress bar (for non-drag interactions)
|
||||
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") {
|
||||
(window as any).lastSeekedPosition = seekTime;
|
||||
}
|
||||
|
||||
onSeek(seekTime);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle toggling fullscreen
|
||||
const handleFullscreen = () => {
|
||||
if (videoRef.current) {
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen();
|
||||
} else {
|
||||
videoRef.current.requestFullscreen();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Handle click on video to play/pause
|
||||
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()
|
||||
.then(() => {
|
||||
logger.debug(
|
||||
"iOS: Play started successfully at position:",
|
||||
videoRef.current?.currentTime
|
||||
);
|
||||
onPlayPause(); // Update parent state after successful play
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("iOS: Error playing video:", err);
|
||||
});
|
||||
}
|
||||
}, 50);
|
||||
} else {
|
||||
// Normal play (non-iOS or no remembered position)
|
||||
video
|
||||
.play()
|
||||
.then(() => {
|
||||
logger.debug("Normal: Play started successfully");
|
||||
onPlayPause(); // Update parent state after successful play
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Error playing video:", err);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// If playing, pause and update state
|
||||
video.pause();
|
||||
onPlayPause();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="video-player-container">
|
||||
<video
|
||||
ref={videoRef}
|
||||
preload="auto"
|
||||
crossOrigin="anonymous"
|
||||
onClick={handleVideoClick}
|
||||
playsInline
|
||||
webkit-playsinline="true"
|
||||
x-webkit-airplay="allow"
|
||||
controls={false}
|
||||
muted={isMuted}
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Play/Pause Indicator (shows based on current state) */}
|
||||
<div className={`play-pause-indicator ${isPlaying ? "pause-icon" : "play-icon"}`}></div>
|
||||
|
||||
{/* Video Controls Overlay */}
|
||||
<div className="video-controls">
|
||||
{/* Time and Duration */}
|
||||
<div className="video-time-display">
|
||||
<span className="video-current-time">{formatTime(currentTime)}</span>
|
||||
<span className="video-duration">/ {formatTime(duration)}</span>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar with enhanced dragging */}
|
||||
<div
|
||||
ref={progressRef}
|
||||
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>
|
||||
|
||||
{/* Floating time tooltip when dragging */}
|
||||
{isDraggingProgress && (
|
||||
<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"
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
<line x1="12" y1="19" x2="12" y2="23"></line>
|
||||
<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"
|
||||
>
|
||||
<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"
|
||||
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"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VideoPlayer;
|
||||
967
frontend-tools/video-editor/client/src/hooks/useVideoTrimmer.tsx
Normal file
@@ -0,0 +1,967 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { generateThumbnail } from "@/lib/videoUtils";
|
||||
import { formatDetailedTime } from "@/lib/timeUtils";
|
||||
import logger from "@/lib/logger";
|
||||
import type { Segment } from "@/components/ClipSegments";
|
||||
|
||||
// Represents a state of the editor for undo/redo
|
||||
interface EditorState {
|
||||
trimStart: number;
|
||||
trimEnd: number;
|
||||
splitPoints: number[];
|
||||
clipSegments: Segment[];
|
||||
action?: string;
|
||||
}
|
||||
|
||||
const useVideoTrimmer = () => {
|
||||
// Video element reference and state
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
|
||||
// Timeline state
|
||||
const [thumbnails, setThumbnails] = useState<string[]>([]);
|
||||
const [trimStart, setTrimStart] = useState(0);
|
||||
const [trimEnd, setTrimEnd] = useState(0);
|
||||
const [splitPoints, setSplitPoints] = useState<number[]>([]);
|
||||
const [zoomLevel, setZoomLevel] = useState(1); // Start with 1x zoom level
|
||||
|
||||
// Clip segments state
|
||||
const [clipSegments, setClipSegments] = useState<Segment[]>([]);
|
||||
|
||||
// History state for undo/redo
|
||||
const [history, setHistory] = useState<EditorState[]>([]);
|
||||
const [historyPosition, setHistoryPosition] = useState(-1);
|
||||
|
||||
// Track unsaved changes
|
||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||
|
||||
// State for playing segments
|
||||
const [isPlayingSegments, setIsPlayingSegments] = useState(false);
|
||||
const [currentSegmentIndex, setCurrentSegmentIndex] = useState(0);
|
||||
|
||||
// Monitor for history changes
|
||||
useEffect(() => {
|
||||
if (history.length > 0) {
|
||||
// For debugging - moved to console.debug
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
console.debug(
|
||||
`History state updated: ${history.length} entries, position: ${historyPosition}`
|
||||
);
|
||||
// Log actions in history to help debug undo/redo
|
||||
const actions = history.map(
|
||||
(state, idx) =>
|
||||
`${idx}: ${state.action || "unknown"} (segments: ${state.clipSegments.length})`
|
||||
);
|
||||
console.debug("History actions:", actions);
|
||||
}
|
||||
|
||||
// If there's at least one history entry and it wasn't a save operation, mark as having unsaved changes
|
||||
const lastAction = history[historyPosition]?.action || "";
|
||||
if (lastAction !== "save" && lastAction !== "save_copy" && lastAction !== "save_segments") {
|
||||
setHasUnsavedChanges(true);
|
||||
}
|
||||
}
|
||||
}, [history, historyPosition]);
|
||||
|
||||
// Set up page unload warning
|
||||
useEffect(() => {
|
||||
// Event handler for beforeunload
|
||||
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||
if (hasUnsavedChanges) {
|
||||
// Standard way of showing a confirmation dialog before leaving
|
||||
const message = "Your edits will get lost if you leave the page. Do you want to continue?";
|
||||
e.preventDefault();
|
||||
e.returnValue = message; // Chrome requires returnValue to be set
|
||||
return message; // For other browsers
|
||||
}
|
||||
};
|
||||
|
||||
// Add event listener
|
||||
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||
|
||||
// Clean up
|
||||
return () => {
|
||||
window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||
};
|
||||
}, [hasUnsavedChanges]);
|
||||
|
||||
// Initialize video event listeners
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
const handleLoadedMetadata = () => {
|
||||
setDuration(video.duration);
|
||||
setTrimEnd(video.duration);
|
||||
|
||||
// Generate placeholders and create initial segment
|
||||
const initializeEditor = async () => {
|
||||
// Generate thumbnail for initial segment
|
||||
const segmentThumbnail = await generateThumbnail(video, video.duration / 2);
|
||||
|
||||
// Create an initial segment that spans the entire video
|
||||
const initialSegment: Segment = {
|
||||
id: 1,
|
||||
name: "segment",
|
||||
startTime: 0,
|
||||
endTime: video.duration,
|
||||
thumbnail: segmentThumbnail
|
||||
};
|
||||
|
||||
// Initialize history state with the full-length segment
|
||||
const initialState: EditorState = {
|
||||
trimStart: 0,
|
||||
trimEnd: video.duration,
|
||||
splitPoints: [],
|
||||
clipSegments: [initialSegment]
|
||||
};
|
||||
|
||||
setHistory([initialState]);
|
||||
setHistoryPosition(0);
|
||||
setClipSegments([initialSegment]);
|
||||
|
||||
// Generate timeline thumbnails
|
||||
const count = 6;
|
||||
const interval = video.duration / count;
|
||||
const placeholders: string[] = [];
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const time = interval * i + interval / 2;
|
||||
const thumbnail = await generateThumbnail(video, time);
|
||||
placeholders.push(thumbnail);
|
||||
}
|
||||
|
||||
setThumbnails(placeholders);
|
||||
};
|
||||
|
||||
initializeEditor();
|
||||
};
|
||||
|
||||
const handleTimeUpdate = () => {
|
||||
setCurrentTime(video.currentTime);
|
||||
};
|
||||
|
||||
const handlePlay = () => {
|
||||
setIsPlaying(true);
|
||||
setVideoInitialized(true);
|
||||
};
|
||||
|
||||
const handlePause = () => {
|
||||
setIsPlaying(false);
|
||||
};
|
||||
|
||||
const handleEnded = () => {
|
||||
setIsPlaying(false);
|
||||
video.currentTime = trimStart;
|
||||
};
|
||||
|
||||
// Add event listeners
|
||||
video.addEventListener("loadedmetadata", handleLoadedMetadata);
|
||||
video.addEventListener("timeupdate", handleTimeUpdate);
|
||||
video.addEventListener("play", handlePlay);
|
||||
video.addEventListener("pause", handlePause);
|
||||
video.addEventListener("ended", handleEnded);
|
||||
|
||||
return () => {
|
||||
// Remove event listeners
|
||||
video.removeEventListener("loadedmetadata", handleLoadedMetadata);
|
||||
video.removeEventListener("timeupdate", handleTimeUpdate);
|
||||
video.removeEventListener("play", handlePlay);
|
||||
video.removeEventListener("pause", handlePause);
|
||||
video.removeEventListener("ended", handleEnded);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Play/pause video
|
||||
const playPauseVideo = () => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
if (isPlaying) {
|
||||
video.pause();
|
||||
} else {
|
||||
// iOS Safari fix: Use the last seeked position if available
|
||||
if (!isPlaying && typeof window !== "undefined" && window.lastSeekedPosition > 0) {
|
||||
// Only apply this if the video is not at the same position already
|
||||
// This avoids unnecessary seeking which might cause playback issues
|
||||
if (Math.abs(video.currentTime - window.lastSeekedPosition) > 0.1) {
|
||||
video.currentTime = window.lastSeekedPosition;
|
||||
}
|
||||
}
|
||||
// If at the end of the trim range, reset to the beginning
|
||||
else if (video.currentTime >= trimEnd) {
|
||||
video.currentTime = trimStart;
|
||||
}
|
||||
|
||||
video
|
||||
.play()
|
||||
.then(() => {
|
||||
// Play started successfully
|
||||
// Reset the last seeked position after successfully starting playback
|
||||
if (typeof window !== "undefined") {
|
||||
window.lastSeekedPosition = 0;
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Error starting playback:", err);
|
||||
setIsPlaying(false); // Reset state if play failed
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Seek to a specific time
|
||||
const seekVideo = (time: number) => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
// Track if the video was playing before seeking
|
||||
const wasPlaying = !video.paused;
|
||||
|
||||
// Update the video position
|
||||
video.currentTime = time;
|
||||
setCurrentTime(time);
|
||||
|
||||
// Store the position in a global state accessible to iOS Safari
|
||||
// This ensures when play is pressed later, it remembers the position
|
||||
if (typeof window !== "undefined") {
|
||||
window.lastSeekedPosition = time;
|
||||
}
|
||||
|
||||
// Resume playback if it was playing before
|
||||
if (wasPlaying) {
|
||||
// Play immediately without delay
|
||||
video
|
||||
.play()
|
||||
.then(() => {
|
||||
setIsPlaying(true); // Update state to reflect we're playing
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Error resuming playback:", err);
|
||||
setIsPlaying(false);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Save the current state to history with a debounce buffer
|
||||
// This helps prevent multiple rapid saves for small adjustments
|
||||
const saveState = (action?: string) => {
|
||||
// Deep clone to ensure state is captured correctly
|
||||
const newState: EditorState = {
|
||||
trimStart,
|
||||
trimEnd,
|
||||
splitPoints: [...splitPoints],
|
||||
clipSegments: JSON.parse(JSON.stringify(clipSegments)), // Deep clone to avoid reference issues
|
||||
action: action || "manual_save" // Track the action that triggered this save
|
||||
};
|
||||
|
||||
// Check if state is significantly different from last saved state
|
||||
const lastState = history[historyPosition];
|
||||
|
||||
// Helper function to compare segments deeply
|
||||
const haveSegmentsChanged = () => {
|
||||
if (!lastState || lastState.clipSegments.length !== newState.clipSegments.length) {
|
||||
return true; // Different length means significant change
|
||||
}
|
||||
|
||||
// Compare each segment's start and end times
|
||||
for (let i = 0; i < newState.clipSegments.length; i++) {
|
||||
const oldSeg = lastState.clipSegments[i];
|
||||
const newSeg = newState.clipSegments[i];
|
||||
|
||||
if (!oldSeg || !newSeg) return true;
|
||||
|
||||
// Check if any time values changed by more than 0.001 seconds (1ms)
|
||||
if (
|
||||
Math.abs(oldSeg.startTime - newSeg.startTime) > 0.001 ||
|
||||
Math.abs(oldSeg.endTime - newSeg.endTime) > 0.001
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false; // No significant changes found
|
||||
};
|
||||
|
||||
const isSignificantChange =
|
||||
!lastState ||
|
||||
lastState.trimStart !== newState.trimStart ||
|
||||
lastState.trimEnd !== newState.trimEnd ||
|
||||
lastState.splitPoints.length !== newState.splitPoints.length ||
|
||||
haveSegmentsChanged();
|
||||
|
||||
// Additionally, check if there's an explicit action from a UI event
|
||||
const hasExplicitActionFlag = newState.action !== undefined;
|
||||
|
||||
// Only proceed if this is a significant change or if explicitly requested
|
||||
if (isSignificantChange || hasExplicitActionFlag) {
|
||||
// Get the current position to avoid closure issues
|
||||
const currentPosition = historyPosition;
|
||||
|
||||
// Use functional updates to ensure we're working with the latest state
|
||||
setHistory((prevHistory) => {
|
||||
// If we're not at the end of history, truncate
|
||||
if (currentPosition < prevHistory.length - 1) {
|
||||
const newHistory = prevHistory.slice(0, currentPosition + 1);
|
||||
return [...newHistory, newState];
|
||||
} else {
|
||||
// Just append to current history
|
||||
return [...prevHistory, newState];
|
||||
}
|
||||
});
|
||||
|
||||
// Update position using functional update
|
||||
setHistoryPosition((prev) => {
|
||||
const newPosition = prev + 1;
|
||||
// "Saved state to history position", newPosition)
|
||||
return newPosition;
|
||||
});
|
||||
} else {
|
||||
// logger.debug("Skipped non-significant state save");
|
||||
}
|
||||
};
|
||||
|
||||
// Listen for trim handle update events
|
||||
useEffect(() => {
|
||||
const handleTrimUpdate = (e: CustomEvent) => {
|
||||
if (e.detail) {
|
||||
const { time, isStart, recordHistory, action } = e.detail;
|
||||
|
||||
if (isStart) {
|
||||
setTrimStart(time);
|
||||
} else {
|
||||
setTrimEnd(time);
|
||||
}
|
||||
|
||||
// Only record in history if explicitly requested
|
||||
if (recordHistory) {
|
||||
// Use a small timeout to ensure the state is updated
|
||||
setTimeout(() => {
|
||||
saveState(action || (isStart ? "adjust_trim_start" : "adjust_trim_end"));
|
||||
}, 10);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("update-trim", handleTrimUpdate as EventListener);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("update-trim", handleTrimUpdate as EventListener);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Listen for segment update events and split-at-time events
|
||||
useEffect(() => {
|
||||
const handleUpdateSegments = (e: CustomEvent) => {
|
||||
if (e.detail && e.detail.segments) {
|
||||
// Check if this is a significant change that should be recorded in history
|
||||
// Default to true to ensure all segment changes are recorded
|
||||
const isSignificantChange = e.detail.recordHistory !== false;
|
||||
// Get the action type if provided
|
||||
const actionType = e.detail.action || "update_segments";
|
||||
|
||||
// Log the update details
|
||||
logger.debug(
|
||||
`Updating segments with action: ${actionType}, recordHistory: ${isSignificantChange ? "true" : "false"}`
|
||||
);
|
||||
|
||||
// Update segment state immediately for UI feedback
|
||||
setClipSegments(e.detail.segments);
|
||||
|
||||
// Always save state to history for non-intermediate actions
|
||||
if (isSignificantChange) {
|
||||
// A slight delay helps avoid race conditions but we need to
|
||||
// ensure we capture the state properly
|
||||
setTimeout(() => {
|
||||
// Deep clone to ensure state is captured correctly
|
||||
const segmentsClone = JSON.parse(JSON.stringify(e.detail.segments));
|
||||
|
||||
// Create a complete state snapshot
|
||||
const stateWithAction: EditorState = {
|
||||
trimStart,
|
||||
trimEnd,
|
||||
splitPoints: [...splitPoints],
|
||||
clipSegments: segmentsClone,
|
||||
action: actionType // Store the action type in the state
|
||||
};
|
||||
|
||||
// Get the current history position to ensure we're using the latest value
|
||||
const currentHistoryPosition = historyPosition;
|
||||
|
||||
// Update history with the functional pattern to avoid stale closure issues
|
||||
setHistory((prevHistory) => {
|
||||
// If we're not at the end of the history, truncate
|
||||
if (currentHistoryPosition < prevHistory.length - 1) {
|
||||
const newHistory = prevHistory.slice(0, currentHistoryPosition + 1);
|
||||
return [...newHistory, stateWithAction];
|
||||
} else {
|
||||
// Just append to current history
|
||||
return [...prevHistory, stateWithAction];
|
||||
}
|
||||
});
|
||||
|
||||
// Ensure the historyPosition is updated to the correct position
|
||||
setHistoryPosition((prev) => {
|
||||
const newPosition = prev + 1;
|
||||
logger.debug(
|
||||
`Saved state with action: ${actionType} to history position ${newPosition}`
|
||||
);
|
||||
return newPosition;
|
||||
});
|
||||
}, 20); // Slightly increased delay to ensure state updates are complete
|
||||
} else {
|
||||
logger.debug(
|
||||
`Skipped saving state to history for action: ${actionType} (recordHistory=false)`
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSplitSegment = async (e: Event) => {
|
||||
const customEvent = e as CustomEvent;
|
||||
if (
|
||||
customEvent.detail &&
|
||||
typeof customEvent.detail.time === "number" &&
|
||||
typeof customEvent.detail.segmentId === "number"
|
||||
) {
|
||||
// Get the time and segment ID from the event
|
||||
const timeToSplit = customEvent.detail.time;
|
||||
const segmentId = customEvent.detail.segmentId;
|
||||
|
||||
// Move the current time to the split position
|
||||
seekVideo(timeToSplit);
|
||||
|
||||
// Find the segment to split
|
||||
const segmentToSplit = clipSegments.find((seg) => seg.id === segmentId);
|
||||
if (!segmentToSplit) return;
|
||||
|
||||
// Make sure the split point is within the segment
|
||||
if (timeToSplit <= segmentToSplit.startTime || timeToSplit >= segmentToSplit.endTime) {
|
||||
return; // Can't split outside segment boundaries
|
||||
}
|
||||
|
||||
// Create two new segments from the split
|
||||
const newSegments = [...clipSegments];
|
||||
|
||||
// Remove the original segment
|
||||
const segmentIndex = newSegments.findIndex((seg) => seg.id === segmentId);
|
||||
if (segmentIndex === -1) return;
|
||||
|
||||
newSegments.splice(segmentIndex, 1);
|
||||
|
||||
// Create first half of the split segment - no thumbnail needed
|
||||
const firstHalf: Segment = {
|
||||
id: Date.now(),
|
||||
name: `${segmentToSplit.name}-A`,
|
||||
startTime: segmentToSplit.startTime,
|
||||
endTime: timeToSplit,
|
||||
thumbnail: "" // Empty placeholder - we'll use dynamic colors instead
|
||||
};
|
||||
|
||||
// Create second half of the split segment - no thumbnail needed
|
||||
const secondHalf: Segment = {
|
||||
id: Date.now() + 1,
|
||||
name: `${segmentToSplit.name}-B`,
|
||||
startTime: timeToSplit,
|
||||
endTime: segmentToSplit.endTime,
|
||||
thumbnail: "" // Empty placeholder - we'll use dynamic colors instead
|
||||
};
|
||||
|
||||
// Add the new segments
|
||||
newSegments.push(firstHalf, secondHalf);
|
||||
|
||||
// Sort segments by start time
|
||||
newSegments.sort((a, b) => a.startTime - b.startTime);
|
||||
|
||||
// Update state
|
||||
setClipSegments(newSegments);
|
||||
saveState("split_segment");
|
||||
}
|
||||
};
|
||||
|
||||
// Handle delete segment event
|
||||
const handleDeleteSegment = async (e: Event) => {
|
||||
const customEvent = e as CustomEvent;
|
||||
if (customEvent.detail && typeof customEvent.detail.segmentId === "number") {
|
||||
const segmentId = customEvent.detail.segmentId;
|
||||
|
||||
// Find and remove the segment
|
||||
const newSegments = clipSegments.filter((segment) => segment.id !== segmentId);
|
||||
|
||||
if (newSegments.length !== clipSegments.length) {
|
||||
// If all segments are deleted, create a new full video segment
|
||||
if (newSegments.length === 0 && videoRef.current) {
|
||||
// Create a new default segment that spans the entire video
|
||||
// No need to generate a thumbnail - we'll use dynamic colors
|
||||
const defaultSegment: Segment = {
|
||||
id: Date.now(),
|
||||
name: "segment",
|
||||
startTime: 0,
|
||||
endTime: videoRef.current.duration,
|
||||
thumbnail: "" // Empty placeholder - we'll use dynamic colors instead
|
||||
};
|
||||
|
||||
// Reset the trim points as well
|
||||
setTrimStart(0);
|
||||
setTrimEnd(videoRef.current.duration);
|
||||
setSplitPoints([]);
|
||||
setClipSegments([defaultSegment]);
|
||||
} else {
|
||||
// Just update the segments normally
|
||||
setClipSegments(newSegments);
|
||||
}
|
||||
saveState("delete_segment");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("update-segments", handleUpdateSegments as EventListener);
|
||||
document.addEventListener("split-segment", handleSplitSegment as EventListener);
|
||||
document.addEventListener("delete-segment", handleDeleteSegment as EventListener);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("update-segments", handleUpdateSegments as EventListener);
|
||||
document.removeEventListener("split-segment", handleSplitSegment as EventListener);
|
||||
document.removeEventListener("delete-segment", handleDeleteSegment as EventListener);
|
||||
};
|
||||
}, [clipSegments, duration]);
|
||||
|
||||
// Handle trim start change
|
||||
const handleTrimStartChange = (time: number) => {
|
||||
setTrimStart(time);
|
||||
saveState("adjust_trim_start");
|
||||
};
|
||||
|
||||
// Handle trim end change
|
||||
const handleTrimEndChange = (time: number) => {
|
||||
setTrimEnd(time);
|
||||
saveState("adjust_trim_end");
|
||||
};
|
||||
|
||||
// Handle split at current position
|
||||
const handleSplit = async () => {
|
||||
if (!videoRef.current) return;
|
||||
|
||||
// Add current time to split points if not already present
|
||||
if (!splitPoints.includes(currentTime)) {
|
||||
const newSplitPoints = [...splitPoints, currentTime].sort((a, b) => a - b);
|
||||
setSplitPoints(newSplitPoints);
|
||||
|
||||
// Generate segments based on split points
|
||||
const newSegments: Segment[] = [];
|
||||
let startTime = 0;
|
||||
|
||||
for (let i = 0; i <= newSplitPoints.length; i++) {
|
||||
const endTime = i < newSplitPoints.length ? newSplitPoints[i] : duration;
|
||||
|
||||
if (startTime < endTime) {
|
||||
// No need to generate thumbnails - we'll use dynamic colors
|
||||
newSegments.push({
|
||||
id: Date.now() + i,
|
||||
name: `Segment ${i + 1}`,
|
||||
startTime,
|
||||
endTime,
|
||||
thumbnail: "" // Empty placeholder - we'll use dynamic colors instead
|
||||
});
|
||||
|
||||
startTime = endTime;
|
||||
}
|
||||
}
|
||||
|
||||
setClipSegments(newSegments);
|
||||
saveState("create_split_points");
|
||||
}
|
||||
};
|
||||
|
||||
// Handle reset of all edits
|
||||
const handleReset = async () => {
|
||||
setTrimStart(0);
|
||||
setTrimEnd(duration);
|
||||
setSplitPoints([]);
|
||||
|
||||
// Create a new default segment that spans the entire video
|
||||
if (!videoRef.current) return;
|
||||
|
||||
// No need to generate thumbnails - we'll use dynamic colors
|
||||
const defaultSegment: Segment = {
|
||||
id: Date.now(),
|
||||
name: "segment",
|
||||
startTime: 0,
|
||||
endTime: duration,
|
||||
thumbnail: "" // Empty placeholder - we'll use dynamic colors instead
|
||||
};
|
||||
|
||||
setClipSegments([defaultSegment]);
|
||||
saveState("reset_all");
|
||||
};
|
||||
|
||||
// Handle undo
|
||||
const handleUndo = () => {
|
||||
if (historyPosition > 0) {
|
||||
const previousState = history[historyPosition - 1];
|
||||
logger.debug(
|
||||
`** UNDO ** to position ${historyPosition - 1}, action: ${previousState.action}, segments: ${previousState.clipSegments.length}`
|
||||
);
|
||||
|
||||
// Log segment details to help debug
|
||||
logger.debug(
|
||||
"Segment details after undo:",
|
||||
previousState.clipSegments.map(
|
||||
(seg) =>
|
||||
`ID: ${seg.id}, Time: ${formatDetailedTime(seg.startTime)} - ${formatDetailedTime(seg.endTime)}`
|
||||
)
|
||||
);
|
||||
|
||||
// Apply the previous state with deep cloning to avoid reference issues
|
||||
setTrimStart(previousState.trimStart);
|
||||
setTrimEnd(previousState.trimEnd);
|
||||
setSplitPoints([...previousState.splitPoints]);
|
||||
setClipSegments(JSON.parse(JSON.stringify(previousState.clipSegments)));
|
||||
setHistoryPosition(historyPosition - 1);
|
||||
} else {
|
||||
logger.debug("Cannot undo: at earliest history position");
|
||||
}
|
||||
};
|
||||
|
||||
// Handle redo
|
||||
const handleRedo = () => {
|
||||
if (historyPosition < history.length - 1) {
|
||||
const nextState = history[historyPosition + 1];
|
||||
logger.debug(
|
||||
`** REDO ** to position ${historyPosition + 1}, action: ${nextState.action}, segments: ${nextState.clipSegments.length}`
|
||||
);
|
||||
|
||||
// Log segment details to help debug
|
||||
logger.debug(
|
||||
"Segment details after redo:",
|
||||
nextState.clipSegments.map(
|
||||
(seg) =>
|
||||
`ID: ${seg.id}, Time: ${formatDetailedTime(seg.startTime)} - ${formatDetailedTime(seg.endTime)}`
|
||||
)
|
||||
);
|
||||
|
||||
// Apply the next state with deep cloning to avoid reference issues
|
||||
setTrimStart(nextState.trimStart);
|
||||
setTrimEnd(nextState.trimEnd);
|
||||
setSplitPoints([...nextState.splitPoints]);
|
||||
setClipSegments(JSON.parse(JSON.stringify(nextState.clipSegments)));
|
||||
setHistoryPosition(historyPosition + 1);
|
||||
} else {
|
||||
logger.debug("Cannot redo: at latest history position");
|
||||
}
|
||||
};
|
||||
|
||||
// Handle zoom level change
|
||||
const handleZoomChange = (level: number) => {
|
||||
setZoomLevel(level);
|
||||
};
|
||||
|
||||
// Handle play/pause of the full video
|
||||
const handlePlay = () => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
if (isPlaying) {
|
||||
// Pause the video
|
||||
video.pause();
|
||||
setIsPlaying(false);
|
||||
} else {
|
||||
// iOS Safari fix: Check for lastSeekedPosition
|
||||
if (typeof window !== "undefined" && window.lastSeekedPosition > 0) {
|
||||
// Only seek if the position is significantly different
|
||||
if (Math.abs(video.currentTime - window.lastSeekedPosition) > 0.1) {
|
||||
console.log("handlePlay: Using lastSeekedPosition", window.lastSeekedPosition);
|
||||
video.currentTime = window.lastSeekedPosition;
|
||||
}
|
||||
}
|
||||
|
||||
// Play the video from current position with proper promise handling
|
||||
video
|
||||
.play()
|
||||
.then(() => {
|
||||
setIsPlaying(true);
|
||||
// Reset lastSeekedPosition after successful play
|
||||
if (typeof window !== "undefined") {
|
||||
window.lastSeekedPosition = 0;
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Error playing video:", err);
|
||||
setIsPlaying(false); // Reset state if play failed
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Toggle mute state
|
||||
const toggleMute = () => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
video.muted = !video.muted;
|
||||
setIsMuted(!isMuted);
|
||||
};
|
||||
|
||||
// Handle save action
|
||||
const handleSave = () => {
|
||||
// Sort segments chronologically by start time before saving
|
||||
const sortedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
|
||||
|
||||
// Create the JSON data for saving
|
||||
const saveData = {
|
||||
type: "save",
|
||||
segments: sortedSegments.map((segment) => ({
|
||||
startTime: formatDetailedTime(segment.startTime),
|
||||
endTime: formatDetailedTime(segment.endTime)
|
||||
}))
|
||||
};
|
||||
|
||||
// Display JSON in alert (for demonstration purposes)
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
console.debug("Saving data:", saveData);
|
||||
}
|
||||
|
||||
// Mark as saved - no unsaved changes
|
||||
setHasUnsavedChanges(false);
|
||||
|
||||
// Debug message
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
console.debug("Changes saved - reset unsaved changes flag");
|
||||
}
|
||||
|
||||
// Save to history with special "save" action to mark saved state
|
||||
saveState("save");
|
||||
|
||||
// In a real implementation, this would make a POST request to save the data
|
||||
// logger.debug("Save data:", saveData);
|
||||
};
|
||||
|
||||
// Handle save a copy action
|
||||
const handleSaveACopy = () => {
|
||||
// Sort segments chronologically by start time before saving
|
||||
const sortedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
|
||||
|
||||
// Create the JSON data for saving as a copy
|
||||
const saveData = {
|
||||
type: "save_as_a_copy",
|
||||
segments: sortedSegments.map((segment) => ({
|
||||
startTime: formatDetailedTime(segment.startTime),
|
||||
endTime: formatDetailedTime(segment.endTime)
|
||||
}))
|
||||
};
|
||||
|
||||
// Display JSON in alert (for demonstration purposes)
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
console.debug("Saving data as copy:", saveData);
|
||||
}
|
||||
|
||||
// Mark as saved - no unsaved changes
|
||||
setHasUnsavedChanges(false);
|
||||
|
||||
// Debug message
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
console.debug("Changes saved as copy - reset unsaved changes flag");
|
||||
}
|
||||
|
||||
// Save to history with special "save_copy" action to mark saved state
|
||||
saveState("save_copy");
|
||||
};
|
||||
|
||||
// Handle save segments individually action
|
||||
const handleSaveSegments = () => {
|
||||
// Sort segments chronologically by start time before saving
|
||||
const sortedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
|
||||
|
||||
// Create the JSON data for saving individual segments
|
||||
const saveData = {
|
||||
type: "save_segments",
|
||||
segments: sortedSegments.map((segment) => ({
|
||||
name: segment.name,
|
||||
startTime: formatDetailedTime(segment.startTime),
|
||||
endTime: formatDetailedTime(segment.endTime)
|
||||
}))
|
||||
};
|
||||
|
||||
// Display JSON in alert (for demonstration purposes)
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
console.debug("Saving data as segments:", saveData);
|
||||
}
|
||||
|
||||
// Mark as saved - no unsaved changes
|
||||
setHasUnsavedChanges(false);
|
||||
|
||||
// Debug message
|
||||
logger.debug("All segments saved individually - reset unsaved changes flag");
|
||||
|
||||
// Save to history with special "save_segments" action to mark saved state
|
||||
saveState("save_segments");
|
||||
};
|
||||
|
||||
// Handle seeking with mobile check
|
||||
const handleMobileSafeSeek = (time: number) => {
|
||||
// Only allow seeking if not on mobile or if video has been played
|
||||
if (!isMobile || videoInitialized) {
|
||||
seekVideo(time);
|
||||
}
|
||||
};
|
||||
|
||||
// Check if device is mobile
|
||||
const isMobile =
|
||||
typeof window !== "undefined" &&
|
||||
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile|mobile|CriOS/i.test(
|
||||
navigator.userAgent
|
||||
);
|
||||
|
||||
// Add videoInitialized state
|
||||
const [videoInitialized, setVideoInitialized] = useState(false);
|
||||
|
||||
// Effect to handle segments playback
|
||||
useEffect(() => {
|
||||
if (!isPlayingSegments || !videoRef.current) return;
|
||||
|
||||
const video = videoRef.current;
|
||||
const orderedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
|
||||
|
||||
const handleSegmentsPlayback = () => {
|
||||
const currentSegment = orderedSegments[currentSegmentIndex];
|
||||
if (!currentSegment) return;
|
||||
|
||||
const currentTime = video.currentTime;
|
||||
|
||||
// If we're before the current segment's start, jump to it
|
||||
if (currentTime < currentSegment.startTime) {
|
||||
video.currentTime = currentSegment.startTime;
|
||||
return;
|
||||
}
|
||||
|
||||
// If we've reached the end of the current segment
|
||||
if (currentTime >= currentSegment.endTime - 0.01) {
|
||||
if (currentSegmentIndex < orderedSegments.length - 1) {
|
||||
// Move to next segment
|
||||
const nextSegment = orderedSegments[currentSegmentIndex + 1];
|
||||
video.currentTime = nextSegment.startTime;
|
||||
setCurrentSegmentIndex(currentSegmentIndex + 1);
|
||||
|
||||
// If video is somehow paused, ensure it keeps playing
|
||||
if (video.paused) {
|
||||
logger.debug("Ensuring playback continues to next segment");
|
||||
video.play().catch((err) => {
|
||||
console.error("Error continuing segment playback:", err);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// End of all segments - only pause when we reach the very end
|
||||
video.pause();
|
||||
setIsPlayingSegments(false);
|
||||
setCurrentSegmentIndex(0);
|
||||
video.removeEventListener("timeupdate", handleSegmentsPlayback);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
video.addEventListener("timeupdate", handleSegmentsPlayback);
|
||||
|
||||
// Start playing if not already playing
|
||||
if (video.paused && orderedSegments.length > 0) {
|
||||
video.currentTime = orderedSegments[0].startTime;
|
||||
video.play().catch(console.error);
|
||||
}
|
||||
|
||||
return () => {
|
||||
video.removeEventListener("timeupdate", handleSegmentsPlayback);
|
||||
};
|
||||
}, [isPlayingSegments, currentSegmentIndex, clipSegments]);
|
||||
|
||||
// Effect to handle manual segment index updates during segments playback
|
||||
useEffect(() => {
|
||||
const handleSegmentIndexUpdate = (event: CustomEvent) => {
|
||||
const { segmentIndex } = event.detail;
|
||||
if (isPlayingSegments && segmentIndex !== currentSegmentIndex) {
|
||||
logger.debug(
|
||||
`Updating current segment index from ${currentSegmentIndex} to ${segmentIndex}`
|
||||
);
|
||||
setCurrentSegmentIndex(segmentIndex);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("update-segment-index", handleSegmentIndexUpdate as EventListener);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener(
|
||||
"update-segment-index",
|
||||
handleSegmentIndexUpdate as EventListener
|
||||
);
|
||||
};
|
||||
}, [isPlayingSegments, currentSegmentIndex]);
|
||||
|
||||
// Handle play segments
|
||||
const handlePlaySegments = () => {
|
||||
const video = videoRef.current;
|
||||
if (!video || clipSegments.length === 0) return;
|
||||
|
||||
if (isPlayingSegments) {
|
||||
// Stop segments playback
|
||||
video.pause();
|
||||
setIsPlayingSegments(false);
|
||||
setCurrentSegmentIndex(0);
|
||||
} else {
|
||||
// Start segments playback
|
||||
setIsPlayingSegments(true);
|
||||
setCurrentSegmentIndex(0);
|
||||
|
||||
// Start segments playback
|
||||
|
||||
// Sort segments by start time
|
||||
const orderedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
|
||||
|
||||
// Start from the first segment
|
||||
video.currentTime = orderedSegments[0].startTime;
|
||||
|
||||
// Start playback with proper error handling
|
||||
video.play().catch((err) => {
|
||||
console.error("Error starting segments playback:", err);
|
||||
setIsPlayingSegments(false);
|
||||
});
|
||||
|
||||
logger.debug("Starting playback of all segments continuously");
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
videoRef,
|
||||
currentTime,
|
||||
duration,
|
||||
isPlaying,
|
||||
setIsPlaying,
|
||||
isMuted,
|
||||
isPlayingSegments,
|
||||
thumbnails,
|
||||
trimStart,
|
||||
trimEnd,
|
||||
splitPoints,
|
||||
zoomLevel,
|
||||
clipSegments,
|
||||
hasUnsavedChanges,
|
||||
historyPosition,
|
||||
history,
|
||||
handleTrimStartChange,
|
||||
handleTrimEndChange,
|
||||
handleZoomChange,
|
||||
handleMobileSafeSeek,
|
||||
handleSplit,
|
||||
handleReset,
|
||||
handleUndo,
|
||||
handleRedo,
|
||||
handlePlaySegments,
|
||||
toggleMute,
|
||||
handleSave,
|
||||
handleSaveACopy,
|
||||
handleSaveSegments,
|
||||
isMobile,
|
||||
videoInitialized,
|
||||
setVideoInitialized
|
||||
};
|
||||
};
|
||||
|
||||
export default useVideoTrimmer;
|
||||
805
frontend-tools/video-editor/client/src/index.css
Normal file
@@ -0,0 +1,805 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--foreground: 20 14.3% 4.1%;
|
||||
--muted: 60 4.8% 95.9%;
|
||||
--muted-foreground: 25 5.3% 44.7%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 20 14.3% 4.1%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 20 14.3% 4.1%;
|
||||
--border: 20 5.9% 90%;
|
||||
--input: 20 5.9% 90%;
|
||||
--primary: 207 90% 54%;
|
||||
--primary-foreground: 211 100% 99%;
|
||||
--secondary: 30 84% 54%; /* Changed from red (0) to orange (30) */
|
||||
--secondary-foreground: 60 9.1% 97.8%;
|
||||
--accent: 60 4.8% 95.9%;
|
||||
--accent-foreground: 24 9.8% 10%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 60 9.1% 97.8%;
|
||||
--ring: 20 14.3% 4.1%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
}
|
||||
|
||||
/* Video Player Styles */
|
||||
.video-player {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
background-color: #000;
|
||||
overflow: hidden;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.video-controls {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: linear-gradient(to top, rgba(0, 0, 0, 0.8), transparent);
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.video-current-time {
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.video-progress {
|
||||
position: relative;
|
||||
height: 4px;
|
||||
background-color: rgba(255, 255, 255, 0.3);
|
||||
border-radius: 2px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.video-progress-fill {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
background-color: hsl(var(--primary));
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.video-scrubber {
|
||||
position: absolute;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
margin-left: -6px;
|
||||
background-color: white;
|
||||
border-radius: 50%;
|
||||
top: -4px;
|
||||
}
|
||||
|
||||
/* Play/Pause indicator for video player */
|
||||
.video-player-container {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.play-pause-indicator {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 70px;
|
||||
height: 70px;
|
||||
border-radius: 50%;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
z-index: 20;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
pointer-events: none;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.play-icon {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='36' height='36' fill='white'%3E%3Cpath d='M8 5v14l11-7z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.pause-icon {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='36' height='36' fill='white'%3E%3Cpath d='M6 19h4V5H6v14zm8-14v14h4V5h-4z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
/* Only show play/pause indicator on hover */
|
||||
.video-player-container:hover .play-pause-indicator {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Timeline Styles */
|
||||
.timeline-scroll-container {
|
||||
height: 6rem;
|
||||
border-radius: 0.375rem;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
margin-bottom: 0.75rem;
|
||||
background-color: #eee; /* Very light gray background */
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.timeline-container {
|
||||
position: relative;
|
||||
background-color: #eee; /* Very light gray background */
|
||||
height: 6rem;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.timeline-marker {
|
||||
position: absolute;
|
||||
top: -10px;
|
||||
height: calc(100% + 10px);
|
||||
width: 2px;
|
||||
background-color: red;
|
||||
z-index: 100; /* Highest z-index to stay on top of everything */
|
||||
pointer-events: none; /* Allow clicks to pass through to segments underneath */
|
||||
box-shadow: 0 0 4px rgba(255, 0, 0, 0.5); /* Add subtle glow effect */
|
||||
}
|
||||
|
||||
.trim-line-marker {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background-color: rgba(0, 123, 255, 0.9); /* Primary blue color */
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.trim-handle {
|
||||
width: 8px;
|
||||
background-color: rgba(108, 117, 125, 0.9); /* Secondary gray color */
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
cursor: ew-resize;
|
||||
z-index: 15;
|
||||
}
|
||||
|
||||
.trim-handle.left {
|
||||
left: -4px;
|
||||
}
|
||||
|
||||
.trim-handle.right {
|
||||
right: -4px;
|
||||
}
|
||||
|
||||
.timeline-thumbnail {
|
||||
height: 100%;
|
||||
border-right: 1px solid rgba(0, 0, 0, 0.1);
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
.split-point {
|
||||
position: absolute;
|
||||
width: 2px;
|
||||
background-color: rgba(108, 117, 125, 0.9); /* Secondary gray color */
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
/* Clip Segment styles */
|
||||
.clip-segment {
|
||||
position: absolute;
|
||||
height: 95%;
|
||||
top: 0;
|
||||
border-radius: 4px;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-blend-mode: soft-light;
|
||||
/* Border is now set in the color-specific rules */
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
overflow: hidden;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
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 {
|
||||
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 {
|
||||
background-color: transparent;
|
||||
border: 2px solid rgba(108, 117, 125, 0.9); /* Gray border */
|
||||
}
|
||||
|
||||
.clip-segment:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
transform: translateY(-1px);
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.clip-segment:active {
|
||||
cursor: grabbing;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.clip-segment.selected {
|
||||
border-width: 3px; /* Make border thicker instead of changing color */
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
z-index: 25;
|
||||
filter: brightness(1.2);
|
||||
}
|
||||
|
||||
.clip-segment-info {
|
||||
background-color: rgba(226, 230, 234, 0.9); /* Light gray background */
|
||||
color: #000000; /* Pure black text */
|
||||
padding: 6px 8px;
|
||||
font-size: 0.7rem;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
border-radius: 4px 4px 0 0;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.clip-segment-name {
|
||||
font-weight: bold;
|
||||
color: #000000; /* Pure black text */
|
||||
}
|
||||
|
||||
.clip-segment-time {
|
||||
font-size: 0.65rem;
|
||||
color: #000000; /* Pure black text */
|
||||
}
|
||||
|
||||
.clip-segment-duration {
|
||||
font-size: 0.65rem;
|
||||
color: #000000; /* Pure black text */
|
||||
background: rgba(179, 217, 255, 0.4); /* Light blue background */
|
||||
padding: 1px 4px;
|
||||
border-radius: 2px;
|
||||
display: inline-block;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.clip-segment-handle {
|
||||
position: absolute;
|
||||
width: 8px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(108, 117, 125, 0.9); /* Secondary gray color */
|
||||
cursor: ew-resize;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.clip-segment-handle::after {
|
||||
content: "↔";
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
text-shadow: 0 0 2px rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
.clip-segment-handle.left {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.clip-segment-handle.right {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.clip-segment-handle:hover {
|
||||
background-color: rgba(0, 123, 255, 0.9); /* Primary blue color */
|
||||
width: 10px;
|
||||
}
|
||||
|
||||
/* Zoom Slider */
|
||||
input[type="range"] {
|
||||
-webkit-appearance: none;
|
||||
height: 6px;
|
||||
background: #e0e0e0;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 123, 255, 0.9); /* Primary blue color */
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Tooltip styles */
|
||||
[data-tooltip] {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
[data-tooltip]::before {
|
||||
content: attr(data-tooltip);
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
margin-bottom: 8px;
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
color: white;
|
||||
padding: 5px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
white-space: nowrap;
|
||||
z-index: 1000;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition:
|
||||
opacity 0.2s,
|
||||
visibility 0.2s;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
[data-tooltip]::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border-width: 5px;
|
||||
border-style: solid;
|
||||
border-color: rgba(0, 0, 0, 0.8) transparent transparent transparent;
|
||||
margin-bottom: 0px;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition:
|
||||
opacity 0.2s,
|
||||
visibility 0.2s;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Only show tooltips on devices with mouse hover capability */
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
[data-tooltip]:hover::before,
|
||||
[data-tooltip]:hover::after {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
/* Hide button tooltips (simple hover labels) on touch devices */
|
||||
@media (pointer: coarse) {
|
||||
[data-tooltip]::before,
|
||||
[data-tooltip]::after {
|
||||
display: none !important;
|
||||
content: none !important;
|
||||
opacity: 0 !important;
|
||||
visibility: hidden !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Fix for buttons with disabled state */
|
||||
button[disabled][data-tooltip]::before,
|
||||
button[disabled][data-tooltip]::after {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Custom tooltip for action buttons - completely different approach */
|
||||
.tooltip-action-btn {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tooltip-action-btn[data-tooltip]::before,
|
||||
.tooltip-action-btn[data-tooltip]::after {
|
||||
/* Reset standard tooltip styles first */
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.tooltip-action-btn[data-tooltip]::before {
|
||||
content: attr(data-tooltip);
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 3px;
|
||||
white-space: nowrap;
|
||||
|
||||
/* Position below the button */
|
||||
bottom: -35px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.tooltip-action-btn[data-tooltip]::after {
|
||||
content: "";
|
||||
border-width: 5px;
|
||||
border-style: solid;
|
||||
border-color: transparent transparent rgba(0, 0, 0, 0.8) transparent;
|
||||
|
||||
/* Position the arrow */
|
||||
bottom: -15px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
/* Only show tooltips on devices with mouse hover capability */
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.tooltip-action-btn:hover[data-tooltip]::before,
|
||||
.tooltip-action-btn:hover[data-tooltip]::after {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
/* Ensure tooltip container has proper space */
|
||||
|
||||
/* Segment tooltip styles */
|
||||
.segment-tooltip {
|
||||
background-color: rgba(179, 217, 255, 0.95); /* Light blue color */
|
||||
color: #000000; /* Pure black text */
|
||||
border-radius: 4px;
|
||||
padding: 6px; /* Regular padding now that we have custom tooltips */
|
||||
min-width: 140px; /* Increased width to accommodate the new delete button */
|
||||
z-index: 1000; /* Increased z-index */
|
||||
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.segment-tooltip::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: -6px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 6px solid transparent;
|
||||
border-right: 6px solid transparent;
|
||||
border-top: 6px solid rgba(179, 217, 255, 0.95); /* Light blue color */
|
||||
}
|
||||
|
||||
.tooltip-time {
|
||||
font-size: 0.85rem;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
margin-bottom: 6px;
|
||||
color: #000000; /* Pure black text */
|
||||
}
|
||||
|
||||
.tooltip-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 5px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tooltip-action-btn {
|
||||
background-color: rgba(0, 123, 255, 0.2); /* Light blue background */
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
padding: 6px;
|
||||
transition: background-color 0.2s;
|
||||
min-width: 20px !important;
|
||||
}
|
||||
|
||||
.tooltip-action-btn:hover {
|
||||
background-color: rgba(0, 123, 255, 0.4); /* Slightly darker on hover */
|
||||
}
|
||||
|
||||
.tooltip-action-btn svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
stroke: currentColor;
|
||||
}
|
||||
|
||||
/* Adjust for the hand icons specifically */
|
||||
.tooltip-action-btn.set-in svg,
|
||||
.tooltip-action-btn.set-out svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0 auto;
|
||||
fill: currentColor;
|
||||
stroke: none;
|
||||
}
|
||||
|
||||
/* Empty space tooltip styling */
|
||||
.empty-space-tooltip {
|
||||
background-color: white;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0px 2px 8px rgba(0, 0, 0, 0.15);
|
||||
padding: 8px;
|
||||
z-index: 50;
|
||||
min-width: 120px;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.empty-space-tooltip::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: -8px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border-width: 8px 8px 0;
|
||||
border-style: solid;
|
||||
border-color: white transparent transparent;
|
||||
}
|
||||
|
||||
.tooltip-action-btn.new-segment {
|
||||
width: auto;
|
||||
padding: 6px 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.tooltip-btn-text {
|
||||
font-size: 0.8rem;
|
||||
white-space: nowrap;
|
||||
color: #000000; /* Pure black text */
|
||||
}
|
||||
|
||||
.icon-new-segment {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
/* Zoom dropdown styling */
|
||||
.zoom-dropdown-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.zoom-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background-color: rgba(108, 117, 125, 0.8);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 8px 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.zoom-button:hover {
|
||||
background-color: rgba(108, 117, 125, 1);
|
||||
}
|
||||
|
||||
.zoom-dropdown {
|
||||
background-color: white;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.15);
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.zoom-option {
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.zoom-option:hover {
|
||||
background-color: rgba(0, 123, 255, 0.1);
|
||||
}
|
||||
|
||||
.zoom-option.selected {
|
||||
background-color: rgba(0, 123, 255, 0.2);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Save buttons styling */
|
||||
.save-button,
|
||||
.save-copy-button,
|
||||
.save-segments-button {
|
||||
background-color: rgba(0, 123, 255, 0.8);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 8px 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.save-button:hover,
|
||||
.save-copy-button:hover {
|
||||
background-color: rgba(0, 123, 255, 1);
|
||||
}
|
||||
|
||||
.save-copy-button {
|
||||
background-color: rgba(108, 117, 125, 0.8);
|
||||
}
|
||||
|
||||
.save-copy-button:hover {
|
||||
background-color: rgba(108, 117, 125, 1);
|
||||
}
|
||||
|
||||
/* Time navigation input styling */
|
||||
.time-nav-label {
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.time-input {
|
||||
padding: 6px 10px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #ccc;
|
||||
width: 150px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.time-button-group {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.time-button {
|
||||
background-color: rgba(108, 117, 125, 0.8);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 6px 8px;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.time-button:hover {
|
||||
background-color: rgba(108, 117, 125, 1);
|
||||
}
|
||||
|
||||
/* Timeline navigation and zoom controls responsiveness */
|
||||
.timeline-controls {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap; /* Allow wrapping on smaller screens */
|
||||
padding: 12px;
|
||||
background-color: #f5f5f5;
|
||||
border-radius: 6px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.time-navigation {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.controls-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Media queries for responsive design */
|
||||
@media (max-width: 768px) {
|
||||
.timeline-controls {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.controls-right {
|
||||
margin-top: 10px;
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
text-align: center;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* Timeline header styling */
|
||||
.timeline-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
margin-bottom: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.timeline-title {
|
||||
font-weight: bold;
|
||||
margin-right: 20px;
|
||||
}
|
||||
|
||||
.timeline-title-text {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.current-time,
|
||||
.duration-time {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.time-code {
|
||||
font-family: monospace;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.timeline-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.time-navigation {
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.time-button-group {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.controls-right {
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.save-button,
|
||||
.save-copy-button {
|
||||
margin-top: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.zoom-dropdown-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.zoom-button {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
31
frontend-tools/video-editor/client/src/lib/logger.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* A consistent logger utility that only outputs debug messages in development
|
||||
* but always shows errors, warnings, and info messages.
|
||||
*/
|
||||
const logger = {
|
||||
/**
|
||||
* Logs debug messages only in development environment
|
||||
*/
|
||||
debug: (...args: any[]) => {
|
||||
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;
|
||||
55
frontend-tools/video-editor/client/src/lib/queryClient.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { QueryClient, QueryFunction } from "@tanstack/react-query";
|
||||
|
||||
async function throwIfResNotOk(res: Response) {
|
||||
if (!res.ok) {
|
||||
const text = (await res.text()) || res.statusText;
|
||||
throw new Error(`${res.status}: ${text}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiRequest(
|
||||
method: string,
|
||||
url: string,
|
||||
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"
|
||||
});
|
||||
|
||||
await throwIfResNotOk(res);
|
||||
return res;
|
||||
}
|
||||
|
||||
type UnauthorizedBehavior = "returnNull" | "throw";
|
||||
export const getQueryFn: <T>(options: { on401: UnauthorizedBehavior }) => QueryFunction<T> =
|
||||
({ on401: unauthorizedBehavior }) =>
|
||||
async ({ queryKey }) => {
|
||||
const res = await fetch(queryKey[0] as string, {
|
||||
credentials: "include"
|
||||
});
|
||||
|
||||
if (unauthorizedBehavior === "returnNull" && res.status === 401) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await throwIfResNotOk(res);
|
||||
return await res.json();
|
||||
};
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
queryFn: getQueryFn({ on401: "throw" }),
|
||||
refetchInterval: false,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: Infinity,
|
||||
retry: false
|
||||
},
|
||||
mutations: {
|
||||
retry: false
|
||||
}
|
||||
}
|
||||
});
|
||||
34
frontend-tools/video-editor/client/src/lib/timeUtils.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Format seconds to HH:MM:SS.mmm format with millisecond precision
|
||||
*/
|
||||
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}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Format seconds to MM:SS format - now uses the detailed format with hours and milliseconds
|
||||
*/
|
||||
export const formatTime = (seconds: number): string => {
|
||||
// Use the detailed format instead of the old MM:SS format
|
||||
return formatDetailedTime(seconds);
|
||||
};
|
||||
|
||||
/**
|
||||
* Format seconds to HH:MM:SS format - now uses the detailed format with milliseconds
|
||||
*/
|
||||
export const formatLongTime = (seconds: number): string => {
|
||||
// Use the detailed format
|
||||
return formatDetailedTime(seconds);
|
||||
};
|
||||
6
frontend-tools/video-editor/client/src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
47
frontend-tools/video-editor/client/src/lib/videoUtils.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* 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 => {
|
||||
// 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}%)`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Legacy function kept for compatibility
|
||||
* Now returns a data URL for a solid color square instead of a video thumbnail
|
||||
*/
|
||||
export const generateThumbnail = async (
|
||||
videoElement: HTMLVideoElement,
|
||||
time: number
|
||||
): Promise<string> => {
|
||||
return new Promise((resolve) => {
|
||||
// Create a small canvas for the solid color
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 10; // Much smaller - we only need a color
|
||||
canvas.height = 10;
|
||||
|
||||
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);
|
||||
resolve(dataUrl);
|
||||
});
|
||||
};
|
||||
37
frontend-tools/video-editor/client/src/main.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./index.css";
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.MEDIA_DATA = {
|
||||
videoUrl: "",
|
||||
mediaId: ""
|
||||
};
|
||||
window.lastSeekedPosition = 0;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
MEDIA_DATA: {
|
||||
videoUrl: string;
|
||||
mediaId: string;
|
||||
};
|
||||
seekToFunction?: (time: number) => void;
|
||||
lastSeekedPosition: number;
|
||||
}
|
||||
}
|
||||
|
||||
// Mount the components when the DOM is ready
|
||||
const mountComponents = () => {
|
||||
const trimContainer = document.getElementById("video-editor-trim-root");
|
||||
if (trimContainer) {
|
||||
const trimRoot = createRoot(trimContainer);
|
||||
trimRoot.render(<App />);
|
||||
}
|
||||
};
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", mountComponents);
|
||||
} else {
|
||||
mountComponents();
|
||||
}
|
||||
118
frontend-tools/video-editor/client/src/services/videoApi.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
// API service for video trimming operations
|
||||
|
||||
interface TrimVideoRequest {
|
||||
segments: {
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
name?: string;
|
||||
}[];
|
||||
saveAsCopy?: 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
|
||||
}
|
||||
|
||||
// Helper function to simulate delay
|
||||
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,
|
||||
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" },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// For error responses, return with error status and message
|
||||
if (response.status === 400) {
|
||||
// Handle 400 Bad Request - return with error details
|
||||
try {
|
||||
// Try to get error details from response
|
||||
const errorData = await response.json();
|
||||
return {
|
||||
status: 400,
|
||||
error: errorData.error || "An error occurred during processing",
|
||||
msg: "Video Processing Error",
|
||||
url_redirect: ""
|
||||
};
|
||||
} catch (parseError) {
|
||||
// If can't parse response JSON, return generic error
|
||||
return {
|
||||
status: 400,
|
||||
error: "An error occurred during video processing",
|
||||
msg: "Video Processing Error",
|
||||
url_redirect: ""
|
||||
};
|
||||
}
|
||||
} else if (response.status !== 404) {
|
||||
// Handle other error responses
|
||||
try {
|
||||
// Try to get error details from response
|
||||
const errorData = await response.json();
|
||||
return {
|
||||
status: response.status,
|
||||
error: errorData.error || "An error occurred during processing",
|
||||
msg: "Video Processing Error",
|
||||
url_redirect: ""
|
||||
};
|
||||
} catch (parseError) {
|
||||
// If can't parse response JSON, return generic error
|
||||
return {
|
||||
status: response.status,
|
||||
error: "An error occurred during video processing",
|
||||
msg: "Video Processing Error",
|
||||
url_redirect: ""
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// If endpoint not ready (404), return mock success response
|
||||
await delay(1500); // Simulate 1.5 second server delay
|
||||
return {
|
||||
status: 200, // Mock success status
|
||||
msg: "Video Processed Successfully", // Updated per requirements
|
||||
url_redirect: `./view?m=${mediaId}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Successful response
|
||||
const jsonResponse = await response.json();
|
||||
return {
|
||||
status: 200,
|
||||
msg: "Video Processed Successfully", // Ensure the success message is correct
|
||||
url_redirect: jsonResponse.url_redirect || `./view?m=${mediaId}`,
|
||||
...jsonResponse
|
||||
};
|
||||
} catch (error) {
|
||||
// For any fetch errors, return mock success response with delay
|
||||
await delay(1500); // Simulate 1.5 second server delay
|
||||
return {
|
||||
status: 200, // Mock success status
|
||||
msg: "Video Processed Successfully", // Consistent with requirements
|
||||
url_redirect: `./view?m=${mediaId}`
|
||||
};
|
||||
}
|
||||
|
||||
/* Mock implementation that simulates network latency
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve({
|
||||
msg: "Video is processing for trim",
|
||||
url_redirect: `./view?m=${mediaId}`
|
||||
});
|
||||
}, 1500); // Simulate 1.5 second server delay
|
||||
});
|
||||
*/
|
||||
};
|
||||
196
frontend-tools/video-editor/client/src/styles/ClipSegments.css
Normal file
@@ -0,0 +1,196 @@
|
||||
#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;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
margin-bottom: 5px;
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
color: white;
|
||||
text-align: center;
|
||||
padding: 5px 10px;
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition:
|
||||
opacity 0.2s,
|
||||
visibility 0.2s;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
[data-tooltip]:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border-width: 5px;
|
||||
border-style: solid;
|
||||
border-color: rgba(0, 0, 0, 0.8) transparent transparent transparent;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
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,
|
||||
[data-tooltip]:after {
|
||||
display: none !important;
|
||||
content: none !important;
|
||||
opacity: 0 !important;
|
||||
visibility: hidden !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
}
|
||||
.clip-segments-container {
|
||||
margin-top: 1rem;
|
||||
background-color: white;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.clip-segments-title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--foreground, #333);
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.segment-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.25rem;
|
||||
margin-bottom: 0.5rem;
|
||||
transition: box-shadow 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.segment-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.segment-thumbnail {
|
||||
width: 4rem;
|
||||
height: 2.25rem;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
border-radius: 0.25rem;
|
||||
margin-right: 0.75rem;
|
||||
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.segment-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.segment-title {
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
color: black;
|
||||
}
|
||||
|
||||
.segment-time {
|
||||
font-size: 0.75rem;
|
||||
color: black;
|
||||
}
|
||||
|
||||
.segment-duration {
|
||||
font-size: 0.75rem;
|
||||
margin-top: 0.25rem;
|
||||
display: inline-block;
|
||||
background-color: #f3f4f6;
|
||||
padding: 0 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
color: black;
|
||||
}
|
||||
|
||||
.segment-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.delete-button {
|
||||
padding: 0.375rem;
|
||||
color: #4b5563;
|
||||
background-color: #e5e7eb;
|
||||
border-radius: 9999px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 0.2s,
|
||||
color 0.2s;
|
||||
min-width: auto;
|
||||
|
||||
&:hover {
|
||||
color: black;
|
||||
background-color: #d1d5db;
|
||||
}
|
||||
|
||||
svg {
|
||||
height: 1rem;
|
||||
width: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-message {
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
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);
|
||||
}
|
||||
}
|
||||
397
frontend-tools/video-editor/client/src/styles/EditingTools.css
Normal file
@@ -0,0 +1,397 @@
|
||||
#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;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
margin-bottom: 5px;
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
color: white;
|
||||
text-align: center;
|
||||
padding: 5px 10px;
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition:
|
||||
opacity 0.2s,
|
||||
visibility 0.2s;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
[data-tooltip]:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border-width: 5px;
|
||||
border-style: solid;
|
||||
border-color: rgba(0, 0, 0, 0.8) transparent transparent transparent;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
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,
|
||||
[data-tooltip]:after {
|
||||
display: none !important;
|
||||
content: none !important;
|
||||
opacity: 0 !important;
|
||||
visibility: hidden !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.editing-tools-container {
|
||||
background-color: white;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
margin-bottom: 2.5rem;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.flex-container {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
gap: 15px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.flex-container.single-row {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
/* Show full text on larger screens, hide short text */
|
||||
.full-text {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.short-text {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Reset text always visible by default */
|
||||
.reset-text {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.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;
|
||||
color: #333;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
min-width: auto;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
svg {
|
||||
height: 1.25rem;
|
||||
width: 1.25rem;
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.divider {
|
||||
border-right: 1px solid #d1d5db;
|
||||
height: 1.5rem;
|
||||
margin: 0 0.5rem;
|
||||
}
|
||||
|
||||
/* Style for play buttons with highlight effect */
|
||||
.play-button,
|
||||
.preview-button {
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
min-width: 80px;
|
||||
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);
|
||||
color: #3b82f6;
|
||||
border: 1px solid #3b82f6;
|
||||
animation: bluePulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes bluePulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.4);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 8px rgba(59, 130, 246, 0);
|
||||
}
|
||||
100% {
|
||||
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) {
|
||||
/* Reset everything to prevent any changes */
|
||||
color: inherit !important;
|
||||
transform: none !important;
|
||||
font-size: 0.875rem !important;
|
||||
width: auto !important;
|
||||
background: none !important;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
opacity: 0.8;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
/* 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 */
|
||||
.button-group.secondary .button-text {
|
||||
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 {
|
||||
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 {
|
||||
justify-content: space-between;
|
||||
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;
|
||||
}
|
||||
|
||||
.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%;
|
||||
}
|
||||
}
|
||||
}
|
||||