Chocobozzz/PeerTube

View on GitHub
server/core/controllers/feeds/video-podcast-feeds.ts

Summary

Maintainability
B
5 hrs
Test Coverage
import { Feed } from '@peertube/feed'
import { CustomTag, CustomXMLNS, LiveItemStatus } from '@peertube/feed/lib/typings/index.js'
import { maxBy, sortObjectComparator } from '@peertube/peertube-core-utils'
import { ActorImageType, VideoFile, VideoInclude, VideoResolution, VideoState } from '@peertube/peertube-models'
import { InternalEventEmitter } from '@server/lib/internal-event-emitter.js'
import { Hooks } from '@server/lib/plugins/hooks.js'
import { getVideoFileMimeType } from '@server/lib/video-file.js'
import { buildPodcastGroupsCache, cacheRouteFactory, videoFeedsPodcastSetCacheKey } from '@server/middlewares/index.js'
import { MVideo, MVideoCaptionVideo, MVideoFullLight } from '@server/types/models/index.js'
import express from 'express'
import { extname } from 'path'
import { buildNSFWFilter } from '../../helpers/express-utils.js'
import { MIMETYPES, ROUTE_CACHE_LIFETIME, WEBSERVER } from '../../initializers/constants.js'
import { asyncMiddleware, setFeedPodcastContentType, videoFeedsPodcastValidator } from '../../middlewares/index.js'
import { VideoCaptionModel } from '../../models/video/video-caption.js'
import { VideoModel } from '../../models/video/video.js'
import { buildFeedMetadata, getCommonVideoFeedAttributes, getVideosForFeeds, initFeed } from './shared/index.js'

const videoPodcastFeedsRouter = express.Router()

// ---------------------------------------------------------------------------

const { middleware: podcastCacheRouteMiddleware, instance: podcastApiCache } = cacheRouteFactory({
  headerBlacklist: [ 'Content-Type' ]
})

for (const event of ([ 'video-created', 'video-updated', 'video-deleted' ] as const)) {
  InternalEventEmitter.Instance.on(event, ({ video }) => {
    if (video.remote) return

    podcastApiCache.clearGroupSafe(buildPodcastGroupsCache({ channelId: video.channelId }))
  })
}

for (const event of ([ 'channel-updated', 'channel-deleted' ] as const)) {
  InternalEventEmitter.Instance.on(event, ({ channel }) => {
    podcastApiCache.clearGroupSafe(buildPodcastGroupsCache({ channelId: channel.id }))
  })
}

// ---------------------------------------------------------------------------

videoPodcastFeedsRouter.get('/podcast/videos.xml',
  setFeedPodcastContentType,
  videoFeedsPodcastSetCacheKey,
  podcastCacheRouteMiddleware(ROUTE_CACHE_LIFETIME.FEEDS),
  asyncMiddleware(videoFeedsPodcastValidator),
  asyncMiddleware(generateVideoPodcastFeed)
)

// ---------------------------------------------------------------------------

export {
  videoPodcastFeedsRouter
}

// ---------------------------------------------------------------------------

async function generateVideoPodcastFeed (req: express.Request, res: express.Response) {
  const videoChannel = res.locals.videoChannel

  const { name, userName, description, imageUrl, accountImageUrl, email, link, accountLink } = await buildFeedMetadata({ videoChannel })

  const data = await getVideosForFeeds({
    sort: '-publishedAt',
    nsfw: buildNSFWFilter(),
    // Prevent podcast feeds from listing videos in other instances
    // helps prevent duplicates when they are indexed -- only the author should control them
    isLocal: true,
    include: VideoInclude.FILES,
    videoChannelId: videoChannel?.id
  })

  const customTags: CustomTag[] = await Hooks.wrapObject(
    [],
    'filter:feed.podcast.channel.create-custom-tags.result',
    { videoChannel }
  )

  const customXMLNS: CustomXMLNS[] = await Hooks.wrapObject(
    [],
    'filter:feed.podcast.rss.create-custom-xmlns.result'
  )

  const feed = initFeed({
    name,
    description,
    link,
    isPodcast: true,
    imageUrl,

    locked: email
      ? { isLocked: true, email } // Default to true because we have no way of offering a redirect yet
      : undefined,

    person: [ { name: userName, href: accountLink, img: accountImageUrl } ],
    resourceType: 'videos',
    queryString: new URL(WEBSERVER.URL + req.url).search,
    medium: 'video',
    customXMLNS,
    customTags
  })

  await addVideosToPodcastFeed(feed, data)

  // Now the feed generation is done, let's send it!
  return res.send(feed.podcast()).end()
}

type PodcastMedia =
  {
    type: string
    length: number
    bitrate: number
    sources: { uri: string, contentType?: string }[]
    title: string
    language?: string
  } |
  {
    sources: { uri: string }[]
    type: string
    title: string
  }

