]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/commitdiff
Add ability to list all local videos
authorChocobozzz <me@florianbigard.com>
Wed, 10 Oct 2018 09:46:50 +0000 (11:46 +0200)
committerChocobozzz <me@florianbigard.com>
Wed, 10 Oct 2018 09:46:50 +0000 (11:46 +0200)
Including private/unlisted for moderators/admins

17 files changed:
server/controllers/api/accounts.ts
server/controllers/api/search.ts
server/controllers/api/video-channel.ts
server/controllers/feeds.ts
server/helpers/custom-validators/videos.ts
server/helpers/express-utils.ts
server/middlewares/validators/search.ts
server/middlewares/validators/videos/videos.ts
server/models/video/video.ts
server/tests/api/check-params/index.ts
server/tests/api/check-params/videos-filter.ts [new file with mode: 0644]
server/tests/api/videos/index.ts
server/tests/api/videos/videos-filter.ts [new file with mode: 0644]
shared/models/search/videos-search-query.model.ts
shared/models/users/user-right.enum.ts
shared/models/users/user-role.ts
shared/models/videos/video-query.type.ts

index b7691ccba2f8e2544bdda03b3d4b4b222d13b20d..8e3f600108b9ef90b521820151777e7009ef99be 100644 (file)
@@ -86,9 +86,11 @@ async function listAccountVideos (req: express.Request, res: express.Response, n
     languageOneOf: req.query.languageOneOf,
     tagsOneOf: req.query.tagsOneOf,
     tagsAllOf: req.query.tagsAllOf,
+    filter: req.query.filter,
     nsfw: buildNSFWFilter(res, req.query.nsfw),
     withFiles: false,
-    accountId: account.id
+    accountId: account.id,
+    userId: res.locals.oauth ? res.locals.oauth.token.User.id : undefined
   })
 
   return res.json(getFormattedObjects(resultList.data, resultList.total))
index 4be2b5ef7898a14ead15677888a756351429153f..a8a6cfb0821fb185602c0dd27c4b0783ffb3e13d 100644 (file)
@@ -118,6 +118,7 @@ async function searchVideosDB (query: VideosSearchQuery, res: express.Response)
   const options = Object.assign(query, {
     includeLocalVideos: true,
     nsfw: buildNSFWFilter(res, query.nsfw),
+    filter: query.filter,
     userId: res.locals.oauth ? res.locals.oauth.token.User.id : undefined
   })
   const resultList = await VideoModel.searchAndPopulateAccountAndServer(options)
index 1fa842d9c984b2a112c6184831666e113c5e9bb1..c84d1be580f8aa44dbafcb7ca618ac093f99029a 100644 (file)
@@ -215,9 +215,11 @@ async function listVideoChannelVideos (req: express.Request, res: express.Respon
     languageOneOf: req.query.languageOneOf,
     tagsOneOf: req.query.tagsOneOf,
     tagsAllOf: req.query.tagsAllOf,
+    filter: req.query.filter,
     nsfw: buildNSFWFilter(res, req.query.nsfw),
     withFiles: false,
-    videoChannelId: videoChannelInstance.id
+    videoChannelId: videoChannelInstance.id,
+    userId: res.locals.oauth ? res.locals.oauth.token.User.id : undefined
   })
 
   return res.json(getFormattedObjects(resultList.data, resultList.total))
index b30ad8e8de63348fe475f7f2a6ecbe38d699f4c5..ccb9b60292e7a26818f602290cbb95ef24d79860 100644 (file)
@@ -1,7 +1,14 @@
 import * as express from 'express'
 import { CONFIG, FEEDS, ROUTE_CACHE_LIFETIME } from '../initializers/constants'
 import { THUMBNAILS_SIZE } from '../initializers'
-import { asyncMiddleware, setDefaultSort, videoCommentsFeedsValidator, videoFeedsValidator, videosSortValidator } from '../middlewares'
+import {
+  asyncMiddleware,
+  commonVideosFiltersValidator,
+  setDefaultSort,
+  videoCommentsFeedsValidator,
+  videoFeedsValidator,
+  videosSortValidator
+} from '../middlewares'
 import { VideoModel } from '../models/video/video'
 import * as Feed from 'pfeed'
 import { AccountModel } from '../models/account/account'
