video-playlist.ts 13 KB

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