diff --git a/Makefile b/Makefile
index 3d40cb14..87936e91 100644
--- a/Makefile
+++ b/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; \
diff --git a/README.md b/README.md
index 1636f8b6..34695c7f 100644
--- a/README.md
+++ b/README.md
@@ -101,6 +101,7 @@ There are two ways to run MediaCMS, through Docker Compose and through installin
* [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
diff --git a/cms/settings.py b/cms/settings.py
index 075c2518..c6205cd5 100644
--- a/cms/settings.py
+++ b/cms/settings.py
@@ -498,6 +498,9 @@ 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
diff --git a/cms/version.py b/cms/version.py
index 02f4b901..1d01958d 100644
--- a/cms/version.py
+++ b/cms/version.py
@@ -1 +1 @@
-VERSION = "6.3.0"
+VERSION = "6.4.0"
diff --git a/docs/admins_docs.md b/docs/admins_docs.md
index 071533e8..b9de5704 100644
--- a/docs/admins_docs.md
+++ b/docs/admins_docs.md
@@ -168,8 +168,6 @@ By default, all these services are enabled, but in order to create a scaleable d
Also see the `Dockerfile` for other environment variables which you may wish to override. Application settings, eg. `FRONTEND_HOST` can also be overridden by updating the `deploy/docker/local_settings.py` file.
-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`
### Simple Deployment, accessed as http://localhost
@@ -502,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,4 +975,4 @@ 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.
+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.
diff --git a/docs/dev_exp.md b/docs/dev_exp.md
index b66d87e9..9caa4d02 100644
--- a/docs/dev_exp.md
+++ b/docs/dev_exp.md
@@ -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
diff --git a/docs/developers_docs.md b/docs/developers_docs.md
index 9947d855..1c26f734 100644
--- a/docs/developers_docs.md
+++ b/docs/developers_docs.md
@@ -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 ;)
diff --git a/docs/media_permissions.md b/docs/media_permissions.md
new file mode 100644
index 00000000..be57cb04
--- /dev/null
+++ b/docs/media_permissions.md
@@ -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.
diff --git a/files/forms.py b/files/forms.py
index 1988eb2f..c62cf71f 100644
--- a/files/forms.py
+++ b/files/forms.py
@@ -68,14 +68,18 @@ 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'))
diff --git a/files/methods.py b/files/methods.py
index 1cd5ea71..78502a58 100644
--- a/files/methods.py
+++ b/files/methods.py
@@ -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
diff --git a/files/migrations/0011_mediapermission.py b/files/migrations/0011_mediapermission.py
new file mode 100644
index 00000000..24c900bb
--- /dev/null
+++ b/files/migrations/0011_mediapermission.py
@@ -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')},
+ },
+ ),
+ ]
diff --git a/files/models/__init__.py b/files/models/__init__.py
new file mode 100644
index 00000000..d58009d1
--- /dev/null
+++ b/files/models/__init__.py
@@ -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
diff --git a/files/models/category.py b/files/models/category.py
new file mode 100644
index 00000000..347b17f7
--- /dev/null
+++ b/files/models/category.py
@@ -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
diff --git a/files/models/comment.py b/files/models/comment.py
new file mode 100644
index 00000000..6ba2830b
--- /dev/null
+++ b/files/models/comment.py
@@ -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()
diff --git a/files/models/encoding.py b/files/models/encoding.py
new file mode 100644
index 00000000..ec209659
--- /dev/null
+++ b/files/models/encoding.py
@@ -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
diff --git a/files/models/license.py b/files/models/license.py
new file mode 100644
index 00000000..dafe74f0
--- /dev/null
+++ b/files/models/license.py
@@ -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
diff --git a/files/models.py b/files/models/media.py
similarity index 55%
rename from files/models.py
rename to files/models/media.py
index 1729094b..03e08b7e 100644
--- a/files/models.py
+++ b/files/models/media.py
@@ -3,15 +3,12 @@ import json
import logging
import os
import random
-import re
-import tempfile
import uuid
import m3u8
from django.conf import settings
from django.contrib.postgres.indexes import GinIndex
from django.contrib.postgres.search import SearchVectorField
-from django.core.exceptions import ValidationError
from django.core.files import File
from django.db import models
from django.db.models import Func, Value
@@ -19,108 +16,25 @@ from django.db.models.signals import m2m_changed, post_delete, post_save, pre_de
from django.dispatch import receiver
from django.urls import reverse
from django.utils import timezone
-from django.utils.crypto import get_random_string
from django.utils.html import strip_tags
from imagekit.models import ProcessedImageField
from imagekit.processors import ResizeToFit
-from mptt.models import MPTTModel, TreeForeignKey
-from . import helpers
-from .stop_words import STOP_WORDS
+from .. import helpers
+from ..stop_words import STOP_WORDS
+from .encoding import EncodeProfile, Encoding
+from .utils import (
+ ENCODE_RESOLUTIONS_KEYS,
+ MEDIA_ENCODING_STATUS,
+ MEDIA_STATES,
+ MEDIA_TYPES_SUPPORTED,
+ original_media_file_path,
+ original_thumbnail_file_path,
+)
+from .video_data import VideoTrimRequest
logger = logging.getLogger(__name__)
-RE_TIMECODE = re.compile(r"(\d+:\d+:\d+.\d+)")
-
-# 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}"
-
class Media(models.Model):
"""The most important model for MediaCMS"""
@@ -138,7 +52,6 @@ class Media(models.Model):
null=True,
help_text="Media can exist in one or no Channels",
)
-
description = models.TextField(blank=True)
dislikes = models.IntegerField(default=0)
@@ -566,7 +479,7 @@ class Media(models.Model):
To be used on the video player
"""
- from . import tasks
+ from .. import tasks
tasks.produce_sprite_from_video.delay(self.friendly_token)
return True
@@ -582,7 +495,7 @@ class Media(models.Model):
profiles = EncodeProfile.objects.filter(active=True)
profiles = list(profiles)
- from . import tasks
+ from .. import tasks
# attempt to break media file in chunks
if self.duration > settings.CHUNKIZE_VIDEO_DURATION and chunkize:
@@ -638,7 +551,7 @@ class Media(models.Model):
self.save(update_fields=["encoding_status", "listable", "preview_file_path"])
if encoding and encoding.status == "success" and encoding.profile.codec == "h264" and action == "add" and not encoding.chunk:
- from . import tasks
+ from .. import tasks
tasks.create_hls.delay(self.friendly_token)
@@ -993,585 +906,26 @@ class Media(models.Model):
return data
-class License(models.Model):
- """A Base license model to be used in Media"""
+class MediaPermission(models.Model):
+ """Model to store user permissions for media"""
- title = models.CharField(max_length=100, unique=True)
- description = models.TextField(blank=True)
-
- def __str__(self):
- return self.title
-
-
-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,
+ PERMISSION_CHOICES = (
+ ("viewer", "Viewer"),
+ ("editor", "Editor"),
+ ("owner", "Owner"),
)
- 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
+ owner_user = models.ForeignKey('users.User', on_delete=models.CASCADE, related_name='granted_permissions')
+ user = models.ForeignKey('users.User', on_delete=models.CASCADE)
+ media = models.ForeignKey('Media', on_delete=models.CASCADE, related_name='permissions')
+ permission = models.CharField(max_length=20, choices=PERMISSION_CHOICES)
+ created_at = models.DateTimeField(auto_now_add=True)
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"""
-
- 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.listings_thumbnail:
- return self.listings_thumbnail
- if self.thumbnail:
- return helpers.url_from_path(self.thumbnail.path)
-
- 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,
- )
+ unique_together = ('user', 'media')
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
-
-
-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})
-
-
-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
-
-
-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}"
-
-
-def validate_rating(value):
- if -1 >= value or value > 5:
- raise ValidationError("score has to be between 0 and 5")
-
-
-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}"
-
-
-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"]
-
-
-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()
-
-
-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})"
+ return f"{self.user.username} - {self.media.title} ({self.permission})"
@receiver(post_save, sender=Media)
@@ -1585,7 +939,7 @@ def media_save(sender, instance, created, **kwargs):
return False
if created:
- from .methods import notify_users
+ from ..methods import notify_users
instance.media_init()
notify_users(friendly_token=instance.friendly_token, action="media_added")
@@ -1616,11 +970,6 @@ def media_file_pre_delete(sender, instance, **kwargs):
tag.update_tag_media()
-@receiver(post_delete, sender=VideoChapterData)
-def videochapterdata_delete(sender, instance, **kwargs):
- helpers.rm_dir(instance.media.video_chapters_folder)
-
-
@receiver(post_delete, sender=Media)
def media_file_delete(sender, instance, **kwargs):
"""
@@ -1661,166 +1010,3 @@ def media_m2m(sender, instance, **kwargs):
if instance.tags.all():
for tag in instance.tags.all():
tag.update_tag_media()
-
-
-@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
diff --git a/files/models/playlist.py b/files/models/playlist.py
new file mode 100644
index 00000000..cd747713
--- /dev/null
+++ b/files/models/playlist.py
@@ -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"]
diff --git a/files/models/rating.py b/files/models/rating.py
new file mode 100644
index 00000000..94ea8c2b
--- /dev/null
+++ b/files/models/rating.py
@@ -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}"
diff --git a/files/models/subtitle.py b/files/models/subtitle.py
new file mode 100644
index 00000000..e0e34d35
--- /dev/null
+++ b/files/models/subtitle.py
@@ -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
diff --git a/files/models/utils.py b/files/models/utils.py
new file mode 100644
index 00000000..d999533a
--- /dev/null
+++ b/files/models/utils.py
@@ -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")
diff --git a/files/models/video_data.py b/files/models/video_data.py
new file mode 100644
index 00000000..489244f6
--- /dev/null
+++ b/files/models/video_data.py
@@ -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)
diff --git a/files/tasks.py b/files/tasks.py
index 037128c2..63ac986f 100644
--- a/files/tasks.py
+++ b/files/tasks.py
@@ -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:
@@ -833,7 +833,7 @@ def update_listings_thumbnails():
# 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:
diff --git a/files/urls.py b/files/urls.py
index 7e590064..a9570a3c 100644
--- a/files/urls.py
+++ b/files/urls.py
@@ -48,6 +48,8 @@ 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(
diff --git a/files/views.py b/files/views.py
deleted file mode 100644
index 433f09cc..00000000
--- a/files/views.py
+++ /dev/null
@@ -1,1744 +0,0 @@
-import json
-from datetime import datetime, timedelta
-
-from allauth.socialaccount.models import SocialApp
-from django.conf import settings
-from django.contrib import messages
-from django.contrib.auth.decorators import login_required
-from django.contrib.postgres.search import SearchQuery
-from django.core.mail import EmailMessage
-from django.db.models import Q
-from django.http import Http404, HttpResponse, HttpResponseRedirect, JsonResponse
-from django.shortcuts import get_object_or_404, redirect, render
-from django.urls import reverse
-from django.views.decorators.csrf import csrf_exempt
-from drf_yasg import openapi as 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 USER_MEDIA_ACTIONS, MediaAction
-from cms.custom_pagination import FastPaginationWithoutCount
-from cms.permissions import (
- IsAuthorizedToAdd,
- IsAuthorizedToAddComment,
- IsUserOrEditor,
- user_allowed_to_upload,
-)
-from cms.version import VERSION
-from identity_providers.models import LoginOption
-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 clean_query, get_alphanumeric_only, produce_ffmpeg_commands
-from .methods import (
- check_comment_for_mention,
- create_video_trim_request,
- get_user_or_session,
- handle_video_chapters,
- is_mediacms_editor,
- list_tasks,
- notify_user_on_comment,
- show_recommended_media,
- show_related_media,
- update_user_ratings,
-)
-from .models import (
- Category,
- Comment,
- EncodeProfile,
- Encoding,
- Media,
- Playlist,
- PlaylistMedia,
- Subtitle,
- Tag,
- VideoTrimRequest,
-)
-from .serializers import (
- CategorySerializer,
- CommentSerializer,
- EncodeProfileSerializer,
- MediaSearchSerializer,
- MediaSerializer,
- PlaylistDetailSerializer,
- PlaylistSerializer,
- SingleMediaSerializer,
- TagSerializer,
-)
-from .stop_words import STOP_WORDS
-from .tasks import save_user_action, video_trim_task
-
-VALID_USER_ACTIONS = [action for action, name in USER_MEDIA_ACTIONS]
-
-
-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 == media.user 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 == media.user 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(Q(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 media.user.id == request.user.id 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)
-
-
-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(self, request, format=None):
- # Show media
- params = self.request.query_params
- show_param = params.get("show", "")
-
- author_param = params.get("author", "").strip()
- if author_param:
- user_queryset = User.objects.all()
- user = get_object_or_404(user_queryset, username=author_param)
- if show_param == "recommended":
- pagination_class = FastPaginationWithoutCount
- media = show_recommended_media(request, limit=50)
- else:
- pagination_class = api_settings.DEFAULT_PAGINATION_CLASS
- if author_param:
- # in case request.user is the user here, show
- # all media independant of state
- if self.request.user == user:
- basic_query = Q(user=user)
- else:
- basic_query = Q(listable=True, user=user)
- else:
- # base listings should show safe content
- basic_query = Q(listable=True)
-
- if show_param == "featured":
- media = Media.objects.filter(basic_query, featured=True)
- else:
- media = Media.objects.filter(basic_query).order_by("-add_date")
-
- paginator = pagination_class()
-
- if show_param != "recommended":
- media = media.prefetch_related("user")
- 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 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, password=None):
- 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" and not (self.request.user == media.user or is_mediacms_editor(self.request.user)):
- if getattr(settings, 'USE_RBAC', False) and self.request.user.is_authenticated and self.request.user.has_member_access_to_media(media):
- pass
- elif (not password) or (not media.password) or (password != media.password):
- 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, password=password)
- 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
- 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)
-
- media = Media.objects.filter(state="public", is_reviewed=True)
-
- if query:
- # move this processing to a prepare_query function
- query = 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 getattr(settings, 'USE_RBAC', False) and request.user.is_authenticated:
- c_object = Category.objects.filter(title=category, is_rbac_category=True).first()
- if c_object and request.user.has_member_access_to_category(c_object):
- # show all media where user has access based on RBAC
- media = Media.objects.filter(category=c_object)
-
- 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")
- 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)
-
-
-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)
-
-
-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 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)
-
-
-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)
-
-
-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):
- if is_mediacms_editor(request.user):
- categories = Category.objects.filter()
- else:
- categories = Category.objects.filter(is_rbac_category=False)
-
- 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)
-
-
-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)
-
-
-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)
-
-
-def saml_metadata(request):
- if not (hasattr(settings, "USE_SAML") and settings.USE_SAML):
- raise Http404
-
- xml_parts = ['']
- saml_social_apps = SocialApp.objects.filter(provider='saml')
- entity_id = f"{settings.FRONTEND_HOST}/saml/metadata/"
- xml_parts.append(f'