@@ -22,6 +29,7 @@ feedsRouter.get('/feeds/videos.:format',
   videosSortValidator,
   setDefaultSort,
   asyncMiddleware(cacheRoute(ROUTE_CACHE_LIFETIME.FEEDS)),
+  commonVideosFiltersValidator,
   asyncMiddleware(videoFeedsValidator),
   asyncMiddleware(generateVideoFeed)
 )
index 714f7ac956c25b8dcc35f02b716830430a131e10..a13b09ac80c31ebb23f58b007c4723134f753750 100644 (file)
@@ -3,7 +3,7 @@ import 'express-validator'
 import { values } from 'lodash'
 import 'multer'
 import * as validator from 'validator'
-import { UserRight, VideoPrivacy, VideoRateType } from '../../../shared'
+import { UserRight, VideoFilter, VideoPrivacy, VideoRateType } from '../../../shared'
 import {
   CONSTRAINTS_FIELDS,
   VIDEO_CATEGORIES,
@@ -22,6 +22,10 @@ import { fetchVideo, VideoFetchType } from '../video'
 
 const VIDEOS_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.VIDEOS
 
+function isVideoFilterValid (filter: VideoFilter) {
+  return filter === 'local' || filter === 'all-local'
+}
+
 function isVideoCategoryValid (value: any) {
   return value === null || VIDEO_CATEGORIES[ value ] !== undefined
 }
@@ -225,5 +229,6 @@ export {
   isVideoExist,
   isVideoImage,
   isVideoChannelOfAccountExist,
-  isVideoSupportValid
+  isVideoSupportValid,
+  isVideoFilterValid
 }
index 8a9cee8c57a72756f857f44d14bb6cd3edc7c0c7..162fe2244118a514ce5c38e2461419855881b380 100644 (file)
@@ -2,7 +2,6 @@ import * as express from 'express'
 import * as multer from 'multer'
 import { CONFIG, REMOTE_SCHEME } from '../initializers'
 import { logger } from './logger'
-import { User } from '../../shared/models/users'
 import { deleteFileAsync, generateRandomString } from './utils'
 import { extname } from 'path'
 import { isArray } from './custom-validators/misc'
@@ -101,7 +100,7 @@ function createReqFiles (
 }
 
 function isUserAbleToSearchRemoteURI (res: express.Response) {
-  const user: User = res.locals.oauth ? res.locals.oauth.token.User : undefined
+  const user: UserModel = res.locals.oauth ? res.locals.oauth.token.User : undefined
 
   return CONFIG.SEARCH.REMOTE_URI.ANONYMOUS === true ||
     (CONFIG.SEARCH.REMOTE_URI.USERS === true && user !== undefined)
index 8baf643a50d042cd6d984e5e05b95c5212cde6be..6a95d60958feb4e2a9bd9d05ef2885a4f2c647a2 100644 (file)
@@ -2,8 +2,7 @@ import * as express from 'express'
 import { areValidationErrors } from './utils'
 import { logger } from '../../helpers/logger'
 import { query } from 'express-validator/check'
-import { isNumberArray, isStringArray, isNSFWQueryValid } from '../../helpers/custom-validators/search'
-import { isBooleanValid, isDateValid, toArray } from '../../helpers/custom-validators/misc'
+import { isDateValid } from '../../helpers/custom-validators/misc'
 
 const videosSearchValidator = [
   query('search').optional().not().isEmpty().withMessage('Should have a valid search'),
@@ -35,44 +34,9 @@ const videoChannelsSearchValidator = [
   }
 ]
 
-const commonVideosFiltersValidator = [
-  query('categoryOneOf')
-    .optional()
-    .customSanitizer(toArray)
-    .custom(isNumberArray).withMessage('Should have a valid one of category array'),
-  query('licenceOneOf')
-    .optional()
-    .customSanitizer(toArray)
-    .custom(isNumberArray).withMessage('Should have a valid one of licence array'),
-  query('languageOneOf')
-    .optional()
-    .customSanitizer(toArray)
-    .custom(isStringArray).withMessage('Should have a valid one of language array'),
-  query('tagsOneOf')
-    .optional()
-    .customSanitizer(toArray)
-    .custom(isStringArray).withMessage('Should have a valid one of tags array'),
-  query('tagsAllOf')
-    .optional()
-    .customSanitizer(toArray)
-    .custom(isStringArray).withMessage('Should have a valid all of tags array'),
-  query('nsfw')
-    .optional()
-    .custom(isNSFWQueryValid).withMessage('Should have a valid NSFW attribute'),
-
-  (req: express.Request, res: express.Response, next: express.NextFunction) => {
-    logger.debug('Checking commons video filters query', { parameters: req.query })
-
-    if (areValidationErrors(req, res)) return
-
-    return next()
-  }
-]
-
 // ---------------------------------------------------------------------------
 
 export {
-  commonVideosFiltersValidator,
   videoChannelsSearchValidator,
   videosSearchValidator
 }
index 1d0a64bb18863ea55354099a6540552bab68c2a7..9dc52a13487061bba4a67443aca18c72a9c62062 100644 (file)
@@ -1,6 +1,6 @@
 import * as express from 'express'
 import 'express-validator'
-import { body, param, ValidationChain } from 'express-validator/check'
+import { body, param, query, ValidationChain } from 'express-validator/check'
 import { UserRight, VideoChangeOwnershipStatus, VideoPrivacy } from '../../../../shared'
 import {
   isBooleanValid,
@@ -8,6 +8,7 @@ import {
   isIdOrUUIDValid,
   isIdValid,
   isUUIDValid,
+  toArray,
   toIntOrNull,
   toValueOrNull
 } from '../../../helpers/custom-validators/misc'
@@ -19,6 +20,7 @@ import {
   isVideoDescriptionValid,
   isVideoExist,
   isVideoFile,
+  isVideoFilterValid,
   isVideoImage,
   isVideoLanguageValid,
   isVideoLicenceValid,
@@ -42,6 +44,7 @@ import { VideoChangeOwnershipAccept } from '../../../../shared/models/videos/vid
 import { VideoChangeOwnershipModel } from '../../../models/video/video-change-ownership'
 import { AccountModel } from '../../../models/account/account'
 import { VideoFetchType } from '../../../helpers/video'
+import { isNSFWQueryValid, isNumberArray, isStringArray } from '../../../helpers/custom-validators/search'
 
 const videosAddValidator = getCommonVideoAttributes().concat([
   body('videofile')
@@ -359,6 +362,51 @@ function getCommonVideoAttributes () {
   ] as (ValidationChain | express.Handler)[]
 }
 
+const commonVideosFiltersValidator = [
+  query('categoryOneOf')
+    .optional()
+    .customSanitizer(toArray)
+    .custom(isNumberArray).withMessage('Should have a valid one of category array'),
+  query('licenceOneOf')
+    .optional()
+    .customSanitizer(toArray)
+    .custom(isNumberArray).withMessage('Should have a valid one of licence array'),
+  query('languageOneOf')
+    .optional()
+    .customSanitizer(toArray)
+    .custom(isStringArray).withMessage('Should have a valid one of language array'),
+  query('tagsOneOf')
+    .optional()
+    .customSanitizer(toArray)
+    .custom(isStringArray).withMessage('Should have a valid one of tags array'),
+  query('tagsAllOf')
+    .optional()
+    .customSanitizer(toArray)
+    .custom(isStringArray).withMessage('Should have a valid all of tags array'),
+  query('nsfw')
+    .optional()
+    .custom(isNSFWQueryValid).withMessage('Should have a valid NSFW attribute'),
+  query('filter')
+    .optional()
+    .custom(isVideoFilterValid).withMessage('Should have a valid filter attribute'),
+
+  (req: express.Request, res: express.Response, next: express.NextFunction) => {
+    logger.debug('Checking commons video filters query', { parameters: req.query })
+
+    if (areValidationErrors(req, res)) return
+
+    const user: UserModel = res.locals.oauth ? res.locals.oauth.token.User : undefined
+    if (req.query.filter === 'all-local' && (!user || user.hasRight(UserRight.SEE_ALL_VIDEOS) === false)) {
+      res.status(401)
+         .json({ error: 'You are not allowed to see all local videos.' })
+
+      return
+    }
+
+    return next()
+  }
+]
+
 // ---------------------------------------------------------------------------
 
 export {
@@ -375,7 +423,9 @@ export {
   videosTerminateChangeOwnershipValidator,
   videosAcceptChangeOwnershipValidator,
 
-  getCommonVideoAttributes
+  getCommonVideoAttributes,
+
+  commonVideosFiltersValidator
 }
 
 // ---------------------------------------------------------------------------
index 070ac762395014a7d8900743416c3629c2a2968b..4f3f75613d101b803aaa355a471a40f973defce6 100644 (file)
@@ -235,7 +235,14 @@ type AvailableForListIDsOptions = {
               )
             }
           ]
-        },
+        }
+      },
+      include: []
+    }
+
+    // Only list public/published videos
+    if (!options.filter || options.filter !== 'all-local') {
+      const privacyWhere = {
         // Always list public videos
         privacy: VideoPrivacy.PUBLIC,
         // Always list published videos, or videos that are being transcoded but on which we don't want to wait for transcoding
@@ -250,8 +257,9 @@ type AvailableForListIDsOptions = {
             }
           }
         ]
