Skip to content
Snippets Groups Projects
Select Git revision
  • master
1 result

index.html

Blame
  • Code owners
    Assign users and groups as approvers for specific file changes. Learn more.
    medium.py 21.56 KiB
    from datetime import datetime
    from enum import Enum
    from pathlib import Path
    
    import videoag_common
    from videoag_common.database import *
    from videoag_common.miscellaneous import *
    from videoag_common.api_object import *
    from videoag_common.media_process import *
    
    from .course import Lecture
    from .job import Job
    
    
    LOAD_SOURCE_MEDIUM_LECTURE = RelationshipLoadMarker()
    LOAD_PUBLISH_MEDIUM_LECTURE = RelationshipLoadMarker()
    LOAD_PUBLISH_MEDIUM_TARGET_MEDIUM = RelationshipLoadMarker()
    LOAD_TARGET_MEDIUM_PUBLISH_MEDIUM = RelationshipLoadMarker()
    LOAD_TARGET_MEDIUM_LECTURE = RelationshipLoadMarker()
    LOAD_TARGET_MEDIUM_PRODUCER_JOB = RelationshipLoadMarker()
    
    
    _API_BASE_URL = videoag_common.config["API_BASE_URL"]
    if _API_BASE_URL.endswith("/"):
        raise ValueError("API_BASE_URL must NOT end with /")
    
    
    class SourceMediumStatus(Enum):
        SORTING_UPCOMING = "sorting_upcoming"
        NO_SORT_DUE_TO_RECENT_MODIFICATION = "no_sort_due_to_recent_modification"
        NO_SORT_DUE_TO_FILE_FORMAT = "no_sort_due_to_file_format"
        ERROR = "error"
        SORTED = "sorted"
    
    
    _SOURCE_MEDIUM_STATUS_ENUM = create_enum_type(SourceMediumStatus)
    
    
    # A file which was uploaded and needs to be assigned to a lecture, to be converted to a SourceFileTargetMedium
    class SourceMedium(DeletableApiObject, Base):
        __table_args__ = (
            CheckConstraint(
                "NOT tag IS NULL OR lecture_id IS NULL",
                name="check_tag_is_set",
                comment="Tag must be set if this has a lecture id"
            ),
            CheckConstraint(
                "NOT sha256 IS NULL OR lecture_id IS NULL",
                name="check_sha256_is_set",
                comment="sha256 must be set if this has a lecture id"
            ),
        )
        
        file_path: Mapped[str] = api_mapped(
            mapped_column(String(collation=STRING_COLLATION), nullable=False, index=True),  # Can't be unique because of deleted entries
            ApiStringField(
                include_in_data=True,
                data_notes="Path is relative to website data directory. Does NOT start with a /"
            )
        )
        file_modification_time: Mapped[datetime] = api_mapped(
            mapped_column(sql.DateTime, nullable=False),
            ApiDatetimeField(
                include_in_data=True
            )
        )
        status: Mapped[SourceMediumStatus] = api_mapped(
            mapped_column(_SOURCE_MEDIUM_STATUS_ENUM, nullable=False),
            ApiEnumField(
                include_in_data=True
            )
        )
        force_immediate_sort: Mapped[bool] = api_mapped(
            mapped_column(nullable=False, default=False),
            ApiBooleanField(
                include_in_config=True, config_directly_modifiable=True,
                include_in_data=True,
                data_notes="Set this to true to sort the file immediately (when sorter is running) ignoring a recent "
                           "modification, file format or any previous error"
            )
        )
        sorter_error_message: Mapped[str] = api_mapped(
            mapped_column(Text(collation=STRING_COLLATION), nullable=True),
            ApiStringField(
                include_in_data=True
            )
        )
        update_time: Mapped[datetime] = api_mapped(
            mapped_column(sql.DateTime, nullable=False),
            ApiDatetimeField(
                include_in_data=True
            )
        )
        lecture_id: Mapped[int] = mapped_column(ForeignKey("lecture.id"), nullable=True, index=True)
        sha256: Mapped[str] = api_mapped(
            mapped_column(String(length=64, collation=STRING_COLLATION), nullable=True),
            ApiStringField(
                include_in_data=True,
                data_notes="Only calculated once this is sorted"
            )
        )
        file_size: Mapped[int] = api_mapped(
            mapped_column(sql.types.BigInteger, nullable=True),
            ApiIntegerField(
                include_in_data=True,
                data_notes="Only calculated once this is sorted"
            )
        )
        # metadata is reserved
        file_metadata: Mapped[JsonTypes] = api_mapped(
            mapped_column(sql.JSON, nullable=True),
            ApiJsonField(
                include_in_data=True,
                # See source file sorter for contents
                data_notes="Only calculated once this is sorted. There are no guarantees regarding the content"
            )
        )
        tag: Mapped[str] = api_mapped(
            mapped_column(String(collation=STRING_COLLATION), nullable=True),
            ApiStringField(
                include_in_data=True
            )
        )
        
        lecture: Mapped[Lecture] = api_mapped(
            relationship(
                back_populates="source_media",
                primaryjoin=lambda: Lecture.id == SourceMedium.lecture_id,
                lazy="raise_on_sql"
            ),
            ApiMany2OneRelationshipField(
                include_in_data=True, data_foreign_in_context=True
            )
        )
        
        @classmethod
        def load_options(cls, is_mod: bool, to_load: list[RelationshipLoadMarker], from_lecture=False, **kwargs) -> list[ExecutableOption]:
            options = super().load_options(is_mod, to_load, **kwargs)
            
            if LOAD_TARGET_MEDIUM_PUBLISH_MEDIUM in to_load:
                options.append(orm.joinedload(TargetMedium.publish_medium).options(
                    *PublishMedium.load_options(is_mod, to_load)
                ))
            
            if from_lecture:
                options.append(orm.immediateload(SourceMedium.lecture))
            elif LOAD_SOURCE_MEDIUM_LECTURE in to_load:
                options.append(orm.joinedload(SourceMedium.lecture).options(
                    *Lecture.load_options(is_mod, to_load)
                ))
            
            return options
    
    
    class TargetMediumType(Enum):
        PLAIN_VIDEO = "plain_video"
        PLAIN_AUDIO = "plain_audio"
        THUMBNAIL = "thumbnail"
        IMAGE = "image"
    
    
    _TARGET_MEDIUM_TYPE_ENUM = create_enum_type(TargetMediumType)
    
    
    def _check_extended_mode_target_medium_serialization(med, args):
        return hasattr(args, "extended_target_medium") and args.extended_target_medium
    
    
    class TargetMedium(DeletableApiObject, Base):
        """
        This table stores information about all media files. Entries should only be set to deleted when the corresponding
        file is deleted
        """
        __mapper_args__ = {
            "polymorphic_on": "type",
            "with_polymorphic": "*"  # Always load all attributes for all types
        }
        # This isn't pretty. With a joined table inheritance it might be a bit nicer, but we couldn't check is_produced
        # Also with a joined table inheritance we would have much more columns (since here we can reuse columns for different types)
        __table_args__ = (
            CheckConstraint(
                f"type NOT IN ('plain_video', 'plain_audio') OR NOT is_produced OR duration_sec IS NOT NULL",
                name="check_duration_sec_not_null"
            ),
            CheckConstraint(
                f"type NOT IN ('plain_video', 'plain_audio', 'thumbnail') OR NOT is_produced OR file_path IS NOT NULL",
                name="check_file_path_not_null"
            ),
            CheckConstraint(
                f"type NOT IN ('plain_video', 'plain_audio') OR NOT is_produced OR audio_sample_rate IS NOT NULL",
                name="check_audio_sample_rate_not_null"
            ),
            CheckConstraint(
                f"type NOT IN ('plain_video', 'plain_audio') OR NOT is_produced OR audio_channel_count IS NOT NULL",
                name="check_audio_channel_count_not_null"
            ),
            CheckConstraint(
                f"type NOT IN ('plain_video', 'thumbnail') OR NOT is_produced OR vertical_resolution IS NOT NULL",
                name="check_vertical_resolution_not_null"
            ),
            CheckConstraint(
                f"type NOT IN ('plain_video', 'thumbnail') OR NOT is_produced OR horizontal_resolution IS NOT NULL",
                name="check_horizontal_resolution_not_null"
            ),
            CheckConstraint(
                f"type NOT IN ('plain_video') OR NOT is_produced OR video_frame_rate_numerator IS NOT NULL",
                name="check_video_frame_rate_numerator_not_null"
            ),
            CheckConstraint(
                f"type NOT IN ('plain_video') OR NOT is_produced OR video_frame_rate_denominator IS NOT NULL",
                name="check_video_frame_rate_denominator_not_null"
            ),
        )
        
        # Note that these four may NOT be unique. E.g. if one output is generated twice (because another output of the same
        # producer was deleted, etc.)
        lecture_id: Mapped[int] = mapped_column(ForeignKey("lecture.id"), nullable=False, index=True)
        process_sha256: Mapped[str] = api_mapped(
            mapped_column(String(length=64, collation=STRING_COLLATION), nullable=False),
            ApiStringField(
                include_in_data=True, data_if=_check_extended_mode_target_medium_serialization,
                data_notes="Only included in extended mode (explicitly stated)"
            )
        )
        process_target_id: Mapped[str] = api_mapped(
            mapped_column(String(collation=STRING_COLLATION), nullable=False),
            ApiStringField(
                include_in_data=True, data_if=_check_extended_mode_target_medium_serialization,
                data_notes="Only included in extended mode (explicitly stated)"
            )
        )
        input_data_sha256: Mapped[str] = api_mapped(
            mapped_column(String(length=64, collation=STRING_COLLATION), nullable=False),
            ApiStringField(
                include_in_data=True, data_if=_check_extended_mode_target_medium_serialization,
                data_notes="Only included in extended mode (explicitly stated)"
            )
        )
        
        producer_job_id: Mapped[int] = mapped_column(ForeignKey("job.id"), nullable=True)
        is_produced: Mapped[bool] = api_mapped(
            mapped_column(nullable=False, default=False),
            ApiBooleanField(
                include_in_data=True, data_if=_check_extended_mode_target_medium_serialization,
                data_notes="Only included in extended mode (explicitly stated)"
            )
        )
        to_be_replaced: Mapped[bool] = api_mapped(
            mapped_column(nullable=False, default=False),
            ApiBooleanField(
                include_in_data=True, data_if=_check_extended_mode_target_medium_serialization,
                data_notes="Only included in extended mode (explicitly stated)"
            )
        )
        type: Mapped[TargetMediumType] = api_mapped(
            mapped_column(_TARGET_MEDIUM_TYPE_ENUM, nullable=False),
            ApiEnumField(
                include_in_data=True
            )
        )
        
        lecture: Mapped[Lecture] = relationship(
            primaryjoin=lambda: Lecture.id == TargetMedium.lecture_id,
            lazy="raise_on_sql"
        )
        producer_job: Mapped[Job] = api_mapped(
            relationship(
                primaryjoin=lambda: Job.id == TargetMedium.producer_job_id,
                lazy="raise_on_sql"
            ),
            ApiMany2OneRelationshipField(
                include_in_data=True, data_if=_check_extended_mode_target_medium_serialization,
                data_notes="Only included in extended mode (explicitly stated)"
            )
        )
        publish_medium: Mapped["PublishMedium"] = relationship(
            primaryjoin=lambda: PublishMedium.target_medium_id == TargetMedium.id,
            back_populates="target_medium",
            lazy="raise_on_sql"
        )
        
        @hybrid_method
        def is_active(self, usable_check: bool = True, from_lecture: bool = False, from_publish_medium: bool = False, **kwargs):
            cond = super().is_active(**kwargs)
            if usable_check:
                cond &= self.is_produced
            if not from_publish_medium and not from_lecture:
                cond &= self.hybrid_rel(self.lecture, "is_active", **kwargs)
            return cond
        
        @hybrid_method
        def has_access(self,
                       is_mod: bool,
                       usable_check: bool = True,
                       visibility_check: bool = True,
                       from_lecture: bool = False,
                       from_publish_medium: bool = False,
                       **kwargs):
            cond = super().has_access(
                is_mod,
                usable_check=usable_check,
                from_lecture=from_lecture,
                from_publish_medium=from_publish_medium,
                **kwargs
            )
            if not from_publish_medium:
                cond &= self.hybrid_rel(self.publish_medium, "has_access", is_mod, visibility_check=visibility_check, **kwargs)
            return cond
        
        @classmethod
        def load_options(cls, is_mod: bool, to_load: list[RelationshipLoadMarker], from_publish_medium=False, **kwargs) -> list[ExecutableOption]:
            options = super().load_options(is_mod, to_load, **kwargs)
            
            # There is no check for from_publish_medium because even though TargetMedium.publish_medium has a simple join,
            # an immediateload would cause an extra sql because it is like the list part of a many-to-one relationship
            # (although we use it as a one-to-one)
            
            if LOAD_TARGET_MEDIUM_PUBLISH_MEDIUM in to_load:
                options.append(orm.joinedload(TargetMedium.publish_medium).options(
                    *PublishMedium.load_options(is_mod, to_load)
                ))
            
            if LOAD_TARGET_MEDIUM_LECTURE in to_load:
                if (from_publish_medium or LOAD_TARGET_MEDIUM_PUBLISH_MEDIUM) and LOAD_PUBLISH_MEDIUM_LECTURE in to_load:
                    # Causes no extra sql, just that the back reference is set (relationship has simple join)
                    # because the lecture will have been loaded by the publish medium
                    options.append(orm.immediateload(TargetMedium.lecture))
                else:
                    options.append(orm.joinedload(TargetMedium.lecture).options(
                        *Lecture.load_options(is_mod, to_load)
                    ))
            
            if LOAD_TARGET_MEDIUM_PRODUCER_JOB in to_load:
                options.append(orm.selectinload(TargetMedium.producer_job).options(
                    *Job.load_options(is_mod, to_load)
                ))
            
            return options
    
    
    class FileMedium(ApiObject):
        file_path: Mapped[str] = mapped_column(String(collation=STRING_COLLATION), nullable=True, use_existing_column=True, index=True)  # TODO move?
        
        @api_include_in_data(
            type_id="string",
            data_notes="URL to the mediums's file (Maybe with a redirect)"
        )
        def url(self) -> str:
            return f"{_API_BASE_URL}/resources/target_medium/{self.id}"
        
        def get_default_file_path_no_ending(self):
            assert isinstance(self, TargetMedium)
            return f"{get_permanent_lecture_dir(self.lecture)}/target-{self.id}.{self.process_target_id}"
    
    
    class SingleAudioContainingMedium(ApiObject):
        
        audio_sample_rate: Mapped[int] = api_mapped(
            mapped_column(nullable=True, use_existing_column=True),
            ApiIntegerField(
                include_in_data=True,
                data_notes="In Hz"
            )
        )
        audio_channel_count: Mapped[int] = api_mapped(
            mapped_column(nullable=True, use_existing_column=True),
            ApiIntegerField(
                include_in_data=True
            )
        )
    
    
    class SingleVideoContainingMedium(ApiObject):
        
        vertical_resolution: Mapped[int] = api_mapped(
            mapped_column(nullable=True, use_existing_column=True),
            ApiIntegerField(
                include_in_data=True
            )
        )
        horizontal_resolution: Mapped[int] = api_mapped(
            mapped_column(nullable=True, use_existing_column=True),
            ApiIntegerField(
                include_in_data=True
            )
        )
        video_frame_rate_numerator: Mapped[int] = api_mapped(
            mapped_column(nullable=True, use_existing_column=True),
            ApiIntegerField(
                include_in_data=True
            )
        )
        video_frame_rate_denominator: Mapped[int] = api_mapped(
            mapped_column(nullable=True, use_existing_column=True),
            ApiIntegerField(
                include_in_data=True
            )
        )
    
    
    class SingleImageContainingMedium(ApiObject):
        
        vertical_resolution: Mapped[int] = api_mapped(
            mapped_column(nullable=True, use_existing_column=True),
            ApiIntegerField(
                include_in_data=True
            )
        )
        horizontal_resolution: Mapped[int] = api_mapped(
            mapped_column(nullable=True, use_existing_column=True),
            ApiIntegerField(
                include_in_data=True
            )
        )
    
    
    class PlainVideoTargetMedium(TargetMedium, FileMedium, SingleVideoContainingMedium, SingleAudioContainingMedium):
        __tablename__ = None  # Prevent our own base from adding a table name. This should be a single-table inheritance
        __mapper_args__ = {
            "polymorphic_identity": TargetMediumType.PLAIN_VIDEO
        }
        duration_sec: Mapped[int] = api_mapped(
            mapped_column(nullable=True, use_existing_column=True),
            ApiIntegerField(
                include_in_data=True
            )
        )
        
        # TODO check vars not null (except audio)
    
    
    class PlainAudioTargetMedium(TargetMedium, FileMedium, SingleAudioContainingMedium):
        __tablename__ = None  # Prevent our own base from adding a table name. This should be a single-table inheritance
        __mapper_args__ = {
            "polymorphic_identity": TargetMediumType.PLAIN_AUDIO
        }
        duration_sec: Mapped[int] = api_mapped(
            mapped_column(nullable=True, use_existing_column=True),
            ApiIntegerField(
                include_in_data=True
            )
        )
    
    
    # Thumbnail is a different type so that the Frontend knows what to display as the thumbnail
    class ThumbnailTargetMedium(TargetMedium, FileMedium, SingleImageContainingMedium):
        __tablename__ = None  # Prevent our own base from adding a table name. This should be a single-table inheritance
        __mapper_args__ = {
            "polymorphic_identity": TargetMediumType.THUMBNAIL
        }
    
    
    class ImageTargetMedium(TargetMedium, FileMedium, SingleImageContainingMedium):
        __tablename__ = None  # Prevent our own base from adding a table name. This should be a single-table inheritance
        __mapper_args__ = {
            "polymorphic_identity": TargetMediumType.IMAGE
        }
    
    
    class PublishMedium(VisibilityApiObject, DeletableApiObject, Base):
        __api_data__ = ApiObjectClass(
            parent_relationship_config_ids=["lecture"]
        )
        
        lecture_id: Mapped[int] = mapped_column(ForeignKey("lecture.id"), nullable=False, index=True)
        title: Mapped[str] = api_mapped(
            mapped_column(Text(collation=STRING_COLLATION), nullable=False, default=""),
            ApiStringField(
                max_length=256,
                include_in_config=True, config_directly_modifiable=True,
                include_in_data=True
            )
        )
        target_medium_id: Mapped["TargetMedium"] = mapped_column(ForeignKey("target_medium.id"), nullable=False)
        
        lecture: Mapped[Lecture] = relationship(
            primaryjoin=lambda: Lecture.id == PublishMedium.lecture_id,
            back_populates="publish_media",
            lazy="raise_on_sql"
        )
        target_medium: Mapped["TargetMedium"] = relationship(
            primaryjoin=lambda: sql.and_(
                orm.foreign(PublishMedium.target_medium_id) == orm.remote(TargetMedium.id),  # foreign is the ForeignKey!
                TargetMedium.has_access(is_mod=True, from_publish_medium=True)
            ),
            back_populates="publish_medium",
            lazy="raise_on_sql"
        )
        public_target_medium: Mapped["TargetMedium"] = relationship(
            primaryjoin=lambda: sql.and_(
                orm.foreign(PublishMedium.target_medium_id) == orm.remote(TargetMedium.id),  # foreign is the ForeignKey!
                TargetMedium.has_access(is_mod=False, from_publish_medium=True)
            ),
            back_populates="publish_medium",
            lazy="raise_on_sql",
            viewonly=True
        )
        
        __table_args__ = (
            sql.Index(
                "check_target_medium_unique",
                target_medium_id,
                unique=True,
                postgresql_where="NOT deleted"
            ),
        )
        
        @api_include_in_data(
            type_id="target_medium",
            data_id="target_medium"
        )
        def _data_get_target_media(self, is_mod: bool):
            return self.target_medium if is_mod else self.public_target_medium
        
        @hybrid_method
        def is_active(self, from_lecture: bool = False, **kwargs):
            cond = super().is_active(**kwargs)
            if not from_lecture:
                cond &= self.hybrid_rel(self.lecture, "is_active", **kwargs)
            return cond
        
        @hybrid_method
        def has_access(self,
                       is_mod: bool,
                       visibility_check: bool = True,
                       from_lecture: bool = False,
                       **kwargs):
            cond = super().has_access(is_mod, visibility_check=visibility_check, from_lecture=from_lecture, **kwargs)
            if not from_lecture:
                cond &= self.hybrid_rel(self.lecture, "has_access", is_mod, visibility_check=visibility_check, **kwargs)
            return cond
        
        @classmethod
        def load_options(cls, is_mod: bool, to_load: list[RelationshipLoadMarker], from_lecture=False, **kwargs) -> list[ExecutableOption]:
            options = super().load_options(is_mod, to_load, **kwargs)
            
            if from_lecture:
                # Causes no extra sql, just that the back reference is set (relationship has simple join)
                options.append(orm.immediateload(PublishMedium.lecture))
            elif LOAD_PUBLISH_MEDIUM_LECTURE in to_load:
                options.append(
                    orm.joinedload(PublishMedium.lecture).options(*Lecture.load_options(is_mod, to_load))
                )
            
            if LOAD_PUBLISH_MEDIUM_TARGET_MEDIUM in to_load:
                options.append(
                    orm.joinedload(PublishMedium.target_medium if is_mod else PublishMedium.public_target_medium).options(
                        *TargetMedium.load_options(is_mod, to_load, from_publish_medium=True)
                    ))
            
            return options
    
    
    class MediaProcessTemplate(ApiObject, Base):
        
        name: Mapped[str] = api_mapped(
            mapped_column(String(collation=STRING_COLLATION), nullable=False),
            ApiStringField(
                max_length=256,
                include_in_config=True
            )
        )
        process: Mapped[dict] = api_mapped(
            mapped_column(sql.JSON, nullable=False),
            ApiMediaProcessField(
                include_in_config=True
            )
        )
        
        @property
        def process_obj(self):
            return MediaProcess.from_json(CJsonValue(self.process).as_object())