video-playlist.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576
  1. import { join } from 'path'
  2. import { FindOptions, literal, Op, ScopeOptions, Transaction, WhereOptions } from 'sequelize'
  3. import {
  4. AllowNull,
  5. BelongsTo,
  6. Column,
  7. CreatedAt,
  8. DataType,
  9. Default,
  10. ForeignKey,
  11. HasMany,
  12. HasOne,
  13. Is,
  14. IsUUID,
  15. Model,
  16. Scopes,
  17. Table,
  18. UpdatedAt
  19. } from 'sequelize-typescript'
  20. import { MAccountId, MChannelId } from '@server/types/models'
  21. import { ActivityIconObject } from '../../../shared/models/activitypub/objects'
  22. import { PlaylistObject } from '../../../shared/models/activitypub/objects/playlist-object'
  23. import { VideoPlaylistPrivacy } from '../../../shared/models/videos/playlist/video-playlist-privacy.model'
  24. import { VideoPlaylistType } from '../../../shared/models/videos/playlist/video-playlist-type.model'
  25. import { VideoPlaylist } from '../../../shared/models/videos/playlist/video-playlist.model'
  26. import { activityPubCollectionPagination } from '../../helpers/activitypub'
  27. import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
  28. import {
  29. isVideoPlaylistDescriptionValid,
  30. isVideoPlaylistNameValid,
  31. isVideoPlaylistPrivacyValid
  32. } from '../../helpers/custom-validators/video-playlists'
  33. import {
  34. ACTIVITY_PUB,
  35. CONSTRAINTS_FIELDS,
  36. STATIC_PATHS,
  37. THUMBNAILS_SIZE,
  38. VIDEO_PLAYLIST_PRIVACIES,
  39. VIDEO_PLAYLIST_TYPES,
  40. WEBSERVER
  41. } from '../../initializers/constants'
  42. import { MThumbnail } from '../../types/models/video/thumbnail'
  43. import {
  44. MVideoPlaylistAccountThumbnail,
  45. MVideoPlaylistAP,
  46. MVideoPlaylistFormattable,
  47. MVideoPlaylistFull,
  48. MVideoPlaylistFullSummary,
  49. MVideoPlaylistIdWithElements
  50. } from '../../types/models/video/video-playlist'
  51. import { AccountModel, ScopeNames as AccountScopeNames, SummaryOptions } from '../account/account'
  52. import { buildServerIdsFollowedBy, buildWhereIdOrUUID, getPlaylistSort, isOutdated, throwIfNotValid } from '../utils'
  53. import { ThumbnailModel } from './thumbnail'
  54. import { ScopeNames as VideoChannelScopeNames, VideoChannelModel } from './video-channel'
  55. import { VideoPlaylistElementModel } from './video-playlist-element'
  56. enum ScopeNames {
  57. AVAILABLE_FOR_LIST = 'AVAILABLE_FOR_LIST',
  58. WITH_VIDEOS_LENGTH = 'WITH_VIDEOS_LENGTH',
  59. WITH_ACCOUNT_AND_CHANNEL_SUMMARY = 'WITH_ACCOUNT_AND_CHANNEL_SUMMARY',
  60. WITH_ACCOUNT = 'WITH_ACCOUNT',
  61. WITH_THUMBNAIL = 'WITH_THUMBNAIL',
  62. WITH_ACCOUNT_AND_CHANNEL = 'WITH_ACCOUNT_AND_CHANNEL'
  63. }
  64. type AvailableForListOptions = {
  65. followerActorId: number
  66. type?: VideoPlaylistType
  67. accountId?: number
  68. videoChannelId?: number
  69. listMyPlaylists?: boolean
  70. search?: string
  71. }
  72. @Scopes(() => ({
  73. [ScopeNames.WITH_THUMBNAIL]: {
  74. include: [
  75. {
  76. model: ThumbnailModel,
  77. required: false
  78. }
  79. ]
  80. },
  81. [ScopeNames.WITH_VIDEOS_LENGTH]: {
  82. attributes: {
  83. include: [
  84. [
  85. literal('(SELECT COUNT("id") FROM "videoPlaylistElement" WHERE "videoPlaylistId" = "VideoPlaylistModel"."id")'),
  86. 'videosLength'
  87. ]
  88. ]
  89. }
  90. } as FindOptions,
  91. [ScopeNames.WITH_ACCOUNT]: {
  92. include: [
  93. {
  94. model: AccountModel,
  95. required: true
  96. }
  97. ]
  98. },
  99. [ScopeNames.WITH_ACCOUNT_AND_CHANNEL_SUMMARY]: {
  100. include: [
  101. {
  102. model: AccountModel.scope(AccountScopeNames.SUMMARY),
  103. required: true
  104. },
  105. {
  106. model: VideoChannelModel.scope(VideoChannelScopeNames.SUMMARY),
  107. required: false
  108. }
  109. ]
  110. },
  111. [ScopeNames.WITH_ACCOUNT_AND_CHANNEL]: {
  112. include: [
  113. {
  114. model: AccountModel,
  115. required: true
  116. },
  117. {
  118. model: VideoChannelModel,
  119. required: false
  120. }
  121. ]
  122. },
  123. [ScopeNames.AVAILABLE_FOR_LIST]: (options: AvailableForListOptions) => {
  124. let whereActor: WhereOptions = {}
  125. const whereAnd: WhereOptions[] = []
  126. if (options.listMyPlaylists !== true) {
  127. whereAnd.push({
  128. privacy: VideoPlaylistPrivacy.PUBLIC
  129. })
  130. // Only list local playlists OR playlists that are on an instance followed by actorId
  131. const inQueryInstanceFollow = buildServerIdsFollowedBy(options.followerActorId)
  132. whereActor = {
  133. [Op.or]: [
  134. {
  135. serverId: null
  136. },
  137. {
  138. serverId: {
  139. [Op.in]: literal(inQueryInstanceFollow)
  140. }
  141. }
  142. ]
  143. }
  144. }
  145. if (options.accountId) {
  146. whereAnd.push({
  147. ownerAccountId: options.accountId
  148. })
  149. }
  150. if (options.videoChannelId) {
  151. whereAnd.push({
  152. videoChannelId: options.videoChannelId
  153. })
  154. }
  155. if (options.type) {
  156. whereAnd.push({
  157. type: options.type
  158. })
  159. }
  160. if (options.search) {
  161. whereAnd.push({
  162. name: {
  163. [Op.iLike]: '%' + options.search + '%'
  164. }
  165. })
  166. }
  167. const where = {
  168. [Op.and]: whereAnd
  169. }
  170. return {
  171. where,
  172. include: [
  173. {
  174. model: AccountModel.scope({
  175. method: [ AccountScopeNames.SUMMARY, { whereActor } as SummaryOptions ]
  176. }),
  177. required: true
  178. },
  179. {
  180. model: VideoChannelModel.scope(VideoChannelScopeNames.SUMMARY),
  181. required: false
  182. }
  183. ]
  184. } as FindOptions
  185. }
  186. }))
  187. @Table({
  188. tableName: 'videoPlaylist',
  189. indexes: [
  190. {
  191. fields: [ 'ownerAccountId' ]
  192. },
  193. {
  194. fields: [ 'videoChannelId' ]
  195. },
  196. {
  197. fields: [ 'url' ],
  198. unique: true
  199. }
  200. ]
  201. })
  202. export class VideoPlaylistModel extends Model {
  203. @CreatedAt
  204. createdAt: Date
  205. @UpdatedAt
  206. updatedAt: Date
  207. @AllowNull(false)
  208. @Is('VideoPlaylistName', value => throwIfNotValid(value, isVideoPlaylistNameValid, 'name'))
  209. @Column
  210. name: string
  211. @AllowNull(true)
  212. @Is('VideoPlaylistDescription', value => throwIfNotValid(value, isVideoPlaylistDescriptionValid, 'description', true))
  213. @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_PLAYLISTS.DESCRIPTION.max))
  214. description: string
  215. @AllowNull(false)
  216. @Is('VideoPlaylistPrivacy', value => throwIfNotValid(value, isVideoPlaylistPrivacyValid, 'privacy'))
  217. @Column
  218. privacy: VideoPlaylistPrivacy
  219. @AllowNull(false)
  220. @Is('VideoPlaylistUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url'))
  221. @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_PLAYLISTS.URL.max))
  222. url: string
  223. @AllowNull(false)
  224. @Default(DataType.UUIDV4)
  225. @IsUUID(4)
  226. @Column(DataType.UUID)
  227. uuid: string
  228. @AllowNull(false)
  229. @Default(VideoPlaylistType.REGULAR)
  230. @Column
  231. type: VideoPlaylistType
  232. @ForeignKey(() => AccountModel)
  233. @Column
  234. ownerAccountId: number
  235. @BelongsTo(() => AccountModel, {
  236. foreignKey: {
  237. allowNull: false
  238. },
  239. onDelete: 'CASCADE'
  240. })
  241. OwnerAccount: AccountModel
  242. @ForeignKey(() => VideoChannelModel)
  243. @Column
  244. videoChannelId: number
  245. @BelongsTo(() => VideoChannelModel, {
  246. foreignKey: {
  247. allowNull: true
  248. },
  249. onDelete: 'CASCADE'
  250. })
  251. VideoChannel: VideoChannelModel
  252. @HasMany(() => VideoPlaylistElementModel, {
  253. foreignKey: {
  254. name: 'videoPlaylistId',
  255. allowNull: false
  256. },
  257. onDelete: 'CASCADE'
  258. })
  259. VideoPlaylistElements: VideoPlaylistElementModel[]
  260. @HasOne(() => ThumbnailModel, {
  261. foreignKey: {
  262. name: 'videoPlaylistId',
  263. allowNull: true
  264. },
  265. onDelete: 'CASCADE',
  266. hooks: true
  267. })
  268. Thumbnail: ThumbnailModel
  269. static listForApi (options: {
  270. followerActorId: number
  271. start: number
  272. count: number
  273. sort: string
  274. type?: VideoPlaylistType
  275. accountId?: number
  276. videoChannelId?: number
  277. listMyPlaylists?: boolean
  278. search?: string
  279. }) {
  280. const query = {
  281. offset: options.start,
  282. limit: options.count,
  283. order: getPlaylistSort(options.sort)
  284. }
  285. const scopes: (string | ScopeOptions)[] = [
  286. {
  287. method: [
  288. ScopeNames.AVAILABLE_FOR_LIST,
  289. {
  290. type: options.type,
  291. followerActorId: options.followerActorId,
  292. accountId: options.accountId,
  293. videoChannelId: options.videoChannelId,
  294. listMyPlaylists: options.listMyPlaylists,
  295. search: options.search
  296. } as AvailableForListOptions
  297. ]
  298. },
  299. ScopeNames.WITH_VIDEOS_LENGTH,
  300. ScopeNames.WITH_THUMBNAIL
  301. ]
  302. return VideoPlaylistModel
  303. .scope(scopes)
  304. .findAndCountAll(query)
  305. .then(({ rows, count }) => {
  306. return { total: count, data: rows }
  307. })
  308. }
  309. static listPublicUrlsOfForAP (options: { account?: MAccountId, channel?: MChannelId }, start: number, count: number) {
  310. const where = {
  311. privacy: VideoPlaylistPrivacy.PUBLIC
  312. }
  313. if (options.account) {
  314. Object.assign(where, { ownerAccountId: options.account.id })
  315. }
  316. if (options.channel) {
  317. Object.assign(where, { videoChannelId: options.channel.id })
  318. }
  319. const query = {
  320. attributes: [ 'url' ],
  321. offset: start,
  322. limit: count,
  323. where
  324. }
  325. return VideoPlaylistModel.findAndCountAll(query)
  326. .then(({ rows, count }) => {
  327. return { total: count, data: rows.map(p => p.url) }
  328. })
  329. }
  330. static listPlaylistIdsOf (accountId: number, videoIds: number[]): Promise<MVideoPlaylistIdWithElements[]> {
  331. const query = {
  332. attributes: [ 'id' ],
  333. where: {
  334. ownerAccountId: accountId
  335. },
  336. include: [
  337. {
  338. attributes: [ 'id', 'videoId', 'startTimestamp', 'stopTimestamp' ],
  339. model: VideoPlaylistElementModel.unscoped(),
  340. where: {
  341. videoId: {
  342. [Op.in]: videoIds
  343. }
  344. },
  345. required: true
  346. }
  347. ]
  348. }
  349. return VideoPlaylistModel.findAll(query)
  350. }
  351. static doesPlaylistExist (url: string) {
  352. const query = {
  353. attributes: [ 'id' ],
  354. where: {
  355. url
  356. }
  357. }
  358. return VideoPlaylistModel
  359. .findOne(query)
  360. .then(e => !!e)
  361. }
  362. static loadWithAccountAndChannelSummary (id: number | string, transaction: Transaction): Promise<MVideoPlaylistFullSummary> {
  363. const where = buildWhereIdOrUUID(id)
  364. const query = {
  365. where,
  366. transaction
  367. }
  368. return VideoPlaylistModel
  369. .scope([ ScopeNames.WITH_ACCOUNT_AND_CHANNEL_SUMMARY, ScopeNames.WITH_VIDEOS_LENGTH, ScopeNames.WITH_THUMBNAIL ])
  370. .findOne(query)
  371. }
  372. static loadWithAccountAndChannel (id: number | string, transaction: Transaction): Promise<MVideoPlaylistFull> {
  373. const where = buildWhereIdOrUUID(id)
  374. const query = {
  375. where,
  376. transaction
  377. }
  378. return VideoPlaylistModel
  379. .scope([ ScopeNames.WITH_ACCOUNT_AND_CHANNEL, ScopeNames.WITH_VIDEOS_LENGTH, ScopeNames.WITH_THUMBNAIL ])
  380. .findOne(query)
  381. }
  382. static loadByUrlAndPopulateAccount (url: string): Promise<MVideoPlaylistAccountThumbnail> {
  383. const query = {
  384. where: {
  385. url
  386. }
  387. }
  388. return VideoPlaylistModel.scope([ ScopeNames.WITH_ACCOUNT, ScopeNames.WITH_THUMBNAIL ]).findOne(query)
  389. }
  390. static getPrivacyLabel (privacy: VideoPlaylistPrivacy) {
  391. return VIDEO_PLAYLIST_PRIVACIES[privacy] || 'Unknown'
  392. }
  393. static getTypeLabel (type: VideoPlaylistType) {
  394. return VIDEO_PLAYLIST_TYPES[type] || 'Unknown'
  395. }
  396. static resetPlaylistsOfChannel (videoChannelId: number, transaction: Transaction) {
  397. const query = {
  398. where: {
  399. videoChannelId
  400. },
  401. transaction
  402. }
  403. return VideoPlaylistModel.update({ privacy: VideoPlaylistPrivacy.PRIVATE, videoChannelId: null }, query)
  404. }
  405. async setAndSaveThumbnail (thumbnail: MThumbnail, t: Transaction) {
  406. thumbnail.videoPlaylistId = this.id
  407. this.Thumbnail = await thumbnail.save({ transaction: t })
  408. }
  409. hasThumbnail () {
  410. return !!this.Thumbnail
  411. }
  412. hasGeneratedThumbnail () {
  413. return this.hasThumbnail() && this.Thumbnail.automaticallyGenerated === true
  414. }
  415. generateThumbnailName () {
  416. const extension = '.jpg'
  417. return 'playlist-' + this.uuid + extension
  418. }
  419. getThumbnailUrl () {
  420. if (!this.hasThumbnail()) return null
  421. return WEBSERVER.URL + STATIC_PATHS.THUMBNAILS + this.Thumbnail.filename
  422. }
  423. getThumbnailStaticPath () {
  424. if (!this.hasThumbnail()) return null
  425. return join(STATIC_PATHS.THUMBNAILS, this.Thumbnail.filename)
  426. }
  427. getWatchUrl () {
  428. return WEBSERVER.URL + '/videos/watch/playlist/' + this.uuid
  429. }
  430. getEmbedStaticPath () {
  431. return '/video-playlists/embed/' + this.uuid
  432. }
  433. setAsRefreshed () {
  434. this.changed('updatedAt', true)
  435. return this.save()
  436. }
  437. isOwned () {
  438. return this.OwnerAccount.isOwned()
  439. }
  440. isOutdated () {
  441. if (this.isOwned()) return false
  442. return isOutdated(this, ACTIVITY_PUB.VIDEO_PLAYLIST_REFRESH_INTERVAL)
  443. }
  444. toFormattedJSON (this: MVideoPlaylistFormattable): VideoPlaylist {
  445. return {
  446. id: this.id,
  447. uuid: this.uuid,
  448. isLocal: this.isOwned(),
  449. displayName: this.name,
  450. description: this.description,
  451. privacy: {
  452. id: this.privacy,
  453. label: VideoPlaylistModel.getPrivacyLabel(this.privacy)
  454. },
  455. thumbnailPath: this.getThumbnailStaticPath(),
  456. embedPath: this.getEmbedStaticPath(),
  457. type: {
  458. id: this.type,
  459. label: VideoPlaylistModel.getTypeLabel(this.type)
  460. },
  461. videosLength: this.get('videosLength') as number,
  462. createdAt: this.createdAt,
  463. updatedAt: this.updatedAt,
  464. ownerAccount: this.OwnerAccount.toFormattedSummaryJSON(),
  465. videoChannel: this.VideoChannel
  466. ? this.VideoChannel.toFormattedSummaryJSON()
  467. : null
  468. }
  469. }
  470. toActivityPubObject (this: MVideoPlaylistAP, page: number, t: Transaction): Promise<PlaylistObject> {
  471. const handler = (start: number, count: number) => {
  472. return VideoPlaylistElementModel.listUrlsOfForAP(this.id, start, count, t)
  473. }
  474. let icon: ActivityIconObject
  475. if (this.hasThumbnail()) {
  476. icon = {
  477. type: 'Image' as 'Image',
  478. url: this.getThumbnailUrl(),
  479. mediaType: 'image/jpeg' as 'image/jpeg',
  480. width: THUMBNAILS_SIZE.width,
  481. height: THUMBNAILS_SIZE.height
  482. }
  483. }
  484. return activityPubCollectionPagination(this.url, handler, page)
  485. .then(o => {
  486. return Object.assign(o, {
  487. type: 'Playlist' as 'Playlist',
  488. name: this.name,
  489. content: this.description,
  490. uuid: this.uuid,
  491. published: this.createdAt.toISOString(),
  492. updated: this.updatedAt.toISOString(),
  493. attributedTo: this.VideoChannel ? [ this.VideoChannel.Actor.url ] : [],
  494. icon
  495. })
  496. })
  497. }
  498. }