-      },
-      include: []
+      }
+
+      Object.assign(query.where, privacyWhere)
     }
 
     if (options.filter || options.accountId || options.videoChannelId) {
@@ -969,6 +977,10 @@ export class VideoModel extends Model<VideoModel> {
     trendingDays?: number,
     userId?: number
   }, countVideos = true) {
+    if (options.filter && options.filter === 'all-local' && !options.userId) {
+      throw new Error('Try to filter all-local but no userId is provided')
+    }
+
     const query: IFindOptions<VideoModel> = {
       offset: options.start,
       limit: options.count,
@@ -1021,7 +1033,8 @@ export class VideoModel extends Model<VideoModel> {
     tagsAllOf?: string[]
     durationMin?: number // seconds
     durationMax?: number // seconds
-    userId?: number
+    userId?: number,
+    filter?: VideoFilter
   }) {
     const whereAnd = []
 
@@ -1098,7 +1111,8 @@ export class VideoModel extends Model<VideoModel> {
       languageOneOf: options.languageOneOf,
       tagsOneOf: options.tagsOneOf,
       tagsAllOf: options.tagsAllOf,
-      userId: options.userId
+      userId: options.userId,
+      filter: options.filter
     }
 
     return VideoModel.getAvailableForApi(query, queryOptions)
@@ -1262,7 +1276,7 @@ export class VideoModel extends Model<VideoModel> {
   }
 
   private static buildActorWhereWithFilter (filter?: VideoFilter) {
-    if (filter && filter === 'local') {
+    if (filter && (filter === 'local' || filter === 'all-local')) {
       return {
         serverId: null
       }
index 71a217649f6357b86187d660266464182b5f7190..bfc550ae50368fb78d29ed5044df7e8286ad4071 100644 (file)
@@ -15,4 +15,5 @@ import './video-channels'
 import './video-comments'
 import './video-imports'
 import './videos'
+import './videos-filter'
 import './videos-history'
diff --git a/server/tests/api/check-params/videos-filter.ts b/server/tests/api/check-params/videos-filter.ts
new file mode 100644 (file)
index 0000000..784cd8b
--- /dev/null
@@ -0,0 +1,127 @@
+/* tslint:disable:no-unused-expression */
+
+import * as chai from 'chai'
+import 'mocha'
+import {
+  createUser,
+  flushTests,
+  killallServers,
+  makeGetRequest,
+  runServer,
+  ServerInfo,
+  setAccessTokensToServers,
+  userLogin
+} from '../../utils'
+import { UserRole } from '../../../../shared/models/users'
+
+const expect = chai.expect
+
+async function testEndpoints (server: ServerInfo, token: string, filter: string, statusCodeExpected: number) {
+  const paths = [
+    '/api/v1/video-channels/root_channel/videos',
+    '/api/v1/accounts/root/videos',
+    '/api/v1/videos',
+    '/api/v1/search/videos'
+  ]
+
+  for (const path of paths) {
+    await makeGetRequest({
+      url: server.url,
+      path,
+      token,
+      query: {
+        filter
+      },
+      statusCodeExpected
+    })
+  }
+}
+
+describe('Test videos filters', function () {
+  let server: ServerInfo
+  let userAccessToken: string
+  let moderatorAccessToken: string
+
+  // ---------------------------------------------------------------
+
+  before(async function () {
+    this.timeout(30000)
+
+    await flushTests()
+
+    server = await runServer(1)
+
+    await setAccessTokensToServers([ server ])
+
+    const user = { username: 'user1', password: 'my super password' }
+    await createUser(server.url, server.accessToken, user.username, user.password)
+    userAccessToken = await userLogin(server, user)
+
+    const moderator = { username: 'moderator', password: 'my super password' }
+    await createUser(
+      server.url,
+      server.accessToken,
+      moderator.username,
+      moderator.password,
+      undefined,
+      undefined,
+      UserRole.MODERATOR
+    )
+    moderatorAccessToken = await userLogin(server, moderator)
+  })
+
+  describe('When setting a video filter', function () {
+
+    it('Should fail with a bad filter', async function () {
+      await testEndpoints(server, server.accessToken, 'bad-filter', 400)
+    })
+
+    it('Should succeed with a good filter', async function () {
+      await testEndpoints(server, server.accessToken,'local', 200)
+    })
+
+    it('Should fail to list all-local with a simple user', async function () {
+      await testEndpoints(server, userAccessToken, 'all-local', 401)
+    })
+
+    it('Should succeed to list all-local with a moderator', async function () {
+      await testEndpoints(server, moderatorAccessToken, 'all-local', 200)
+    })
+
+    it('Should succeed to list all-local with an admin', async function () {
+      await testEndpoints(server, server.accessToken, 'all-local', 200)
+    })
+
+    // Because we cannot authenticate the user on the RSS endpoint
+    it('Should fail on the feeds endpoint with the all-local filter', async function () {
+      await makeGetRequest({
+        url: server.url,
+        path: '/feeds/videos.json',
+        statusCodeExpected: 401,
+        query: {
+          filter: 'all-local'
+        }
+      })
+    })
+
+    it('Should succed on the feeds endpoint with the local filter', async function () {
+      await makeGetRequest({
+        url: server.url,
+        path: '/feeds/videos.json',
+        statusCodeExpected: 200,
+        query: {
+          filter: 'local'
+        }
+      })
+    })
+  })
+
+  after(async function () {
+    killallServers([ server ])
+
+    // Keep the logs if the test failed
+    if (this['ok']) {
+      await flushTests()
+    }
+  })
+})
index 09bb62a8dec83ea24326850a61ff2df9126bc52f..9bdb78491a185add90902680343adb4a5d45309a 100644 (file)
@@ -14,5 +14,6 @@ import './video-nsfw'
 import './video-privacy'
 import './video-schedule-update'
 import './video-transcoder'
+import './videos-filter'
 import './videos-history'
 import './videos-overview'
diff --git a/server/tests/api/videos/videos-filter.ts b/server/tests/api/videos/videos-filter.ts
new file mode 100644 (file)
index 0000000..a758812
--- /dev/null
@@ -0,0 +1,130 @@
+/* tslint:disable:no-unused-expression */
+
+import * as chai from 'chai'
+import 'mocha'
+import {
+  createUser,
+  doubleFollow,
+  flushAndRunMultipleServers,
+  flushTests,
+  killallServers,
+  makeGetRequest,
+  ServerInfo,
+  setAccessTokensToServers,
+  uploadVideo,
+  userLogin
+} from '../../utils'
+import { Video, VideoPrivacy } from '../../../../shared/models/videos'
+import { UserRole } from '../../../../shared/models/users'
+
+const expect = chai.expect
+
+async function getVideosNames (server: ServerInfo, token: string, filter: string, statusCodeExpected = 200) {
+  const paths = [
+    '/api/v1/video-channels/root_channel/videos',
+    '/api/v1/accounts/root/videos',
+    '/api/v1/videos',
+    '/api/v1/search/videos'
+  ]
+
+  const videosResults: Video[][] = []
+
+  for (const path of paths) {
+    const res = await makeGetRequest({
+      url: server.url,
+      path,
+      token,
+      query: {
+        sort: 'createdAt',
+        filter
+      },
+      statusCodeExpected
+    })
+
+    videosResults.push(res.body.data.map(v => v.name))
+  }
+
+  return videosResults
+}
+
+describe('Test videos filter validator', function () {
+  let servers: ServerInfo[]
+
+  // ---------------------------------------------------------------
+
+  before(async function () {
+    this.timeout(120000)
+
+    await flushTests()
+
+    servers = await flushAndRunMultipleServers(2)
+
+    await setAccessTokensToServers(servers)
+
+    for (const server of servers) {
+      const moderator = { username: 'moderator', password: 'my super password' }
+      await createUser(
+        server.url,
+        server.accessToken,
+        moderator.username,
+        moderator.password,
+        undefined,
+        undefined,
+        UserRole.MODERATOR
+      )
+      server['moderatorAccessToken'] = await userLogin(server, moderator)
+
+      await uploadVideo(server.url, server.accessToken, { name: 'public ' + server.serverNumber })
+
+      {
+        const attributes = { name: 'unlisted ' + server.serverNumber, privacy: VideoPrivacy.UNLISTED }
+        await uploadVideo(server.url, server.accessToken, attributes)
+      }
+
+      {
+        const attributes = { name: 'private ' + server.serverNumber, privacy: VideoPrivacy.PRIVATE }
+        await uploadVideo(server.url, server.accessToken, attributes)
+      }
+    }
+
+    await doubleFollow(servers[0], servers[1])
+  })
+
+  describe('Check videos filter', function () {
+
+    it('Should display local videos', async function () {
+      for (const server of servers) {
+        const namesResults = await getVideosNames(server, server.accessToken, 'local')
+        for (const names of namesResults) {
+          expect(names).to.have.lengthOf(1)
+          expect(names[ 0 ]).to.equal('public ' + server.serverNumber)
+        }
+      }
+    })
+
+    it('Should display all local videos by the admin or the moderator', async function () {
+      for (const server of servers) {
+        for (const token of [ server.accessToken, server['moderatorAccessToken'] ]) {
+
+          const namesResults = await getVideosNames(server, token, 'all-local')
+          for (const names of namesResults) {
+            expect(names).to.have.lengthOf(3)
+
+            expect(names[ 0 ]).to.equal('public ' + server.serverNumber)
+            expect(names[ 1 ]).to.equal('unlisted ' + server.serverNumber)
+            expect(names[ 2 ]).to.equal('private ' + server.serverNumber)
+          }
+        }
+      }
+    })
+  })
+
+  after(async function () {
+    killallServers(servers)
+
+    // Keep the logs if the test failed
+    if (this['ok']) {
+      await flushTests()
+    }
+  })
+})
index 29aa5c100c716c22ebc84e8aa252c3574635c024..0db220758286d7a9f1d1da6eecabcaad81ccfa7b 100644 (file)
@@ -1,4 +1,5 @@
 import { NSFWQuery } from './nsfw-query.model'
+import { VideoFilter } from '../videos'
 
 export interface VideosSearchQuery {
   search?: string
@@ -23,4 +24,6 @@ export interface VideosSearchQuery {
 
   durationMin?: number // seconds
   durationMax?: number // seconds
+
+  filter?: VideoFilter
 }
index c4ccd632f376cc2c55b83c03232ee19dfb545b14..ed2c536ce3367571039780584f809c4c07d09dfa 100644 (file)
@@ -14,5 +14,6 @@ export enum UserRight {
   REMOVE_ANY_VIDEO_CHANNEL,
   REMOVE_ANY_VIDEO_COMMENT,
   UPDATE_ANY_VIDEO,
+  SEE_ALL_VIDEOS,
   CHANGE_VIDEO_OWNERSHIP
 }
index 552aad9999f9352ff95a6907983b90971688fa62..d7020c0f2bf5b7a4bd339d51eb66e4fe23058e29 100644 (file)
@@ -26,7 +26,8 @@ const userRoleRights: { [ id: number ]: UserRight[] } = {
     UserRight.REMOVE_ANY_VIDEO,
     UserRight.REMOVE_ANY_VIDEO_CHANNEL,
     UserRight.REMOVE_ANY_VIDEO_COMMENT,
-    UserRight.UPDATE_ANY_VIDEO
+    UserRight.UPDATE_ANY_VIDEO,
+    UserRight.SEE_ALL_VIDEOS
   ],
 
   [UserRole.USER]: []
index ff0f527f3f9a19f576a19effc04578d6aa53596a..f76a91aad1062c49d2d96399e3017cc2415f1b36 100644 (file)
@@ -1 +1 @@
-export type VideoFilter = 'local'
+export type VideoFilter = 'local' | 'all-local'