async function generatePodcastItem (options: {
  video: VideoModel
  liveItem: boolean
  media: PodcastMedia[]
}) {
  const { video, liveItem, media } = options

  const customTags: CustomTag[] = await Hooks.wrapObject(
    [],
    'filter:feed.podcast.video.create-custom-tags.result',
    { video, liveItem }
  )

  const account = video.VideoChannel.Account

  const author = {
    name: account.getDisplayName(),
    href: account.getClientUrl()
  }

  const commonAttributes = getCommonVideoFeedAttributes(video)
  const guid = liveItem
    ? `${video.uuid}_${video.publishedAt.toISOString()}`
    : commonAttributes.link

  let personImage: string

  if (account.Actor.hasImage(ActorImageType.AVATAR)) {
    const avatar = maxBy(account.Actor.Avatars, 'width')
    personImage = WEBSERVER.URL + avatar.getStaticPath()
  }

  return {
    guid,
    ...commonAttributes,

    trackers: video.getTrackerUrls(),

    author: [ author ],
    person: [
      {
        ...author,

        img: personImage
      }
    ],

    media,

    socialInteract: [
      {
        uri: video.url,
        protocol: 'activitypub',
        accountUrl: account.getClientUrl()
      }
    ],

    customTags
  }
}

async function addVideosToPodcastFeed (feed: Feed, videos: VideoModel[]) {
  const captionsGroup = await VideoCaptionModel.listCaptionsOfMultipleVideos(videos.map(v => v.id))

  for (const video of videos) {
    if (!video.isLive) {
      await addVODPodcastItem({ feed, video, captionsGroup })
    } else if (video.isLive && video.state !== VideoState.LIVE_ENDED) {
      await addLivePodcastItem({ feed, video })
    }
  }
}

async function addVODPodcastItem (options: {
  feed: Feed
  video: VideoModel
  captionsGroup: { [ id: number ]: MVideoCaptionVideo[] }
}) {
  const { feed, video, captionsGroup } = options

  const webVideos = video.getFormattedWebVideoFilesJSON(true)
    .map(f => buildVODWebVideoFile(video, f))
    .sort(sortObjectComparator('bitrate', 'desc'))

  const streamingPlaylistFiles = buildVODStreamingPlaylists(video)

  // Order matters here, the first media URI will be the "default"
  // So web videos are default if enabled
  const media = [ ...webVideos, ...streamingPlaylistFiles ]

  const videoCaptions = buildVODCaptions(video, captionsGroup[video.id])
  const item = await generatePodcastItem({ video, liveItem: false, media })

  feed.addPodcastItem({ ...item, subTitle: videoCaptions })
}

async function addLivePodcastItem (options: {
  feed: Feed
  video: VideoModel
}) {
  const { feed, video } = options

  let status: LiveItemStatus

  switch (video.state) {
    case VideoState.WAITING_FOR_LIVE:
      status = LiveItemStatus.pending
      break
    case VideoState.PUBLISHED:
      status = LiveItemStatus.live
      break
  }

  const item = await generatePodcastItem({ video, liveItem: true, media: buildLiveStreamingPlaylists(video) })

  feed.addPodcastLiveItem({ ...item, status, start: video.updatedAt.toISOString() })
}

// ---------------------------------------------------------------------------

function buildVODWebVideoFile (video: MVideo, videoFile: VideoFile) {
  const sources = [
    { uri: videoFile.fileUrl },
    { uri: videoFile.torrentUrl, contentType: 'application/x-bittorrent' }
  ]

  if (videoFile.magnetUri) {
    sources.push({ uri: videoFile.magnetUri })
  }

  return {
    type: getVideoFileMimeType(extname(videoFile.fileUrl), videoFile.resolution.id === VideoResolution.H_NOVIDEO),
    title: videoFile.resolution.label,
    length: videoFile.size,
    bitrate: videoFile.size / video.duration * 8,
    language: video.language,
    sources
  }
}

function buildVODStreamingPlaylists (video: MVideoFullLight) {
  const hls = video.getHLSPlaylist()
  if (!hls) return []

  return [
    {
      type: 'application/x-mpegURL',
      title: 'HLS',
      sources: [
        { uri: hls.getMasterPlaylistUrl(video) }
      ],
      language: video.language
    }
  ]
}

function buildLiveStreamingPlaylists (video: MVideoFullLight) {
  const hls = video.getHLSPlaylist()

  return [
    {
      type: 'application/x-mpegURL',
      title: `HLS live stream`,
      sources: [
        { uri: hls.getMasterPlaylistUrl(video) }
      ],
      language: video.language
    }
  ]
}

function buildVODCaptions (video: MVideo, videoCaptions: MVideoCaptionVideo[]) {
  return videoCaptions.map(caption => {
    const type = MIMETYPES.VIDEO_CAPTIONS.EXT_MIMETYPE[extname(caption.filename)]
    if (!type) return null

    return {
      url: caption.getFileUrl(video),
      language: caption.language,
      type,
      rel: 'captions'
    }
  }).filter(c => c)
}