app/model/commands/PublishAtomCommand.scala (248 lines of code) (raw):
package model.commands
import com.gu.contentatom.thrift.{ContentAtomEvent, EventType}
import com.gu.media.logging.Logging
import com.gu.media.model.Platform.Youtube
import com.gu.media.model.{AuditMessage, _}
import com.gu.media.youtube.{YouTubeMetadataUpdate, YoutubeDescription}
import com.gu.media.{Capi, MediaAtomMakerPermissionsProvider}
import com.gu.pandomainauth.model.{User => PandaUser}
import data.DataStores
import model._
import model.commands.CommandExceptions._
import util.{AWSConfig, ThumbnailGenerator, YouTube}
import java.time.Instant
import java.util.Date
import scala.concurrent.ExecutionContext.Implicits.global
import scala.concurrent.Future
import scala.util.{Failure, Success}
case class PublishAtomCommand(
id: String,
override val stores: DataStores,
youtube: YouTube,
user: PandaUser,
capi: Capi,
permissionsProvider: MediaAtomMakerPermissionsProvider,
awsConfig: AWSConfig,
thumbnailGenerator: ThumbnailGenerator)
extends Command with Logging {
type T = Future[MediaAtom]
def process(): T = {
log.info(s"Request to publish atom $id")
val thriftPreviewAtom = getPreviewAtom(id)
val previewAtom = MediaAtom.fromThrift(thriftPreviewAtom)
if(previewAtom.privacyStatus.contains(PrivacyStatus.Private)) {
log.error(s"Unable to publish atom ${previewAtom.id}, privacy status is set to private")
AtomPublishFailed("Atom status set to private")
}
val contentChangeDetails = thriftPreviewAtom.contentChangeDetails
val now = Instant.now().toEpochMilli
(contentChangeDetails.expiry, contentChangeDetails.scheduledLaunch, contentChangeDetails.embargo) match {
case (Some(expiry), _, _) if expiry.date <= now => {
log.error(s"Unable to publish expired atom. atom=${previewAtom.id} expiry=${expiry.date}")
AtomPublishFailed("Atom has expired")
}
case (_, _, Some(embargo)) if embargo.date > now => {
log.error(s"Unable to publish atom with embargo date. atom=${previewAtom.id} embargo=${embargo.date}")
AtomPublishFailed("Atom embargoed")
}
case (_, Some(schedule), _) if schedule.date > now => {
log.error(s"Unable to publish atom as schedule time in the future. atom=${previewAtom.id} schedule=${schedule.date} now=$now")
AtomPublishFailed("Atom scheduled for the future")
}
case (_, Some(schedule), Some(embargo)) if schedule.date < embargo.date => {
log.error(s"Unable to publish atom as embargoed after schedule. atom=${previewAtom.id} schedule=${schedule.date} embargo=${embargo.date}")
AtomPublishFailed("Embargo set after schedule")
}
case (_, _, _) => {
previewAtom.getActiveYouTubeAsset() match {
case Some(asset) =>
val publishedAtom = getPublishedAtom()
val adSettings = AdSettings(youtube.minDurationForAds, youtube.minDurationForMidroll, previewAtom)
val status = getResultingPrivacyStatus(previewAtom, publishedAtom)
val updatedPreviewAtom = if (publishedAtom.isDefined) {
previewAtom.copy(
blockAds = adSettings.blockAds,
privacyStatus = Some(status)
)
} else {
// on first publish, set YouTube title and description to that of the Atom
// this is because there's no guarantee that the YouTube furniture gets subbed before publication and can result in draft furniture being used
previewAtom.copy(
blockAds = adSettings.blockAds,
privacyStatus = Some(status),
youtubeTitle = previewAtom.title,
youtubeDescription = YoutubeDescription.clean(previewAtom.description)
)
}
updateYouTube(publishedAtom, updatedPreviewAtom, asset).map { atomWithYoutubeUpdates =>
publish(atomWithYoutubeUpdates, user)
}
case _ => Future.successful(publish(previewAtom, user))
}
}
}
}
private def getPublishedAtom(): Option[MediaAtom] = {
try {
val thriftPublishedAtom = getPublishedAtom(id)
Some(MediaAtom.fromThrift(thriftPublishedAtom))
} catch {
case _: Throwable => None
}
}
private def getResultingPrivacyStatus(previewAtom: MediaAtom, maybePublishedAtom: Option[MediaAtom]): PrivacyStatus = {
val atomToTakePrivacyStatusFrom =
if (userCanMakeVideoPublic(previewAtom, user)) previewAtom else maybePublishedAtom.getOrElse(previewAtom)
atomToTakePrivacyStatusFrom.privacyStatus.getOrElse(PrivacyStatus.Unlisted)
}
private def userCanMakeVideoPublic(atom: MediaAtom, user: PandaUser): Boolean =
!atom.channelId.exists(youtube.channelsRequiringPermission) ||
permissionsProvider.getStatusPermissions(user).setVideosOnAllChannelsPublic
private def publish(atom: MediaAtom, user: PandaUser): MediaAtom = {
log.info(s"Publishing atom $id")
val changeRecord = Some(ChangeRecord.now(user))
val updatedAtom = atom.copy(
contentChangeDetails = atom.contentChangeDetails.copy(
published = changeRecord,
lastModified = changeRecord,
scheduledLaunch = None,
embargo = None
)
)
AuditMessage(id, "Publish", getUsername(user)).logMessage()
val updatedAtomToPublish = UpdateAtomCommand(id, updatedAtom, stores, user, awsConfig).process()
val publishedAtom = publishAtomToLive(updatedAtomToPublish)
updateInactiveAssets(publishedAtom)
publishedAtom
}
private def publishAtomToLive(mediaAtom: MediaAtom): MediaAtom = {
val atom = mediaAtom.asThrift
val event = ContentAtomEvent(atom, EventType.Update, (new Date()).getTime())
livePublisher.publishAtomEvent(event) match {
case Success(_) =>
publishedDataStore.updateAtom(atom) match {
case Right(_) => {
log.info(s"Successfully published atom: ${id} (revision ${atom.contentChangeDetails.revision})")
MediaAtom.fromThrift(atom)
}
case Left(err) =>
log.error("Unable to update datastore after publish", err)
AtomPublishFailed(s"Could not save published atom")
}
case Failure(err) =>
log.error("Unable to publish atom to kinesis", err)
AtomPublishFailed(s"Could not publish atom")
}
}
private def updateYouTube(publishedAtom: Option[MediaAtom], previewAtom: MediaAtom, asset: Asset): Future[MediaAtom] = {
previewAtom.channelId match {
case Some(channel) if youtube.allChannels.contains(channel) =>
if (youtube.usePartnerApi) {
createOrUpdateYoutubeClaim(publishedAtom, previewAtom, asset)
}
updateYoutubeMetadata(previewAtom, asset)
updateYoutubeThumbnail(previewAtom, asset).recover {
case e: Throwable =>
log.error("failed to update thumbnail; skipping", e)
previewAtom
}
case Some(_) =>
// third party YouTube video that we do not have permission to edit
Future.successful(previewAtom)
case None if youtube.cannotReachYoutube =>
// the atom will be missing a channel because we couldn't query YouTube at all
Future.successful(previewAtom)
case None =>
AtomPublishFailed("Atom missing YouTube channel")
}
}
private def hasNewAssets(previewAtom: MediaAtom, publishedAtom: MediaAtom): Boolean = {
val previewVersion = previewAtom.activeVersion.get
publishedAtom.activeVersion match {
case None => true
case Some(publishedVersion) => {
publishedVersion != previewVersion
}
}
}
private def createOrUpdateYoutubeClaim(maybePublishedAtom: Option[MediaAtom], previewAtom: MediaAtom, asset: Asset): Future[MediaAtom] = Future{
maybePublishedAtom match {
case Some(publishedAtom) => {
if (!hasNewAssets(previewAtom, publishedAtom) && (previewAtom.blockAds == publishedAtom.blockAds)) {
YouTubeMessage(previewAtom.id, "N/A", "Claim Update", "No change to assets or BlockAds field, not editing YouTube Claim").logMessage()
previewAtom
} else {
previewAtom.category match {
case Category.Hosted | Category.Paid => {
val claimUpdate = youtube.createOrUpdateClaim(previewAtom.id, asset.id, AdSettings.NONE)
handleYouTubeMessages(claimUpdate, "YouTube Claim Update: Block ads on Glabs atom", previewAtom, asset.id)
}
case _ => {
val adSettings = AdSettings(youtube.minDurationForAds, youtube.minDurationForMidroll, previewAtom)
val activeAssetClaimUpdate = youtube.createOrUpdateClaim(previewAtom.id, asset.id, adSettings)
handleYouTubeMessages(activeAssetClaimUpdate, "YouTube Claim Update: block ads updated", previewAtom, asset.id)
val oldActiveAsset = publishedAtom.getActiveAsset().get
val oldActiveAssetClaimUpdate = youtube.createOrUpdateClaim(previewAtom.id, oldActiveAsset.id, AdSettings.NONE)
handleYouTubeMessages(oldActiveAssetClaimUpdate, "YouTube Claim Update: ads blocked on previous active asset",
previewAtom, oldActiveAsset.id)
}
}
}
}
// atom hasn't been published yet
case _ => {
val adSettings = AdSettings(youtube.minDurationForAds, youtube.minDurationForMidroll, previewAtom)
val claimUpdate = youtube.createOrUpdateClaim(previewAtom.id, asset.id, adSettings)
handleYouTubeMessages(claimUpdate, "YouTube Claim Update: creating a claim", previewAtom, asset.id)
}
}
}
private def updateYoutubeMetadata(previewAtom: MediaAtom, asset: Asset): MediaAtom = {
val metadata = YouTubeMetadataUpdate(
title = Some(previewAtom.youtubeTitle),
categoryId = previewAtom.youtubeCategoryId,
description = previewAtom.youtubeDescription,
tags = previewAtom.tags,
license = previewAtom.license,
privacyStatus = previewAtom.privacyStatus.map(_.name)
).withSaneTitle()
val youTubeMetadataUpdate: Either[VideoUpdateError, String] = youtube.updateMetadata(
asset.id,
if (previewAtom.blockAds) metadata.withoutContentBundleTags() else metadata.withContentBundleTags() // content bundle tags only needed on monetized videos
)
handleYouTubeMessages(youTubeMetadataUpdate, "YouTube Metadata Update", previewAtom, asset.id)
}
private def setYoutubeThumbnail(atom: MediaAtom, image: Image, asset: Asset): MediaAtom = {
val thumbnail = atom.isOnCommercialChannel(youtube.commercialChannels) match {
case Some(isCommercial) if isCommercial => thumbnailGenerator.getThumbnail(image)
case _ => thumbnailGenerator.getBrandedThumbnail(image, atom.id)
}
val thumbnailUpdate = youtube.updateThumbnail(asset.id, thumbnail)
handleYouTubeMessages(thumbnailUpdate, "YouTube Thumbnail Update", atom, asset.id)
}
private def updateYoutubeThumbnail(atom: MediaAtom, asset: Asset): Future[MediaAtom] = Future{
(atom.youtubeOverrideImage, atom.posterImage) match {
case (Some(youtubeOverrideImage), _) => setYoutubeThumbnail(atom, youtubeOverrideImage, asset)
case (None, Some(posterImage)) => setYoutubeThumbnail(atom, posterImage, asset)
case (None, None) => atom
}
}
private def updateInactiveAssets(atom: MediaAtom): Unit = {
atom.getActiveYouTubeAsset().foreach { activeAsset =>
val youTubeAssets = atom.assets.filter(_.platform == Youtube)
val inactiveAssets = youTubeAssets.filterNot(_.id == activeAsset.id)
//TODO be better! Use the correct type rather than converting to the right type
val status = PrivacyStatus.Private.asThrift.get
inactiveAssets.foreach { asset =>
val privacyStatusUpdate = youtube.setStatus(asset.id, status)
handleYouTubeMessages(privacyStatusUpdate, "YouTube Privacy Status Update", atom, asset.id)
}
}
}
private def handleYouTubeMessages(message: Either[VideoUpdateError, String], updateType: String, atom: MediaAtom, assetId: String): MediaAtom = {
message match {
case Right(okMessage: String) => {
YouTubeMessage(atom.id, assetId, updateType, okMessage).logMessage()
atom
}
case Left(error: VideoUpdateError) => {
YouTubeMessage(atom.id, assetId, updateType, error.errorToLog, isError = true).logMessage()
AtomPublishFailed(s"Error in $updateType: ${error.getErrorToClient()}")
}
}
}
}