mirror of
https://github.com/mediacms-io/mediacms.git
synced 2025-12-06 12:32:30 -05:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e790795bfd | ||
|
|
de99d84c18 | ||
|
|
8aa89c0958 | ||
|
|
df98b65704 | ||
|
|
a607996bfa | ||
|
|
79f2e2bb11 | ||
|
|
d54732040a | ||
|
|
e8520bc7cd | ||
|
|
b6e46e7b62 | ||
|
|
36eab954bd | ||
|
|
610716533b | ||
|
|
4f1c4a2b4c | ||
|
|
83f3eec940 | ||
|
|
a5acce4ab1 | ||
|
|
a4e9309350 | ||
|
|
6beaf0bbe2 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -25,3 +25,7 @@ yt.readme.md
|
||||
frontend-tools/.DS_Store
|
||||
static/video_editor/videos/sample-video-30s.mp4
|
||||
static/video_editor/videos/sample-video-37s.mp4
|
||||
/frontend-tools/video-editor-v2
|
||||
.DS_Store
|
||||
static/video_editor/videos/sample-video-10m.mp4
|
||||
static/video_editor/videos/sample-video-10s.mp4
|
||||
|
||||
18
Dockerfile
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
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; \
|
||||
|
||||
15
README.md
15
README.md
@@ -38,7 +38,7 @@ A demo is available at https://demo.mediacms.io
|
||||
- **Configurable actions**: allow download, add comments, add likes, dislikes, report media
|
||||
- **Configuration options**: change logos, fonts, styling, add more pages
|
||||
- **Enhanced video player**: customized video.js player with multiple resolution and playback speed options
|
||||
- **Multiple transcoding profiles**: sane defaults for multiple dimensions (240p, 360p, 480p, 720p, 1080p) and multiple profiles (h264, h265, vp9)
|
||||
- **Multiple transcoding profiles**: sane defaults for multiple dimensions (144p, 240p, 360p, 480p, 720p, 1080p) and multiple profiles (h264, h265, vp9)
|
||||
- **Adaptive video streaming**: possible through HLS protocol
|
||||
- **Subtitles/CC**: support for multilingual subtitle files
|
||||
- **Scalable transcoding**: transcoding through priorities. Experimental support for remote workers
|
||||
@@ -93,20 +93,15 @@ There are two ways to run MediaCMS, through Docker Compose and through installin
|
||||
|
||||
A complete guide can be found on the blog post [How to self-host and share your videos in 2021](https://medium.com/@MediaCMS.io/how-to-self-host-and-share-your-videos-in-2021-14067e3b291b).
|
||||
|
||||
## Configuration
|
||||
|
||||
Visit [Configuration](docs/admins_docs.md#5-configuration) page.
|
||||
|
||||
|
||||
## Information for developers
|
||||
Check out the new section on the [Developer Experience](docs/dev_exp.md) page
|
||||
|
||||
|
||||
## Documentation
|
||||
|
||||
* [Users documentation](docs/user_docs.md) page
|
||||
* [Administrators documentation](docs/admins_docs.md) page
|
||||
* [Developers documentation](docs/developers_docs.md) page
|
||||
* [Configuration](docs/admins_docs.md#5-configuration) page
|
||||
* [Transcoding](docs/transcoding.md) page
|
||||
* [Developer Experience](docs/dev_exp.md) page
|
||||
* [Media Permissions](docs/media_permissions.md) page
|
||||
|
||||
|
||||
## Technology
|
||||
|
||||
@@ -186,7 +186,7 @@ CHUNKIZE_VIDEO_DURATION = 60 * 5
|
||||
VIDEO_CHUNKS_DURATION = 60 * 4
|
||||
|
||||
# always get these two, even if upscaling
|
||||
MINIMUM_RESOLUTIONS_TO_ENCODE = [240, 360]
|
||||
MINIMUM_RESOLUTIONS_TO_ENCODE = [144, 240]
|
||||
|
||||
# default settings for notifications
|
||||
# not all of them are implemented
|
||||
@@ -376,16 +376,7 @@ LOGGING = {
|
||||
},
|
||||
}
|
||||
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.postgresql",
|
||||
"NAME": "mediacms",
|
||||
"HOST": "127.0.0.1",
|
||||
"PORT": "5432",
|
||||
"USER": "mediacms",
|
||||
"PASSWORD": "mediacms",
|
||||
}
|
||||
}
|
||||
DATABASES = {"default": {"ENGINE": "django.db.backends.postgresql", "NAME": "mediacms", "HOST": "127.0.0.1", "PORT": "5432", "USER": "mediacms", "PASSWORD": "mediacms", "OPTIONS": {'pool': True}}}
|
||||
|
||||
|
||||
REDIS_LOCATION = "redis://127.0.0.1:6379/1"
|
||||
@@ -453,6 +444,7 @@ DEFAULT_AUTO_FIELD = 'django.db.models.AutoField'
|
||||
LANGUAGES = [
|
||||
('ar', _('Arabic')),
|
||||
('bn', _('Bengali')),
|
||||
('da', _('Danish')),
|
||||
('nl', _('Dutch')),
|
||||
('en', _('English')),
|
||||
('fr', _('French')),
|
||||
@@ -465,11 +457,13 @@ LANGUAGES = [
|
||||
('pt', _('Portuguese')),
|
||||
('ru', _('Russian')),
|
||||
('zh-hans', _('Simplified Chinese')),
|
||||
('sl', _('Slovenian')),
|
||||
('zh-hant', _('Traditional Chinese')),
|
||||
('es', _('Spanish')),
|
||||
('tr', _('Turkish')),
|
||||
('el', _('Greek')),
|
||||
('ur', _('Urdu')),
|
||||
('he', _('Hebrew')),
|
||||
]
|
||||
|
||||
LANGUAGE_CODE = 'en' # default language
|
||||
@@ -501,6 +495,15 @@ JAZZMIN_UI_TWEAKS = {"theme": "flatly"}
|
||||
USE_ROUNDED_CORNERS = True
|
||||
|
||||
ALLOW_VIDEO_TRIMMER = True
|
||||
|
||||
ALLOW_CUSTOM_MEDIA_URLS = False
|
||||
|
||||
# Whether to allow anonymous users to list all users
|
||||
ALLOW_ANONYMOUS_USER_LISTING = True
|
||||
|
||||
# ffmpeg options
|
||||
FFMPEG_DEFAULT_PRESET = "medium" # see https://trac.ffmpeg.org/wiki/Encode/H.264
|
||||
|
||||
try:
|
||||
# keep a local_settings.py file for local overrides
|
||||
from .local_settings import * # noqa
|
||||
@@ -540,13 +543,5 @@ except ImportError:
|
||||
|
||||
|
||||
if GLOBAL_LOGIN_REQUIRED:
|
||||
# this should go after the AuthenticationMiddleware middleware
|
||||
MIDDLEWARE.insert(6, "login_required.middleware.LoginRequiredMiddleware")
|
||||
LOGIN_REQUIRED_IGNORE_PATHS = [
|
||||
r'/accounts/login/$',
|
||||
r'/accounts/logout/$',
|
||||
r'/accounts/signup/$',
|
||||
r'/accounts/password/.*/$',
|
||||
r'/accounts/confirm-email/.*/$',
|
||||
# r'/api/v[0-9]+/',
|
||||
]
|
||||
auth_index = MIDDLEWARE.index("django.contrib.auth.middleware.AuthenticationMiddleware")
|
||||
MIDDLEWARE.insert(auth_index + 1, "django.contrib.auth.middleware.LoginRequiredMiddleware")
|
||||
|
||||
@@ -1 +1 @@
|
||||
VERSION = "6.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},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Table of contents
|
||||
- [1. Welcome](#1-welcome)
|
||||
- [2. Server Installaton](#2-server-installation)
|
||||
- [2. Single Server Installaton](#2-single-server-installation)
|
||||
- [3. Docker Installation](#3-docker-installation)
|
||||
- [4. Docker Deployment options](#4-docker-deployment-options)
|
||||
- [5. Configuration](#5-configuration)
|
||||
@@ -25,20 +25,20 @@
|
||||
- [22. Role-Based Access Control](#22-role-based-access-control)
|
||||
- [23. SAML setup](#23-saml-setup)
|
||||
- [24. Identity Providers setup](#24-identity-providers-setup)
|
||||
|
||||
- [25. Custom urls](#25-custom-urls)
|
||||
|
||||
|
||||
## 1. Welcome
|
||||
This page is created for MediaCMS administrators that are responsible for setting up the software, maintaining it and making modifications.
|
||||
|
||||
## 2. Server Installation
|
||||
## 2. Single Server Installation
|
||||
|
||||
The core dependencies are Python3, Django3, Celery, PostgreSQL, Redis, ffmpeg. Any system that can have these dependencies installed, can run MediaCMS. But we strongly suggest installing on Linux Ubuntu (tested on versions 20, 22).
|
||||
The core dependencies are python3, Django, celery, PostgreSQL, redis, ffmpeg. Any system that can have these dependencies installed, can run MediaCMS. But the install.sh is only tested in Linux Ubuntu 24 and 22 versions.
|
||||
|
||||
Installation on an Ubuntu system with git utility installed should be completed in a few minutes with the following steps.
|
||||
Installation on an Ubuntu 22/24 system with git utility installed should be completed in a few minutes with the following steps.
|
||||
Make sure you run it as user root, on a clear system, since the automatic script will install and configure the following services: Celery/PostgreSQL/Redis/Nginx and will override any existing settings.
|
||||
|
||||
Automated script - tested on Ubuntu 20, Ubuntu 22 and Debian Buster
|
||||
|
||||
|
||||
```bash
|
||||
mkdir /home/mediacms.io && cd /home/mediacms.io/
|
||||
@@ -89,13 +89,11 @@ Database can be backed up with pg_dump and media_files on /home/mediacms.io/medi
|
||||
## Installation
|
||||
Install a recent version of [Docker](https://docs.docker.com/get-docker/), and [Docker Compose](https://docs.docker.com/compose/install/).
|
||||
|
||||
For Ubuntu 20/22 systems this is:
|
||||
For Ubuntu systems this is:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://get.docker.com -o get-docker.sh
|
||||
sudo sh get-docker.sh
|
||||
sudo curl -L "https://github.com/docker/compose/releases/download/1.29.2/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
|
||||
sudo chmod +x /usr/local/bin/docker-compose
|
||||
```
|
||||
|
||||
Then run as root
|
||||
@@ -111,7 +109,7 @@ If you want to explore more options (including setup of https with letsencrypt c
|
||||
Run
|
||||
|
||||
```bash
|
||||
docker-compose up
|
||||
docker compose up
|
||||
```
|
||||
|
||||
This will download all MediaCMS related Docker images and start all containers. Once it finishes, MediaCMS will be installed and available on http://localhost or http://ip
|
||||
@@ -131,8 +129,8 @@ Get latest MediaCMS image and stop/start containers
|
||||
```bash
|
||||
cd /path/to/mediacms/installation
|
||||
docker pull mediacms/mediacms
|
||||
docker-compose down
|
||||
docker-compose up
|
||||
docker compose down
|
||||
docker compose up
|
||||
```
|
||||
|
||||
### Update from version 2 to version 3
|
||||
@@ -170,9 +168,7 @@ By default, all these services are enabled, but in order to create a scaleable d
|
||||
|
||||
Also see the `Dockerfile` for other environment variables which you may wish to override. Application settings, eg. `FRONTEND_HOST` can also be overridden by updating the `deploy/docker/local_settings.py` file.
|
||||
|
||||
See example deployments in the sections below. These example deployments have been tested on `docker-compose version 1.27.4` running on `Docker version 19.03.13`
|
||||
|
||||
To run, update the configs above if necessary, build the image by running `docker-compose build`, then run `docker-compose run`
|
||||
To run, update the configs above if necessary, build the image by running `docker compose build`, then run `docker compose run`
|
||||
|
||||
### Simple Deployment, accessed as http://localhost
|
||||
|
||||
@@ -189,7 +185,7 @@ Edit this file and set `VIRTUAL_HOST` as my_domain.com, `LETSENCRYPT_HOST` as my
|
||||
|
||||
Edit `deploy/docker/local_settings.py` and set https://my_domain.com as `FRONTEND_HOST`
|
||||
|
||||
Now run docker-compose -f docker-compose-letsencrypt.yaml up, when installation finishes you will be able to access https://my_domain.com using a valid Letsencrypt certificate!
|
||||
Now run `docker compose -f docker-compose-letsencrypt.yaml up`, when installation finishes you will be able to access https://my_domain.com using a valid Letsencrypt certificate!
|
||||
|
||||
### Advanced Deployment, accessed as http://localhost:8000
|
||||
|
||||
@@ -230,7 +226,7 @@ Single server installation: edit `cms/local_settings.py`, make a change and rest
|
||||
Docker Compose installation: edit `deploy/docker/local_settings.py`, make a change and restart MediaCMS containers
|
||||
|
||||
```bash
|
||||
#docker-compose restart web celery_worker celery_beat
|
||||
#docker compose restart web celery_worker celery_beat
|
||||
```
|
||||
|
||||
### 5.1 Change portal logo
|
||||
@@ -504,6 +500,16 @@ By default `CAN_COMMENT = "all"` means that all registered users can add comment
|
||||
|
||||
- **advancedUser**, only users that are marked as advanced users can add comment. Admins or MediaCMS managers can make users advanced users by editing their profile and selecting advancedUser.
|
||||
|
||||
### 5.26 Control whether anonymous users can list all users
|
||||
|
||||
By default, anonymous users can view the list of all users on the platform. To restrict this to authenticated users only, set:
|
||||
|
||||
```
|
||||
ALLOW_ANONYMOUS_USER_LISTING = False
|
||||
```
|
||||
|
||||
When set to False, only logged-in users will be able to access the user listing API endpoint.
|
||||
|
||||
|
||||
## 6. Manage pages
|
||||
to be written
|
||||
@@ -967,3 +973,6 @@ USE_IDENTITY_PROVIDERS = True
|
||||
|
||||
Visiting the admin, you will see the Identity Providers tab and you can add one.
|
||||
|
||||
## 25. Custom urls
|
||||
To enable custom urls, set `ALLOW_CUSTOM_MEDIA_URLS = True` on settings.py or local_settings.py
|
||||
This will enable editing the URL of the media, while editing a media. If the URL is already taken you get a message you cannot update this.
|
||||
|
||||
@@ -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
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
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)
|
||||
@@ -1,5 +1,7 @@
|
||||
from django.conf import settings
|
||||
|
||||
from cms.version import VERSION
|
||||
|
||||
from .frontend_translations import get_translation, get_translation_strings
|
||||
from .methods import is_mediacms_editor, is_mediacms_manager
|
||||
|
||||
@@ -37,6 +39,7 @@ def stuff(request):
|
||||
ret["USE_SAML"] = settings.USE_SAML
|
||||
ret["USE_RBAC"] = settings.USE_RBAC
|
||||
ret["USE_ROUNDED_CORNERS"] = settings.USE_ROUNDED_CORNERS
|
||||
ret["VERSION"] = VERSION
|
||||
|
||||
if request.user.is_superuser:
|
||||
ret["DJANGO_ADMIN_URL"] = settings.DJANGO_ADMIN_URL
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -22,6 +22,7 @@ class MediaMetadataForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Media
|
||||
fields = (
|
||||
"friendly_token",
|
||||
"title",
|
||||
"new_tags",
|
||||
"add_date",
|
||||
@@ -34,15 +35,17 @@ class MediaMetadataForm(forms.ModelForm):
|
||||
widgets = {
|
||||
"new_tags": MultipleSelect(),
|
||||
"description": forms.Textarea(attrs={'rows': 4}),
|
||||
"add_date": forms.DateInput(attrs={'type': 'date'}),
|
||||
"add_date": forms.DateInput(attrs={'type': 'date'}, format='%Y-%m-%d'),
|
||||
"thumbnail_time": forms.NumberInput(attrs={'min': 0, 'step': 0.1}),
|
||||
}
|
||||
labels = {
|
||||
"friendly_token": "Slug",
|
||||
"uploaded_poster": "Poster Image",
|
||||
"thumbnail_time": "Thumbnail Time (seconds)",
|
||||
}
|
||||
help_texts = {
|
||||
"title": "",
|
||||
"friendly_token": "Media URL slug",
|
||||
"thumbnail_time": "Select the time in seconds for the video thumbnail",
|
||||
"uploaded_poster": "Maximum file size: 5MB",
|
||||
}
|
||||
@@ -50,6 +53,8 @@ class MediaMetadataForm(forms.ModelForm):
|
||||
def __init__(self, user, *args, **kwargs):
|
||||
self.user = user
|
||||
super(MediaMetadataForm, self).__init__(*args, **kwargs)
|
||||
if not getattr(settings, 'ALLOW_CUSTOM_MEDIA_URLS', False):
|
||||
self.fields.pop("friendly_token")
|
||||
if self.instance.media_type != "video":
|
||||
self.fields.pop("thumbnail_time")
|
||||
if self.instance.media_type == "image":
|
||||
@@ -63,20 +68,37 @@ class MediaMetadataForm(forms.ModelForm):
|
||||
self.helper.form_method = 'post'
|
||||
self.helper.form_enctype = "multipart/form-data"
|
||||
self.helper.form_show_errors = False
|
||||
self.helper.layout = Layout(
|
||||
|
||||
layout_fields = [
|
||||
CustomField('title'),
|
||||
CustomField('new_tags'),
|
||||
CustomField('add_date'),
|
||||
CustomField('description'),
|
||||
CustomField('uploaded_poster'),
|
||||
CustomField('enable_comments'),
|
||||
)
|
||||
]
|
||||
if self.instance.media_type != "image":
|
||||
layout_fields.append(CustomField('uploaded_poster'))
|
||||
|
||||
self.helper.layout = Layout(*layout_fields)
|
||||
|
||||
if self.instance.media_type == "video":
|
||||
self.helper.layout.append(CustomField('thumbnail_time'))
|
||||
if getattr(settings, 'ALLOW_CUSTOM_MEDIA_URLS', False):
|
||||
self.helper.layout.insert(0, CustomField('friendly_token'))
|
||||
|
||||
self.helper.layout.append(FormActions(Submit('submit', 'Update Media', css_class='primaryAction')))
|
||||
|
||||
def clean_friendly_token(self):
|
||||
token = self.cleaned_data.get("friendly_token", "").strip()
|
||||
|
||||
if token:
|
||||
if not all(c.isalnum() or c in "-_" for c in token):
|
||||
raise forms.ValidationError("Slug can only contain alphanumeric characters, underscores, or hyphens.")
|
||||
|
||||
if Media.objects.filter(friendly_token=token).exclude(pk=self.instance.pk).exists():
|
||||
raise forms.ValidationError("This slug is already in use. Please choose a different one.")
|
||||
return token
|
||||
|
||||
def clean_uploaded_poster(self):
|
||||
image = self.cleaned_data.get("uploaded_poster", False)
|
||||
if image:
|
||||
|
||||
104
files/frontend_translations/da.py
Normal file
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
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': 'לפני שנים',
|
||||
}
|
||||
104
files/frontend_translations/sl.py
Normal file
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",
|
||||
}
|
||||
@@ -34,12 +34,6 @@ BUF_SIZE_MULTIPLIER = 1.5
|
||||
KEYFRAME_DISTANCE = 4
|
||||
KEYFRAME_DISTANCE_MIN = 2
|
||||
|
||||
# speed presets
|
||||
# see https://trac.ffmpeg.org/wiki/Encode/H.264
|
||||
X26x_PRESET = "medium" # "medium"
|
||||
X265_PRESET = "medium"
|
||||
X26x_PRESET_BIG_HEIGHT = "faster"
|
||||
|
||||
# VP9_SPEED = 1 # between 0 and 4, lower is slower
|
||||
VP9_SPEED = 2
|
||||
|
||||
@@ -55,6 +49,7 @@ VIDEO_CRFS = {
|
||||
VIDEO_BITRATES = {
|
||||
"h264": {
|
||||
25: {
|
||||
144: 150,
|
||||
240: 300,
|
||||
360: 500,
|
||||
480: 1000,
|
||||
@@ -67,6 +62,7 @@ VIDEO_BITRATES = {
|
||||
},
|
||||
"h265": {
|
||||
25: {
|
||||
144: 75,
|
||||
240: 150,
|
||||
360: 275,
|
||||
480: 500,
|
||||
@@ -79,6 +75,7 @@ VIDEO_BITRATES = {
|
||||
},
|
||||
"vp9": {
|
||||
25: {
|
||||
144: 75,
|
||||
240: 150,
|
||||
360: 275,
|
||||
480: 500,
|
||||
@@ -173,7 +170,7 @@ def rm_dir(directory):
|
||||
|
||||
def url_from_path(filename):
|
||||
# TODO: find a way to preserver http - https ...
|
||||
return "{0}{1}".format(settings.MEDIA_URL, filename.replace(settings.MEDIA_ROOT, ""))
|
||||
return f"{settings.MEDIA_URL}{filename.replace(settings.MEDIA_ROOT, '')}"
|
||||
|
||||
|
||||
def create_temp_file(suffix=None, dir=settings.TEMP_DIRECTORY):
|
||||
@@ -488,7 +485,7 @@ def show_file_size(size):
|
||||
if size:
|
||||
size = size / 1000000
|
||||
size = round(size, 1)
|
||||
size = "{0}MB".format(str(size))
|
||||
size = f"{str(size)}MB"
|
||||
return size
|
||||
|
||||
|
||||
@@ -596,17 +593,13 @@ def get_base_ffmpeg_command(
|
||||
cmd = base_cmd[:]
|
||||
|
||||
# preset settings
|
||||
preset = getattr(settings, "FFMPEG_DEFAULT_PRESET", "medium")
|
||||
|
||||
if encoder == "libvpx-vp9":
|
||||
if pass_number == 1:
|
||||
speed = 4
|
||||
else:
|
||||
speed = VP9_SPEED
|
||||
elif encoder in ["libx264"]:
|
||||
preset = X26x_PRESET
|
||||
elif encoder in ["libx265"]:
|
||||
preset = X265_PRESET
|
||||
if target_height >= 720:
|
||||
preset = X26x_PRESET_BIG_HEIGHT
|
||||
|
||||
if encoder == "libx264":
|
||||
level = "4.2" if target_height <= 1080 else "5.2"
|
||||
@@ -730,7 +723,7 @@ def produce_ffmpeg_commands(media_file, media_info, resolution, codec, output_fi
|
||||
return False
|
||||
|
||||
if media_info.get("video_height") < resolution:
|
||||
if resolution not in [240, 360]: # always get these two
|
||||
if resolution not in settings.MINIMUM_RESOLUTIONS_TO_ENCODE:
|
||||
return False
|
||||
|
||||
# if codec == "h264_baseline":
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -166,14 +166,14 @@ Media becomes private if it gets reported %s times\n
|
||||
)
|
||||
|
||||
if settings.ADMINS_NOTIFICATIONS.get("MEDIA_REPORTED", False):
|
||||
title = "[{}] - Media was reported".format(settings.PORTAL_NAME)
|
||||
title = f"[{settings.PORTAL_NAME}] - Media was reported"
|
||||
d = {}
|
||||
d["title"] = title
|
||||
d["msg"] = msg
|
||||
d["to"] = settings.ADMIN_EMAIL_LIST
|
||||
notify_items.append(d)
|
||||
if settings.USERS_NOTIFICATIONS.get("MEDIA_REPORTED", False):
|
||||
title = "[{}] - Media was reported".format(settings.PORTAL_NAME)
|
||||
title = f"[{settings.PORTAL_NAME}] - Media was reported"
|
||||
d = {}
|
||||
d["title"] = title
|
||||
d["msg"] = msg
|
||||
@@ -182,7 +182,7 @@ Media becomes private if it gets reported %s times\n
|
||||
|
||||
if action == "media_added" and media:
|
||||
if settings.ADMINS_NOTIFICATIONS.get("MEDIA_ADDED", False):
|
||||
title = "[{}] - Media was added".format(settings.PORTAL_NAME)
|
||||
title = f"[{settings.PORTAL_NAME}] - Media was added"
|
||||
msg = """
|
||||
Media %s was added by user %s.
|
||||
""" % (
|
||||
@@ -195,7 +195,7 @@ Media %s was added by user %s.
|
||||
d["to"] = settings.ADMIN_EMAIL_LIST
|
||||
notify_items.append(d)
|
||||
if settings.USERS_NOTIFICATIONS.get("MEDIA_ADDED", False):
|
||||
title = "[{}] - Your media was added".format(settings.PORTAL_NAME)
|
||||
title = f"[{settings.PORTAL_NAME}] - Your media was added"
|
||||
msg = """
|
||||
Your media has been added! It will be encoded and will be available soon.
|
||||
URL: %s
|
||||
@@ -339,7 +339,7 @@ def notify_user_on_comment(friendly_token):
|
||||
media_url = settings.SSL_FRONTEND_HOST + media.get_absolute_url()
|
||||
|
||||
if user.notification_on_comments:
|
||||
title = "[{}] - A comment was added".format(settings.PORTAL_NAME)
|
||||
title = f"[{settings.PORTAL_NAME}] - A comment was added"
|
||||
msg = """
|
||||
A comment has been added to your media %s .
|
||||
View it on %s
|
||||
@@ -363,7 +363,7 @@ def notify_user_on_mention(friendly_token, user_mentioned, cleaned_comment):
|
||||
media_url = settings.SSL_FRONTEND_HOST + media.get_absolute_url()
|
||||
|
||||
if user.notification_on_comments:
|
||||
title = "[{}] - You were mentioned in a comment".format(settings.PORTAL_NAME)
|
||||
title = f"[{settings.PORTAL_NAME}] - You were mentioned in a comment"
|
||||
msg = """
|
||||
You were mentioned in a comment on %s .
|
||||
View it on %s
|
||||
@@ -567,3 +567,42 @@ def handle_video_chapters(media, chapters):
|
||||
video_chapter = models.VideoChapterData.objects.create(media=media, data=chapters)
|
||||
|
||||
return media.chapter_data
|
||||
|
||||
|
||||
def change_media_owner(media_id, new_user):
|
||||
"""Change the owner of a media
|
||||
|
||||
Args:
|
||||
media_id: ID of the media to change owner
|
||||
new_user: New user object to set as owner
|
||||
|
||||
Returns:
|
||||
Media object or None if media not found
|
||||
"""
|
||||
media = models.Media.objects.filter(id=media_id).first()
|
||||
if not media:
|
||||
return None
|
||||
|
||||
# Change the owner
|
||||
media.user = new_user
|
||||
media.save(update_fields=["user"])
|
||||
|
||||
# Update any related permissions
|
||||
media_permissions = models.MediaPermission.objects.filter(media=media)
|
||||
for permission in media_permissions:
|
||||
permission.owner_user = new_user
|
||||
permission.save(update_fields=["owner_user"])
|
||||
|
||||
return media
|
||||
|
||||
|
||||
def copy_media(media_id):
|
||||
"""Create a copy of a media
|
||||
|
||||
Args:
|
||||
media_id: ID of the media to copy
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
pass
|
||||
|
||||
17
files/migrations/0009_alter_media_friendly_token.py
Normal file
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
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
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
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
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
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
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
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
|
||||
File diff suppressed because it is too large
Load Diff
97
files/models/playlist.py
Normal file
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
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
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
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
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)
|
||||
@@ -136,8 +136,8 @@ def chunkize_media(self, friendly_token, profiles, force=True):
|
||||
cwd = os.path.dirname(os.path.realpath(media.media_file.path))
|
||||
file_name = media.media_file.path.split("/")[-1]
|
||||
random_prefix = produce_friendly_token()
|
||||
file_format = "{0}_{1}".format(random_prefix, file_name)
|
||||
chunks_file_name = "%02d_{0}".format(file_format)
|
||||
file_format = f"{random_prefix}_{file_name}"
|
||||
chunks_file_name = f"%02d_{file_format}"
|
||||
chunks_file_name += ".mkv"
|
||||
cmd = [
|
||||
settings.FFMPEG_COMMAND,
|
||||
@@ -162,7 +162,7 @@ def chunkize_media(self, friendly_token, profiles, force=True):
|
||||
chunks.append(ch[0])
|
||||
if not chunks:
|
||||
# command completely failed to segment file.putting to normal encode
|
||||
logger.info("Failed to break file {0} in chunks." " Putting to normal encode queue".format(friendly_token))
|
||||
logger.info(f"Failed to break file {friendly_token} in chunks. Putting to normal encode queue")
|
||||
for profile in profiles:
|
||||
if media.video_height and media.video_height < profile.resolution:
|
||||
if profile.resolution not in settings.MINIMUM_RESOLUTIONS_TO_ENCODE:
|
||||
@@ -211,7 +211,7 @@ def chunkize_media(self, friendly_token, profiles, force=True):
|
||||
priority=priority,
|
||||
)
|
||||
|
||||
logger.info("got {0} chunks and will encode to {1} profiles".format(len(chunks), to_profiles))
|
||||
logger.info(f"got {len(chunks)} chunks and will encode to {to_profiles} profiles")
|
||||
return True
|
||||
|
||||
|
||||
@@ -355,8 +355,8 @@ def encode_media(
|
||||
# return False
|
||||
|
||||
with tempfile.TemporaryDirectory(dir=settings.TEMP_DIRECTORY) as temp_dir:
|
||||
tf = create_temp_file(suffix=".{0}".format(profile.extension), dir=temp_dir)
|
||||
tfpass = create_temp_file(suffix=".{0}".format(profile.extension), dir=temp_dir)
|
||||
tf = create_temp_file(suffix=f".{profile.extension}", dir=temp_dir)
|
||||
tfpass = create_temp_file(suffix=f".{profile.extension}", dir=temp_dir)
|
||||
ffmpeg_commands = produce_ffmpeg_commands(
|
||||
original_media_path,
|
||||
media.media_info,
|
||||
@@ -398,7 +398,7 @@ def encode_media(
|
||||
if n_times % 60 == 0:
|
||||
encoding.progress = percent
|
||||
encoding.save(update_fields=["progress", "update_date"])
|
||||
logger.info("Saved {0}".format(round(percent, 2)))
|
||||
logger.info(f"Saved {round(percent, 2)}")
|
||||
n_times += 1
|
||||
except DatabaseError:
|
||||
# primary reason for this is that the encoding has been deleted, because
|
||||
@@ -451,7 +451,7 @@ def encode_media(
|
||||
|
||||
with open(tf, "rb") as f:
|
||||
myfile = File(f)
|
||||
output_name = "{0}.{1}".format(get_file_name(original_media_path), profile.extension)
|
||||
output_name = f"{get_file_name(original_media_path)}.{profile.extension}"
|
||||
encoding.media_file.save(content=myfile, name=output_name)
|
||||
encoding.total_run_time = (encoding.update_date - encoding.add_date).seconds
|
||||
|
||||
@@ -472,7 +472,7 @@ def produce_sprite_from_video(friendly_token):
|
||||
try:
|
||||
media = Media.objects.get(friendly_token=friendly_token)
|
||||
except BaseException:
|
||||
logger.info("failed to get media with friendly_token %s" % friendly_token)
|
||||
logger.info(f"failed to get media with friendly_token {friendly_token}")
|
||||
return False
|
||||
|
||||
with tempfile.TemporaryDirectory(dir=settings.TEMP_DIRECTORY) as tmpdirname:
|
||||
@@ -516,7 +516,7 @@ def create_hls(friendly_token):
|
||||
try:
|
||||
media = Media.objects.get(friendly_token=friendly_token)
|
||||
except BaseException:
|
||||
logger.info("failed to get media with friendly_token %s" % friendly_token)
|
||||
logger.info(f"failed to get media with friendly_token {friendly_token}")
|
||||
return False
|
||||
|
||||
p = media.uid.hex
|
||||
@@ -558,7 +558,7 @@ def check_running_states():
|
||||
|
||||
encodings = Encoding.objects.filter(status="running")
|
||||
|
||||
logger.info("got {0} encodings that are in state running".format(encodings.count()))
|
||||
logger.info(f"got {encodings.count()} encodings that are in state running")
|
||||
changed = 0
|
||||
for encoding in encodings:
|
||||
now = datetime.now(encoding.update_date.tzinfo)
|
||||
@@ -575,7 +575,7 @@ def check_running_states():
|
||||
# TODO: allign with new code + chunksize...
|
||||
changed += 1
|
||||
if changed:
|
||||
logger.info("changed from running to pending on {0} items".format(changed))
|
||||
logger.info(f"changed from running to pending on {changed} items")
|
||||
return True
|
||||
|
||||
|
||||
@@ -585,7 +585,7 @@ def check_media_states():
|
||||
# check encoding status of not success media
|
||||
media = Media.objects.filter(Q(encoding_status="running") | Q(encoding_status="fail") | Q(encoding_status="pending"))
|
||||
|
||||
logger.info("got {0} media that are not in state success".format(media.count()))
|
||||
logger.info(f"got {media.count()} media that are not in state success")
|
||||
|
||||
changed = 0
|
||||
for m in media:
|
||||
@@ -593,7 +593,7 @@ def check_media_states():
|
||||
m.save(update_fields=["encoding_status"])
|
||||
changed += 1
|
||||
if changed:
|
||||
logger.info("changed encoding status to {0} media items".format(changed))
|
||||
logger.info(f"changed encoding status to {changed} media items")
|
||||
return True
|
||||
|
||||
|
||||
@@ -628,7 +628,7 @@ def check_pending_states():
|
||||
media.encode(profiles=[profile], force=False)
|
||||
changed += 1
|
||||
if changed:
|
||||
logger.info("set to the encode queue {0} encodings that were on pending state".format(changed))
|
||||
logger.info(f"set to the encode queue {changed} encodings that were on pending state")
|
||||
return True
|
||||
|
||||
|
||||
@@ -652,7 +652,7 @@ def check_missing_profiles():
|
||||
# if they appear on the meanwhile (eg on a big queue)
|
||||
changed += 1
|
||||
if changed:
|
||||
logger.info("set to the encode queue {0} profiles".format(changed))
|
||||
logger.info(f"set to the encode queue {changed} profiles")
|
||||
return True
|
||||
|
||||
|
||||
@@ -820,7 +820,7 @@ def update_listings_thumbnails():
|
||||
# Categories
|
||||
used_media = []
|
||||
saved = 0
|
||||
qs = Category.objects.filter().order_by("-media_count")
|
||||
qs = Category.objects.filter()
|
||||
for object in qs:
|
||||
media = Media.objects.exclude(friendly_token__in=used_media).filter(category=object, state="public", is_reviewed=True).order_by("-views").first()
|
||||
if media:
|
||||
@@ -828,12 +828,12 @@ def update_listings_thumbnails():
|
||||
object.save(update_fields=["listings_thumbnail"])
|
||||
used_media.append(media.friendly_token)
|
||||
saved += 1
|
||||
logger.info("updated {} categories".format(saved))
|
||||
logger.info(f"updated {saved} categories")
|
||||
|
||||
# Tags
|
||||
used_media = []
|
||||
saved = 0
|
||||
qs = Tag.objects.filter().order_by("-media_count")
|
||||
qs = Tag.objects.filter()
|
||||
for object in qs:
|
||||
media = Media.objects.exclude(friendly_token__in=used_media).filter(tags=object, state="public", is_reviewed=True).order_by("-views").first()
|
||||
if media:
|
||||
@@ -841,7 +841,7 @@ def update_listings_thumbnails():
|
||||
object.save(update_fields=["listings_thumbnail"])
|
||||
used_media.append(media.friendly_token)
|
||||
saved += 1
|
||||
logger.info("updated {} tags".format(saved))
|
||||
logger.info(f"updated {saved} tags")
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@@ -48,10 +48,12 @@ urlpatterns = [
|
||||
re_path(r"^view", views.view_media, name="get_media"),
|
||||
re_path(r"^upload", views.upload_media, name="upload_media"),
|
||||
# API VIEWS
|
||||
re_path(r"^api/v1/media/user/bulk_actions$", views.MediaBulkUserActions.as_view()),
|
||||
re_path(r"^api/v1/media/user/bulk_actions/$", views.MediaBulkUserActions.as_view()),
|
||||
re_path(r"^api/v1/media$", views.MediaList.as_view()),
|
||||
re_path(r"^api/v1/media/$", views.MediaList.as_view()),
|
||||
re_path(
|
||||
r"^api/v1/media/(?P<friendly_token>[\w]*)$",
|
||||
r"^api/v1/media/(?P<friendly_token>[\w\-_]*)$",
|
||||
views.MediaDetail.as_view(),
|
||||
name="api_get_media",
|
||||
),
|
||||
|
||||
1736
files/views.py
1736
files/views.py
File diff suppressed because it is too large
Load Diff
43
files/views/__init__.py
Normal file
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
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
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
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
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
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
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
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
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
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
BIN
fixtures/test_image2.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
3
frontend-tools/video-editor/.gitignore
vendored
3
frontend-tools/video-editor/.gitignore
vendored
@@ -10,3 +10,6 @@ client/public/videos/sample-video-30s.mp4
|
||||
client/public/videos/sample-video-37s.mp4
|
||||
videos/sample-video-37s.mp4
|
||||
client/public/videos/sample-video-30s.mp4
|
||||
client/public/videos/sample-video-1.mp4
|
||||
client/public/videos/sample-video-10m.mp4
|
||||
client/public/videos/sample-video-10s.mp4
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
*
|
||||
22
frontend-tools/video-editor/.prettierrc
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
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"
|
||||
}
|
||||
@@ -128,4 +128,44 @@ npm run deploy
|
||||
|
||||
## API Integration
|
||||
|
||||
The video editor interfaces with MediaCMS through a set of API endpoints for retrieving and saving video edits.
|
||||
The video editor interfaces with MediaCMS through a set of API endpoints for retrieving and saving video edits.
|
||||
|
||||
Sure! Here's your updated `README.md` section with a new **"Code Formatting"** section using Prettier. I placed it after the "Development" section to keep the flow logical:
|
||||
|
||||
---
|
||||
|
||||
## Code Formatting
|
||||
|
||||
To automatically format all source files using [Prettier](https://prettier.io):
|
||||
|
||||
```bash
|
||||
# Format all code in the src directory
|
||||
npx prettier --write src/
|
||||
```
|
||||
|
||||
Or for specific file types:
|
||||
|
||||
```bash
|
||||
cd frontend-tools/video-editor/
|
||||
npx prettier --write "client/src/**/*.{js,jsx,ts,tsx,json,css,scss,md}"
|
||||
```
|
||||
|
||||
You can also add this as a script in `package.json`:
|
||||
|
||||
```json
|
||||
"scripts": {
|
||||
"format": "prettier --write client/src/"
|
||||
}
|
||||
```
|
||||
|
||||
Then run:
|
||||
|
||||
```bash
|
||||
yarn format
|
||||
# or
|
||||
npm run format
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
Let me know if you'd like to auto-format on commit using `lint-staged` + `husky`.
|
||||
|
||||
@@ -16,7 +16,6 @@ const App = () => {
|
||||
isPlaying,
|
||||
setIsPlaying,
|
||||
isMuted,
|
||||
isPreviewMode,
|
||||
thumbnails,
|
||||
trimStart,
|
||||
trimEnd,
|
||||
@@ -34,7 +33,6 @@ const App = () => {
|
||||
handleReset,
|
||||
handleUndo,
|
||||
handleRedo,
|
||||
handlePreview,
|
||||
toggleMute,
|
||||
handleSave,
|
||||
handleSaveACopy,
|
||||
@@ -43,7 +41,7 @@ const App = () => {
|
||||
videoInitialized,
|
||||
setVideoInitialized,
|
||||
isPlayingSegments,
|
||||
handlePlaySegments,
|
||||
handlePlaySegments
|
||||
} = useVideoTrimmer();
|
||||
|
||||
// Function to play from the beginning
|
||||
@@ -71,31 +69,31 @@ const App = () => {
|
||||
|
||||
const handlePlay = () => {
|
||||
if (!videoRef.current) return;
|
||||
|
||||
|
||||
const video = videoRef.current;
|
||||
|
||||
|
||||
// If already playing, just pause the video
|
||||
if (isPlaying) {
|
||||
video.pause();
|
||||
setIsPlaying(false);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const currentPosition = Number(video.currentTime.toFixed(6)); // Fix to microsecond precision
|
||||
|
||||
|
||||
// Find the next stopping point based on current position
|
||||
let stopTime = duration;
|
||||
let currentSegment = null;
|
||||
let nextSegment = null;
|
||||
|
||||
|
||||
// Sort segments by start time to ensure correct order
|
||||
const sortedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
|
||||
|
||||
|
||||
// First, check if we're inside a segment or exactly at its start/end
|
||||
currentSegment = sortedSegments.find(seg => {
|
||||
currentSegment = sortedSegments.find((seg) => {
|
||||
const segStartTime = Number(seg.startTime.toFixed(6));
|
||||
const segEndTime = Number(seg.endTime.toFixed(6));
|
||||
|
||||
|
||||
// Check if we're inside the segment
|
||||
if (currentPosition > segStartTime && currentPosition < segEndTime) {
|
||||
return true;
|
||||
@@ -111,15 +109,15 @@ const App = () => {
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
|
||||
// If we're not in a segment, find the next segment
|
||||
if (!currentSegment) {
|
||||
nextSegment = sortedSegments.find(seg => {
|
||||
nextSegment = sortedSegments.find((seg) => {
|
||||
const segStartTime = Number(seg.startTime.toFixed(6));
|
||||
return segStartTime > currentPosition;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Determine where to stop based on position
|
||||
if (currentSegment) {
|
||||
// If we're in a segment, stop at its end
|
||||
@@ -128,113 +126,123 @@ const App = () => {
|
||||
// If we're in a cutaway and there's a next segment, stop at its start
|
||||
stopTime = Number(nextSegment.startTime.toFixed(6));
|
||||
}
|
||||
|
||||
|
||||
// Create a boundary checker function with high precision
|
||||
const checkBoundary = () => {
|
||||
if (!video) return;
|
||||
|
||||
|
||||
const currentPosition = Number(video.currentTime.toFixed(6));
|
||||
const timeLeft = Number((stopTime - currentPosition).toFixed(6));
|
||||
|
||||
|
||||
// If we've reached or passed the boundary
|
||||
if (timeLeft <= 0 || currentPosition >= stopTime) {
|
||||
// First pause playback
|
||||
video.pause();
|
||||
|
||||
|
||||
// Force exact position with multiple verification attempts
|
||||
const setExactPosition = () => {
|
||||
if (!video) return;
|
||||
|
||||
|
||||
// Set to exact boundary time
|
||||
video.currentTime = stopTime;
|
||||
handleMobileSafeSeek(stopTime);
|
||||
|
||||
|
||||
const actualPosition = Number(video.currentTime.toFixed(6));
|
||||
const difference = Number(Math.abs(actualPosition - stopTime).toFixed(6));
|
||||
|
||||
|
||||
logger.debug("Position verification:", {
|
||||
target: formatDetailedTime(stopTime),
|
||||
actual: formatDetailedTime(actualPosition),
|
||||
difference: difference
|
||||
});
|
||||
|
||||
|
||||
// If we're not exactly at the target position, try one more time
|
||||
if (difference > 0) {
|
||||
video.currentTime = stopTime;
|
||||
handleMobileSafeSeek(stopTime);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Multiple attempts to ensure precision, with increasing delays
|
||||
setExactPosition();
|
||||
setTimeout(setExactPosition, 5); // Quick first retry
|
||||
setTimeout(setExactPosition, 5); // Quick first retry
|
||||
setTimeout(setExactPosition, 10); // Second retry
|
||||
setTimeout(setExactPosition, 20); // Third retry if needed
|
||||
setTimeout(setExactPosition, 50); // Final verification
|
||||
|
||||
|
||||
// Remove our boundary checker
|
||||
video.removeEventListener('timeupdate', checkBoundary);
|
||||
video.removeEventListener("timeupdate", checkBoundary);
|
||||
setIsPlaying(false);
|
||||
|
||||
|
||||
// Log the final position for debugging
|
||||
logger.debug("Stopped at position:", {
|
||||
target: formatDetailedTime(stopTime),
|
||||
actual: formatDetailedTime(video.currentTime),
|
||||
type: currentSegment ? "segment end" : (nextSegment ? "next segment start" : "end of video"),
|
||||
segment: currentSegment ? {
|
||||
id: currentSegment.id,
|
||||
start: formatDetailedTime(currentSegment.startTime),
|
||||
end: formatDetailedTime(currentSegment.endTime)
|
||||
} : null,
|
||||
nextSegment: nextSegment ? {
|
||||
id: nextSegment.id,
|
||||
start: formatDetailedTime(nextSegment.startTime),
|
||||
end: formatDetailedTime(nextSegment.endTime)
|
||||
} : null
|
||||
type: currentSegment
|
||||
? "segment end"
|
||||
: nextSegment
|
||||
? "next segment start"
|
||||
: "end of video",
|
||||
segment: currentSegment
|
||||
? {
|
||||
id: currentSegment.id,
|
||||
start: formatDetailedTime(currentSegment.startTime),
|
||||
end: formatDetailedTime(currentSegment.endTime)
|
||||
}
|
||||
: null,
|
||||
nextSegment: nextSegment
|
||||
? {
|
||||
id: nextSegment.id,
|
||||
start: formatDetailedTime(nextSegment.startTime),
|
||||
end: formatDetailedTime(nextSegment.endTime)
|
||||
}
|
||||
: null
|
||||
});
|
||||
|
||||
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Start our boundary checker
|
||||
video.addEventListener('timeupdate', checkBoundary);
|
||||
|
||||
video.addEventListener("timeupdate", checkBoundary);
|
||||
|
||||
// Start playing
|
||||
video.play()
|
||||
video
|
||||
.play()
|
||||
.then(() => {
|
||||
setIsPlaying(true);
|
||||
setVideoInitialized(true);
|
||||
logger.debug("Playback started:", {
|
||||
from: formatDetailedTime(currentPosition),
|
||||
to: formatDetailedTime(stopTime),
|
||||
currentSegment: currentSegment ? {
|
||||
id: currentSegment.id,
|
||||
start: formatDetailedTime(currentSegment.startTime),
|
||||
end: formatDetailedTime(currentSegment.endTime)
|
||||
} : 'None',
|
||||
nextSegment: nextSegment ? {
|
||||
id: nextSegment.id,
|
||||
start: formatDetailedTime(nextSegment.startTime),
|
||||
end: formatDetailedTime(nextSegment.endTime)
|
||||
} : 'None'
|
||||
currentSegment: currentSegment
|
||||
? {
|
||||
id: currentSegment.id,
|
||||
start: formatDetailedTime(currentSegment.startTime),
|
||||
end: formatDetailedTime(currentSegment.endTime)
|
||||
}
|
||||
: "None",
|
||||
nextSegment: nextSegment
|
||||
? {
|
||||
id: nextSegment.id,
|
||||
start: formatDetailedTime(nextSegment.startTime),
|
||||
end: formatDetailedTime(nextSegment.endTime)
|
||||
}
|
||||
: "None"
|
||||
});
|
||||
})
|
||||
.catch(err => {
|
||||
.catch((err) => {
|
||||
console.error("Error playing video:", err);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-background min-h-screen">
|
||||
<MobilePlayPrompt
|
||||
videoRef={videoRef}
|
||||
onPlay={handlePlay}
|
||||
/>
|
||||
|
||||
<MobilePlayPrompt videoRef={videoRef} onPlay={handlePlay} />
|
||||
|
||||
<div className="container mx-auto px-4 py-6 max-w-6xl">
|
||||
{/* Video Player */}
|
||||
<VideoPlayer
|
||||
<VideoPlayer
|
||||
videoRef={videoRef}
|
||||
currentTime={currentTime}
|
||||
duration={duration}
|
||||
@@ -246,15 +254,13 @@ const App = () => {
|
||||
/>
|
||||
|
||||
{/* Editing Tools */}
|
||||
<EditingTools
|
||||
<EditingTools
|
||||
onSplit={handleSplit}
|
||||
onReset={handleReset}
|
||||
onUndo={handleUndo}
|
||||
onRedo={handleRedo}
|
||||
onPreview={handlePreview}
|
||||
onPlaySegments={handlePlaySegments}
|
||||
onPlay={handlePlay}
|
||||
isPreviewMode={isPreviewMode}
|
||||
isPlaying={isPlaying}
|
||||
isPlayingSegments={isPlayingSegments}
|
||||
canUndo={historyPosition > 0}
|
||||
@@ -262,7 +268,7 @@ const App = () => {
|
||||
/>
|
||||
|
||||
{/* Timeline Controls */}
|
||||
<TimelineControls
|
||||
<TimelineControls
|
||||
currentTime={currentTime}
|
||||
duration={duration}
|
||||
thumbnails={thumbnails}
|
||||
@@ -279,7 +285,6 @@ const App = () => {
|
||||
onSave={handleSave}
|
||||
onSaveACopy={handleSaveACopy}
|
||||
onSaveSegments={handleSaveSegments}
|
||||
isPreviewMode={isPreviewMode}
|
||||
hasUnsavedChanges={hasUnsavedChanges}
|
||||
isIOSUninitialized={isMobile && !videoInitialized}
|
||||
isPlaying={isPlaying}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { formatTime, formatLongTime } from "@/lib/timeUtils";
|
||||
import '../styles/ClipSegments.css';
|
||||
import "../styles/ClipSegments.css";
|
||||
|
||||
export interface Segment {
|
||||
id: number;
|
||||
@@ -16,41 +16,36 @@ interface ClipSegmentsProps {
|
||||
const ClipSegments = ({ segments }: ClipSegmentsProps) => {
|
||||
// Sort segments by startTime
|
||||
const sortedSegments = [...segments].sort((a, b) => a.startTime - b.startTime);
|
||||
|
||||
|
||||
// Handle delete segment click
|
||||
const handleDeleteSegment = (segmentId: number) => {
|
||||
// Create and dispatch the delete event
|
||||
const deleteEvent = new CustomEvent('delete-segment', {
|
||||
detail: { segmentId }
|
||||
const deleteEvent = new CustomEvent("delete-segment", {
|
||||
detail: { segmentId }
|
||||
});
|
||||
document.dispatchEvent(deleteEvent);
|
||||
};
|
||||
|
||||
|
||||
// Generate the same color background for a segment as shown in the timeline
|
||||
const getSegmentColorClass = (index: number) => {
|
||||
// Return CSS class based on index modulo 8
|
||||
// Return CSS class based on index modulo 8
|
||||
// This matches the CSS nth-child selectors in the timeline
|
||||
return `segment-default-color segment-color-${(index % 8) + 1}`;
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div className="clip-segments-container">
|
||||
<h3 className="clip-segments-title">Clip Segments</h3>
|
||||
|
||||
|
||||
{sortedSegments.map((segment, index) => (
|
||||
<div
|
||||
key={segment.id}
|
||||
className={`segment-item ${getSegmentColorClass(index)}`}
|
||||
>
|
||||
<div key={segment.id} className={`segment-item ${getSegmentColorClass(index)}`}>
|
||||
<div className="segment-content">
|
||||
<div
|
||||
className="segment-thumbnail"
|
||||
<div
|
||||
className="segment-thumbnail"
|
||||
style={{ backgroundImage: `url(${segment.thumbnail})` }}
|
||||
></div>
|
||||
<div className="segment-info">
|
||||
<div className="segment-title">
|
||||
Segment {index + 1}
|
||||
</div>
|
||||
<div className="segment-title">Segment {index + 1}</div>
|
||||
<div className="segment-time">
|
||||
{formatTime(segment.startTime)} - {formatTime(segment.endTime)}
|
||||
</div>
|
||||
@@ -60,20 +55,24 @@ const ClipSegments = ({ segments }: ClipSegmentsProps) => {
|
||||
</div>
|
||||
</div>
|
||||
<div className="segment-actions">
|
||||
<button
|
||||
className="delete-button"
|
||||
<button
|
||||
className="delete-button"
|
||||
aria-label="Delete Segment"
|
||||
data-tooltip="Delete this segment"
|
||||
onClick={() => handleDeleteSegment(segment.id)}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
|
||||
{sortedSegments.length === 0 && (
|
||||
<div className="empty-message">
|
||||
No segments created yet. Use the split button to create segments.
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import '../styles/EditingTools.css';
|
||||
import { useEffect, useState } from 'react';
|
||||
import "../styles/EditingTools.css";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface EditingToolsProps {
|
||||
onSplit: () => void;
|
||||
onReset: () => void;
|
||||
onUndo: () => void;
|
||||
onRedo: () => void;
|
||||
onPreview: () => void;
|
||||
onPlaySegments: () => void;
|
||||
onPlay: () => void;
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
isPreviewMode?: boolean;
|
||||
isPlaying?: boolean;
|
||||
isPlayingSegments?: boolean;
|
||||
}
|
||||
@@ -21,14 +19,12 @@ const EditingTools = ({
|
||||
onReset,
|
||||
onUndo,
|
||||
onRedo,
|
||||
onPreview,
|
||||
onPlaySegments,
|
||||
onPlay,
|
||||
canUndo,
|
||||
canRedo,
|
||||
isPreviewMode = false,
|
||||
isPlaying = false,
|
||||
isPlayingSegments = false,
|
||||
isPlayingSegments = false
|
||||
}: EditingToolsProps) => {
|
||||
const [isSmallScreen, setIsSmallScreen] = useState(false);
|
||||
|
||||
@@ -38,17 +34,17 @@ const EditingTools = ({
|
||||
};
|
||||
|
||||
checkScreenSize();
|
||||
window.addEventListener('resize', checkScreenSize);
|
||||
return () => window.removeEventListener('resize', checkScreenSize);
|
||||
window.addEventListener("resize", checkScreenSize);
|
||||
return () => window.removeEventListener("resize", checkScreenSize);
|
||||
}, []);
|
||||
|
||||
// Handle play button click with iOS fix
|
||||
const handlePlay = () => {
|
||||
// Ensure lastSeekedPosition is used when play is clicked
|
||||
if (typeof window !== 'undefined') {
|
||||
if (typeof window !== "undefined") {
|
||||
console.log("Play button clicked, current lastSeekedPosition:", window.lastSeekedPosition);
|
||||
}
|
||||
|
||||
|
||||
// Call the original handler
|
||||
onPlay();
|
||||
};
|
||||
@@ -59,15 +55,25 @@ const EditingTools = ({
|
||||
{/* Left side - Play buttons group */}
|
||||
<div className="button-group play-buttons-group">
|
||||
{/* Play Segments button */}
|
||||
<button
|
||||
<button
|
||||
className={`button segments-button`}
|
||||
onClick={onPlaySegments}
|
||||
data-tooltip={isPlayingSegments ? "Stop segments playback" : "Play segments in one continuous flow"}
|
||||
style={{ fontSize: '0.875rem' }}
|
||||
data-tooltip={
|
||||
isPlayingSegments ? "Stop segments playback" : "Play segments in one continuous flow"
|
||||
}
|
||||
style={{ fontSize: "0.875rem" }}
|
||||
>
|
||||
{isPlayingSegments ? (
|
||||
<>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="10" y1="15" x2="10" y2="9" />
|
||||
<line x1="14" y1="15" x2="14" y2="9" />
|
||||
@@ -77,7 +83,15 @@ const EditingTools = ({
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polygon points="10 8 16 12 10 16 10 8" />
|
||||
</svg>
|
||||
@@ -116,18 +130,26 @@ const EditingTools = ({
|
||||
)}
|
||||
</button> */}
|
||||
|
||||
{/* Standard Play button (only shown when not in preview mode or segments playback) */}
|
||||
{!isPreviewMode && (!isPlayingSegments || !isSmallScreen) && (
|
||||
<button
|
||||
className={`button play-button ${isPlayingSegments ? 'greyed-out' : ''}`}
|
||||
{/* Standard Play button (only shown when not in segments playback on small screens) */}
|
||||
{(!isPlayingSegments || !isSmallScreen) && (
|
||||
<button
|
||||
className={`button play-button ${isPlayingSegments ? "greyed-out" : ""}`}
|
||||
onClick={handlePlay}
|
||||
data-tooltip={isPlaying ? "Pause video" : "Play full video"}
|
||||
style={{ fontSize: '0.875rem' }}
|
||||
style={{ fontSize: "0.875rem" }}
|
||||
disabled={isPlayingSegments}
|
||||
>
|
||||
{isPlaying ? (
|
||||
{isPlaying && !isPlayingSegments ? (
|
||||
<>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="10" y1="15" x2="10" y2="9" />
|
||||
<line x1="14" y1="15" x2="14" y2="9" />
|
||||
@@ -137,7 +159,15 @@ const EditingTools = ({
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polygon points="10 8 16 12 10 16 10 8" />
|
||||
</svg>
|
||||
@@ -147,7 +177,7 @@ const EditingTools = ({
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
|
||||
{/* Segments Playback message (replaces play button during segments playback) */}
|
||||
{/* {isPlayingSegments && !isSmallScreen && (
|
||||
<div className="segments-playback-message">
|
||||
@@ -159,7 +189,7 @@ const EditingTools = ({
|
||||
Preview Mode
|
||||
</div>
|
||||
)} */}
|
||||
|
||||
|
||||
{/* Preview mode message (replaces play button) */}
|
||||
{/* {isPreviewMode && (
|
||||
<div className="preview-mode-message">
|
||||
@@ -172,43 +202,64 @@ const EditingTools = ({
|
||||
</div>
|
||||
)} */}
|
||||
</div>
|
||||
|
||||
|
||||
{/* Right side - Editing tools */}
|
||||
<div className="button-group secondary">
|
||||
<button
|
||||
<button
|
||||
className="button"
|
||||
aria-label="Undo"
|
||||
data-tooltip="Undo last action"
|
||||
disabled={!canUndo}
|
||||
aria-label="Undo"
|
||||
data-tooltip={isPlayingSegments ? "Disabled during preview" : "Undo last action"}
|
||||
disabled={!canUndo || isPlayingSegments}
|
||||
onClick={onUndo}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M9 14 4 9l5-5"/>
|
||||
<path d="M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5v0a5.5 5.5 0 0 1-5.5 5.5H11"/>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M9 14 4 9l5-5" />
|
||||
<path d="M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5v0a5.5 5.5 0 0 1-5.5 5.5H11" />
|
||||
</svg>
|
||||
<span className="button-text">Undo</span>
|
||||
</button>
|
||||
<button
|
||||
<button
|
||||
className="button"
|
||||
aria-label="Redo"
|
||||
data-tooltip="Redo last undone action"
|
||||
disabled={!canRedo}
|
||||
aria-label="Redo"
|
||||
data-tooltip={isPlayingSegments ? "Disabled during preview" : "Redo last undone action"}
|
||||
disabled={!canRedo || isPlayingSegments}
|
||||
onClick={onRedo}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="m15 14 5-5-5-5"/>
|
||||
<path d="M20 9H9.5A5.5 5.5 0 0 0 4 14.5v0A5.5 5.5 0 0 0 9.5 20H13"/>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="m15 14 5-5-5-5" />
|
||||
<path d="M20 9H9.5A5.5 5.5 0 0 0 4 14.5v0A5.5 5.5 0 0 0 9.5 20H13" />
|
||||
</svg>
|
||||
<span className="button-text">Redo</span>
|
||||
</button>
|
||||
<div className="divider"></div>
|
||||
<button
|
||||
<button
|
||||
className="button"
|
||||
onClick={onReset}
|
||||
data-tooltip="Reset to full video"
|
||||
data-tooltip={isPlayingSegments ? "Disabled during preview" : "Reset to full video"}
|
||||
disabled={isPlayingSegments}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clipRule="evenodd" />
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span className="reset-text">Reset</span>
|
||||
</button>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import '../styles/IOSPlayPrompt.css';
|
||||
import React, { useState, useEffect } from "react";
|
||||
import "../styles/IOSPlayPrompt.css";
|
||||
|
||||
interface MobilePlayPromptProps {
|
||||
videoRef: React.RefObject<HTMLVideoElement>;
|
||||
@@ -13,7 +13,9 @@ const MobilePlayPrompt: React.FC<MobilePlayPromptProps> = ({ videoRef, onPlay })
|
||||
useEffect(() => {
|
||||
const checkIsMobile = () => {
|
||||
// More comprehensive check for mobile/tablet devices
|
||||
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile|mobile|CriOS/i.test(navigator.userAgent);
|
||||
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile|mobile|CriOS/i.test(
|
||||
navigator.userAgent
|
||||
);
|
||||
};
|
||||
|
||||
// Always show for mobile devices on each visit
|
||||
@@ -31,9 +33,9 @@ const MobilePlayPrompt: React.FC<MobilePlayPromptProps> = ({ videoRef, onPlay })
|
||||
setIsVisible(false);
|
||||
};
|
||||
|
||||
video.addEventListener('play', handlePlay);
|
||||
video.addEventListener("play", handlePlay);
|
||||
return () => {
|
||||
video.removeEventListener('play', handlePlay);
|
||||
video.removeEventListener("play", handlePlay);
|
||||
};
|
||||
}, [videoRef]);
|
||||
|
||||
@@ -62,11 +64,8 @@ const MobilePlayPrompt: React.FC<MobilePlayPromptProps> = ({ videoRef, onPlay })
|
||||
<li>Then you'll be able to use all timeline controls</li>
|
||||
</ol>
|
||||
</div> */}
|
||||
|
||||
<button
|
||||
className="mobile-play-button"
|
||||
onClick={handlePlayClick}
|
||||
>
|
||||
|
||||
<button className="mobile-play-button" onClick={handlePlayClick}>
|
||||
Click to start editing...
|
||||
</button>
|
||||
</div>
|
||||
@@ -74,4 +73,4 @@ const MobilePlayPrompt: React.FC<MobilePlayPromptProps> = ({ videoRef, onPlay })
|
||||
);
|
||||
};
|
||||
|
||||
export default MobilePlayPrompt;
|
||||
export default MobilePlayPrompt;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { formatTime } from "@/lib/timeUtils";
|
||||
import '../styles/IOSVideoPlayer.css';
|
||||
import "../styles/IOSVideoPlayer.css";
|
||||
|
||||
interface IOSVideoPlayerProps {
|
||||
videoRef: React.RefObject<HTMLVideoElement>;
|
||||
@@ -8,14 +8,10 @@ interface IOSVideoPlayerProps {
|
||||
duration: number;
|
||||
}
|
||||
|
||||
const IOSVideoPlayer = ({
|
||||
videoRef,
|
||||
currentTime,
|
||||
duration,
|
||||
}: IOSVideoPlayerProps) => {
|
||||
const IOSVideoPlayer = ({ videoRef, currentTime, duration }: IOSVideoPlayerProps) => {
|
||||
const [videoUrl, setVideoUrl] = useState<string>("");
|
||||
const [iosVideoRef, setIosVideoRef] = useState<HTMLVideoElement | null>(null);
|
||||
|
||||
|
||||
// Refs for hold-to-continue functionality
|
||||
const incrementIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const decrementIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
@@ -27,17 +23,17 @@ const IOSVideoPlayer = ({
|
||||
if (decrementIntervalRef.current) clearInterval(decrementIntervalRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
// Get the video source URL from the main player
|
||||
useEffect(() => {
|
||||
if (videoRef.current && videoRef.current.querySelector('source')) {
|
||||
const source = videoRef.current.querySelector('source') as HTMLSourceElement;
|
||||
if (videoRef.current && videoRef.current.querySelector("source")) {
|
||||
const source = videoRef.current.querySelector("source") as HTMLSourceElement;
|
||||
if (source && source.src) {
|
||||
setVideoUrl(source.src);
|
||||
}
|
||||
} else {
|
||||
// Fallback to sample video if needed
|
||||
setVideoUrl("/videos/sample-video-37s.mp4");
|
||||
setVideoUrl("/videos/sample-video-10m.mp4");
|
||||
}
|
||||
}, [videoRef]);
|
||||
|
||||
@@ -61,13 +57,13 @@ const IOSVideoPlayer = ({
|
||||
const startIncrement = (e: React.MouseEvent | React.TouchEvent) => {
|
||||
// Prevent default to avoid text selection
|
||||
e.preventDefault();
|
||||
|
||||
|
||||
if (!iosVideoRef) return;
|
||||
if (incrementIntervalRef.current) clearInterval(incrementIntervalRef.current);
|
||||
|
||||
|
||||
// First immediate adjustment
|
||||
iosVideoRef.currentTime = Math.min(iosVideoRef.duration, iosVideoRef.currentTime + 0.05);
|
||||
|
||||
|
||||
// Setup continuous adjustment
|
||||
incrementIntervalRef.current = setInterval(() => {
|
||||
if (iosVideoRef) {
|
||||
@@ -88,13 +84,13 @@ const IOSVideoPlayer = ({
|
||||
const startDecrement = (e: React.MouseEvent | React.TouchEvent) => {
|
||||
// Prevent default to avoid text selection
|
||||
e.preventDefault();
|
||||
|
||||
|
||||
if (!iosVideoRef) return;
|
||||
if (decrementIntervalRef.current) clearInterval(decrementIntervalRef.current);
|
||||
|
||||
|
||||
// First immediate adjustment
|
||||
iosVideoRef.currentTime = Math.max(0, iosVideoRef.currentTime - 0.05);
|
||||
|
||||
|
||||
// Setup continuous adjustment
|
||||
decrementIntervalRef.current = setInterval(() => {
|
||||
if (iosVideoRef) {
|
||||
@@ -115,12 +111,14 @@ const IOSVideoPlayer = ({
|
||||
<div className="ios-video-player-container">
|
||||
{/* Current Time / Duration Display */}
|
||||
<div className="ios-time-display mb-2">
|
||||
<span className="text-sm">{formatTime(currentTime)} / {formatTime(duration)}</span>
|
||||
<span className="text-sm">
|
||||
{formatTime(currentTime)} / {formatTime(duration)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
{/* iOS-optimized Video Element with Native Controls */}
|
||||
<video
|
||||
ref={ref => setIosVideoRef(ref)}
|
||||
ref={(ref) => setIosVideoRef(ref)}
|
||||
className="w-full rounded-md"
|
||||
src={videoUrl}
|
||||
controls
|
||||
@@ -133,26 +131,26 @@ const IOSVideoPlayer = ({
|
||||
<source src={videoUrl} type="video/mp4" />
|
||||
<p>Your browser doesn't support HTML5 video.</p>
|
||||
</video>
|
||||
|
||||
|
||||
{/* iOS Video Skip Controls */}
|
||||
<div className="ios-skip-controls mt-3 flex justify-center gap-4">
|
||||
<button
|
||||
<button
|
||||
onClick={jumpBackward15}
|
||||
className="ios-control-btn flex items-center justify-center bg-purple-500 text-white py-2 px-4 rounded-md"
|
||||
>
|
||||
-15s
|
||||
</button>
|
||||
<button
|
||||
<button
|
||||
onClick={jumpForward15}
|
||||
className="ios-control-btn flex items-center justify-center bg-purple-500 text-white py-2 px-4 rounded-md"
|
||||
>
|
||||
+15s
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
{/* iOS Fine Control Buttons */}
|
||||
<div className="ios-fine-controls mt-2 flex justify-center gap-4">
|
||||
<button
|
||||
<button
|
||||
onMouseDown={startDecrement}
|
||||
onTouchStart={startDecrement}
|
||||
onMouseUp={stopDecrement}
|
||||
@@ -163,7 +161,7 @@ const IOSVideoPlayer = ({
|
||||
>
|
||||
-50ms
|
||||
</button>
|
||||
<button
|
||||
<button
|
||||
onMouseDown={startIncrement}
|
||||
onTouchStart={startIncrement}
|
||||
onMouseUp={stopIncrement}
|
||||
@@ -175,7 +173,7 @@ const IOSVideoPlayer = ({
|
||||
+50ms
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="ios-note mt-2 text-xs text-gray-500">
|
||||
<p>This player uses native iOS controls for better compatibility with iOS devices.</p>
|
||||
</div>
|
||||
@@ -183,4 +181,4 @@ const IOSVideoPlayer = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default IOSVideoPlayer;
|
||||
export default IOSVideoPlayer;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import '../styles/Modal.css';
|
||||
import React, { useEffect } from "react";
|
||||
import "../styles/Modal.css";
|
||||
|
||||
interface ModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -9,36 +9,30 @@ interface ModalProps {
|
||||
actions?: React.ReactNode;
|
||||
}
|
||||
|
||||
const Modal: React.FC<ModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
title,
|
||||
children,
|
||||
actions
|
||||
}) => {
|
||||
const Modal: React.FC<ModalProps> = ({ isOpen, onClose, title, children, actions }) => {
|
||||
// Close modal when Escape key is pressed
|
||||
useEffect(() => {
|
||||
const handleEscapeKey = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && isOpen) {
|
||||
if (event.key === "Escape" && isOpen) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleEscapeKey);
|
||||
|
||||
|
||||
document.addEventListener("keydown", handleEscapeKey);
|
||||
|
||||
// Disable body scrolling when modal is open
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
document.body.style.overflow = "hidden";
|
||||
}
|
||||
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleEscapeKey);
|
||||
document.body.style.overflow = '';
|
||||
document.removeEventListener("keydown", handleEscapeKey);
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
|
||||
// Handle click outside the modal content to close it
|
||||
const handleClickOutside = (event: React.MouseEvent) => {
|
||||
if (event.target === event.currentTarget) {
|
||||
@@ -48,23 +42,19 @@ const Modal: React.FC<ModalProps> = ({
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={handleClickOutside}>
|
||||
<div className="modal-container" onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-container" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2 className="modal-title">{title}</h2>
|
||||
<button
|
||||
className="modal-close-button"
|
||||
onClick={onClose}
|
||||
aria-label="Close modal"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
<button className="modal-close-button" onClick={onClose} aria-label="Close modal">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
@@ -72,19 +62,13 @@ const Modal: React.FC<ModalProps> = ({
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="modal-content">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{actions && (
|
||||
<div className="modal-actions">
|
||||
{actions}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="modal-content">{children}</div>
|
||||
|
||||
{actions && <div className="modal-actions">{actions}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Modal;
|
||||
export default Modal;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
import React, { useRef, useEffect, useState } from "react";
|
||||
import { formatTime, formatDetailedTime } from "@/lib/timeUtils";
|
||||
import logger from '../lib/logger';
|
||||
import '../styles/VideoPlayer.css';
|
||||
import logger from "../lib/logger";
|
||||
import "../styles/VideoPlayer.css";
|
||||
|
||||
interface VideoPlayerProps {
|
||||
videoRef: React.RefObject<HTMLVideoElement>;
|
||||
@@ -32,37 +32,37 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
const isDraggingProgressRef = useRef(false);
|
||||
const [tooltipPosition, setTooltipPosition] = useState({ x: 0 });
|
||||
const [tooltipTime, setTooltipTime] = useState(0);
|
||||
|
||||
const sampleVideoUrl = typeof window !== 'undefined' &&
|
||||
(window as any).MEDIA_DATA?.videoUrl ||
|
||||
"/videos/sample-video-37s.mp4";
|
||||
|
||||
|
||||
const sampleVideoUrl =
|
||||
(typeof window !== "undefined" && (window as any).MEDIA_DATA?.videoUrl) ||
|
||||
"/videos/sample-video-10m.mp4";
|
||||
|
||||
// Detect iOS device
|
||||
useEffect(() => {
|
||||
const checkIOS = () => {
|
||||
const userAgent = navigator.userAgent || navigator.vendor || (window as any).opera;
|
||||
return /iPad|iPhone|iPod/.test(userAgent) && !(window as any).MSStream;
|
||||
};
|
||||
|
||||
|
||||
setIsIOS(checkIOS());
|
||||
|
||||
|
||||
// Check if video was previously initialized
|
||||
if (typeof window !== 'undefined') {
|
||||
const wasInitialized = localStorage.getItem('video_initialized') === 'true';
|
||||
if (typeof window !== "undefined") {
|
||||
const wasInitialized = localStorage.getItem("video_initialized") === "true";
|
||||
setHasInitialized(wasInitialized);
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
// Update initialized state when video plays
|
||||
useEffect(() => {
|
||||
if (isPlaying && !hasInitialized) {
|
||||
setHasInitialized(true);
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('video_initialized', 'true');
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem("video_initialized", "true");
|
||||
}
|
||||
}
|
||||
}, [isPlaying, hasInitialized]);
|
||||
|
||||
|
||||
// Add iOS-specific attributes to prevent fullscreen playback
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
@@ -70,15 +70,15 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
|
||||
// These attributes need to be set directly on the DOM element
|
||||
// for iOS Safari to respect inline playback
|
||||
video.setAttribute('playsinline', 'true');
|
||||
video.setAttribute('webkit-playsinline', 'true');
|
||||
video.setAttribute('x-webkit-airplay', 'allow');
|
||||
video.setAttribute("playsinline", "true");
|
||||
video.setAttribute("webkit-playsinline", "true");
|
||||
video.setAttribute("x-webkit-airplay", "allow");
|
||||
|
||||
// Store the last known good position for iOS
|
||||
const handleTimeUpdate = () => {
|
||||
if (!isDraggingProgressRef.current) {
|
||||
setLastPosition(video.currentTime);
|
||||
if (typeof window !== 'undefined') {
|
||||
if (typeof window !== "undefined") {
|
||||
window.lastSeekedPosition = video.currentTime;
|
||||
}
|
||||
}
|
||||
@@ -86,33 +86,33 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
|
||||
// Handle iOS-specific play/pause state
|
||||
const handlePlay = () => {
|
||||
logger.debug('Video play event fired');
|
||||
logger.debug("Video play event fired");
|
||||
if (isIOS) {
|
||||
setHasInitialized(true);
|
||||
localStorage.setItem('video_initialized', 'true');
|
||||
localStorage.setItem("video_initialized", "true");
|
||||
}
|
||||
};
|
||||
|
||||
const handlePause = () => {
|
||||
logger.debug('Video pause event fired');
|
||||
logger.debug("Video pause event fired");
|
||||
};
|
||||
|
||||
video.addEventListener('timeupdate', handleTimeUpdate);
|
||||
video.addEventListener('play', handlePlay);
|
||||
video.addEventListener('pause', handlePause);
|
||||
video.addEventListener("timeupdate", handleTimeUpdate);
|
||||
video.addEventListener("play", handlePlay);
|
||||
video.addEventListener("pause", handlePause);
|
||||
|
||||
return () => {
|
||||
video.removeEventListener('timeupdate', handleTimeUpdate);
|
||||
video.removeEventListener('play', handlePlay);
|
||||
video.removeEventListener('pause', handlePause);
|
||||
video.removeEventListener("timeupdate", handleTimeUpdate);
|
||||
video.removeEventListener("play", handlePlay);
|
||||
video.removeEventListener("pause", handlePause);
|
||||
};
|
||||
}, [videoRef, isIOS, isDraggingProgressRef]);
|
||||
|
||||
|
||||
// Save current time to lastPosition when it changes (from external seeking)
|
||||
useEffect(() => {
|
||||
setLastPosition(currentTime);
|
||||
}, [currentTime]);
|
||||
|
||||
|
||||
// Jump 10 seconds forward
|
||||
const handleForward = () => {
|
||||
const newTime = Math.min(currentTime + 10, duration);
|
||||
@@ -126,58 +126,58 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
onSeek(newTime);
|
||||
setLastPosition(newTime);
|
||||
};
|
||||
|
||||
|
||||
// Calculate progress percentage
|
||||
const progressPercentage = duration > 0 ? (currentTime / duration) * 100 : 0;
|
||||
|
||||
// Handle start of progress bar dragging
|
||||
const handleProgressDragStart = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
|
||||
setIsDraggingProgress(true);
|
||||
isDraggingProgressRef.current = true;
|
||||
|
||||
|
||||
// Get initial position
|
||||
handleProgressDrag(e);
|
||||
|
||||
|
||||
// Set up document-level event listeners for mouse movement and release
|
||||
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||
if (isDraggingProgressRef.current) {
|
||||
handleProgressDrag(moveEvent);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsDraggingProgress(false);
|
||||
isDraggingProgressRef.current = false;
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
|
||||
|
||||
// Handle progress dragging for both mouse and touch events
|
||||
const handleProgressDrag = (e: MouseEvent | React.MouseEvent) => {
|
||||
if (!progressRef.current) return;
|
||||
|
||||
|
||||
const rect = progressRef.current.getBoundingClientRect();
|
||||
const clickPosition = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
||||
const seekTime = duration * clickPosition;
|
||||
|
||||
|
||||
// Update tooltip position and time
|
||||
setTooltipPosition({ x: e.clientX });
|
||||
setTooltipTime(seekTime);
|
||||
|
||||
|
||||
// Store position locally for iOS Safari - critical for timeline seeking
|
||||
setLastPosition(seekTime);
|
||||
|
||||
|
||||
// Also store globally for integration with other components
|
||||
if (typeof window !== 'undefined') {
|
||||
if (typeof window !== "undefined") {
|
||||
(window as any).lastSeekedPosition = seekTime;
|
||||
}
|
||||
|
||||
|
||||
onSeek(seekTime);
|
||||
};
|
||||
|
||||
@@ -185,59 +185,59 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
const handleProgressTouchStart = (e: React.TouchEvent) => {
|
||||
if (!progressRef.current || !e.touches[0]) return;
|
||||
e.preventDefault();
|
||||
|
||||
|
||||
setIsDraggingProgress(true);
|
||||
isDraggingProgressRef.current = true;
|
||||
|
||||
|
||||
// Get initial position using touch
|
||||
handleProgressTouchMove(e);
|
||||
|
||||
|
||||
// Set up document-level event listeners for touch movement and release
|
||||
const handleTouchMove = (moveEvent: TouchEvent) => {
|
||||
if (isDraggingProgressRef.current) {
|
||||
handleProgressTouchMove(moveEvent);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
setIsDraggingProgress(false);
|
||||
isDraggingProgressRef.current = false;
|
||||
document.removeEventListener('touchmove', handleTouchMove);
|
||||
document.removeEventListener('touchend', handleTouchEnd);
|
||||
document.removeEventListener('touchcancel', handleTouchEnd);
|
||||
document.removeEventListener("touchmove", handleTouchMove);
|
||||
document.removeEventListener("touchend", handleTouchEnd);
|
||||
document.removeEventListener("touchcancel", handleTouchEnd);
|
||||
};
|
||||
|
||||
document.addEventListener('touchmove', handleTouchMove, { passive: false });
|
||||
document.addEventListener('touchend', handleTouchEnd);
|
||||
document.addEventListener('touchcancel', handleTouchEnd);
|
||||
|
||||
document.addEventListener("touchmove", handleTouchMove, { passive: false });
|
||||
document.addEventListener("touchend", handleTouchEnd);
|
||||
document.addEventListener("touchcancel", handleTouchEnd);
|
||||
};
|
||||
|
||||
|
||||
// Handle touch dragging on progress bar
|
||||
const handleProgressTouchMove = (e: TouchEvent | React.TouchEvent) => {
|
||||
if (!progressRef.current) return;
|
||||
|
||||
|
||||
// Get the touch coordinates
|
||||
const touch = 'touches' in e ? e.touches[0] : null;
|
||||
const touch = "touches" in e ? e.touches[0] : null;
|
||||
if (!touch) return;
|
||||
|
||||
|
||||
e.preventDefault(); // Prevent scrolling while dragging
|
||||
|
||||
|
||||
const rect = progressRef.current.getBoundingClientRect();
|
||||
const touchPosition = Math.max(0, Math.min(1, (touch.clientX - rect.left) / rect.width));
|
||||
const seekTime = duration * touchPosition;
|
||||
|
||||
|
||||
// Update tooltip position and time
|
||||
setTooltipPosition({ x: touch.clientX });
|
||||
setTooltipTime(seekTime);
|
||||
|
||||
|
||||
// Store position for iOS Safari
|
||||
setLastPosition(seekTime);
|
||||
|
||||
|
||||
// Also store globally for integration with other components
|
||||
if (typeof window !== 'undefined') {
|
||||
if (typeof window !== "undefined") {
|
||||
(window as any).lastSeekedPosition = seekTime;
|
||||
}
|
||||
|
||||
|
||||
onSeek(seekTime);
|
||||
};
|
||||
|
||||
@@ -245,20 +245,20 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
// If we're already dragging, don't handle the click
|
||||
if (isDraggingProgress) return;
|
||||
|
||||
|
||||
if (progressRef.current) {
|
||||
const rect = progressRef.current.getBoundingClientRect();
|
||||
const clickPosition = (e.clientX - rect.left) / rect.width;
|
||||
const seekTime = duration * clickPosition;
|
||||
|
||||
|
||||
// Store position locally for iOS Safari - critical for timeline seeking
|
||||
setLastPosition(seekTime);
|
||||
|
||||
|
||||
// Also store globally for integration with other components
|
||||
if (typeof window !== 'undefined') {
|
||||
if (typeof window !== "undefined") {
|
||||
(window as any).lastSeekedPosition = seekTime;
|
||||
}
|
||||
|
||||
|
||||
onSeek(seekTime);
|
||||
}
|
||||
};
|
||||
@@ -278,38 +278,43 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
const handleVideoClick = () => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
|
||||
// If the video is paused, we want to play it
|
||||
if (video.paused) {
|
||||
// For iOS Safari: Before playing, explicitly seek to the remembered position
|
||||
if (isIOS && lastPosition !== null && lastPosition > 0) {
|
||||
logger.debug("iOS: Explicitly setting position before play:", lastPosition);
|
||||
|
||||
|
||||
// First, seek to the position
|
||||
video.currentTime = lastPosition;
|
||||
|
||||
|
||||
// Use a small timeout to ensure seeking is complete before play
|
||||
setTimeout(() => {
|
||||
if (videoRef.current) {
|
||||
// Try to play with proper promise handling
|
||||
videoRef.current.play()
|
||||
videoRef.current
|
||||
.play()
|
||||
.then(() => {
|
||||
logger.debug("iOS: Play started successfully at position:", videoRef.current?.currentTime);
|
||||
logger.debug(
|
||||
"iOS: Play started successfully at position:",
|
||||
videoRef.current?.currentTime
|
||||
);
|
||||
onPlayPause(); // Update parent state after successful play
|
||||
})
|
||||
.catch(err => {
|
||||
.catch((err) => {
|
||||
console.error("iOS: Error playing video:", err);
|
||||
});
|
||||
}
|
||||
}, 50);
|
||||
} else {
|
||||
// Normal play (non-iOS or no remembered position)
|
||||
video.play()
|
||||
video
|
||||
.play()
|
||||
.then(() => {
|
||||
logger.debug("Normal: Play started successfully");
|
||||
onPlayPause(); // Update parent state after successful play
|
||||
})
|
||||
.catch(err => {
|
||||
.catch((err) => {
|
||||
console.error("Error playing video:", err);
|
||||
});
|
||||
}
|
||||
@@ -336,19 +341,17 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
<source src={sampleVideoUrl} type="video/mp4" />
|
||||
<p>Your browser doesn't support HTML5 video.</p>
|
||||
</video>
|
||||
|
||||
|
||||
{/* iOS First-play indicator - only shown on first visit for iOS devices when not initialized */}
|
||||
{isIOS && !hasInitialized && !isPlaying && (
|
||||
<div className="ios-first-play-indicator">
|
||||
<div className="ios-play-message">
|
||||
Tap Play to initialize video controls
|
||||
</div>
|
||||
<div className="ios-play-message">Tap Play to initialize video controls</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{/* Play/Pause Indicator (shows based on current state) */}
|
||||
<div className={`play-pause-indicator ${isPlaying ? 'pause-icon' : 'play-icon'}`}></div>
|
||||
|
||||
<div className={`play-pause-indicator ${isPlaying ? "pause-icon" : "play-icon"}`}></div>
|
||||
|
||||
{/* Video Controls Overlay */}
|
||||
<div className="video-controls">
|
||||
{/* Time and Duration */}
|
||||
@@ -356,47 +359,52 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
<span className="video-current-time">{formatTime(currentTime)}</span>
|
||||
<span className="video-duration">/ {formatTime(duration)}</span>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Progress Bar with enhanced dragging */}
|
||||
<div
|
||||
<div
|
||||
ref={progressRef}
|
||||
className={`video-progress ${isDraggingProgress ? 'dragging' : ''}`}
|
||||
className={`video-progress ${isDraggingProgress ? "dragging" : ""}`}
|
||||
onClick={handleProgressClick}
|
||||
onMouseDown={handleProgressDragStart}
|
||||
onTouchStart={handleProgressTouchStart}
|
||||
>
|
||||
<div
|
||||
className="video-progress-fill"
|
||||
style={{ width: `${progressPercentage}%` }}
|
||||
></div>
|
||||
<div
|
||||
className="video-scrubber"
|
||||
style={{ left: `${progressPercentage}%` }}
|
||||
></div>
|
||||
|
||||
<div className="video-progress-fill" style={{ width: `${progressPercentage}%` }}></div>
|
||||
<div className="video-scrubber" style={{ left: `${progressPercentage}%` }}></div>
|
||||
|
||||
{/* Floating time tooltip when dragging */}
|
||||
{isDraggingProgress && (
|
||||
<div className="video-time-tooltip" style={{
|
||||
left: `${tooltipPosition.x}px`,
|
||||
transform: 'translateX(-50%)'
|
||||
}}>
|
||||
<div
|
||||
className="video-time-tooltip"
|
||||
style={{
|
||||
left: `${tooltipPosition.x}px`,
|
||||
transform: "translateX(-50%)"
|
||||
}}
|
||||
>
|
||||
{formatDetailedTime(tooltipTime)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
{/* Controls - Mute and Fullscreen buttons */}
|
||||
<div className="video-controls-buttons">
|
||||
{/* Mute/Unmute Button */}
|
||||
{onToggleMute && (
|
||||
<button
|
||||
className="mute-button"
|
||||
<button
|
||||
className="mute-button"
|
||||
aria-label={isMuted ? "Unmute" : "Mute"}
|
||||
onClick={onToggleMute}
|
||||
data-tooltip={isMuted ? "Unmute" : "Mute"}
|
||||
>
|
||||
{isMuted ? (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<line x1="1" y1="1" x2="23" y2="23"></line>
|
||||
<path d="M9 9v3a3 3 0 0 0 5.12 2.12M15 9.34V4a3 3 0 0 0-5.94-.6"></path>
|
||||
<path d="M17 16.95A7 7 0 0 1 5 12v-2m14 0v2a7 7 0 0 1-.11 1.23"></path>
|
||||
@@ -404,23 +412,35 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
<line x1="8" y1="23" x2="16" y2="23"></line>
|
||||
</svg>
|
||||
) : (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon>
|
||||
<path d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07"></path>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
|
||||
{/* Fullscreen Button */}
|
||||
<button
|
||||
className="fullscreen-button"
|
||||
<button
|
||||
className="fullscreen-button"
|
||||
aria-label="Fullscreen"
|
||||
onClick={handleFullscreen}
|
||||
data-tooltip="Toggle fullscreen"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M3 4a1 1 0 011-1h4a1 1 0 010 2H6.414l2.293 2.293a1 1 0 01-1.414 1.414L5 6.414V8a1 1 0 01-2 0V4zm9 1a1 1 0 010-2h4a1 1 0 011 1v4a1 1 0 01-2 0V6.414l-2.293 2.293a1 1 0 11-1.414-1.414L13.586 5H12zm-9 7a1 1 0 012 0v1.586l2.293-2.293a1 1 0 011.414 1.414L6.414 15H8a1 1 0 010 2H4a1 1 0 01-1-1v-4zm13-1a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 010-2h1.586l-2.293-2.293a1 1 0 011.414-1.414L15 13.586V12a1 1 0 011-1z" clipRule="evenodd" />
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M3 4a1 1 0 011-1h4a1 1 0 010 2H6.414l2.293 2.293a1 1 0 01-1.414 1.414L5 6.414V8a1 1 0 01-2 0V4zm9 1a1 1 0 010-2h4a1 1 0 011 1v4a1 1 0 01-2 0V6.414l-2.293 2.293a1 1 0 11-1.414-1.414L13.586 5H12zm-9 7a1 1 0 012 0v1.586l2.293-2.293a1 1 0 011.414 1.414L6.414 15H8a1 1 0 010 2H4a1 1 0 01-1-1v-4zm13-1a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 010-2h1.586l-2.293-2.293a1 1 0 011.414-1.414L15 13.586V12a1 1 0 011-1z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -125,13 +125,13 @@
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
margin-bottom: 0.75rem;
|
||||
background-color: #EEE; /* Very light gray background */
|
||||
background-color: #eee; /* Very light gray background */
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.timeline-container {
|
||||
position: relative;
|
||||
background-color: #EEE; /* Very light gray background */
|
||||
background-color: #eee; /* Very light gray background */
|
||||
height: 6rem;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
@@ -208,17 +208,27 @@
|
||||
overflow: hidden;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
transition: box-shadow 0.2s, transform 0.1s;
|
||||
transition:
|
||||
box-shadow 0.2s,
|
||||
transform 0.1s;
|
||||
/* Original z-index for stacking order based on segment ID */
|
||||
z-index: 15;
|
||||
}
|
||||
|
||||
/* No background colors for segments, just borders with 2-color scheme */
|
||||
.clip-segment:nth-child(odd), .segment-color-1, .segment-color-3, .segment-color-5, .segment-color-7 {
|
||||
.clip-segment:nth-child(odd),
|
||||
.segment-color-1,
|
||||
.segment-color-3,
|
||||
.segment-color-5,
|
||||
.segment-color-7 {
|
||||
background-color: transparent;
|
||||
border: 2px solid rgba(0, 123, 255, 0.9); /* Blue border */
|
||||
}
|
||||
.clip-segment:nth-child(even), .segment-color-2, .segment-color-4, .segment-color-6, .segment-color-8 {
|
||||
.clip-segment:nth-child(even),
|
||||
.segment-color-2,
|
||||
.segment-color-4,
|
||||
.segment-color-6,
|
||||
.segment-color-8 {
|
||||
background-color: transparent;
|
||||
border: 2px solid rgba(108, 117, 125, 0.9); /* Gray border */
|
||||
}
|
||||
@@ -315,7 +325,7 @@
|
||||
input[type="range"] {
|
||||
-webkit-appearance: none;
|
||||
height: 6px;
|
||||
background: #E0E0E0;
|
||||
background: #e0e0e0;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
@@ -350,12 +360,14 @@ input[type="range"]::-webkit-slider-thumb {
|
||||
z-index: 1000;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.2s, visibility 0.2s;
|
||||
transition:
|
||||
opacity 0.2s,
|
||||
visibility 0.2s;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
[data-tooltip]::after {
|
||||
content: '';
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
@@ -366,7 +378,9 @@ input[type="range"]::-webkit-slider-thumb {
|
||||
margin-bottom: 0px;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.2s, visibility 0.2s;
|
||||
transition:
|
||||
opacity 0.2s,
|
||||
visibility 0.2s;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -464,7 +478,7 @@ button[disabled][data-tooltip]::after {
|
||||
}
|
||||
|
||||
.segment-tooltip::after {
|
||||
content: '';
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: -6px;
|
||||
left: 50%;
|
||||
@@ -539,7 +553,7 @@ button[disabled][data-tooltip]::after {
|
||||
}
|
||||
|
||||
.empty-space-tooltip::after {
|
||||
content: '';
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: -8px;
|
||||
left: 50%;
|
||||
@@ -617,7 +631,9 @@ button[disabled][data-tooltip]::after {
|
||||
}
|
||||
|
||||
/* Save buttons styling */
|
||||
.save-button, .save-copy-button, .save-segments-button {
|
||||
.save-button,
|
||||
.save-copy-button,
|
||||
.save-segments-button {
|
||||
background-color: rgba(0, 123, 255, 0.8);
|
||||
color: white;
|
||||
border: none;
|
||||
@@ -628,7 +644,8 @@ button[disabled][data-tooltip]::after {
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.save-button:hover, .save-copy-button:hover {
|
||||
.save-button:hover,
|
||||
.save-copy-button:hover {
|
||||
background-color: rgba(0, 123, 255, 1);
|
||||
}
|
||||
|
||||
@@ -735,7 +752,8 @@ button[disabled][data-tooltip]::after {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.current-time, .duration-time {
|
||||
.current-time,
|
||||
.duration-time {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -770,7 +788,8 @@ button[disabled][data-tooltip]::after {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.save-button, .save-copy-button {
|
||||
.save-button,
|
||||
.save-copy-button {
|
||||
margin-top: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -7,25 +7,25 @@ const logger = {
|
||||
* Logs debug messages only in development environment
|
||||
*/
|
||||
debug: (...args: any[]) => {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
console.debug(...args);
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
/**
|
||||
* Always logs error messages
|
||||
*/
|
||||
error: (...args: any[]) => console.error(...args),
|
||||
|
||||
|
||||
/**
|
||||
* Always logs warning messages
|
||||
*/
|
||||
warn: (...args: any[]) => console.warn(...args),
|
||||
|
||||
|
||||
/**
|
||||
* Always logs info messages
|
||||
*/
|
||||
info: (...args: any[]) => console.info(...args)
|
||||
};
|
||||
|
||||
export default logger;
|
||||
export default logger;
|
||||
|
||||
@@ -10,13 +10,13 @@ async function throwIfResNotOk(res: Response) {
|
||||
export async function apiRequest(
|
||||
method: string,
|
||||
url: string,
|
||||
data?: unknown | undefined,
|
||||
data?: unknown | undefined
|
||||
): Promise<Response> {
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: data ? { "Content-Type": "application/json" } : {},
|
||||
body: data ? JSON.stringify(data) : undefined,
|
||||
credentials: "include",
|
||||
credentials: "include"
|
||||
});
|
||||
|
||||
await throwIfResNotOk(res);
|
||||
@@ -24,13 +24,11 @@ export async function apiRequest(
|
||||
}
|
||||
|
||||
type UnauthorizedBehavior = "returnNull" | "throw";
|
||||
export const getQueryFn: <T>(options: {
|
||||
on401: UnauthorizedBehavior;
|
||||
}) => QueryFunction<T> =
|
||||
export const getQueryFn: <T>(options: { on401: UnauthorizedBehavior }) => QueryFunction<T> =
|
||||
({ on401: unauthorizedBehavior }) =>
|
||||
async ({ queryKey }) => {
|
||||
const res = await fetch(queryKey[0] as string, {
|
||||
credentials: "include",
|
||||
credentials: "include"
|
||||
});
|
||||
|
||||
if (unauthorizedBehavior === "returnNull" && res.status === 401) {
|
||||
@@ -48,10 +46,10 @@ export const queryClient = new QueryClient({
|
||||
refetchInterval: false,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: Infinity,
|
||||
retry: false,
|
||||
retry: false
|
||||
},
|
||||
mutations: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
retry: false
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -3,17 +3,17 @@
|
||||
*/
|
||||
export const formatDetailedTime = (seconds: number): string => {
|
||||
if (isNaN(seconds)) return "00:00:00.000";
|
||||
|
||||
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const remainingSeconds = Math.floor(seconds % 60);
|
||||
const milliseconds = Math.round((seconds % 1) * 1000);
|
||||
|
||||
|
||||
const formattedHours = String(hours).padStart(2, "0");
|
||||
const formattedMinutes = String(minutes).padStart(2, "0");
|
||||
const formattedSeconds = String(remainingSeconds).padStart(2, "0");
|
||||
const formattedMilliseconds = String(milliseconds).padStart(3, "0");
|
||||
|
||||
|
||||
return `${formattedHours}:${formattedMinutes}:${formattedSeconds}.${formattedMilliseconds}`;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
@@ -2,20 +2,17 @@
|
||||
* Generate a solid color background for a segment
|
||||
* Returns a CSS color based on the segment position
|
||||
*/
|
||||
export const generateSolidColor = (
|
||||
time: number,
|
||||
duration: number
|
||||
): string => {
|
||||
export const generateSolidColor = (time: number, duration: number): string => {
|
||||
// Use the time position to create different colors
|
||||
// This gives each segment a different color without needing an image
|
||||
const position = Math.min(Math.max(time / (duration || 1), 0), 1);
|
||||
|
||||
|
||||
// Calculate color based on position
|
||||
// Use an extremely light blue-based color palette
|
||||
const hue = 210; // Blue base
|
||||
const saturation = 40 + Math.floor(position * 20); // 40-60% (less saturated)
|
||||
const lightness = 85 + Math.floor(position * 8); // 85-93% (extremely light)
|
||||
|
||||
|
||||
return `hsl(${hue}, ${saturation}%, ${lightness}%)`;
|
||||
};
|
||||
|
||||
@@ -24,27 +21,27 @@ export const generateSolidColor = (
|
||||
* Now returns a data URL for a solid color square instead of a video thumbnail
|
||||
*/
|
||||
export const generateThumbnail = async (
|
||||
videoElement: HTMLVideoElement,
|
||||
videoElement: HTMLVideoElement,
|
||||
time: number
|
||||
): Promise<string> => {
|
||||
return new Promise((resolve) => {
|
||||
// Create a small canvas for the solid color
|
||||
const canvas = document.createElement('canvas');
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 10; // Much smaller - we only need a color
|
||||
canvas.height = 10;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (ctx) {
|
||||
// Get the solid color based on time
|
||||
const color = generateSolidColor(time, videoElement.duration);
|
||||
|
||||
|
||||
// Fill with solid color
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
|
||||
|
||||
// Convert to data URL (much smaller now)
|
||||
const dataUrl = canvas.toDataURL('image/png', 0.5);
|
||||
const dataUrl = canvas.toDataURL("image/png", 0.5);
|
||||
resolve(dataUrl);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createRoot } from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./index.css";
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
if (typeof window !== "undefined") {
|
||||
window.MEDIA_DATA = {
|
||||
videoUrl: "",
|
||||
mediaId: ""
|
||||
@@ -30,8 +30,8 @@ const mountComponents = () => {
|
||||
}
|
||||
};
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', mountComponents);
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", mountComponents);
|
||||
} else {
|
||||
mountComponents();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,36 +4,36 @@ interface TrimVideoRequest {
|
||||
segments: {
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
name?: string;
|
||||
name?: string;
|
||||
}[];
|
||||
saveAsCopy?: boolean;
|
||||
saveIndividualSegments?: boolean;
|
||||
saveIndividualSegments?: boolean;
|
||||
}
|
||||
|
||||
interface TrimVideoResponse {
|
||||
msg: string;
|
||||
url_redirect: string;
|
||||
status?: number; // HTTP status code for success/error
|
||||
error?: string; // Error message if status is not 200
|
||||
status?: number; // HTTP status code for success/error
|
||||
error?: string; // Error message if status is not 200
|
||||
}
|
||||
|
||||
// Helper function to simulate delay
|
||||
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
// For now, we'll use a mock API that returns a promise
|
||||
// This can be replaced with actual API calls later
|
||||
export const trimVideo = async (
|
||||
mediaId: string,
|
||||
mediaId: string,
|
||||
data: TrimVideoRequest
|
||||
): Promise<TrimVideoResponse> => {
|
||||
try {
|
||||
// Attempt the real API call
|
||||
const response = await fetch(`/api/v1/media/${mediaId}/trim_video`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
|
||||
if (!response.ok) {
|
||||
// For error responses, return with error status and message
|
||||
if (response.status === 400) {
|
||||
@@ -86,7 +86,7 @@ export const trimVideo = async (
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Successful response
|
||||
const jsonResponse = await response.json();
|
||||
return {
|
||||
@@ -104,7 +104,7 @@ export const trimVideo = async (
|
||||
url_redirect: `./view?m=${mediaId}`
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/* Mock implementation that simulates network latency
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
@@ -115,4 +115,4 @@ export const trimVideo = async (
|
||||
}, 1500); // Simulate 1.5 second server delay
|
||||
});
|
||||
*/
|
||||
};
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
[data-tooltip] {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
|
||||
[data-tooltip]:before {
|
||||
content: attr(data-tooltip);
|
||||
position: absolute;
|
||||
@@ -21,13 +21,15 @@
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.2s, visibility 0.2s;
|
||||
transition:
|
||||
opacity 0.2s,
|
||||
visibility 0.2s;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
||||
[data-tooltip]:after {
|
||||
content: '';
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
@@ -37,17 +39,19 @@
|
||||
border-color: rgba(0, 0, 0, 0.8) transparent transparent transparent;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.2s, visibility 0.2s;
|
||||
transition:
|
||||
opacity 0.2s,
|
||||
visibility 0.2s;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
||||
[data-tooltip]:hover:before,
|
||||
[data-tooltip]:hover:after {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Hide button tooltips on touch devices */
|
||||
@media (pointer: coarse) {
|
||||
[data-tooltip]:before,
|
||||
@@ -143,7 +147,9 @@
|
||||
border-radius: 9999px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s, color 0.2s;
|
||||
transition:
|
||||
background-color 0.2s,
|
||||
color 0.2s;
|
||||
min-width: auto;
|
||||
|
||||
&:hover {
|
||||
@@ -163,12 +169,28 @@
|
||||
color: rgba(51, 51, 51, 0.7);
|
||||
}
|
||||
|
||||
.segment-color-1 { background-color: rgba(59, 130, 246, 0.15); }
|
||||
.segment-color-2 { background-color: rgba(16, 185, 129, 0.15); }
|
||||
.segment-color-3 { background-color: rgba(245, 158, 11, 0.15); }
|
||||
.segment-color-4 { background-color: rgba(239, 68, 68, 0.15); }
|
||||
.segment-color-5 { background-color: rgba(139, 92, 246, 0.15); }
|
||||
.segment-color-6 { background-color: rgba(236, 72, 153, 0.15); }
|
||||
.segment-color-7 { background-color: rgba(6, 182, 212, 0.15); }
|
||||
.segment-color-8 { background-color: rgba(250, 204, 21, 0.15); }
|
||||
}
|
||||
.segment-color-1 {
|
||||
background-color: rgba(59, 130, 246, 0.15);
|
||||
}
|
||||
.segment-color-2 {
|
||||
background-color: rgba(16, 185, 129, 0.15);
|
||||
}
|
||||
.segment-color-3 {
|
||||
background-color: rgba(245, 158, 11, 0.15);
|
||||
}
|
||||
.segment-color-4 {
|
||||
background-color: rgba(239, 68, 68, 0.15);
|
||||
}
|
||||
.segment-color-5 {
|
||||
background-color: rgba(139, 92, 246, 0.15);
|
||||
}
|
||||
.segment-color-6 {
|
||||
background-color: rgba(236, 72, 153, 0.15);
|
||||
}
|
||||
.segment-color-7 {
|
||||
background-color: rgba(6, 182, 212, 0.15);
|
||||
}
|
||||
.segment-color-8 {
|
||||
background-color: rgba(250, 204, 21, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
#video-editor-trim-root {
|
||||
|
||||
/* Tooltip styles - only on desktop where hover is available */
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
[data-tooltip] {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
|
||||
[data-tooltip]:before {
|
||||
content: attr(data-tooltip);
|
||||
position: absolute;
|
||||
@@ -22,13 +21,15 @@
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.2s, visibility 0.2s;
|
||||
transition:
|
||||
opacity 0.2s,
|
||||
visibility 0.2s;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
||||
[data-tooltip]:after {
|
||||
content: '';
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
@@ -38,17 +39,19 @@
|
||||
border-color: rgba(0, 0, 0, 0.8) transparent transparent transparent;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.2s, visibility 0.2s;
|
||||
transition:
|
||||
opacity 0.2s,
|
||||
visibility 0.2s;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
||||
[data-tooltip]:hover:before,
|
||||
[data-tooltip]:hover:after {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Hide button tooltips on touch devices */
|
||||
@media (pointer: coarse) {
|
||||
[data-tooltip]:before,
|
||||
@@ -86,7 +89,7 @@
|
||||
.full-text {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
|
||||
.short-text {
|
||||
display: none;
|
||||
}
|
||||
@@ -99,20 +102,20 @@
|
||||
.button-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
|
||||
&.play-buttons-group {
|
||||
gap: 0.75rem;
|
||||
justify-content: flex-start;
|
||||
flex: 0 0 auto; /* Don't expand to fill space */
|
||||
}
|
||||
|
||||
|
||||
&.secondary {
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
margin-left: auto; /* Push to right edge */
|
||||
}
|
||||
|
||||
|
||||
button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -121,17 +124,16 @@
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
min-width: auto;
|
||||
|
||||
/* Disabled hover effect as requested */
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
|
||||
svg {
|
||||
height: 1.25rem;
|
||||
width: 1.25rem;
|
||||
@@ -144,10 +146,11 @@
|
||||
border-right: 1px solid #d1d5db;
|
||||
height: 1.5rem;
|
||||
margin: 0 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Style for play buttons with highlight effect */
|
||||
.play-button, .preview-button {
|
||||
.play-button,
|
||||
.preview-button {
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -157,13 +160,13 @@
|
||||
justify-content: center;
|
||||
font-size: 0.875rem !important;
|
||||
}
|
||||
|
||||
|
||||
/* Greyed out play button when segments are playing */
|
||||
.play-button.greyed-out {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
|
||||
/* Highlighted stop button with blue pulse on small screens */
|
||||
.segments-button.highlighted-stop {
|
||||
background-color: rgba(59, 130, 246, 0.1);
|
||||
@@ -171,7 +174,7 @@
|
||||
border: 1px solid #3b82f6;
|
||||
animation: bluePulse 2s infinite;
|
||||
}
|
||||
|
||||
|
||||
@keyframes bluePulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.4);
|
||||
@@ -183,9 +186,10 @@
|
||||
box-shadow: 0 0 0 0 rgba(59, 130, 246, 0);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Completely disable ALL hover effects for play buttons */
|
||||
.play-button:hover:not(:disabled), .preview-button:hover:not(:disabled) {
|
||||
.play-button:hover:not(:disabled),
|
||||
.preview-button:hover:not(:disabled) {
|
||||
/* Reset everything to prevent any changes */
|
||||
color: inherit !important;
|
||||
transform: none !important;
|
||||
@@ -193,27 +197,15 @@
|
||||
width: auto !important;
|
||||
background: none !important;
|
||||
}
|
||||
|
||||
.play-button svg, .preview-button svg {
|
||||
|
||||
.play-button svg,
|
||||
.preview-button svg {
|
||||
height: 1.5rem;
|
||||
width: 1.5rem;
|
||||
/* Make sure SVG scales with the button but doesn't change layout */
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Style for the preview mode message that replaces the play button */
|
||||
.preview-mode-message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: rgba(59, 130, 246, 0.1);
|
||||
color: #3b82f6;
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
opacity: 0.8;
|
||||
@@ -225,19 +217,12 @@
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.preview-mode-message svg {
|
||||
height: 1.25rem;
|
||||
width: 1.25rem;
|
||||
margin-right: 0.5rem;
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
|
||||
/* Add responsive button text class */
|
||||
.button-text {
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
|
||||
/* Media queries for the editing tools */
|
||||
@media (max-width: 992px) {
|
||||
/* Hide text for undo/redo buttons on medium screens */
|
||||
@@ -245,76 +230,77 @@
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@media (max-width: 768px) {
|
||||
/* Keep all buttons in a single row, make them more compact */
|
||||
.flex-container.single-row {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
|
||||
.button-group {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
|
||||
/* Keep font size consistent regardless of screen size */
|
||||
.preview-button, .play-button {
|
||||
.preview-button,
|
||||
.play-button {
|
||||
font-size: 0.875rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@media (max-width: 640px) {
|
||||
/* Prevent container overflow on mobile */
|
||||
.editing-tools-container {
|
||||
padding: 0.75rem;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
|
||||
/* At this breakpoint, make preview button text shorter */
|
||||
.preview-button {
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
|
||||
/* Switch to short text versions */
|
||||
.full-text {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
.short-text {
|
||||
display: inline;
|
||||
margin-left: 0.15rem;
|
||||
}
|
||||
|
||||
|
||||
/* Hide reset text */
|
||||
.reset-text {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
/* Ensure buttons stay in correct position */
|
||||
.button-group.play-buttons-group {
|
||||
flex: initial;
|
||||
justify-content: flex-start;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
|
||||
.button-group.secondary {
|
||||
flex: initial;
|
||||
justify-content: flex-end;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
|
||||
/* Reduce button sizes on mobile */
|
||||
.button-group button {
|
||||
padding: 0.375rem;
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
|
||||
.button-group button svg {
|
||||
height: 1.125rem;
|
||||
width: 1.125rem;
|
||||
margin-right: 0.125rem;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@media (max-width: 576px) {
|
||||
/* Keep single row, left-align play buttons, right-align controls */
|
||||
.flex-container.single-row {
|
||||
@@ -322,94 +308,88 @@
|
||||
flex-wrap: nowrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
|
||||
/* Fix left-align for play buttons */
|
||||
.button-group.play-buttons-group {
|
||||
justify-content: flex-start;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
|
||||
/* Fix right-align for editing controls */
|
||||
.button-group.secondary {
|
||||
justify-content: flex-end;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
|
||||
/* Reduce button padding to fit more easily */
|
||||
.button-group button {
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
/* Smaller preview mode message */
|
||||
.preview-mode-message {
|
||||
font-size: 0.8rem;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
|
||||
.divider {
|
||||
margin: 0 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Very small screens - maintain layout but reduce further */
|
||||
@media (max-width: 480px) {
|
||||
.editing-tools-container {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
|
||||
.flex-container.single-row {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
|
||||
.button-group.play-buttons-group,
|
||||
.button-group.secondary {
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
|
||||
.divider {
|
||||
display: none; /* Hide divider on very small screens */
|
||||
}
|
||||
|
||||
|
||||
/* Even smaller buttons on very small screens */
|
||||
.button-group button {
|
||||
padding: 0.125rem;
|
||||
}
|
||||
|
||||
|
||||
.button-group button svg {
|
||||
height: 1rem;
|
||||
width: 1rem;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
|
||||
/* Hide all button text on very small screens */
|
||||
.button-text,
|
||||
.reset-text {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Portrait orientation specific fixes */
|
||||
@media (max-width: 640px) and (orientation: portrait) {
|
||||
.editing-tools-container {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
|
||||
.flex-container.single-row {
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
|
||||
/* Ensure button groups don't overflow */
|
||||
.button-group {
|
||||
max-width: 50%;
|
||||
}
|
||||
|
||||
|
||||
.button-group.play-buttons-group {
|
||||
max-width: 60%;
|
||||
}
|
||||
|
||||
|
||||
.button-group.secondary {
|
||||
max-width: 40%;
|
||||
}
|
||||
|
||||
@@ -132,7 +132,7 @@
|
||||
.ios-notification {
|
||||
padding-top: env(safe-area-inset-top);
|
||||
}
|
||||
|
||||
|
||||
.ios-notification-close {
|
||||
padding: 10px;
|
||||
}
|
||||
@@ -143,11 +143,11 @@
|
||||
.ios-notification-content {
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
|
||||
.ios-notification-message h3 {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
|
||||
.ios-notification-message p,
|
||||
.ios-notification-message ol {
|
||||
font-size: 13px;
|
||||
@@ -164,4 +164,4 @@ html.ios-device {
|
||||
html.ios-device .ios-control-btn {
|
||||
/* Make buttons easier to tap in desktop mode */
|
||||
min-height: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,4 +93,4 @@
|
||||
/* Extra spacing for mobile */
|
||||
padding: 14px 25px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,13 +36,13 @@
|
||||
.ios-video-player-container video {
|
||||
max-height: 50vh; /* Use viewport height on iOS */
|
||||
}
|
||||
|
||||
|
||||
/* Improve controls visibility on iOS */
|
||||
video::-webkit-media-controls {
|
||||
opacity: 1 !important;
|
||||
visibility: visible !important;
|
||||
}
|
||||
|
||||
|
||||
/* Ensure controls don't disappear too quickly */
|
||||
video::-webkit-media-controls-panel {
|
||||
transition-duration: 3s !important;
|
||||
@@ -76,19 +76,19 @@
|
||||
/* Prevent text selection on buttons */
|
||||
.no-select {
|
||||
-webkit-touch-callout: none; /* iOS Safari */
|
||||
-webkit-user-select: none; /* Safari */
|
||||
-khtml-user-select: none; /* Konqueror HTML */
|
||||
-moz-user-select: none; /* Firefox */
|
||||
-ms-user-select: none; /* Internet Explorer/Edge */
|
||||
user-select: none; /* Non-prefixed version, supported by Chrome and Opera */
|
||||
-webkit-user-select: none; /* Safari */
|
||||
-khtml-user-select: none; /* Konqueror HTML */
|
||||
-moz-user-select: none; /* Firefox */
|
||||
-ms-user-select: none; /* Internet Explorer/Edge */
|
||||
user-select: none; /* Non-prefixed version, supported by Chrome and Opera */
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Specifically prevent default behavior on fine controls */
|
||||
.ios-fine-controls button,
|
||||
.ios-fine-controls button,
|
||||
.ios-external-controls .no-select {
|
||||
touch-action: manipulation;
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-user-select: none;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,302 +1,306 @@
|
||||
#video-editor-trim-root {
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
background-color: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
width: 90%;
|
||||
max-width: 500px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
animation: modal-fade-in 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes modal-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.modal-close-button {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: #666;
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.modal-close-button:hover {
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
padding: 20px;
|
||||
color: #333;
|
||||
font-size: 1rem;
|
||||
line-height: 1.5;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 16px 20px;
|
||||
border-top: 1px solid #eee;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.modal-button {
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.modal-button-primary {
|
||||
background-color: #0066cc;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.modal-button-primary:hover {
|
||||
background-color: #0055aa;
|
||||
}
|
||||
|
||||
.modal-button-secondary {
|
||||
background-color: #f0f0f0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.modal-button-secondary:hover {
|
||||
background-color: #e0e0e0;
|
||||
}
|
||||
|
||||
.modal-button-danger {
|
||||
background-color: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.modal-button-danger:hover {
|
||||
background-color: #bd2130;
|
||||
}
|
||||
|
||||
/* Modal content styles */
|
||||
.modal-message {
|
||||
margin-bottom: 16px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.modal-spinner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
border: 4px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: 50%;
|
||||
border-top: 4px solid #0066cc;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.modal-success-icon {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 16px;
|
||||
color: #28a745;
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.modal-success-icon svg {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
color: #4CAF50;
|
||||
animation: success-pop 0.5s ease-out;
|
||||
}
|
||||
|
||||
@keyframes success-pop {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
opacity: 0;
|
||||
}
|
||||
70% {
|
||||
transform: scale(1.1);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-error-icon {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 16px;
|
||||
color: #dc3545;
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.modal-error-icon svg {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
color: #F44336;
|
||||
animation: error-pop 0.5s ease-out;
|
||||
}
|
||||
|
||||
@keyframes error-pop {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
opacity: 0;
|
||||
}
|
||||
70% {
|
||||
transform: scale(1.1);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-choices {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.modal-choice-button {
|
||||
padding: 12px 16px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background-color: #0066cc;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.modal-choice-button:hover {
|
||||
background-color: #0055aa;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.modal-choice-button svg {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.success-link {
|
||||
background-color: #4CAF50;
|
||||
}
|
||||
|
||||
.success-link:hover {
|
||||
background-color: #3d8b40;
|
||||
}
|
||||
|
||||
.centered-choice {
|
||||
margin: 0 auto;
|
||||
width: auto;
|
||||
min-width: 220px;
|
||||
background-color: #0066cc;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.centered-choice:hover {
|
||||
background-color: #0055aa;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.modal-container {
|
||||
width: 95%;
|
||||
background-color: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
width: 90%;
|
||||
max-width: 500px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
animation: modal-fade-in 0.3s ease-out;
|
||||
}
|
||||
|
||||
|
||||
@keyframes modal-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.modal-close-button {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: #666;
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.modal-close-button:hover {
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
padding: 20px;
|
||||
color: #333;
|
||||
font-size: 1rem;
|
||||
line-height: 1.5;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
flex-direction: column;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 16px 20px;
|
||||
border-top: 1px solid #eee;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
|
||||
.modal-button {
|
||||
width: 100%;
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.modal-button-primary {
|
||||
background-color: #0066cc;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.modal-button-primary:hover {
|
||||
background-color: #0055aa;
|
||||
}
|
||||
|
||||
.modal-button-secondary {
|
||||
background-color: #f0f0f0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.modal-button-secondary:hover {
|
||||
background-color: #e0e0e0;
|
||||
}
|
||||
|
||||
.modal-button-danger {
|
||||
background-color: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.modal-button-danger:hover {
|
||||
background-color: #bd2130;
|
||||
}
|
||||
|
||||
/* Modal content styles */
|
||||
.modal-message {
|
||||
margin-bottom: 16px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.modal-spinner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
border: 4px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: 50%;
|
||||
border-top: 4px solid #0066cc;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-success-icon {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 16px;
|
||||
color: #28a745;
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.modal-success-icon svg {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
color: #4caf50;
|
||||
animation: success-pop 0.5s ease-out;
|
||||
}
|
||||
|
||||
@keyframes success-pop {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
opacity: 0;
|
||||
}
|
||||
70% {
|
||||
transform: scale(1.1);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-error-icon {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 16px;
|
||||
color: #dc3545;
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.modal-error-icon svg {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
color: #f44336;
|
||||
animation: error-pop 0.5s ease-out;
|
||||
}
|
||||
|
||||
@keyframes error-pop {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
opacity: 0;
|
||||
}
|
||||
70% {
|
||||
transform: scale(1.1);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-choices {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.modal-choice-button {
|
||||
padding: 12px 16px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background-color: #0066cc;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.modal-choice-button:hover {
|
||||
background-color: #0055aa;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.modal-choice-button svg {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.success-link {
|
||||
background-color: #4caf50;
|
||||
}
|
||||
|
||||
.success-link:hover {
|
||||
background-color: #3d8b40;
|
||||
}
|
||||
|
||||
.centered-choice {
|
||||
margin: 0 auto;
|
||||
width: auto;
|
||||
min-width: 220px;
|
||||
background-color: #0066cc;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.centered-choice:hover {
|
||||
background-color: #0055aa;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.modal-container {
|
||||
width: 95%;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.modal-button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #f44336;
|
||||
font-weight: 500;
|
||||
background-color: rgba(244, 67, 54, 0.1);
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
border-left: 4px solid #f44336;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.redirect-message {
|
||||
margin-top: 20px;
|
||||
color: #555;
|
||||
font-size: 0.95rem;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.countdown {
|
||||
font-weight: bold;
|
||||
color: #0066cc;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #F44336;
|
||||
font-weight: 500;
|
||||
background-color: rgba(244, 67, 54, 0.1);
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
border-left: 4px solid #F44336;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.redirect-message {
|
||||
margin-top: 20px;
|
||||
color: #555;
|
||||
font-size: 0.95rem;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.countdown {
|
||||
font-weight: bold;
|
||||
color: #0066cc;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
}
|
||||
@@ -56,7 +56,7 @@
|
||||
|
||||
.timeline-marker {
|
||||
position: absolute;
|
||||
height: 82px; /* Increased height to extend below timeline */
|
||||
height: 82px; /* Increased height to extend below timeline */
|
||||
width: 2px;
|
||||
background-color: #000;
|
||||
transform: translateX(-50%);
|
||||
@@ -83,7 +83,7 @@
|
||||
|
||||
.timeline-marker-drag {
|
||||
position: absolute;
|
||||
bottom: -12px; /* Changed from -6px to -12px to move it further down */
|
||||
bottom: -12px; /* Changed from -6px to -12px to move it further down */
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 16px;
|
||||
@@ -248,14 +248,14 @@
|
||||
right: 0;
|
||||
border-radius: 0 2px 2px 0;
|
||||
}
|
||||
|
||||
|
||||
/* Enhanced handles for touch devices */
|
||||
@media (pointer: coarse) {
|
||||
.clip-segment-handle {
|
||||
width: 14px; /* Wider target for touch devices */
|
||||
background-color: rgba(0, 0, 0, 0.4); /* Darker by default for better visibility */
|
||||
}
|
||||
|
||||
|
||||
.clip-segment-handle:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
@@ -267,15 +267,15 @@
|
||||
background-color: rgba(255, 255, 255, 0.8);
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
|
||||
.clip-segment-handle.left:after {
|
||||
box-shadow: -2px 0 0 rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
|
||||
.clip-segment-handle.right:after {
|
||||
box-shadow: 2px 0 0 rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
|
||||
/* Active state for touch feedback */
|
||||
.clip-segment-handle:active {
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
@@ -284,19 +284,19 @@
|
||||
.timeline-marker {
|
||||
height: 85px;
|
||||
}
|
||||
|
||||
|
||||
.timeline-marker-head {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
top: -13px;
|
||||
}
|
||||
|
||||
|
||||
.timeline-marker-drag {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
bottom: -18px;
|
||||
}
|
||||
|
||||
|
||||
.timeline-marker-head.dragging {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
@@ -321,7 +321,7 @@
|
||||
|
||||
.segment-tooltip:after,
|
||||
.empty-space-tooltip:after {
|
||||
content: '';
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: -5px;
|
||||
left: 50%;
|
||||
@@ -335,7 +335,7 @@
|
||||
|
||||
.segment-tooltip:before,
|
||||
.empty-space-tooltip:before {
|
||||
content: '';
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: -6px;
|
||||
left: 50%;
|
||||
@@ -438,7 +438,7 @@
|
||||
font-size: 0.875rem;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
margin-right: 0.50rem;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.time-button:hover {
|
||||
@@ -532,8 +532,8 @@
|
||||
}
|
||||
|
||||
/* General styles for all save buttons */
|
||||
.save-button,
|
||||
.save-copy-button,
|
||||
.save-button,
|
||||
.save-copy-button,
|
||||
.save-segments-button {
|
||||
color: #ffffff;
|
||||
background: #0066cc;
|
||||
@@ -548,8 +548,8 @@
|
||||
}
|
||||
|
||||
/* Shared hover effect */
|
||||
.save-button:hover,
|
||||
.save-copy-button:hover,
|
||||
.save-button:hover,
|
||||
.save-copy-button:hover,
|
||||
.save-segments-button:hover {
|
||||
background-color: #0056b3;
|
||||
}
|
||||
@@ -561,30 +561,30 @@
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.save-button,
|
||||
.save-copy-button,
|
||||
|
||||
.save-button,
|
||||
.save-copy-button,
|
||||
.save-segments-button {
|
||||
flex: 1;
|
||||
font-size: 0.7rem;
|
||||
padding: 0.25rem 0.35rem;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Very small screens - adjust save buttons */
|
||||
@media (max-width: 480px) {
|
||||
.save-button,
|
||||
.save-copy-button,
|
||||
.save-button,
|
||||
.save-copy-button,
|
||||
.save-segments-button {
|
||||
font-size: 0.675rem;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
|
||||
/* Remove margins for controls-right buttons */
|
||||
.controls-right {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
|
||||
.controls-right button {
|
||||
margin: 0;
|
||||
}
|
||||
@@ -595,7 +595,7 @@
|
||||
[data-tooltip] {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
|
||||
[data-tooltip]:before {
|
||||
content: attr(data-tooltip);
|
||||
position: absolute;
|
||||
@@ -612,13 +612,15 @@
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.2s, visibility 0.2s;
|
||||
transition:
|
||||
opacity 0.2s,
|
||||
visibility 0.2s;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
||||
[data-tooltip]:after {
|
||||
content: '';
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
@@ -628,17 +630,19 @@
|
||||
border-color: rgba(0, 0, 0, 0.8) transparent transparent transparent;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.2s, visibility 0.2s;
|
||||
transition:
|
||||
opacity 0.2s,
|
||||
visibility 0.2s;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
||||
[data-tooltip]:hover:before,
|
||||
[data-tooltip]:hover:after {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Hide button tooltips on touch devices */
|
||||
@media (pointer: coarse) {
|
||||
[data-tooltip]:before,
|
||||
@@ -669,27 +673,27 @@
|
||||
}
|
||||
|
||||
.modal-success-icon svg {
|
||||
color: #4CAF50;
|
||||
color: #4caf50;
|
||||
animation: fadeIn 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
.modal-error-icon svg {
|
||||
color: #F44336;
|
||||
color: #f44336;
|
||||
animation: fadeIn 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
.success-link {
|
||||
background-color: #4CAF50;
|
||||
background-color: #4caf50;
|
||||
color: white;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.success-link:hover {
|
||||
background-color: #388E3C;
|
||||
background-color: #388e3c;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #F44336;
|
||||
color: #f44336;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@@ -809,47 +813,18 @@
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { opacity: 0.7; transform: scale(1); }
|
||||
50% { opacity: 1; transform: scale(1.05); }
|
||||
100% { opacity: 0.7; transform: scale(1); }
|
||||
}
|
||||
|
||||
/* Preview mode styles */
|
||||
.preview-mode .tooltip-action-btn {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.preview-mode .tooltip-time-btn {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Timeline preview mode styles */
|
||||
.timeline-container-card.preview-mode {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.timeline-container-card.preview-mode .timeline-marker-head,
|
||||
.timeline-container-card.preview-mode .timeline-marker-drag,
|
||||
.timeline-container-card.preview-mode .clip-segment,
|
||||
.timeline-container-card.preview-mode .clip-segment-handle,
|
||||
.timeline-container-card.preview-mode .time-button,
|
||||
.timeline-container-card.preview-mode .zoom-button,
|
||||
.timeline-container-card.preview-mode .save-button,
|
||||
.timeline-container-card.preview-mode .save-copy-button,
|
||||
.timeline-container-card.preview-mode .save-segments-button {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.timeline-container-card.preview-mode .clip-segment:hover {
|
||||
box-shadow: none;
|
||||
border-color: rgba(0, 0, 0, 0.15);
|
||||
background-color: inherit !important;
|
||||
0% {
|
||||
opacity: 0.7;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
100% {
|
||||
opacity: 0.7;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Segments playback mode styles - minimal functional styling */
|
||||
@@ -858,19 +833,26 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.segments-playback-mode .tooltip-action-btn.set-in,
|
||||
.segments-playback-mode .tooltip-action-btn.set-out,
|
||||
.segments-playback-mode .tooltip-action-btn.play-from-start {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.segments-playback-mode .tooltip-action-btn.play,
|
||||
.segments-playback-mode .tooltip-action-btn.pause {
|
||||
opacity: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* During segments playback mode, disable button interactions but keep hover working */
|
||||
.segments-playback-mode .tooltip-time-btn[disabled],
|
||||
.segments-playback-mode .tooltip-action-btn[disabled] {
|
||||
opacity: 0.5 !important;
|
||||
cursor: not-allowed !important;
|
||||
}
|
||||
|
||||
/* Ensure disabled buttons still show tooltips on hover */
|
||||
.segments-playback-mode [data-tooltip][disabled]:hover:before,
|
||||
.segments-playback-mode [data-tooltip][disabled]:hover:after {
|
||||
opacity: 1 !important;
|
||||
visibility: visible !important;
|
||||
}
|
||||
|
||||
/* Show segments playback message */
|
||||
.segments-playback-message {
|
||||
display: flex;
|
||||
@@ -889,4 +871,4 @@
|
||||
width: 1.25rem;
|
||||
margin-right: 0.5rem;
|
||||
color: #3b82f6;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
}
|
||||
|
||||
.tooltip-row:first-child {
|
||||
margin-bottom: 6px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.tooltip-time-btn {
|
||||
@@ -56,6 +56,26 @@
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
/* Disabled state for time display */
|
||||
.tooltip-time-display.disabled {
|
||||
pointer-events: none !important;
|
||||
cursor: not-allowed !important;
|
||||
opacity: 0.6 !important;
|
||||
user-select: none !important;
|
||||
-webkit-user-select: none !important;
|
||||
-moz-user-select: none !important;
|
||||
-ms-user-select: none !important;
|
||||
}
|
||||
|
||||
/* Force disabled tooltips to show on hover for better user feedback */
|
||||
.tooltip-time-btn.disabled[data-tooltip]:hover:before,
|
||||
.tooltip-time-btn.disabled[data-tooltip]:hover:after,
|
||||
.tooltip-action-btn.disabled[data-tooltip]:hover:before,
|
||||
.tooltip-action-btn.disabled[data-tooltip]:hover:after {
|
||||
opacity: 1 !important;
|
||||
visibility: visible !important;
|
||||
}
|
||||
|
||||
.tooltip-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -69,13 +89,13 @@
|
||||
background-color: #f3f4f6;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 5px;
|
||||
padding: 5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
color: #4b5563;
|
||||
width: 26px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
min-width: 20px !important;
|
||||
position: relative; /* Add relative positioning for tooltips */
|
||||
@@ -100,14 +120,16 @@
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.2s, visibility 0.2s;
|
||||
transition:
|
||||
opacity 0.2s,
|
||||
visibility 0.2s;
|
||||
z-index: 2500; /* High z-index */
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Triangle arrow pointing up to the button */
|
||||
.tooltip-action-btn[data-tooltip]:after {
|
||||
content: '';
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 35px; /* Match the before element */
|
||||
left: 50%; /* Center horizontally */
|
||||
@@ -119,7 +141,9 @@
|
||||
margin-left: 0; /* Reset margin */
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.2s, visibility 0.2s;
|
||||
transition:
|
||||
opacity 0.2s,
|
||||
visibility 0.2s;
|
||||
z-index: 2500; /* High z-index */
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -175,7 +199,7 @@
|
||||
}
|
||||
|
||||
.tooltip-action-btn.play-from-start {
|
||||
color: #4f46e5;
|
||||
color: #4f46e5;
|
||||
}
|
||||
|
||||
.tooltip-action-btn.play-from-start:hover {
|
||||
@@ -194,7 +218,7 @@
|
||||
padding: 6px 10px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
color: #10b981;
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.tooltip-action-btn.new-segment:hover {
|
||||
@@ -227,43 +251,80 @@
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
/* Ensure pause button is properly styled when disabled */
|
||||
.tooltip-action-btn.pause.disabled {
|
||||
color: #9ca3af !important;
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.tooltip-action-btn.pause.disabled:hover {
|
||||
background-color: #f3f4f6 !important;
|
||||
color: #9ca3af !important;
|
||||
}
|
||||
|
||||
/* Ensure play button is properly styled when disabled */
|
||||
.tooltip-action-btn.play.disabled {
|
||||
color: #9ca3af !important;
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.tooltip-action-btn.play.disabled:hover {
|
||||
background-color: #f3f4f6 !important;
|
||||
color: #9ca3af !important;
|
||||
}
|
||||
|
||||
/* Ensure time adjustment buttons are properly styled when disabled */
|
||||
.tooltip-time-btn.disabled {
|
||||
opacity: 0.5 !important;
|
||||
cursor: not-allowed !important;
|
||||
background-color: #f3f4f6 !important;
|
||||
color: #9ca3af !important;
|
||||
}
|
||||
|
||||
.tooltip-time-btn.disabled:hover {
|
||||
background-color: #f3f4f6 !important;
|
||||
color: #9ca3af !important;
|
||||
}
|
||||
|
||||
/* Additional mobile optimizations */
|
||||
@media (max-width: 768px) {
|
||||
.two-row-tooltip {
|
||||
padding: 4px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
|
||||
.tooltip-row:first-child {
|
||||
margin-bottom: 4px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
|
||||
.tooltip-time-btn {
|
||||
min-width: 20px !important;
|
||||
font-size: 0.7rem !important;
|
||||
padding: 3px 6px !important;
|
||||
}
|
||||
|
||||
|
||||
.tooltip-time-display {
|
||||
font-size: 0.8rem !important;
|
||||
padding: 3px 4px !important;
|
||||
min-width: 90px !important;
|
||||
}
|
||||
|
||||
|
||||
.tooltip-action-btn {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
|
||||
.tooltip-action-btn.new-segment {
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
|
||||
.tooltip-action-btn svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
|
||||
/* Adjust tooltip position for small screens - maintain the same position but adjust size */
|
||||
.tooltip-action-btn[data-tooltip]:before {
|
||||
min-width: 100px;
|
||||
@@ -272,7 +333,7 @@
|
||||
height: 24px;
|
||||
top: 33px; /* Maintain the same relative distance on mobile */
|
||||
}
|
||||
|
||||
|
||||
.tooltip-action-btn[data-tooltip]:after {
|
||||
top: 33px; /* Match the tooltip position */
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
[data-tooltip] {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
|
||||
[data-tooltip]:before {
|
||||
content: attr(data-tooltip);
|
||||
position: absolute;
|
||||
@@ -21,13 +21,15 @@
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.2s, visibility 0.2s;
|
||||
transition:
|
||||
opacity 0.2s,
|
||||
visibility 0.2s;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
||||
[data-tooltip]:after {
|
||||
content: '';
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
@@ -37,17 +39,19 @@
|
||||
border-color: rgba(0, 0, 0, 0.8) transparent transparent transparent;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.2s, visibility 0.2s;
|
||||
transition:
|
||||
opacity 0.2s,
|
||||
visibility 0.2s;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
||||
[data-tooltip]:hover:before,
|
||||
[data-tooltip]:hover:after {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Hide button tooltips on touch devices */
|
||||
@media (pointer: coarse) {
|
||||
[data-tooltip]:before,
|
||||
@@ -71,7 +75,7 @@
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
|
||||
.video-player-container video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -83,7 +87,7 @@
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
|
||||
/* iOS-specific styles */
|
||||
@supports (-webkit-touch-callout: none) {
|
||||
.video-player-container video {
|
||||
@@ -92,7 +96,7 @@
|
||||
-webkit-touch-callout: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.play-pause-indicator {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
@@ -106,19 +110,19 @@
|
||||
transition: opacity 0.3s;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
||||
.video-player-container:hover .play-pause-indicator {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
|
||||
.play-pause-indicator::before {
|
||||
content: '';
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
|
||||
.play-pause-indicator.play-icon::before {
|
||||
width: 0;
|
||||
height: 0;
|
||||
@@ -127,14 +131,14 @@
|
||||
border-left: 25px solid white;
|
||||
margin-left: 3px;
|
||||
}
|
||||
|
||||
|
||||
.play-pause-indicator.pause-icon::before {
|
||||
width: 20px;
|
||||
height: 25px;
|
||||
border-left: 6px solid white;
|
||||
border-right: 6px solid white;
|
||||
}
|
||||
|
||||
|
||||
/* iOS First-play indicator */
|
||||
.ios-first-play-indicator {
|
||||
position: absolute;
|
||||
@@ -148,7 +152,7 @@
|
||||
justify-content: center;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
|
||||
.ios-play-message {
|
||||
color: white;
|
||||
font-size: 1.2rem;
|
||||
@@ -158,13 +162,22 @@
|
||||
border-radius: 0.5rem;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
|
||||
@keyframes pulse {
|
||||
0% { opacity: 0.7; transform: scale(1); }
|
||||
50% { opacity: 1; transform: scale(1.05); }
|
||||
100% { opacity: 0.7; transform: scale(1); }
|
||||
0% {
|
||||
opacity: 0.7;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
100% {
|
||||
opacity: 0.7;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.video-controls {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
@@ -175,21 +188,21 @@
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
|
||||
.video-player-container:hover .video-controls {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
|
||||
.video-current-time {
|
||||
color: white;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
|
||||
.video-duration {
|
||||
color: white;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
|
||||
.video-time-display {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -197,7 +210,7 @@
|
||||
color: white;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
|
||||
.video-progress {
|
||||
position: relative;
|
||||
height: 6px;
|
||||
@@ -208,11 +221,11 @@
|
||||
touch-action: none; /* Prevent browser handling of drag gestures */
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
|
||||
.video-progress.dragging {
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
|
||||
.video-progress-fill {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
@@ -222,7 +235,7 @@
|
||||
border-radius: 3px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
||||
.video-scrubber {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
@@ -232,9 +245,12 @@
|
||||
background-color: #ff0000;
|
||||
border-radius: 50%;
|
||||
cursor: grab;
|
||||
transition: transform 0.1s ease, width 0.1s ease, height 0.1s ease;
|
||||
transition:
|
||||
transform 0.1s ease,
|
||||
width 0.1s ease,
|
||||
height 0.1s ease;
|
||||
}
|
||||
|
||||
|
||||
/* Make the scrubber larger when dragging for better control */
|
||||
.video-progress.dragging .video-scrubber {
|
||||
transform: translate(-50%, -50%) scale(1.2);
|
||||
@@ -243,22 +259,22 @@
|
||||
cursor: grabbing;
|
||||
box-shadow: 0 0 8px rgba(255, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
|
||||
/* Enhance for touch devices */
|
||||
@media (pointer: coarse) {
|
||||
.video-scrubber {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
|
||||
.video-progress.dragging .video-scrubber {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
|
||||
/* Create a larger invisible touch target */
|
||||
.video-scrubber:before {
|
||||
content: '';
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -10px;
|
||||
left: -10px;
|
||||
@@ -266,14 +282,14 @@
|
||||
bottom: -10px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.video-controls-buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
|
||||
.mute-button,
|
||||
.fullscreen-button {
|
||||
min-width: auto;
|
||||
@@ -283,17 +299,17 @@
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
transition: transform 0.2s;
|
||||
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
|
||||
svg {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Time tooltip that appears when dragging */
|
||||
.video-time-tooltip {
|
||||
position: absolute;
|
||||
@@ -309,10 +325,10 @@
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
|
||||
/* Add a small arrow to the tooltip */
|
||||
.video-time-tooltip:after {
|
||||
content: '';
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: -4px;
|
||||
left: 50%;
|
||||
@@ -323,4 +339,4 @@
|
||||
border-right: 4px solid transparent;
|
||||
border-top: 4px solid rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
"dev": "vite",
|
||||
"start": "NODE_ENV=production node dist/index.js",
|
||||
"check": "tsc",
|
||||
"build:django": "vite build --config vite.video-editor.config.ts --outDir ../../../static/video_editor"
|
||||
"build:django": "vite build --config vite.video-editor.config.ts --outDir ../../../static/video_editor",
|
||||
"format": "npx prettier --write client/src/**/*.{ts,tsx,css}"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.74.4",
|
||||
@@ -35,6 +36,7 @@
|
||||
"autoprefixer": "^10.4.20",
|
||||
"esbuild": "^0.25.0",
|
||||
"postcss": "^8.4.47",
|
||||
"prettier": "^3.6.0",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^5.4.18"
|
||||
|
||||
@@ -1834,6 +1834,11 @@ postcss@^8.4.43, postcss@^8.4.47:
|
||||
picocolors "^1.1.1"
|
||||
source-map-js "^1.2.1"
|
||||
|
||||
prettier@^3.6.0:
|
||||
version "3.6.0"
|
||||
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.6.0.tgz#18ec98d62cb0757a5d4eab40253ff3e6d0fc8dea"
|
||||
integrity sha512-ujSB9uXHJKzM/2GBuE0hBOUgC77CN3Bnpqa+g80bkv3T3A93wL/xlzDATHhnhkzifz/UE2SNOvmbTz5hSkDlHw==
|
||||
|
||||
proxy-addr@~2.0.7:
|
||||
version "2.0.7"
|
||||
resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025"
|
||||
@@ -2087,6 +2092,7 @@ statuses@2.0.1:
|
||||
integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==
|
||||
|
||||
"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0:
|
||||
name string-width-cjs
|
||||
version "4.2.3"
|
||||
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
|
||||
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
|
||||
@@ -2105,6 +2111,7 @@ string-width@^5.0.1, string-width@^5.1.2:
|
||||
strip-ansi "^7.0.1"
|
||||
|
||||
"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
|
||||
name strip-ansi-cjs
|
||||
version "6.0.1"
|
||||
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
|
||||
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
width: 20%;
|
||||
width: 10%;
|
||||
|
||||
&:nth-child(3n + 1),
|
||||
&:nth-child(3n + 2),
|
||||
|
||||
@@ -5,6 +5,11 @@ import { FilterOptions } from '../_shared';
|
||||
|
||||
import './ManageItemList-filters.scss';
|
||||
|
||||
// Get categories from window if available
|
||||
const categories = window.CATEGORIES ?
|
||||
[{ id: 'all', title: 'All' }].concat(window.CATEGORIES.map(cat => ({ id: cat, title: cat }))) :
|
||||
[{ id: 'all', title: 'All' }];
|
||||
|
||||
const filters = {
|
||||
state: [
|
||||
{ id: 'all', title: 'All' },
|
||||
@@ -46,6 +51,7 @@ export function ManageMediaFilters(props) {
|
||||
const [encodingStatus, setEncodingStatus] = useState('all');
|
||||
const [isFeatured, setIsFeatured] = useState('all');
|
||||
const [isReviewed, setIsReviewed] = useState('all');
|
||||
const [category, setCategory] = useState('all');
|
||||
|
||||
const containerRef = useRef(null);
|
||||
const innerContainerRef = useRef(null);
|
||||
@@ -63,6 +69,7 @@ export function ManageMediaFilters(props) {
|
||||
encoding_status: encodingStatus,
|
||||
featured: isFeatured,
|
||||
is_reviewed: isReviewed,
|
||||
category: category,
|
||||
};
|
||||
|
||||
switch (ev.currentTarget.getAttribute('filter')) {
|
||||
@@ -91,6 +98,11 @@ export function ManageMediaFilters(props) {
|
||||
props.onFiltersUpdate(args);
|
||||
setIsReviewed(args.is_reviewed);
|
||||
break;
|
||||
case 'category':
|
||||
args.category = ev.currentTarget.getAttribute('value');
|
||||
props.onFiltersUpdate(args);
|
||||
setCategory(args.category);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,6 +163,13 @@ export function ManageMediaFilters(props) {
|
||||
<FilterOptions id={'featured'} options={filters.featured} selected={isFeatured} onSelect={onFilterSelect} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mi-filter">
|
||||
<div className="mi-filter-title">CATEGORY</div>
|
||||
<div className="mi-filter-options">
|
||||
<FilterOptions id={'category'} options={categories} selected={category} onSelect={onFilterSelect} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
18
install.sh
18
install.sh
@@ -10,8 +10,9 @@ fi
|
||||
|
||||
while true; do
|
||||
read -p "
|
||||
This script will attempt to perform a system update, install required dependencies, install and configure PostgreSQL, NGINX, Redis and a few other utilities.
|
||||
It is expected to run on a new system **with no running instances of any these services**. Make sure you check the script before you continue. Then enter yes or no
|
||||
This script will attempt to perform a system update and install services including PostgreSQL, nginx and Django.
|
||||
It is expected to run on a new system **with no running instances of any these services**.
|
||||
This has been tested only in Ubuntu Linux 22 and 24. Make sure you check the script before you continue. Then enter yes or no
|
||||
" yn
|
||||
case $yn in
|
||||
[Yy]* ) echo "OK!"; break;;
|
||||
@@ -20,15 +21,7 @@ It is expected to run on a new system **with no running instances of any these s
|
||||
esac
|
||||
done
|
||||
|
||||
|
||||
osVersion=$(lsb_release -d)
|
||||
if [[ $osVersion == *"Ubuntu 20"* ]] || [[ $osVersion == *"Ubuntu 22"* ]] || [[ $osVersion == *"buster"* ]] || [[ $osVersion == *"bullseye"* ]]; then
|
||||
echo 'Performing system update and dependency installation, this will take a few minutes'
|
||||
apt-get update && apt-get -y upgrade && apt-get install python3-venv python3-dev virtualenv redis-server postgresql nginx git gcc vim unzip imagemagick python3-certbot-nginx certbot wget xz-utils -y
|
||||
else
|
||||
echo "This script is tested for Ubuntu 20/22 versions only, if you want to try MediaCMS on another system you have to perform the manual installation"
|
||||
exit
|
||||
fi
|
||||
apt-get update && apt-get -y upgrade && apt-get install pkg-config python3-venv python3-dev virtualenv redis-server postgresql nginx git gcc vim unzip imagemagick procps libxml2-dev libxmlsec1-dev libxmlsec1-openssl python3-certbot-nginx certbot wget xz-utils -y
|
||||
|
||||
# install ffmpeg
|
||||
echo "Downloading and installing ffmpeg"
|
||||
@@ -50,6 +43,7 @@ echo 'Creating database to be used in MediaCMS'
|
||||
su -c "psql -c \"CREATE DATABASE mediacms\"" postgres
|
||||
su -c "psql -c \"CREATE USER mediacms WITH ENCRYPTED PASSWORD 'mediacms'\"" postgres
|
||||
su -c "psql -c \"GRANT ALL PRIVILEGES ON DATABASE mediacms TO mediacms\"" postgres
|
||||
su -c "psql -d mediacms -c \"GRANT CREATE, USAGE ON SCHEMA public TO mediacms\"" postgres
|
||||
|
||||
echo 'Creating python virtualenv on /home/mediacms.io'
|
||||
|
||||
@@ -57,7 +51,7 @@ cd /home/mediacms.io
|
||||
virtualenv . --python=python3
|
||||
source /home/mediacms.io/bin/activate
|
||||
cd mediacms
|
||||
pip install -r requirements.txt
|
||||
pip install --no-binary lxml,xmlsec -r requirements.txt
|
||||
|
||||
SECRET_KEY=`python -c 'from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())'`
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
Django==5.1.6
|
||||
djangorestframework==3.15.2
|
||||
lxml==5.0.0 # dont use later version, as theres a strange error "lxml & xmlsec libxml2 library version mismatch"
|
||||
python3-saml==1.16.0
|
||||
django-allauth==65.4.1
|
||||
psycopg==3.2.4
|
||||
psycopg[pool]==3.2.4
|
||||
uwsgi==2.0.28
|
||||
django-redis==5.4.0
|
||||
celery==5.4.0
|
||||
@@ -19,7 +18,6 @@ requests==2.32.3
|
||||
django-celery-email==3.0.0
|
||||
m3u8==6.0.0
|
||||
django-debug-toolbar==5.0.1
|
||||
django-login-required-middleware==0.9.0
|
||||
pre-commit==4.1.0
|
||||
django-jazzmin==3.0.1
|
||||
pysubs2==1.8.0
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -19,4 +19,4 @@
|
||||
{% include "components/footer.html" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block bottomimports %}<script src="{% static "js/base.js" %}"></script>{% endblock bottomimports %}
|
||||
{% block bottomimports %}<script src="{% static "js/base.js" %}?v={{ VERSION }}"></script>{% endblock bottomimports %}
|
||||
@@ -129,7 +129,7 @@
|
||||
{% endblock innercontent %}
|
||||
|
||||
{% block bottomimports %}
|
||||
<script src="{% static "js/add-media.js" %}"></script>
|
||||
<script src="{% static "js/add-media.js" %}?v={{ VERSION }}"></script>
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", function(event) {
|
||||
function getCSRFToken() {
|
||||
|
||||
@@ -41,5 +41,5 @@
|
||||
{% block content %}<div id="page-categories"></div>{% endblock %}
|
||||
|
||||
{% block bottomimports %}
|
||||
<script src="{% static "js/categories.js" %}"></script>
|
||||
<script src="{% static "js/categories.js" %}?v={{ VERSION }}"></script>
|
||||
{% endblock bottomimports %}
|
||||
|
||||
@@ -9,5 +9,5 @@
|
||||
{% block content %}<div id="page-embed"></div>{% endblock content %}
|
||||
|
||||
{% block bottomimports %}
|
||||
<script src="{% static "js/embed.js" %}"></script>
|
||||
<script src="{% static "js/embed.js" %}?v={{ VERSION }}"></script>
|
||||
{% endblock bottomimports %}
|
||||
|
||||
@@ -43,5 +43,5 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block bottomimports %}
|
||||
<script src="{% static "js/featured.js" %}"></script>
|
||||
<script src="{% static "js/featured.js" %}?v={{ VERSION }}"></script>
|
||||
{% endblock bottomimports %}
|
||||
|
||||
@@ -13,5 +13,5 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block bottomimports %}
|
||||
<script src="{% static "js/history.js" %}"></script>
|
||||
<script src="{% static "js/history.js" %}?v={{ VERSION }}"></script>
|
||||
{% endblock bottomimports %}
|
||||
|
||||
@@ -48,5 +48,5 @@
|
||||
{% block content %}<div id="page-home"></div>{% endblock %}
|
||||
|
||||
{% block bottomimports %}
|
||||
<script src="{% static "js/index.js" %}"></script>
|
||||
<script src="{% static "js/index.js" %}?v={{ VERSION }}"></script>
|
||||
{% endblock bottomimports %}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user