ニジカ投稿局 https://tv.nizika.tv
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

309 lines
8.8 KiB

  1. import { Feed } from '@peertube/feed'
  2. import { CustomTag, CustomXMLNS, LiveItemStatus } from '@peertube/feed/lib/typings/index.js'
  3. import { maxBy, sortObjectComparator } from '@peertube/peertube-core-utils'
  4. import { ActorImageType, VideoFile, VideoInclude, VideoResolution, VideoState } from '@peertube/peertube-models'
  5. import { InternalEventEmitter } from '@server/lib/internal-event-emitter.js'
  6. import { Hooks } from '@server/lib/plugins/hooks.js'
  7. import { getVideoFileMimeType } from '@server/lib/video-file.js'
  8. import { buildPodcastGroupsCache, cacheRouteFactory, videoFeedsPodcastSetCacheKey } from '@server/middlewares/index.js'
  9. import { MVideo, MVideoCaptionVideo, MVideoFullLight } from '@server/types/models/index.js'
  10. import express from 'express'
  11. import { extname } from 'path'
  12. import { buildNSFWFilter } from '../../helpers/express-utils.js'
  13. import { MIMETYPES, ROUTE_CACHE_LIFETIME, WEBSERVER } from '../../initializers/constants.js'
  14. import { asyncMiddleware, setFeedPodcastContentType, videoFeedsPodcastValidator } from '../../middlewares/index.js'
  15. import { VideoCaptionModel } from '../../models/video/video-caption.js'
  16. import { VideoModel } from '../../models/video/video.js'
  17. import { buildFeedMetadata, getCommonVideoFeedAttributes, getVideosForFeeds, initFeed } from './shared/index.js'
  18. const videoPodcastFeedsRouter = express.Router()
  19. // ---------------------------------------------------------------------------
  20. const { middleware: podcastCacheRouteMiddleware, instance: podcastApiCache } = cacheRouteFactory({
  21. headerBlacklist: [ 'Content-Type' ]
  22. })
  23. for (const event of ([ 'video-created', 'video-updated', 'video-deleted' ] as const)) {
  24. InternalEventEmitter.Instance.on(event, ({ video }) => {
  25. if (video.remote) return
  26. podcastApiCache.clearGroupSafe(buildPodcastGroupsCache({ channelId: video.channelId }))
  27. })
  28. }
  29. for (const event of ([ 'channel-updated', 'channel-deleted' ] as const)) {
  30. InternalEventEmitter.Instance.on(event, ({ channel }) => {
  31. podcastApiCache.clearGroupSafe(buildPodcastGroupsCache({ channelId: channel.id }))
  32. })
  33. }
  34. // ---------------------------------------------------------------------------
  35. videoPodcastFeedsRouter.get('/podcast/videos.xml',
  36. setFeedPodcastContentType,
  37. videoFeedsPodcastSetCacheKey,
  38. podcastCacheRouteMiddleware(ROUTE_CACHE_LIFETIME.FEEDS),
  39. asyncMiddleware(videoFeedsPodcastValidator),
  40. asyncMiddleware(generateVideoPodcastFeed)
  41. )
  42. // ---------------------------------------------------------------------------
  43. export {
  44. videoPodcastFeedsRouter
  45. }
  46. // ---------------------------------------------------------------------------
  47. async function generateVideoPodcastFeed (req: express.Request, res: express.Response) {
  48. const videoChannel = res.locals.videoChannel
  49. const { name, userName, description, imageUrl, accountImageUrl, email, link, accountLink } = await buildFeedMetadata({ videoChannel })
  50. const data = await getVideosForFeeds({
  51. sort: '-publishedAt',
  52. nsfw: buildNSFWFilter(),
  53. // Prevent podcast feeds from listing videos in other instances
  54. // helps prevent duplicates when they are indexed -- only the author should control them
  55. isLocal: true,
  56. include: VideoInclude.FILES,
  57. videoChannelId: videoChannel?.id
  58. })
  59. const customTags: CustomTag[] = await Hooks.wrapObject(
  60. [],
  61. 'filter:feed.podcast.channel.create-custom-tags.result',
  62. { videoChannel }
  63. )
  64. const customXMLNS: CustomXMLNS[] = await Hooks.wrapObject(
  65. [],
  66. 'filter:feed.podcast.rss.create-custom-xmlns.result'
  67. )
  68. const feed = initFeed({
  69. name,
  70. description,
  71. link,
  72. isPodcast: true,
  73. imageUrl,
  74. locked: email
  75. ? { isLocked: true, email } // Default to true because we have no way of offering a redirect yet
  76. : undefined,
  77. person: [ { name: userName, href: accountLink, img: accountImageUrl } ],
  78. resourceType: 'videos',
  79. queryString: new URL(WEBSERVER.URL + req.url).search,
  80. medium: 'video',
  81. customXMLNS,
  82. customTags
  83. })
  84. await addVideosToPodcastFeed(feed, data)
  85. // Now the feed generation is done, let's send it!
  86. return res.send(feed.podcast()).end()
  87. }
  88. type PodcastMedia =
  89. {
  90. type: string
  91. length: number
  92. bitrate: number
  93. sources: { uri: string, contentType?: string }[]
  94. title: string
  95. language?: string
  96. } |
  97. {
  98. sources: { uri: string }[]
  99. type: string
  100. title: string
  101. }
  102. async function generatePodcastItem (options: {
  103. video: VideoModel
  104. liveItem: boolean
  105. media: PodcastMedia[]
  106. }) {
  107. const { video, liveItem, media } = options
  108. const customTags: CustomTag[] = await Hooks.wrapObject(
  109. [],
  110. 'filter:feed.podcast.video.create-custom-tags.result',
  111. { video, liveItem }
  112. )
  113. const account = video.VideoChannel.Account
  114. const author = {
  115. name: account.getDisplayName(),
  116. href: account.getClientUrl()
  117. }
  118. const commonAttributes = getCommonVideoFeedAttributes(video)
  119. const guid = liveItem
  120. ? `${video.uuid}_${video.publishedAt.toISOString()}`
  121. : commonAttributes.link
  122. let personImage: string
  123. if (account.Actor.hasImage(ActorImageType.AVATAR)) {
  124. const avatar = maxBy(account.Actor.Avatars, 'width')
  125. personImage = WEBSERVER.URL + avatar.getStaticPath()
  126. }
  127. return {
  128. guid,
  129. ...commonAttributes,
  130. trackers: video.getTrackerUrls(),
  131. author: [ author ],
  132. person: [
  133. {
  134. ...author,
  135. img: personImage
  136. }
  137. ],
  138. media,
  139. socialInteract: [
  140. {
  141. uri: video.url,
  142. protocol: 'activitypub',
  143. accountUrl: account.getClientUrl()
  144. }
  145. ],
  146. customTags
  147. }
  148. }
  149. async function addVideosToPodcastFeed (feed: Feed, videos: VideoModel[]) {
  150. const captionsGroup = await VideoCaptionModel.listCaptionsOfMultipleVideos(videos.map(v => v.id))
  151. for (const video of videos) {
  152. if (!video.isLive) {
  153. await addVODPodcastItem({ feed, video, captionsGroup })
  154. } else if (video.isLive && video.state !== VideoState.LIVE_ENDED) {
  155. await addLivePodcastItem({ feed, video })
  156. }
  157. }
  158. }
  159. async function addVODPodcastItem (options: {
  160. feed: Feed
  161. video: VideoModel
  162. captionsGroup: { [ id: number ]: MVideoCaptionVideo[] }
  163. }) {
  164. const { feed, video, captionsGroup } = options
  165. const webVideos = video.getFormattedWebVideoFilesJSON(true)
  166. .map(f => buildVODWebVideoFile(video, f))
  167. .sort(sortObjectComparator('bitrate', 'desc'))
  168. const streamingPlaylistFiles = buildVODStreamingPlaylists(video)
  169. // Order matters here, the first media URI will be the "default"
  170. // So web videos are default if enabled
  171. const media = [ ...webVideos, ...streamingPlaylistFiles ]
  172. const videoCaptions = buildVODCaptions(video, captionsGroup[video.id])
  173. const item = await generatePodcastItem({ video, liveItem: false, media })
  174. feed.addPodcastItem({ ...item, subTitle: videoCaptions })
  175. }
  176. async function addLivePodcastItem (options: {
  177. feed: Feed
  178. video: VideoModel
  179. }) {
  180. const { feed, video } = options
  181. let status: LiveItemStatus
  182. switch (video.state) {
  183. case VideoState.WAITING_FOR_LIVE:
  184. status = LiveItemStatus.pending
  185. break
  186. case VideoState.PUBLISHED:
  187. status = LiveItemStatus.live
  188. break
  189. }
  190. const item = await generatePodcastItem({ video, liveItem: true, media: buildLiveStreamingPlaylists(video) })
  191. feed.addPodcastLiveItem({ ...item, status, start: video.updatedAt.toISOString() })
  192. }
  193. // ---------------------------------------------------------------------------
  194. function buildVODWebVideoFile (video: MVideo, videoFile: VideoFile) {
  195. const sources = [
  196. { uri: videoFile.fileUrl },
  197. { uri: videoFile.torrentUrl, contentType: 'application/x-bittorrent' }
  198. ]
  199. if (videoFile.magnetUri) {
  200. sources.push({ uri: videoFile.magnetUri })
  201. }
  202. return {
  203. type: getVideoFileMimeType(extname(videoFile.fileUrl), videoFile.resolution.id === VideoResolution.H_NOVIDEO),
  204. title: videoFile.resolution.label,
  205. length: videoFile.size,
  206. bitrate: videoFile.size / video.duration * 8,
  207. language: video.language,
  208. sources
  209. }
  210. }
  211. function buildVODStreamingPlaylists (video: MVideoFullLight) {
  212. const hls = video.getHLSPlaylist()
  213. if (!hls) return []
  214. return [
  215. {
  216. type: 'application/x-mpegURL',
  217. title: 'HLS',
  218. sources: [
  219. { uri: hls.getMasterPlaylistUrl(video) }
  220. ],
  221. language: video.language
  222. }
  223. ]
  224. }
  225. function buildLiveStreamingPlaylists (video: MVideoFullLight) {
  226. const hls = video.getHLSPlaylist()
  227. return [
  228. {
  229. type: 'application/x-mpegURL',
  230. title: `HLS live stream`,
  231. sources: [
  232. { uri: hls.getMasterPlaylistUrl(video) }
  233. ],
  234. language: video.language
  235. }
  236. ]
  237. }
  238. function buildVODCaptions (video: MVideo, videoCaptions: MVideoCaptionVideo[]) {
  239. return videoCaptions.map(caption => {
  240. const type = MIMETYPES.VIDEO_CAPTIONS.EXT_MIMETYPE[extname(caption.filename)]
  241. if (!type) return null
  242. return {
  243. url: caption.getFileUrl(video),
  244. language: caption.language,
  245. type,
  246. rel: 'captions'
  247. }
  248. }).filter(c => c)
  249. }