"""Product video model.""" import contextlib import json from typing import Any from sqlalchemy import ( BigInteger, Boolean, Column, DateTime, Enum, ForeignKey, Integer, String, Text, delete, select, update as sql_update, ) from sqlalchemy.orm import Mapped, Session, mapped_column, validates from video.connectors import mysql from video.constants.stream import DEFAULT_PREVIEW_START_TIME from video.exceptions import ProductVideoNotFound from video.logic.metadata import get_associated_track from video.utils import features class ProductVideo(mysql.ArModel): """Product Video.""" __tablename__ = "product_video" primary_key = Column( Integer, name="id", nullable=False, primary_key=True, autoincrement=True, ) release_id = Column( Integer, ForeignKey("releases.release_id"), nullable=False, ) type_of_video: Mapped[str | None] = mapped_column( Enum( "Official Music Video", "Lyric Music Video", "Pseudo Video", "Behind the Scenes", "Promo", "Live Performance", "Other", ) ) parental_advisory: Mapped[str | None] = mapped_column( Enum("No", "Yes", "Clean Version") ) language_of_video_title = Column(String) video_title = Column(String) description = Column(Text) version = Column(String) product_code = Column(String) upc = Column(BigInteger) isrc = Column(String) imprint = Column(String) primary_artist_id = Column(Integer) language_of_video_content = Column(String) lyrics: Mapped[str | None] = mapped_column(Text) c_line_year = Column(Integer) c_line_copyright_holder = Column(String) p_line_year = Column(Integer) p_line_copyright_holder = Column(String) new_release: Mapped[bool | None] = mapped_column(Boolean, default=True) original_release_date = Column(DateTime) release_date = Column(DateTime) special_instructions = Column(Text) genre_id = Column(Integer) subgenre_id = Column(Integer) deliver_to_all: Mapped[bool | None] = mapped_column(Boolean, default=True) keywords: Mapped[str | None] = mapped_column(Text) contributors: Mapped[str | None] = mapped_column(Text) latest_pipeline_run_id = Column(Integer) latest_approval_job_id = Column(Integer) preview_start_time = Column(Integer, default=DEFAULT_PREVIEW_START_TIME) thumbnail_path = Column(String) thumbnail_at_milliseconds = Column(Integer) custom_thumbnail_path = Column(String) prores_path = Column(String) h264_path = Column(String) channel_selection = Column(String) vevo_controlled: Mapped[str | None] = mapped_column(Enum("No", "Yes")) not_for_distribution: Mapped[str | None] = mapped_column( Enum( "N", "AccountingDummy", "EditoriallySuspectContent", "NotforFurtherDistribution", "TVSeasonAccountingDummy", "LabelRCRevenueDummy", "iTunesRingtone", "CatalogDuplicate", "YouTubeRemap", "PhysicalProduct", "IncompleteAssets", "SwitchboardDummy", "SMEAnalyticsDummy", "MissingAssets", "AWALNotOurDistribution", ), default="N", ) submitted_at = Column(DateTime) migrated_metadata_at = Column(DateTime) migrated_asset_at = Column(DateTime) ingest_filename = Column(String, unique=True) associated_track_id: Mapped[int | None] = mapped_column( Integer, unique=False, default=None ) @validates("lyrics") def empty_string_to_null(self, key: str, value: str | None) -> str | None: """Replace empty string with null.""" return value if value else None def is_valid(self) -> bool: """Is this a valid product.""" original_release_date = self.new_release is True or ( self.new_release is False and self.original_release_date ) isrc_is_empty_string = self.isrc is not None and self.isrc == "" upc_is_empty_string = self.upc is not None and self.upc == "" has_composer = False if self.contributors: try: contributors_list = json.loads(self.contributors) has_composer = any( c.get("role", "").lower() == "composer" for c in contributors_list ) except (json.JSONDecodeError, TypeError, AttributeError): has_composer = False composer_required = features.is_ccm_vpb_composer_required() return bool( self.release_id and self.type_of_video and self.video_title and self.language_of_video_title and self.primary_artist_id and self.imprint and not isrc_is_empty_string and not upc_is_empty_string and self.product_code and self.language_of_video_content and self.genre_id and self.subgenre_id and self.parental_advisory and self.c_line_year and self.c_line_copyright_holder and self.p_line_year and self.p_line_copyright_holder and self.release_date and original_release_date and self.channel_selection and self.thumbnail_path and (not composer_required or has_composer) ) def to_dict(self) -> dict[str, Any]: """Get a dict representation.""" keywords = json.loads(self.keywords) if self.keywords else None contributors = json.loads(self.contributors) if self.contributors else None return { "id": self.primary_key, "release_id": self.release_id, "latest_pipeline_run_id": self.latest_pipeline_run_id, "latest_approval_job_id": self.latest_approval_job_id, "type_of_video": self.type_of_video, "language_of_video_title": self.language_of_video_title, "video_title": self.video_title, "version": self.version, "product_code": self.product_code, "description": self.description, "upc": self.upc, "isrc": self.isrc, "imprint": self.imprint, "language_of_video_content": self.language_of_video_content, "lyrics": self.lyrics, "c_line_year": self.c_line_year, "c_line_copyright_holder": self.c_line_copyright_holder, "p_line_year": self.p_line_year, "p_line_copyright_holder": self.p_line_copyright_holder, "new_release": self.new_release, "original_release_date": self.original_release_date, "release_date": self.release_date, "special_instructions": self.special_instructions, "deliver_to_all": self.deliver_to_all, "parental_advisory": self.parental_advisory, "genre_id": self.genre_id, "subgenre_id": self.subgenre_id, "keywords": keywords, "contributors": contributors, "primary_artist_id": self.primary_artist_id, "is_valid": self.is_valid(), "preview_start_time": self.preview_start_time, "thumbnail_path": self.thumbnail_path, "thumbnail_at_milliseconds": self.thumbnail_at_milliseconds, "custom_thumbnail_path": self.custom_thumbnail_path, "prores_path": self.prores_path, "h264_path": self.h264_path, "channel_selection": self.channel_selection, "not_for_distribution": self.not_for_distribution, "submitted_at": self.submitted_at, "ingest_filename": self.ingest_filename, "associated_track_id": self.associated_track_id, "associated_track": get_associated_track(self.associated_track_id), } def _get( release_id: int, session: Session, for_update: bool = False ) -> ProductVideo | None: stmt = select(ProductVideo).where(ProductVideo.release_id == release_id) if for_update: stmt = stmt.with_for_update() return session.execute(stmt).scalar_one_or_none() def get( release_id: int, session: Session | None = None, for_update: bool = False ) -> dict[str, Any]: """Get by release_id.""" ctx = mysql.ar_db_session() if session is None else contextlib.nullcontext(session) with ctx as s: product = _get(release_id, s, for_update=for_update) if not product: return {} return product.to_dict() def get_by_ingest_filename( ingest_filename: str, session: Session | None = None ) -> dict[str, Any]: """Get by ingest_filename.""" ctx = mysql.ar_db_session() if session is None else contextlib.nullcontext(session) with ctx as s: product = s.execute( select(ProductVideo).where(ProductVideo.ingest_filename == ingest_filename) ).scalar_one_or_none() if not product: return {} return product.to_dict() def upsert( product_video: dict[str, Any], session: Session | None = None ) -> dict[str, Any]: """Create or update.""" ctx = mysql.ar_db_session() if session is None else contextlib.nullcontext(session) with ctx as s: product = _get(product_video["release_id"], s) if product: return update(product, product_video, s) return create(product_video, s) def update( product: ProductVideo, product_video: dict[str, Any], session: Session, ) -> dict[str, Any]: """Update product video.""" sanitized = sanitize_input(product_video) for field_name, value in sanitized.items(): setattr(product, field_name, value) session.add(product) session.flush() return product.to_dict() def update_primary_artist( product_id: int, primary_artist_id: int, session: Session | None = None, ) -> dict[str, Any]: """Update primary_artist_id for video product. Raises: ProductVideoNotFound: if no product with the given product_id exists. """ ctx = mysql.ar_db_session() if session is None else contextlib.nullcontext(session) with ctx as s: product = _get(product_id, s) if product is None: raise ProductVideoNotFound(f"Product {product_id} not found.") return update(product, {"primary_artist_id": primary_artist_id}, s) def clear_associated_track( track_ids: list[int], session: Session | None = None ) -> None: """Null associated_track_id on every product_video referencing any track id. Args: track_ids: Track tuids whose references should be cleared. """ if not track_ids: return ctx = mysql.ar_db_session() if session is None else contextlib.nullcontext(session) with ctx as s: s.execute( sql_update(ProductVideo) .where(ProductVideo.associated_track_id.in_(track_ids)) .values(associated_track_id=None) ) def create( product_video: dict[str, Any], session: Session | None = None ) -> dict[str, Any]: """Create product video.""" ctx = mysql.ar_db_session() if session is None else contextlib.nullcontext(session) with ctx as s: sanitized = sanitize_input(product_video) product = ProductVideo(**sanitized) s.add(product) s.flush() return product.to_dict() def delete_product(release_id: int, session: Session) -> None: """Delete product video.""" session.execute(delete(ProductVideo).where(ProductVideo.release_id == release_id)) def sanitize_input(product_video: dict[str, Any]) -> dict[str, Any]: """Drop keys that shouldn't be monkeyed with.""" valid_columns = ProductVideo.__table__.columns.keys() sanitized_keys = product_video.keys() & valid_columns sanitized_dict = {k: product_video[k] for k in sanitized_keys} json_dump_fields(sanitized_dict) sanitized_dict.pop("primary_key", None) return sanitized_dict def json_dump_fields(product_video: dict[str, Any]) -> None: """Pack the fields that need json packing.""" for field in ["keywords", "contributors"]: if field in product_video and product_video[field] is not None: product_video[field] = json.dumps(product_video[field])