diff options
-rw-r--r-- | client/src/app/shared/video/sort-field.type.ts | 1 | ||||
-rw-r--r-- | client/src/app/videos/video-list/video-trending.component.ts | 2 | ||||
-rw-r--r-- | config/default.yaml | 4 | ||||
-rw-r--r-- | config/production.yaml.example | 4 | ||||
-rw-r--r-- | server/initializers/checker.ts | 2 | ||||
-rw-r--r-- | server/initializers/constants.ts | 10 | ||||
-rw-r--r-- | server/models/utils.ts | 44 | ||||
-rw-r--r-- | server/models/video/video.ts | 63 | ||||
-rw-r--r-- | server/tests/api/videos/videos-overview.ts | 20 |
9 files changed, 116 insertions, 34 deletions
diff --git a/client/src/app/shared/video/sort-field.type.ts b/client/src/app/shared/video/sort-field.type.ts index 2192745b9..d1088d244 100644 --- a/client/src/app/shared/video/sort-field.type.ts +++ b/client/src/app/shared/video/sort-field.type.ts | |||
@@ -4,3 +4,4 @@ export type VideoSortField = 'name' | '-name' | |||
4 | | 'createdAt' | '-createdAt' | 4 | | 'createdAt' | '-createdAt' |
5 | | 'views' | '-views' | 5 | | 'views' | '-views' |
6 | | 'likes' | '-likes' | 6 | | 'likes' | '-likes' |
7 | | 'trending' | '-trending' | ||
diff --git a/client/src/app/videos/video-list/video-trending.component.ts b/client/src/app/videos/video-list/video-trending.component.ts index 68bb70265..8f3d3842b 100644 --- a/client/src/app/videos/video-list/video-trending.component.ts +++ b/client/src/app/videos/video-list/video-trending.component.ts | |||
@@ -18,7 +18,7 @@ import { ScreenService } from '@app/shared/misc/screen.service' | |||
18 | export class VideoTrendingComponent extends AbstractVideoList implements OnInit, OnDestroy { | 18 | export class VideoTrendingComponent extends AbstractVideoList implements OnInit, OnDestroy { |
19 | titlePage: string | 19 | titlePage: string |
20 | currentRoute = '/videos/trending' | 20 | currentRoute = '/videos/trending' |
21 | defaultSort: VideoSortField = '-views' | 21 | defaultSort: VideoSortField = '-trending' |
22 | 22 | ||
23 | constructor ( | 23 | constructor ( |
24 | protected router: Router, | 24 | protected router: Router, |
diff --git a/config/default.yaml b/config/default.yaml index ef63fbd28..254fa0c99 100644 --- a/config/default.yaml +++ b/config/default.yaml | |||
@@ -62,6 +62,10 @@ search: | |||
62 | users: true | 62 | users: true |
63 | anonymous: false | 63 | anonymous: false |
64 | 64 | ||
65 | trending: | ||
66 | videos: | ||
67 | interval_days: 7 # Compute trending videos for the last x days | ||
68 | |||
65 | cache: | 69 | cache: |
66 | previews: | 70 | previews: |
67 | size: 500 # Max number of previews you want to cache | 71 | size: 500 # Max number of previews you want to cache |
diff --git a/config/production.yaml.example b/config/production.yaml.example index f7b153698..e33427fae 100644 --- a/config/production.yaml.example +++ b/config/production.yaml.example | |||
@@ -63,6 +63,10 @@ search: | |||
63 | users: true | 63 | users: true |
64 | anonymous: false | 64 | anonymous: false |
65 | 65 | ||
66 | trending: | ||
67 | videos: | ||
68 | interval_days: 7 # Compute trending videos for the last x days | ||
69 | |||
66 | ############################################################################### | 70 | ############################################################################### |
67 | # | 71 | # |
68 | # From this point, all the following keys can be overridden by the web interface | 72 | # From this point, all the following keys can be overridden by the web interface |
diff --git a/server/initializers/checker.ts b/server/initializers/checker.ts index ee02ecf48..b126bf67e 100644 --- a/server/initializers/checker.ts +++ b/server/initializers/checker.ts | |||
@@ -52,7 +52,7 @@ function checkMissedConfig () { | |||
52 | 'signup.enabled', 'signup.limit', 'signup.requires_email_verification', | 52 | 'signup.enabled', 'signup.limit', 'signup.requires_email_verification', |
53 | 'signup.filters.cidr.whitelist', 'signup.filters.cidr.blacklist', | 53 | 'signup.filters.cidr.whitelist', 'signup.filters.cidr.blacklist', |
54 | 'transcoding.enabled', 'transcoding.threads', | 54 | 'transcoding.enabled', 'transcoding.threads', |
55 | 'import.videos.http.enabled', | 55 | 'import.videos.http.enabled', 'import.videos.torrent.enabled', |
56 | 'instance.name', 'instance.short_description', 'instance.description', 'instance.terms', 'instance.default_client_route', | 56 | 'instance.name', 'instance.short_description', 'instance.description', 'instance.terms', 'instance.default_client_route', |
57 | 'instance.default_nsfw_policy', 'instance.robots', | 57 | 'instance.default_nsfw_policy', 'instance.robots', |
58 | 'services.twitter.username', 'services.twitter.whitelisted' | 58 | 'services.twitter.username', 'services.twitter.whitelisted' |
diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts index 6d0503f48..efe27a241 100644 --- a/server/initializers/constants.ts +++ b/server/initializers/constants.ts | |||
@@ -37,14 +37,15 @@ const SORTABLE_COLUMNS = { | |||
37 | JOBS: [ 'createdAt' ], | 37 | JOBS: [ 'createdAt' ], |
38 | VIDEO_ABUSES: [ 'id', 'createdAt', 'state' ], | 38 | VIDEO_ABUSES: [ 'id', 'createdAt', 'state' ], |
39 | VIDEO_CHANNELS: [ 'id', 'name', 'updatedAt', 'createdAt' ], | 39 | VIDEO_CHANNELS: [ 'id', 'name', 'updatedAt', 'createdAt' ], |
40 | VIDEOS: [ 'name', 'duration', 'createdAt', 'publishedAt', 'views', 'likes' ], | ||
41 | VIDEO_IMPORTS: [ 'createdAt' ], | 40 | VIDEO_IMPORTS: [ 'createdAt' ], |
42 | VIDEO_COMMENT_THREADS: [ 'createdAt' ], | 41 | VIDEO_COMMENT_THREADS: [ 'createdAt' ], |
43 | BLACKLISTS: [ 'id', 'name', 'duration', 'views', 'likes', 'dislikes', 'uuid', 'createdAt' ], | 42 | BLACKLISTS: [ 'id', 'name', 'duration', 'views', 'likes', 'dislikes', 'uuid', 'createdAt' ], |
44 | FOLLOWERS: [ 'createdAt' ], | 43 | FOLLOWERS: [ 'createdAt' ], |
45 | FOLLOWING: [ 'createdAt' ], | 44 | FOLLOWING: [ 'createdAt' ], |
46 | 45 | ||
47 | VIDEOS_SEARCH: [ 'match', 'name', 'duration', 'createdAt', 'publishedAt', 'views', 'likes' ], | 46 | VIDEOS: [ 'name', 'duration', 'createdAt', 'publishedAt', 'views', 'likes', 'trending' ], |
47 | |||
48 | VIDEOS_SEARCH: [ 'name', 'duration', 'createdAt', 'publishedAt', 'views', 'likes', 'match' ], | ||
48 | VIDEO_CHANNELS_SEARCH: [ 'match', 'displayName', 'createdAt' ] | 49 | VIDEO_CHANNELS_SEARCH: [ 'match', 'displayName', 'createdAt' ] |
49 | } | 50 | } |
50 | 51 | ||
@@ -201,6 +202,11 @@ const CONFIG = { | |||
201 | ANONYMOUS: config.get<boolean>('search.remote_uri.anonymous') | 202 | ANONYMOUS: config.get<boolean>('search.remote_uri.anonymous') |
202 | } | 203 | } |
203 | }, | 204 | }, |
205 | TRENDING: { | ||
206 | VIDEOS: { | ||
207 | INTERVAL_DAYS: config.get<number>('trending.videos.interval_days') | ||
208 | } | ||
209 | }, | ||
204 | ADMIN: { | 210 | ADMIN: { |
205 | get EMAIL () { return config.get<string>('admin.email') } | 211 | get EMAIL () { return config.get<string>('admin.email') } |
206 | }, | 212 | }, |
diff --git a/server/models/utils.ts b/server/models/utils.ts index eb6653f3d..edb8e1161 100644 --- a/server/models/utils.ts +++ b/server/models/utils.ts | |||
@@ -1,23 +1,31 @@ | |||
1 | // Translate for example "-name" to [ [ 'name', 'DESC' ], [ 'id', 'ASC' ] ] | ||
2 | import { Sequelize } from 'sequelize-typescript' | 1 | import { Sequelize } from 'sequelize-typescript' |
3 | 2 | ||
4 | type SortType = { sortModel: any, sortValue: string } | 3 | type SortType = { sortModel: any, sortValue: string } |
5 | 4 | ||
5 | // Translate for example "-name" to [ [ 'name', 'DESC' ], [ 'id', 'ASC' ] ] | ||
6 | function getSort (value: string, lastSort: string[] = [ 'id', 'ASC' ]) { | 6 | function getSort (value: string, lastSort: string[] = [ 'id', 'ASC' ]) { |
7 | let field: any | 7 | const { direction, field } = buildDirectionAndField(value) |
8 | let direction: 'ASC' | 'DESC' | ||
9 | 8 | ||
10 | if (value.substring(0, 1) === '-') { | 9 | return [ [ field, direction ], lastSort ] |
11 | direction = 'DESC' | 10 | } |
12 | field = value.substring(1) | 11 | |
13 | } else { | 12 | function getVideoSort (value: string, lastSort: string[] = [ 'id', 'ASC' ]) { |
14 | direction = 'ASC' | 13 | let { direction, field } = buildDirectionAndField(value) |
15 | field = value | ||
16 | } | ||
17 | 14 | ||
18 | // Alias | 15 | // Alias |
19 | if (field.toLowerCase() === 'match') field = Sequelize.col('similarity') | 16 | if (field.toLowerCase() === 'match') field = Sequelize.col('similarity') |
20 | 17 | ||
18 | // Sort by aggregation | ||
19 | if (field.toLowerCase() === 'trending') { | ||
20 | return [ | ||
21 | [ Sequelize.fn('COALESCE', Sequelize.fn('SUM', Sequelize.col('VideoViews.views')), '0'), direction ], | ||
22 | |||
23 | [ Sequelize.col('VideoModel.views'), direction ], | ||
24 | |||
25 | lastSort | ||
26 | ] | ||
27 | } | ||
28 | |||
21 | return [ [ field, direction ], lastSort ] | 29 | return [ [ field, direction ], lastSort ] |
22 | } | 30 | } |
23 | 31 | ||
@@ -58,6 +66,7 @@ function createSimilarityAttribute (col: string, value: string) { | |||
58 | export { | 66 | export { |
59 | SortType, | 67 | SortType, |
60 | getSort, | 68 | getSort, |
69 | getVideoSort, | ||
61 | getSortOnModel, | 70 | getSortOnModel, |
62 | createSimilarityAttribute, | 71 | createSimilarityAttribute, |
63 | throwIfNotValid, | 72 | throwIfNotValid, |
@@ -73,3 +82,18 @@ function searchTrigramNormalizeValue (value: string) { | |||
73 | function searchTrigramNormalizeCol (col: string) { | 82 | function searchTrigramNormalizeCol (col: string) { |
74 | return Sequelize.fn('lower', Sequelize.fn('immutable_unaccent', Sequelize.col(col))) | 83 | return Sequelize.fn('lower', Sequelize.fn('immutable_unaccent', Sequelize.col(col))) |
75 | } | 84 | } |
85 | |||
86 | function buildDirectionAndField (value: string) { | ||
87 | let field: any | ||
88 | let direction: 'ASC' | 'DESC' | ||
89 | |||
90 | if (value.substring(0, 1) === '-') { | ||
91 | direction = 'DESC' | ||
92 | field = value.substring(1) | ||
93 | } else { | ||
94 | direction = 'ASC' | ||
95 | field = value | ||
96 | } | ||
97 | |||
98 | return { direction, field } | ||
99 | } | ||
diff --git a/server/models/video/video.ts b/server/models/video/video.ts index 67b123d77..6fb5ececa 100644 --- a/server/models/video/video.ts +++ b/server/models/video/video.ts | |||
@@ -17,6 +17,7 @@ import { | |||
17 | HasMany, | 17 | HasMany, |
18 | HasOne, | 18 | HasOne, |
19 | IFindOptions, | 19 | IFindOptions, |
20 | IIncludeOptions, | ||
20 | Is, | 21 | Is, |
21 | IsInt, | 22 | IsInt, |
22 | IsUUID, | 23 | IsUUID, |
@@ -24,8 +25,7 @@ import { | |||
24 | Model, | 25 | Model, |
25 | Scopes, | 26 | Scopes, |
26 | Table, | 27 | Table, |
27 | UpdatedAt, | 28 | UpdatedAt |
28 | IIncludeOptions | ||
29 | } from 'sequelize-typescript' | 29 | } from 'sequelize-typescript' |
30 | import { VideoPrivacy, VideoResolution, VideoState } from '../../../shared' | 30 | import { VideoPrivacy, VideoResolution, VideoState } from '../../../shared' |
31 | import { VideoTorrentObject } from '../../../shared/models/activitypub/objects' | 31 | import { VideoTorrentObject } from '../../../shared/models/activitypub/objects' |
@@ -77,7 +77,7 @@ import { AccountVideoRateModel } from '../account/account-video-rate' | |||
77 | import { ActorModel } from '../activitypub/actor' | 77 | import { ActorModel } from '../activitypub/actor' |
78 | import { AvatarModel } from '../avatar/avatar' | 78 | import { AvatarModel } from '../avatar/avatar' |
79 | import { ServerModel } from '../server/server' | 79 | import { ServerModel } from '../server/server' |
80 | import { buildTrigramSearchIndex, createSimilarityAttribute, getSort, throwIfNotValid } from '../utils' | 80 | import { buildTrigramSearchIndex, createSimilarityAttribute, getVideoSort, throwIfNotValid } from '../utils' |
81 | import { TagModel } from './tag' | 81 | import { TagModel } from './tag' |
82 | import { VideoAbuseModel } from './video-abuse' | 82 | import { VideoAbuseModel } from './video-abuse' |
83 | import { VideoChannelModel } from './video-channel' | 83 | import { VideoChannelModel } from './video-channel' |
@@ -89,7 +89,7 @@ import { ScheduleVideoUpdateModel } from './schedule-video-update' | |||
89 | import { VideoCaptionModel } from './video-caption' | 89 | import { VideoCaptionModel } from './video-caption' |
90 | import { VideoBlacklistModel } from './video-blacklist' | 90 | import { VideoBlacklistModel } from './video-blacklist' |
91 | import { copy, remove, rename, stat, writeFile } from 'fs-extra' | 91 | import { copy, remove, rename, stat, writeFile } from 'fs-extra' |
92 | import { immutableAssign } from '../../tests/utils' | 92 | import { VideoViewModel } from './video-views' |
93 | 93 | ||
94 | // FIXME: Define indexes here because there is an issue with TS and Sequelize.literal when called directly in the annotation | 94 | // FIXME: Define indexes here because there is an issue with TS and Sequelize.literal when called directly in the annotation |
95 | const indexes: Sequelize.DefineIndexesOptions[] = [ | 95 | const indexes: Sequelize.DefineIndexesOptions[] = [ |
@@ -146,6 +146,7 @@ type AvailableForListIDsOptions = { | |||
146 | withFiles?: boolean | 146 | withFiles?: boolean |
147 | accountId?: number | 147 | accountId?: number |
148 | videoChannelId?: number | 148 | videoChannelId?: number |
149 | trendingDays?: number | ||
149 | } | 150 | } |
150 | 151 | ||
151 | @Scopes({ | 152 | @Scopes({ |
@@ -384,6 +385,21 @@ type AvailableForListIDsOptions = { | |||
384 | } | 385 | } |
385 | } | 386 | } |
386 | 387 | ||
388 | if (options.trendingDays) { | ||
389 | query.include.push({ | ||
390 | attributes: [], | ||
391 | model: VideoViewModel, | ||
392 | required: false, | ||
393 | where: { | ||
394 | startDate: { | ||
395 | [ Sequelize.Op.gte ]: new Date(new Date().getTime() - (24 * 3600 * 1000) * options.trendingDays) | ||
396 | } | ||
397 | } | ||
398 | }) | ||
399 | |||
400 | query.subQuery = false | ||
401 | } | ||
402 | |||
387 | return query | 403 | return query |
388 | }, | 404 | }, |
389 | [ScopeNames.WITH_ACCOUNT_DETAILS]: { | 405 | [ScopeNames.WITH_ACCOUNT_DETAILS]: { |
@@ -649,6 +665,16 @@ export class VideoModel extends Model<VideoModel> { | |||
649 | }) | 665 | }) |
650 | VideoComments: VideoCommentModel[] | 666 | VideoComments: VideoCommentModel[] |
651 | 667 | ||
668 | @HasMany(() => VideoViewModel, { | ||
669 | foreignKey: { | ||
670 | name: 'videoId', | ||
671 | allowNull: false | ||
672 | }, | ||
673 | onDelete: 'cascade', | ||
674 | hooks: true | ||
675 | }) | ||
676 | VideoViews: VideoViewModel[] | ||
677 | |||
652 | @HasOne(() => ScheduleVideoUpdateModel, { | 678 | @HasOne(() => ScheduleVideoUpdateModel, { |
653 | foreignKey: { | 679 | foreignKey: { |
654 | name: 'videoId', | 680 | name: 'videoId', |
@@ -754,7 +780,7 @@ export class VideoModel extends Model<VideoModel> { | |||
754 | distinct: true, | 780 | distinct: true, |
755 | offset: start, | 781 | offset: start, |
756 | limit: count, | 782 | limit: count, |
757 | order: getSort('createdAt', [ 'Tags', 'name', 'ASC' ]), | 783 | order: getVideoSort('createdAt', [ 'Tags', 'name', 'ASC' ]), |
758 | where: { | 784 | where: { |
759 | id: { | 785 | id: { |
760 | [Sequelize.Op.in]: Sequelize.literal('(' + rawQuery + ')') | 786 | [Sequelize.Op.in]: Sequelize.literal('(' + rawQuery + ')') |
@@ -845,7 +871,7 @@ export class VideoModel extends Model<VideoModel> { | |||
845 | const query: IFindOptions<VideoModel> = { | 871 | const query: IFindOptions<VideoModel> = { |
846 | offset: start, | 872 | offset: start, |
847 | limit: count, | 873 | limit: count, |
848 | order: getSort(sort), | 874 | order: getVideoSort(sort), |
849 | include: [ | 875 | include: [ |
850 | { | 876 | { |
851 | model: VideoChannelModel, | 877 | model: VideoChannelModel, |
@@ -902,11 +928,19 @@ export class VideoModel extends Model<VideoModel> { | |||
902 | accountId?: number, | 928 | accountId?: number, |
903 | videoChannelId?: number, | 929 | videoChannelId?: number, |
904 | actorId?: number | 930 | actorId?: number |
931 | trendingDays?: number | ||
905 | }) { | 932 | }) { |
906 | const query = { | 933 | const query: IFindOptions<VideoModel> = { |
907 | offset: options.start, | 934 | offset: options.start, |
908 | limit: options.count, | 935 | limit: options.count, |
909 | order: getSort(options.sort) | 936 | order: getVideoSort(options.sort) |
937 | } | ||
938 | |||
939 | let trendingDays: number | ||
940 | if (options.sort.endsWith('trending')) { | ||
941 | trendingDays = CONFIG.TRENDING.VIDEOS.INTERVAL_DAYS | ||
942 | |||
943 | query.group = 'VideoModel.id' | ||
910 | } | 944 | } |
911 | 945 | ||
912 | // actorId === null has a meaning, so just check undefined | 946 | // actorId === null has a meaning, so just check undefined |
@@ -924,7 +958,8 @@ export class VideoModel extends Model<VideoModel> { | |||
924 | withFiles: options.withFiles, | 958 | withFiles: options.withFiles, |
925 | accountId: options.accountId, | 959 | accountId: options.accountId, |
926 | videoChannelId: options.videoChannelId, | 960 | videoChannelId: options.videoChannelId, |
927 | includeLocalVideos: options.includeLocalVideos | 961 | includeLocalVideos: options.includeLocalVideos, |
962 | trendingDays | ||
928 | } | 963 | } |
929 | 964 | ||
930 | return VideoModel.getAvailableForApi(query, queryOptions) | 965 | return VideoModel.getAvailableForApi(query, queryOptions) |
@@ -1006,7 +1041,7 @@ export class VideoModel extends Model<VideoModel> { | |||
1006 | }, | 1041 | }, |
1007 | offset: options.start, | 1042 | offset: options.start, |
1008 | limit: options.count, | 1043 | limit: options.count, |
1009 | order: getSort(options.sort), | 1044 | order: getVideoSort(options.sort), |
1010 | where: { | 1045 | where: { |
1011 | [ Sequelize.Op.and ]: whereAnd | 1046 | [ Sequelize.Op.and ]: whereAnd |
1012 | } | 1047 | } |
@@ -1177,8 +1212,12 @@ export class VideoModel extends Model<VideoModel> { | |||
1177 | const secondQuery = { | 1212 | const secondQuery = { |
1178 | offset: 0, | 1213 | offset: 0, |
1179 | limit: query.limit, | 1214 | limit: query.limit, |
1180 | order: query.order, | 1215 | attributes: query.attributes, |
1181 | attributes: query.attributes | 1216 | order: [ // Keep original order |
1217 | Sequelize.literal( | ||
1218 | ids.map(id => `"VideoModel".id = ${id} DESC`).join(', ') | ||
1219 | ) | ||
1220 | ] | ||
1182 | } | 1221 | } |
1183 | const rows = await VideoModel.scope(apiScope).findAll(secondQuery) | 1222 | const rows = await VideoModel.scope(apiScope).findAll(secondQuery) |
1184 | 1223 | ||
diff --git a/server/tests/api/videos/videos-overview.ts b/server/tests/api/videos/videos-overview.ts index 1514d1bda..7d1f29c92 100644 --- a/server/tests/api/videos/videos-overview.ts +++ b/server/tests/api/videos/videos-overview.ts | |||
@@ -30,8 +30,10 @@ describe('Test a videos overview', function () { | |||
30 | expect(overview.channels).to.have.lengthOf(0) | 30 | expect(overview.channels).to.have.lengthOf(0) |
31 | }) | 31 | }) |
32 | 32 | ||
33 | it('Should upload 3 videos in a specific category, tag and channel but not include them in overview', async function () { | 33 | it('Should upload 5 videos in a specific category, tag and channel but not include them in overview', async function () { |
34 | for (let i = 0; i < 3; i++) { | 34 | this.timeout(15000) |
35 | |||
36 | for (let i = 0; i < 5; i++) { | ||
35 | await uploadVideo(server.url, server.accessToken, { | 37 | await uploadVideo(server.url, server.accessToken, { |
36 | name: 'video ' + i, | 38 | name: 'video ' + i, |
37 | category: 3, | 39 | category: 3, |
@@ -49,7 +51,7 @@ describe('Test a videos overview', function () { | |||
49 | 51 | ||
50 | it('Should upload another video and include all videos in the overview', async function () { | 52 | it('Should upload another video and include all videos in the overview', async function () { |
51 | await uploadVideo(server.url, server.accessToken, { | 53 | await uploadVideo(server.url, server.accessToken, { |
52 | name: 'video 3', | 54 | name: 'video 5', |
53 | category: 3, | 55 | category: 3, |
54 | tags: [ 'coucou1', 'coucou2' ] | 56 | tags: [ 'coucou1', 'coucou2' ] |
55 | }) | 57 | }) |
@@ -70,11 +72,13 @@ describe('Test a videos overview', function () { | |||
70 | for (const attr of [ 'tags', 'categories', 'channels' ]) { | 72 | for (const attr of [ 'tags', 'categories', 'channels' ]) { |
71 | const obj = overview[attr][0] | 73 | const obj = overview[attr][0] |
72 | 74 | ||
73 | expect(obj.videos).to.have.lengthOf(4) | 75 | expect(obj.videos).to.have.lengthOf(6) |
74 | expect(obj.videos[0].name).to.equal('video 3') | 76 | expect(obj.videos[0].name).to.equal('video 5') |
75 | expect(obj.videos[1].name).to.equal('video 2') | 77 | expect(obj.videos[1].name).to.equal('video 4') |
76 | expect(obj.videos[2].name).to.equal('video 1') | 78 | expect(obj.videos[2].name).to.equal('video 3') |
77 | expect(obj.videos[3].name).to.equal('video 0') | 79 | expect(obj.videos[3].name).to.equal('video 2') |
80 | expect(obj.videos[4].name).to.equal('video 1') | ||
81 | expect(obj.videos[5].name).to.equal('video 0') | ||
78 | } | 82 | } |
79 | 83 | ||
80 | expect(overview.tags.find(t => t.tag === 'coucou1')).to.not.be.undefined | 84 | expect(overview.tags.find(t => t.tag === 'coucou1')).to.not.be.undefined |