]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/commitdiff
Merge branch 'develop' into cli-wrapper 941/head
authorChocobozzz <me@florianbigard.com>
Thu, 20 Sep 2018 14:24:31 +0000 (16:24 +0200)
committerGitHub <noreply@github.com>
Thu, 20 Sep 2018 14:24:31 +0000 (16:24 +0200)
125 files changed:
README.md
SECURITY.md
client/angular.json
client/e2e/src/po/video-watch.po.ts
client/e2e/src/videos.e2e-spec.ts
client/src/app/+admin/moderation/moderation.routes.ts
client/src/app/+admin/users/user-list/user-list.component.ts
client/src/app/menu/menu.component.ts
client/src/app/shared/overview/overview.service.ts
client/src/app/shared/video/abstract-video-list.html
client/src/app/shared/video/abstract-video-list.ts
client/src/app/shared/video/infinite-scroller.directive.ts
client/src/app/shared/video/video-miniature.component.html
client/src/app/shared/video/video-miniature.component.ts
client/src/app/videos/+video-edit/video-add-components/video-upload.component.html
client/src/app/videos/+video-watch/comment/video-comment-add.component.scss
client/src/app/videos/+video-watch/comment/video-comment.component.scss
client/src/app/videos/+video-watch/comment/video-comments.component.scss
client/src/app/videos/+video-watch/video-watch.component.scss
client/src/app/videos/+video-watch/video-watch.component.ts
client/src/app/videos/recommendations/recent-videos-recommendation.service.ts
client/src/app/videos/recommendations/recommended-videos.store.ts
client/src/app/videos/video-list/video-overview.component.html
client/src/assets/player/peertube-videojs-plugin.ts
client/src/assets/player/settings-menu-item.ts
client/src/hmr.ts
client/src/index.html
client/src/manifest.webmanifest [moved from client/src/manifest.json with 99% similarity]
client/src/sass/application.scss
client/src/sass/include/_mixins.scss
client/src/sass/player/index.scss [moved from client/src/sass/player/player.scss with 100% similarity]
client/src/sass/player/peertube-skin.scss
client/src/standalone/videos/embed.scss
config/default.yaml
config/production.yaml.example
config/test.yaml
package.json
scripts/clean/server/test.sh
scripts/create-import-video-file-job.ts
scripts/create-transcoding-job.ts
scripts/prune-storage.ts
server/controllers/activitypub/client.ts
server/controllers/activitypub/inbox.ts
server/controllers/api/config.ts
server/controllers/api/overviews.ts
server/controllers/api/search.ts
server/controllers/api/server/stats.ts
server/controllers/api/users/index.ts
server/controllers/api/users/me.ts
server/controllers/api/video-channel.ts
server/controllers/api/videos/abuse.ts
server/controllers/api/videos/comment.ts
server/controllers/api/videos/import.ts
server/controllers/api/videos/index.ts
server/controllers/api/videos/ownership.ts
server/controllers/api/videos/rate.ts
server/controllers/client.ts
server/helpers/actor.ts [new file with mode: 0644]
server/helpers/audit-logger.ts
server/helpers/custom-validators/activitypub/videos.ts
server/helpers/custom-validators/video-ownership.ts
server/helpers/custom-validators/videos.ts
server/helpers/utils.ts
server/helpers/video.ts [new file with mode: 0644]
server/helpers/webfinger.ts
server/helpers/webtorrent.ts
server/helpers/youtube-dl.ts
server/initializers/checker.ts
server/initializers/constants.ts
server/lib/activitypub/actor.ts
server/lib/activitypub/audience.ts
server/lib/activitypub/cache-file.ts
server/lib/activitypub/process/process-accept.ts
server/lib/activitypub/process/process-announce.ts
server/lib/activitypub/process/process-create.ts
server/lib/activitypub/process/process-delete.ts
server/lib/activitypub/process/process-follow.ts
server/lib/activitypub/process/process-like.ts
server/lib/activitypub/process/process-reject.ts
server/lib/activitypub/process/process-undo.ts
server/lib/activitypub/process/process-update.ts
server/lib/activitypub/process/process.ts
server/lib/activitypub/send/send-announce.ts
server/lib/activitypub/send/send-create.ts
server/lib/activitypub/send/send-delete.ts
server/lib/activitypub/send/send-like.ts
server/lib/activitypub/send/send-undo.ts
server/lib/activitypub/send/send-update.ts
server/lib/activitypub/send/utils.ts
server/lib/activitypub/video-comments.ts
server/lib/activitypub/videos.ts
server/lib/avatar.ts
server/lib/cache/videos-caption-cache.ts
server/lib/cache/videos-preview-cache.ts
server/lib/client-html.ts
server/lib/job-queue/handlers/activitypub-http-fetcher.ts
server/lib/job-queue/handlers/video-file.ts
server/lib/job-queue/handlers/video-import.ts
server/lib/oauth-model.ts
server/lib/schedulers/videos-redundancy-scheduler.ts
server/lib/schedulers/youtube-dl-update-scheduler.ts
server/lib/video-transcoding.ts [new file with mode: 0644]
server/middlewares/validators/users.ts
server/middlewares/validators/video-captions.ts
server/middlewares/validators/video-comments.ts
server/middlewares/validators/videos.ts
server/models/account/account.ts
server/models/account/user.ts
server/models/activitypub/actor.ts
server/models/oauth/oauth-token.ts
server/models/redundancy/video-redundancy.ts
server/models/video/tag.ts
server/models/video/video-format-utils.ts [new file with mode: 0644]
server/models/video/video.ts
server/tests/api/server/jobs.ts
server/tests/api/server/redundancy.ts
server/tests/api/server/stats.ts
server/tests/utils/server/servers.ts
server/tests/utils/server/stats.ts
shared/models/redundancy/videos-redundancy.model.ts
shared/models/server/server-stats.model.ts
support/docker/production/.env
support/docker/production/config/custom-environment-variables.yaml
support/nginx/peertube
yarn.lock

index cd522301b30e5e9de9e575b63980e5509c9049ea..3985f38bdf6e7e0a44cb28fb7dca98a79a76b3b3 100644 (file)
--- a/README.md
+++ b/README.md
@@ -24,8 +24,8 @@ directly in the web browser with <a href="https://github.com/feross/webtorrent">
     <img src="https://david-dm.org/Chocobozzz/PeerTube/dev-status.svg?path=client" alt="devDependency Status" />
   </a>
   
-  <a href="https://www.browserstack.com/automate/public-build/VXBPc0szNjUvRUNsREJQRFF6RkEvSjJBclZ4VUJBUm1hcS9RZGpUbitRST0tLWFWbjNEdVN6eEZpYTk4dGVpMkVlQWc9PQ==--644e755052bf7fe2346eb6e868be8e706718a17c%">
-    <img src='https://www.browserstack.com/automate/badge.svg?badge_key=VXBPc0szNjUvRUNsREJQRFF6RkEvSjJBclZ4VUJBUm1hcS9RZGpUbitRST0tLWFWbjNEdVN6eEZpYTk4dGVpMkVlQWc9PQ==--644e755052bf7fe2346eb6e868be8e706718a17c%'/>
+  <a href="https://www.browserstack.com/automate/public-build/cWJhRDFJbS9qeUhzYW04MnlIVjlQQ0x3aE5POXBaV1lycGo5VlQxK3JqZz0tLTNUWW5ySEVvS1N4UnBhYlhsdXVCeVE9PQ==--db09e291d36a582af8b2929d62a625ed660cdf1d">
+    <img src='https://www.browserstack.com/automate/badge.svg?badge_key=cWJhRDFJbS9qeUhzYW04MnlIVjlQQ0x3aE5POXBaV1lycGo5VlQxK3JqZz0tLTNUWW5ySEVvS1N4UnBhYlhsdXVCeVE9PQ==--db09e291d36a582af8b2929d62a625ed660cdf1d'/>
   </a>
 </p>
 
@@ -97,11 +97,10 @@ BitTorrent) inside the web browser, as of today.
 ## Dependencies
 
   * nginx
-  * PostgreSQL
+  * **PostgreSQL >= 9.6**
   * **Redis >= 2.8.18**
   * **NodeJS >= 8.x**
   * yarn
-  * OpenSSL (cli)
   * **FFmpeg >= 3.x**
 
 ## Run in production
index 37ed19246b711ea8005a2d1b06018829e29b6679..5c668a2a349de199889fdbd71963e7e5b2a7a392 100644 (file)
@@ -30,7 +30,7 @@ To encourage vulnerability research and to avoid any confusion between good-fait
 - Avoid violating the privacy of others, disrupting our systems, destroying data, and/or harming user experience.
 - Use only the Official Channels to discuss vulnerability information with us.
 - Keep the details of any discovered vulnerabilities confidential until they are fixed, according to the Disclosure Terms in this policy.
-- Perform testing only on in-scope systems, and respect systems and activities which are out-of-scope.
+- Perform testing only on in-scope systems, and respect systems and activities which are out-of-scope. Systems currently considered in-scope are the official demonstration/test servers provided by the PeerTube development team.
 - If a vulnerability provides unintended access to data: Limit the amount of data you access to the minimum required for effectively demonstrating a Proof of Concept; and cease testing and submit a report immediately if you encounter any user data during testing, such as Personally Identifiable Information (PII), Personal Healthcare Information (PHI), credit card data, or proprietary information.
 - You should only interact with test accounts you own or with explicit permission from the account holder.
 - Do not engage in extortion.
index 789eeb3d00e4746e6dd58f99d194fde7a283f875..2cf2ecd621f5f6eb4dbb593974da44837493890f 100644 (file)
@@ -24,7 +24,7 @@
             },
             "assets": [
               "src/assets/images",
-              "src/manifest.json"
+              "src/manifest.webmanifest"
             ],
             "styles": [
               "src/sass/application.scss"
             ],
             "assets": [
               "src/assets/images",
-              "src/manifest.json"
+              "src/manifest.webmanifest"
             ]
           }
         },
index 13f4ae94592618e04c7d1bd10703ec61e406d144..e17aebc298186c2efdb0de5dea45a5a049e22513 100644 (file)
@@ -26,8 +26,11 @@ export class VideoWatchPage {
                   .then((texts: any) => texts.map(t => t.trim()))
   }
 
-  waitWatchVideoName (videoName: string, isSafari: boolean) {
-    const elem = element(by.css('.video-info .video-info-name'))
+  waitWatchVideoName (videoName: string, isMobileDevice: boolean, isSafari: boolean) {
+    // On mobile we display the first node, on desktop the second
+    const index = isMobileDevice ? 0 : 1
+
+    const elem = element.all(by.css('.video-info .video-info-name')).get(index)
 
     if (isSafari) return browser.sleep(5000)
 
index 3d4d46292cf7dede9500e94a833620af970b566b..606b6ac5d152f25bc959621daee080840c84dd8f 100644 (file)
@@ -12,7 +12,7 @@ describe('Videos workflow', () => {
   let isSafari = false
 
   beforeEach(async () => {
-    browser.waitForAngularEnabled(false)
+    await browser.waitForAngularEnabled(false)
 
     videoWatchPage = new VideoWatchPage()
     pageUploadPage = new VideoUploadPage()
@@ -62,7 +62,7 @@ describe('Videos workflow', () => {
     if (isMobileDevice || isSafari) videoNameToExcept = await videoWatchPage.clickOnFirstVideo()
     else await videoWatchPage.clickOnVideo(videoName)
 
-    return videoWatchPage.waitWatchVideoName(videoNameToExcept, isSafari)
+    return videoWatchPage.waitWatchVideoName(videoNameToExcept, isMobileDevice, isSafari)
   })
 
   it('Should play the video', async () => {
index b133152d9089d22a26291b68f519a727aafa7915..6d81b9b363149da288cae5f8837e3f07570a6751 100644 (file)
@@ -15,6 +15,16 @@ export const ModerationRoutes: Routes = [
         redirectTo: 'video-abuses/list',
         pathMatch: 'full'
       },
+      {
+        path: 'video-abuses',
+        redirectTo: 'video-abuses/list',
+        pathMatch: 'full'
+      },
+      {
+        path: 'video-blacklist',
+        redirectTo: 'video-blacklist/list',
+        pathMatch: 'full'
+      },
       {
         path: 'video-abuses/list',
         component: VideoAbuseListComponent,
index 57e63d46547e2d1ec483a1a55a08a69630d4c90f..9697ce202e4540e220334913f7509877cb0193c8 100644 (file)
@@ -105,7 +105,8 @@ export class UserListComponent extends RestTable implements OnInit {
       return
     }
 
-    const res = await this.confirmService.confirm(this.i18n('Do you really want to delete this user?'), this.i18n('Delete'))
+    const message = this.i18n('If you remove this user, you will not be able to create another with the same username!')
+    const res = await this.confirmService.confirm(message, this.i18n('Delete'))
     if (res === false) return
 
     this.userService.removeUser(user).subscribe(
index 24cd5aa28225ad4710a49ede2855d7673f3bfb8a..f13ecc2c7be8bc85fae47a2dcc8d676d3aba8691 100644 (file)
@@ -19,8 +19,10 @@ export class MenuComponent implements OnInit {
   private routesPerRight = {
     [UserRight.MANAGE_USERS]: '/admin/users',
     [UserRight.MANAGE_SERVER_FOLLOW]: '/admin/friends',
-    [UserRight.MANAGE_VIDEO_ABUSES]: '/admin/video-abuses',
-    [UserRight.MANAGE_VIDEO_BLACKLIST]: '/admin/video-blacklist'
+    [UserRight.MANAGE_VIDEO_ABUSES]: '/admin/moderation/video-abuses',
+    [UserRight.MANAGE_VIDEO_BLACKLIST]: '/admin/moderation/video-blacklist',
+    [UserRight.MANAGE_JOBS]: '/admin/jobs',
+    [UserRight.MANAGE_CONFIGURATION]: '/admin/config'
   }
 
   constructor (
@@ -67,7 +69,9 @@ export class MenuComponent implements OnInit {
       UserRight.MANAGE_USERS,
       UserRight.MANAGE_SERVER_FOLLOW,
       UserRight.MANAGE_VIDEO_ABUSES,
-      UserRight.MANAGE_VIDEO_BLACKLIST
+      UserRight.MANAGE_VIDEO_BLACKLIST,
+      UserRight.MANAGE_JOBS,
+      UserRight.MANAGE_CONFIGURATION
     ]
 
     for (const adminRight of adminRights) {
index 4a4714af62eb23ba4618c3afce3445e7d8b50b99..097079e6d73a88e21cd3d6874b3c39e086c964ec 100644 (file)
@@ -56,6 +56,8 @@ export class OverviewService {
       }
     }
 
+    if (observables.length === 0) return of(videosOverviewResult)
+
     return forkJoin(observables)
       .pipe(
         // Translate categories
index 0f48b9a64c6a1aa8d6a9d295cce8cc153c1a09d9..d543ab7c12172d34d4f1de4c1b28c07d8a3934fd 100644 (file)
@@ -7,12 +7,12 @@
   <div class="no-results" i18n *ngIf="pagination.totalItems === 0">No results.</div>
   <div
     myInfiniteScroller
-    [pageHeight]="pageHeight"
+    [pageHeight]="pageHeight" [firstLoadedPage]="firstLoadedPage"
     (nearOfTop)="onNearOfTop()" (nearOfBottom)="onNearOfBottom()" (pageChanged)="onPageChanged($event)"
     class="videos" #videosElement
   >
-    <div *ngFor="let videos of videoPages" class="videos-page">
-      <my-video-miniature *ngFor="let video of videos" [video]="video" [user]="user" [ownerDisplayType]="ownerDisplayType"></my-video-miniature>
+    <div *ngFor="let videos of videoPages; trackBy: pageByVideoId" class="videos-page">
+      <my-video-miniature *ngFor="let video of videos; trackBy: videoById" [video]="video" [user]="user" [ownerDisplayType]="ownerDisplayType"></my-video-miniature>
     </div>
   </div>
 </div>
index b8fd7f8eb21b85b289881c48bda96eb891b30aab..6a758ebe02412338f1ac997f6ce886c0fa4d7069 100644 (file)
@@ -36,9 +36,10 @@ export abstract class AbstractVideoList implements OnInit, OnDestroy {
   videoHeight: number
   videoPages: Video[][] = []
   ownerDisplayType: OwnerDisplayType = 'account'
+  firstLoadedPage: number
 
   protected baseVideoWidth = 215
-  protected baseVideoHeight = 230
+  protected baseVideoHeight = 205
 
   protected abstract notificationsService: NotificationsService
   protected abstract authService: AuthService
@@ -80,6 +81,15 @@ export abstract class AbstractVideoList implements OnInit, OnDestroy {
     if (this.resizeSubscription) this.resizeSubscription.unsubscribe()
   }
 
+  pageByVideoId (index: number, page: Video[]) {
+    // Video are unique in all pages
+    return page[0].id
+  }
+
+  videoById (index: number, video: Video) {
+    return video.id
+  }
+
   onNearOfTop () {
     this.previousPage()
   }
@@ -100,7 +110,11 @@ export abstract class AbstractVideoList implements OnInit, OnDestroy {
     this.loadMoreVideos(this.pagination.currentPage)
   }
 
-  loadMoreVideos (page: number) {
+  loadMoreVideos (page: number, loadOnTop = false) {
+    this.adjustVideoPageHeight()
+
+    const currentY = window.scrollY
+
     if (this.loadedPages[page] !== undefined) return
     if (this.loadingPage[page] === true) return
 
@@ -111,6 +125,8 @@ export abstract class AbstractVideoList implements OnInit, OnDestroy {
       ({ videos, totalVideos }) => {
         this.loadingPage[page] = false
 
+        if (this.firstLoadedPage === undefined || this.firstLoadedPage > page) this.firstLoadedPage = page
+
         // Paging is too high, return to the first one
         if (this.pagination.currentPage > 1 && totalVideos <= ((this.pagination.currentPage - 1) * this.pagination.itemsPerPage)) {
           this.pagination.currentPage = 1
@@ -125,8 +141,17 @@ export abstract class AbstractVideoList implements OnInit, OnDestroy {
         // Initialize infinite scroller now we loaded the first page
         if (Object.keys(this.loadedPages).length === 1) {
           // Wait elements creation
-          setTimeout(() => this.infiniteScroller.initialize(), 500)
+          setTimeout(() => {
+            this.infiniteScroller.initialize()
+
+            // At our first load, we did not load the first page
+            // Load the previous page so the user can move on the top (and browser previous pages)
+            if (this.pagination.currentPage > 1) this.loadMoreVideos(this.pagination.currentPage - 1, true)
+          }, 500)
         }
+
+        // Insert elements on the top but keep the scroll in the previous position
+        if (loadOnTop) setTimeout(() => { window.scrollTo(0, currentY + this.pageHeight) }, 0)
       },
       error => {
         this.loadingPage[page] = false
@@ -150,7 +175,7 @@ export abstract class AbstractVideoList implements OnInit, OnDestroy {
     const min = this.minPageLoaded()
 
     if (min > 1) {
-      this.loadMoreVideos(min - 1)
+      this.loadMoreVideos(min - 1, true)
     }
   }
 
@@ -189,6 +214,13 @@ export abstract class AbstractVideoList implements OnInit, OnDestroy {
     this.videoPages = Object.values(this.loadedPages)
   }
 
+  protected adjustVideoPageHeight () {
+    const numberOfPagesLoaded = Object.keys(this.loadedPages).length
+    if (!numberOfPagesLoaded) return
+
+    this.pageHeight = this.videosElement.nativeElement.offsetHeight / numberOfPagesLoaded
+  }
+
   protected buildVideoHeight () {
     // Same ratios than base width/height
     return this.videosElement.nativeElement.offsetWidth * (this.baseVideoHeight / this.baseVideoWidth)
index 4dc1f86e7a36817518740644ad55aed2a4027d8b..a02e9444a72cd91f26d892d7a436b9534906b8e5 100644 (file)
@@ -6,10 +6,9 @@ import { fromEvent, Subscription } from 'rxjs'
   selector: '[myInfiniteScroller]'
 })
 export class InfiniteScrollerDirective implements OnInit, OnDestroy {
-  private static PAGE_VIEW_TOP_MARGIN = 500
-
   @Input() containerHeight: number
   @Input() pageHeight: number
+  @Input() firstLoadedPage = 1
   @Input() percentLimit = 70
   @Input() autoInit = false
 
@@ -23,6 +22,7 @@ export class InfiniteScrollerDirective implements OnInit, OnDestroy {
   private scrollDownSub: Subscription
   private scrollUpSub: Subscription
   private pageChangeSub: Subscription
+  private middleScreen: number
 
   constructor () {
     this.decimalLimit = this.percentLimit / 100
@@ -39,6 +39,8 @@ export class InfiniteScrollerDirective implements OnInit, OnDestroy {
   }
 
   initialize () {
+    this.middleScreen = window.innerHeight / 2
+
     // Emit the last value
     const throttleOptions = { leading: true, trailing: true }
 
@@ -92,6 +94,11 @@ export class InfiniteScrollerDirective implements OnInit, OnDestroy {
   }
 
   private calculateCurrentPage (current: number) {
-    return Math.max(1, Math.round((current + InfiniteScrollerDirective.PAGE_VIEW_TOP_MARGIN) / this.pageHeight))
+    const scrollY = current + this.middleScreen
+
+    const page = Math.max(1, Math.ceil(scrollY / this.pageHeight))
+
+    // Offset page
+    return page + (this.firstLoadedPage - 1)
   }
 }
index 9cf3fb32107a5817bf1d3d6aa465ca2ef72ebbed..cfc483018d7471934e04fbd43253418743c31fd3 100644 (file)
@@ -1,11 +1,11 @@
 <div class="video-miniature">
-  <my-video-thumbnail [video]="video" [nsfw]="isVideoBlur()"></my-video-thumbnail>
+  <my-video-thumbnail [video]="video" [nsfw]="isVideoBlur"></my-video-thumbnail>
 
   <div class="video-miniature-information">
     <a
       tabindex="-1"
       class="video-miniature-name"
-      [routerLink]="[ '/videos/watch', video.uuid ]" [attr.title]="video.name" [ngClass]="{ 'blur-filter': isVideoBlur() }"
+      [routerLink]="[ '/videos/watch', video.uuid ]" [attr.title]="video.name" [ngClass]="{ 'blur-filter': isVideoBlur }"
     >
       {{ video.name }}
     </a>
index 07193ebd580d1cd5f02bd391a7c870faf1d9de4b..27098f4b4a0c097c5987572328d7b8dab1e5d7bb 100644 (file)
@@ -1,4 +1,4 @@
-import { Component, Input, OnInit } from '@angular/core'
+import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core'
 import { User } from '../users'
 import { Video } from './video.model'
 import { ServerService } from '@app/core'
@@ -8,13 +8,16 @@ export type OwnerDisplayType = 'account' | 'videoChannel' | 'auto'
 @Component({
   selector: 'my-video-miniature',
   styleUrls: [ './video-miniature.component.scss' ],
-  templateUrl: './video-miniature.component.html'
+  templateUrl: './video-miniature.component.html',
+  changeDetection: ChangeDetectionStrategy.OnPush
 })
 export class VideoMiniatureComponent implements OnInit {
   @Input() user: User
   @Input() video: Video
   @Input() ownerDisplayType: OwnerDisplayType = 'account'
 
+  isVideoBlur: boolean
+
   private ownerDisplayTypeChosen: 'account' | 'videoChannel'
 
   constructor (private serverService: ServerService) { }
@@ -35,10 +38,8 @@ export class VideoMiniatureComponent implements OnInit {
     } else {
       this.ownerDisplayTypeChosen = 'videoChannel'
     }
-  }
 
-  isVideoBlur () {
-    return this.video.isVideoNSFWForUser(this.user, this.serverService.getConfig())
+    this.isVideoBlur = this.video.isVideoNSFWForUser(this.user, this.serverService.getConfig())
   }
 
   displayOwnerAccount () {
index 8c0723155723a4fce06b5544cc28341db48f6305..ff0e45413ff756fb2580e121f8fa1835ac1a9cc3 100644 (file)
@@ -22,7 +22,7 @@
       <div class="peertube-select-container">
         <select id="first-step-privacy" [(ngModel)]="firstStepPrivacyId">
           <option *ngFor="let privacy of videoPrivacies" [value]="privacy.id">{{ privacy.label }}</option>
-          <option [value]="SPECIAL_SCHEDULED_PRIVACY">Scheduled</option>
+          <option i18n [value]="SPECIAL_SCHEDULED_PRIVACY">Scheduled</option>
         </select>
       </div>
     </div>
index a55e743fbf3c43ba579a19562ed1c330c7ecf737..bb809296a237b1ac75ef79390b3c907964c065c1 100644 (file)
@@ -39,3 +39,9 @@ form {
     @include orange-button
   }
 }
+
+@media screen and (max-width: 450px) {
+  textarea, .submit-comment button {
+    font-size: 14px !important;
+  }
+}
\ No newline at end of file
index f331fab8037b6cd55622ee3375a9a9859b73761b..84da5727e6d534c8949efca2b1b9b4dcd2505774 100644 (file)
@@ -35,6 +35,7 @@
       .comment-account {
         @include disable-default-a-behaviour;
 
+        word-break: break-all;
         color: var(--mainForegroundColor);
         font-weight: $font-bold;
       }
     img { margin-right: 10px; }
   }
 }
+
+@media screen and (max-width: 450px) {
+  .root-comment {
+    font-size: 14px;
+  }
+}
\ No newline at end of file
index d5af929d785dbf3743b1faa3ba70bb6e05aa87e9..04518e07996073d7f7c4318138e5d48df98da1b4 100644 (file)
@@ -31,4 +31,10 @@ my-help {
   .view-replies {
     margin-left: 46px;
   }
-}
\ No newline at end of file
+}
+
+@media screen and (max-width: 450px) {
+  .view-replies {
+    font-size: 14px;
+  }
+}
index fac4bdbe5deccb81aac5a1db8c9190d7d3a21157..eb63cbde78c26a204d7a6355bc165ffd6153f449 100644 (file)
@@ -81,6 +81,7 @@
     flex-grow: 1;
     // Set min width for flex item
     min-width: 1px;
+    max-width: 100%;
 
     .video-info-first-row {
       display: flex;
@@ -472,6 +473,7 @@ my-video-comments {
     margin: 20px 0 0 0;
 
     .video-info {
+      padding: 0;
 
       .video-info-first-row {
 
@@ -484,6 +486,8 @@ my-video-comments {
   }
 
   /deep/ .other-videos {
+    padding-left: 0 !important;
+
     /deep/ .video-miniature  {
       flex-direction: column;
     }
@@ -499,7 +503,27 @@ my-video-comments {
 }
 
 @media screen and (max-width: 450px) {
-  .video-bottom  .action-button .icon-text {
-    display: none !important;
+  .video-bottom {
+    .action-button .icon-text {
+      display: none !important;
+    }
+
+    .video-info .video-info-first-row {
+      .video-info-name {
+        font-size: 18px;
+      }
+
+      .video-info-date-views {
+        font-size: 14px;
+      }
+
+      .video-actions-rates {
+        margin-top: 10px;
+      }
+    }
+
+    .video-info-description {
+      font-size: 14px !important;
+    }
   }
 }
index 834428fa45ce5d91c6186418361c4bc09b8bb876..7a61e355a6a72d400ca2a50276c9345e7bfc70f2 100644 (file)
@@ -1,4 +1,4 @@
-import { catchError, subscribeOn } from 'rxjs/operators'
+import { catchError } from 'rxjs/operators'
 import { ChangeDetectorRef, Component, ElementRef, Inject, LOCALE_ID, NgZone, OnDestroy, OnInit, ViewChild } from '@angular/core'
 import { ActivatedRoute, Router } from '@angular/router'
 import { RedirectService } from '@app/core/routing/redirect.service'
index 4723f7fd038ac028eca38305143a103c847124ed..0ee34b9cb28a5d254f6c37e68be582bcee2c557c 100644 (file)
@@ -25,8 +25,8 @@ export class RecentVideosRecommendationService implements RecommendationService
   getRecommendations (recommendation: RecommendationInfo): Observable<Video[]> {
     return this.fetchPage(1, recommendation)
       .pipe(
-        map(vids => {
-          const otherVideos = vids.filter(v => v.uuid !== recommendation.uuid)
+        map(videos => {
+          const otherVideos = videos.filter(v => v.uuid !== recommendation.uuid)
           return otherVideos.slice(0, this.pageSize)
         })
       )
index eb5c9867f18c1ad2be2914ab5286302329b80baa..858ec3a2789e37a1ddeea131a3488bff8aca2525 100644 (file)
@@ -3,8 +3,8 @@ import { Observable, ReplaySubject } from 'rxjs'
 import { Video } from '@app/shared/video/video.model'
 import { RecommendationInfo } from '@app/shared/video/recommendation-info.model'
 import { RecentVideosRecommendationService } from '@app/videos/recommendations/recent-videos-recommendation.service'
-import { RecommendationService, UUID } from '@app/videos/recommendations/recommendations.service'
-import { map, switchMap, take } from 'rxjs/operators'
+import { RecommendationService } from '@app/videos/recommendations/recommendations.service'
+import { map, shareReplay, switchMap, take } from 'rxjs/operators'
 
 /**
  * This store is intended to provide data for the RecommendedVideosComponent.
@@ -19,9 +19,13 @@ export class RecommendedVideosStore {
     @Inject(RecentVideosRecommendationService) private recommendations: RecommendationService
   ) {
     this.recommendations$ = this.requestsForLoad$$.pipe(
-      switchMap(requestedRecommendation => recommendations.getRecommendations(requestedRecommendation)
-        .pipe(take(1))
-      ))
+      switchMap(requestedRecommendation => {
+        return recommendations.getRecommendations(requestedRecommendation)
+                              .pipe(take(1))
+      }),
+      shareReplay()
+    )
+
     this.hasRecommendations$ = this.recommendations$.pipe(
       map(otherVideos => otherVideos.length > 0)
     )
index 4150cd5e198e85167944ff271ed3fd5805efa9aa..4dad6a6e42528de4dbd01509d6c7d5e86aea3363 100644 (file)
@@ -12,7 +12,7 @@
 
   <div class="section" *ngFor="let object of overview.tags">
     <div class="section-title" i18n>
-      <a routerLink="/search" [queryParams]="{ tagOneOf: [ object.tag ] }">{{ object.tag }}</a>
+      <a routerLink="/search" [queryParams]="{ tagsOneOf: [ object.tag ] }">{{ object.tag }}</a>
     </div>
 
     <my-video-miniature *ngFor="let video of object.videos" [video]="video" [user]="user"></my-video-miniature>
index 4b0677faba6dc43f9a85e8e22277371bf919c7a5..36b80bd72b543b636cd54f99931552e8cf13de02 100644 (file)
@@ -4,7 +4,7 @@ import { VideoFile } from '../../../../shared/models/videos/video.model'
 import { renderVideo } from './video-renderer'
 import './settings-menu-button'
 import { PeertubePluginOptions, VideoJSCaption, VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings'
-import { isMobile, videoFileMaxByResolution, videoFileMinByResolution, timeToInt } from './utils'
+import { isMobile, timeToInt, videoFileMaxByResolution, videoFileMinByResolution } from './utils'
 import * as CacheChunkStore from 'cache-chunk-store'
 import { PeertubeChunkStore } from './peertube-chunk-store'
 import {
@@ -83,11 +83,6 @@ class PeerTubePlugin extends Plugin {
     this.videoCaptions = options.videoCaptions
 
     this.savePlayerSrcFunction = this.player.src
-    // Hack to "simulate" src link in video.js >= 6
-    // Without this, we can't play the video after pausing it
-    // https://github.com/videojs/video.js/blob/master/src/js/player.js#L1633
-    this.player.src = () => true
-
     this.playerElement = options.playerElement
 
     if (this.autoplay === true) this.player.addClass('vjs-has-autoplay')
@@ -104,9 +99,7 @@ class PeerTubePlugin extends Plugin {
 
       this.player.one('play', () => {
         // Don't run immediately scheduler, wait some seconds the TCP connections are made
-        this.runAutoQualitySchedulerTimer = setTimeout(() => {
-          this.runAutoQualityScheduler()
-        }, this.CONSTANTS.AUTO_QUALITY_SCHEDULER)
+        this.runAutoQualitySchedulerTimer = setTimeout(() => this.runAutoQualityScheduler(), this.CONSTANTS.AUTO_QUALITY_SCHEDULER)
       })
     })
 
@@ -167,6 +160,9 @@ class PeerTubePlugin extends Plugin {
     // Do not display error to user because we will have multiple fallback
     this.disableErrorDisplay()
 
+    // Hack to "simulate" src link in video.js >= 6
+    // Without this, we can't play the video after pausing it
+    // https://github.com/videojs/video.js/blob/master/src/js/player.js#L1633
     this.player.src = () => true
     const oldPlaybackRate = this.player.playbackRate()
 
@@ -181,7 +177,66 @@ class PeerTubePlugin extends Plugin {
     this.trigger('videoFileUpdate')
   }
 
-  addTorrent (
+  updateResolution (resolutionId: number, delay = 0) {
+    // Remember player state
+    const currentTime = this.player.currentTime()
+    const isPaused = this.player.paused()
+
+    // Remove poster to have black background
+    this.playerElement.poster = ''
+
+    // Hide bigPlayButton
+    if (!isPaused) {
+      this.player.bigPlayButton.hide()
+    }
+
+    const newVideoFile = this.videoFiles.find(f => f.resolution.id === resolutionId)
+    const options = {
+      forcePlay: false,
+      delay,
+      seek: currentTime + (delay / 1000)
+    }
+    this.updateVideoFile(newVideoFile, options)
+  }
+
+  flushVideoFile (videoFile: VideoFile, destroyRenderer = true) {
+    if (videoFile !== undefined && this.webtorrent.get(videoFile.magnetUri)) {
+      if (destroyRenderer === true && this.renderer && this.renderer.destroy) this.renderer.destroy()
+
+      this.webtorrent.remove(videoFile.magnetUri)
+      console.log('Removed ' + videoFile.magnetUri)
+    }
+  }
+
+  isAutoResolutionOn () {
+    return this.autoResolution
+  }
+
+  enableAutoResolution () {
+    this.autoResolution = true
+    this.trigger('autoResolutionUpdate')
+  }
+
+  disableAutoResolution (forbid = false) {
+    if (forbid === true) this.forbidAutoResolution = true
+
+    this.autoResolution = false
+    this.trigger('autoResolutionUpdate')
+  }
+
+  isAutoResolutionForbidden () {
+    return this.forbidAutoResolution === true
+  }
+
+  getCurrentVideoFile () {
+    return this.currentVideoFile
+  }
+
+  getTorrent () {
+    return this.torrent
+  }
+
+  private addTorrent (
     magnetOrTorrentUrl: string,
     previousVideoFile: VideoFile,
     options: {
@@ -205,26 +260,15 @@ class PeerTubePlugin extends Plugin {
 
       if (oldTorrent) {
         // Pause the old torrent
-        oldTorrent.pause()
-        // Pause does not remove actual peers (in particular the webseed peer)
-        oldTorrent.removePeer(oldTorrent['ws'])
+        this.stopTorrent(oldTorrent)
 
         // We use a fake renderer so we download correct pieces of the next file
-        if (options.delay) {
-          const fakeVideoElem = document.createElement('video')
-          renderVideo(torrent.files[0], fakeVideoElem, { autoplay: false, controls: false }, (err, renderer) => {
-            this.fakeRenderer = renderer
-
-            if (err) console.error('Cannot render new torrent in fake video element.', err)
-
-            // Load the future file at the correct time
-            fakeVideoElem.currentTime = this.player.currentTime() + (options.delay / 2000)
-          })
-        }
+        if (options.delay) this.renderFileInFakeElement(torrent.files[ 0 ], options.delay)
       }
 
       // Render the video in a few seconds? (on resolution change for example, we wait some seconds of the new video resolution)
       this.addTorrentDelay = setTimeout(() => {
+        // We don't need the fake renderer anymore
         this.destroyFakeRenderer()
 
         const paused = this.player.paused()
@@ -232,7 +276,7 @@ class PeerTubePlugin extends Plugin {
         this.flushVideoFile(previousVideoFile)
 
         const renderVideoOptions = { autoplay: false, controls: true }
-        renderVideo(torrent.files[0], this.playerElement, renderVideoOptions,(err, renderer) => {
+        renderVideo(torrent.files[ 0 ], this.playerElement, renderVideoOptions, (err, renderer) => {
           this.renderer = renderer
 
           if (err) return this.fallbackToHttp(done)
@@ -265,7 +309,7 @@ class PeerTubePlugin extends Plugin {
       if (err.message.indexOf('incorrect info hash') !== -1) {
         console.error('Incorrect info hash detected, falling back to torrent file.')
         const newOptions = { forcePlay: true, seek: options.seek }
-        return this.addTorrent(this.torrent['xs'], previousVideoFile, newOptions, done)
+        return this.addTorrent(this.torrent[ 'xs' ], previousVideoFile, newOptions, done)
       }
 
       // Remote instance is down
@@ -277,65 +321,6 @@ class PeerTubePlugin extends Plugin {
     })
   }
 
-  updateResolution (resolutionId: number, delay = 0) {
-    // Remember player state
-    const currentTime = this.player.currentTime()
-    const isPaused = this.player.paused()
-
-    // Remove poster to have black background
-    this.playerElement.poster = ''
-
-    // Hide bigPlayButton
-    if (!isPaused) {
-      this.player.bigPlayButton.hide()
-    }
-
-    const newVideoFile = this.videoFiles.find(f => f.resolution.id === resolutionId)
-    const options = {
-      forcePlay: false,
-      delay,
-      seek: currentTime + (delay / 1000)
-    }
-    this.updateVideoFile(newVideoFile, options)
-  }
-
-  flushVideoFile (videoFile: VideoFile, destroyRenderer = true) {
-    if (videoFile !== undefined && this.webtorrent.get(videoFile.magnetUri)) {
-      if (destroyRenderer === true && this.renderer && this.renderer.destroy) this.renderer.destroy()
-
-      this.webtorrent.remove(videoFile.magnetUri)
-      console.log('Removed ' + videoFile.magnetUri)
-    }
-  }
-
-  isAutoResolutionOn () {
-    return this.autoResolution
-  }
-
-  enableAutoResolution () {
-    this.autoResolution = true
-    this.trigger('autoResolutionUpdate')
-  }
-
-  disableAutoResolution (forbid = false) {
-    if (forbid === true) this.forbidAutoResolution = true
-
-    this.autoResolution = false
-    this.trigger('autoResolutionUpdate')
-  }
-
-  isAutoResolutionForbidden () {
-    return this.forbidAutoResolution === true
-  }
-
-  getCurrentVideoFile () {
-    return this.currentVideoFile
-  }
-
-  getTorrent () {
-    return this.torrent
-  }
-
   private tryToPlay (done?: Function) {
     if (!done) done = function () { /* empty */ }
 
@@ -435,22 +420,22 @@ class PeerTubePlugin extends Plugin {
     if (this.autoplay === true) {
       this.player.posterImage.hide()
 
-      this.updateVideoFile(undefined, { forcePlay: true, seek: this.startTime })
-    } else {
-      // Don't try on iOS that does not support MediaSource
-      if (this.isIOS()) {
-        this.currentVideoFile = this.pickAverageVideoFile()
-        return this.fallbackToHttp(undefined, false)
-      }
+      return this.updateVideoFile(undefined, { forcePlay: true, seek: this.startTime })
+    }
 
-      // Proxy first play
-      const oldPlay = this.player.play.bind(this.player)
-      this.player.play = () => {
-        this.player.addClass('vjs-has-big-play-button-clicked')
-        this.player.play = oldPlay
+    // Don't try on iOS that does not support MediaSource
+    if (this.isIOS()) {
+      this.currentVideoFile = this.pickAverageVideoFile()
+      return this.fallbackToHttp(undefined, false)
+    }
 
-        this.updateVideoFile(undefined, { forcePlay: true, seek: this.startTime })
-      }
+    // Proxy first play
+    const oldPlay = this.player.play.bind(this.player)
+    this.player.play = () => {
+      this.player.addClass('vjs-has-big-play-button-clicked')
+      this.player.play = oldPlay
+
+      this.updateVideoFile(undefined, { forcePlay: true, seek: this.startTime })
     }
   }
 
@@ -607,6 +592,24 @@ class PeerTubePlugin extends Plugin {
     return this.videoFiles[Math.floor(this.videoFiles.length / 2)]
   }
 
+  private stopTorrent (torrent: WebTorrent.Torrent) {
+    torrent.pause()
+    // Pause does not remove actual peers (in particular the webseed peer)
+    torrent.removePeer(torrent[ 'ws' ])
+  }
+
+  private renderFileInFakeElement (file: WebTorrent.TorrentFile, delay: number) {
+    const fakeVideoElem = document.createElement('video')
+    renderVideo(file, fakeVideoElem, { autoplay: false, controls: false }, (err, renderer) => {
+      this.fakeRenderer = renderer
+
+      if (err) console.error('Cannot render new torrent in fake video element.', err)
+
+      // Load the future file at the correct time (in delay MS - 2 seconds)
+      fakeVideoElem.currentTime = this.player.currentTime() + (delay - 2000)
+    })
+  }
+
   private destroyFakeRenderer () {
     if (this.fakeRenderer) {
       if (this.fakeRenderer.destroy) {
index 6e2224e20f29f0c2a931876d06070a3c4511d4f8..f6cf6d0f3709833e37112bbc7495e54408d76ef8 100644 (file)
@@ -38,8 +38,11 @@ class SettingsMenuItem extends MenuItem {
     this.eventHandlers()
 
     player.ready(() => {
-      this.build()
-      this.reset()
+      // Voodoo magic for IOS
+      setTimeout(() => {
+        this.build()
+        this.reset()
+      }, 0)
     })
   }
 
index 4d707a250e1244cbc9dc889b390258f2f9528f9c..d5306a7a2879de9677e226deab878d965a006402 100644 (file)
@@ -1,11 +1,19 @@
 import { NgModuleRef, ApplicationRef } from '@angular/core'
 import { createNewHosts } from '@angularclass/hmr'
+import { enableDebugTools } from '@angular/platform-browser'
 
 export const hmrBootstrap = (module: any, bootstrap: () => Promise<NgModuleRef<any>>) => {
   let ngModule: NgModuleRef<any>
   module.hot.accept()
   bootstrap()
-    .then(mod => ngModule = mod)
+    .then(mod => {
+      ngModule = mod
+
+      const applicationRef = ngModule.injector.get(ApplicationRef);
+      const componentRef = applicationRef.components[ 0 ]
+      // allows to run `ng.profiler.timeChangeDetection();`
+      enableDebugTools(componentRef)
+    })
   module.hot.dispose(() => {
     const appRef: ApplicationRef = ngModule.injector.get(ApplicationRef)
     const elements = appRef.components.map(c => c.location.nativeElement)
index f00af8bff02054ded65e4a8385222dd56465b4ea..593de4ac6bde5940617a8ff0915535a37340d5c3 100644 (file)
@@ -7,7 +7,7 @@
     <meta name="theme-color" content="#fff" />
 
     <!-- Web Manifest file -->
-    <link rel="manifest" href="/manifest.json">
+    <link rel="manifest" href="/manifest.webmanifest">
 
     <!-- /!\ The following comment is used by the server to prerender some tags /!\ -->
 
similarity index 99%
rename from client/src/manifest.json
rename to client/src/manifest.webmanifest
index 30914e35fb164de759c4ac61e61c8bde90fa0a52..3d3c7d6d5ec838163d28be7b590a16bf9288b272 100644 (file)
@@ -24,7 +24,7 @@
       "src": "/client/assets/images/icons/icon-96x96.png",
       "sizes": "96x96",
       "type": "image/png"
-    }, 
+    },
        {
       "src": "/client/assets/images/icons/icon-144x144.png",
       "sizes": "144x144",
index caf039b6d4feaaa80e5f910514068b6e7b3235f9..f21b91d2eda3e94413e28bfb73afcbce04b67755 100644 (file)
@@ -9,7 +9,7 @@ $icon-font-path: '../../node_modules/@neos21/bootstrap3-glyphicons/assets/fonts/
 @import '~video.js/dist/video-js.css';
 
 $assets-path: '../assets/';
-@import './player/player';
+@import './player/index';
 @import './loading-bar';
 
 @import './primeng-custom';
index d755e7df399cfafc9f35c5fc6aaf80994396815f..544f3995764708c89a009f1d66f624ebaefd9dfc 100644 (file)
@@ -53,7 +53,6 @@
   -ms-hyphens: auto;
   -moz-hyphens: auto;
   hyphens: auto;
-  text-align: justify;
 }
 
 @mixin peertube-input-text($width) {
index 185b002223cdf395675cb670e7430db373bcf60a..4e921e9709378f1f92ba862d081d10d6e6a968d9 100644 (file)
 
       width: 37px;
       margin-right: 1px;
+      cursor: pointer;
 
       .vjs-icon-placeholder {
         transition: transform 0.2s ease;
       }
     }
 
-    .vjs-playback-rate {
-      display: none;
-    }
-
     .vjs-peertube {
       padding: 0 !important;
 
index 30650538fcf125907495482b530919961bcc9790..c40ea12080a133e6f3e8e20f20e91352e29758c8 100644 (file)
@@ -4,7 +4,7 @@
 @import '~videojs-dock/dist/videojs-dock.css';
 
 $assets-path: '../../assets/';
-@import '../../sass/player/player';
+@import '../../sass/player/index';
 
 [hidden] {
   display: none !important;
index af29a4379eda35712c51a1402ee7e07805452930..fa1fb628a55394728c6f791e114368e1760a97f0 100644 (file)
@@ -71,9 +71,18 @@ trending:
 # Once you have defined your strategies, choose which instances you want to cache in admin -> manage follows -> following
 redundancy:
   videos:
-#    -
-#      size: '10GB'
-#      strategy: 'most-views' # Cache videos that have the most views
+    check_interval: '1 hour' # How often you want to check new videos to cache
+    strategies:
+#      -
+#        size: '10GB'
+#        strategy: 'most-views' # Cache videos that have the most views
+#      -
+#        size: '10GB'
+#        strategy: 'trending' # Cache trending videos
+#      -
+#        size: '10GB'
+#        strategy: 'recently-added' # Cache recently added videos
+#        minViews: 10 # Having at least x views
 
 cache:
   previews:
@@ -135,7 +144,7 @@ instance:
   # Robot.txt rules. To disallow robots to crawl your instance and disallow indexation of your site, add '/' to "Disallow:'
   robots: |
     User-agent: *
-    Disallow: ''
+    Disallow:
   # Security.txt rules. To discourage researchers from testing your instance and disable security.txt integration, set this to an empty string.
   securitytxt:
     "# If you would like to report a security issue\n# you may report it to:\nContact: https://github.com/Chocobozzz/PeerTube/blob/develop/SECURITY.md\nContact: mailto:"
index ddd43093f45000657421c39ffebb5cd2e07a97c9..4d8752206aa0d446de612a42959ba048cb6bf27f 100644 (file)
@@ -72,9 +72,18 @@ trending:
 # Once you have defined your strategies, choose which instances you want to cache in admin -> manage follows -> following
 redundancy:
   videos:
-#    -
-#      size: '10GB'
-#      strategy: 'most-views' # Cache videos that have the most views
+    check_interval: '1 hour' # How often you want to check new videos to cache
+    strategies:
+#      -
+#        size: '10GB'
+#        strategy: 'most-views' # Cache videos that have the most views
+#      -
+#        size: '10GB'
+#        strategy: 'trending' # Cache trending videos
+#      -
+#        size: '10GB'
+#        strategy: 'recently-added' # Cache recently added videos
+#        minViews: 10 # Having at least x views
 
 ###############################################################################
 #
@@ -149,7 +158,7 @@ instance:
   # Robot.txt rules. To disallow robots to crawl your instance and disallow indexation of your site, add '/' to "Disallow:'
   robots: |
     User-agent: *
-    Disallow: ''
+    Disallow:
   # Security.txt rules. To discourage researchers from testing your instance and disable security.txt integration, set this to an empty string.
   securitytxt:
     "# If you would like to report a security issue\n# you may report it to:\nContact: https://github.com/Chocobozzz/PeerTube/blob/develop/SECURITY.md\nContact: mailto:"
index 0f280eabd1b0f156b5f93d8b7bc2ddee6d7fe985..ad94b00cd2230e12429acdbd4906f3bf9f64953d 100644 (file)
@@ -23,9 +23,18 @@ log:
 
 redundancy:
   videos:
-    -
-      size: '100KB'
-      strategy: 'most-views'
+    check_interval: '5 seconds'
+    strategies:
+      -
+        size: '10MB'
+        strategy: 'most-views'
+      -
+        size: '10MB'
+        strategy: 'trending'
+      -
+        size: '10MB'
+        strategy: 'recently-added'
+        minViews: 1
 
 cache:
   previews:
index cc4f6be5ceb484f682b40ca00f6475cc000f1104..d5cf95b83c3350ebdfc766b520e9df0fea3aae8e 100644 (file)
@@ -73,7 +73,7 @@
   },
   "lint-staged": {
     "*.scss": [
-      "sass-lint -c .sass-lint.yml",
+      "sass-lint -c client/.sass-lint.yml",
       "git add"
     ]
   },
     "jsonld-signatures": "https://github.com/Chocobozzz/jsonld-signatures#rsa2017",
     "lodash": "^4.17.10",
     "magnet-uri": "^5.1.4",
+    "memoizee": "^0.4.14",
     "morgan": "^1.5.3",
     "multer": "^1.1.0",
     "netrc-parser": "^3.1.6",
     "@types/lodash": "^4.14.64",
     "@types/magnet-uri": "^5.1.1",
     "@types/maildev": "^0.0.1",
+    "@types/memoizee": "^0.4.2",
     "@types/mkdirp": "^0.5.1",
     "@types/mocha": "^5.0.0",
     "@types/morgan": "^1.7.32",
index 3b8fe39edee1222897581fb1537c533eeab4fd68..235ff52cc31f909694a2d7ee3fa7b983265b6d7c 100755 (executable)
@@ -2,15 +2,28 @@
 
 set -eu
 
-for i in $(seq 1 6); do
-  dbname="peertube_test$i"
+recreateDB () {
+  dbname="peertube_test$1"
 
   dropdb --if-exists "$dbname"
-  rm -rf "./test$i"
-  rm -f "./config/local-test.json"
-  rm -f "./config/local-test-$i.json"
+
   createdb -O peertube "$dbname"
-  psql -c "CREATE EXTENSION pg_trgm;" "$dbname"
-  psql -c "CREATE EXTENSION unaccent;" "$dbname"
-  redis-cli KEYS "bull-localhost:900$i*" | grep -v empty | xargs --no-run-if-empty redis-cli DEL
+  psql -c "CREATE EXTENSION pg_trgm;" "$dbname" &
+  psql -c "CREATE EXTENSION unaccent;" "$dbname" &
+}
+
+removeFiles () {
+  rm -rf "./test$1" "./config/local-test.json" "./config/local-test-$1.json"
+}
+
+dropRedis () {
+  redis-cli KEYS "bull-localhost:900$1*" | grep -v empty | xargs --no-run-if-empty redis-cli DEL
+}
+
+for i in $(seq 1 6); do
+  recreateDB "$i" &
+  dropRedis "$i" &
+  removeFiles "$i" &
 done
+
+wait
index 2b636014a6636b228cfb7b54fe9bceb610d17f8d..c8c6c642977c8719aa0aa38c7accf7aecbe0efcb 100644 (file)
@@ -25,7 +25,7 @@ run()
 async function run () {
   await initDatabaseModels(true)
 
-  const video = await VideoModel.loadByUUID(program['video'])
+  const video = await VideoModel.loadByUUIDWithFile(program['video'])
   if (!video) throw new Error('Video not found.')
   if (video.isOwned() === false) throw new Error('Cannot import files of a non owned video.')
 
index 3ea30f98e642701d5bf85e60387308c1c0fa88c1..7e5b687bbc2c22520fa5969d79ea29d2c71e5b32 100755 (executable)
@@ -28,7 +28,7 @@ run()
 async function run () {
   await initDatabaseModels(true)
 
-  const video = await VideoModel.loadByUUID(program['video'])
+  const video = await VideoModel.loadByUUIDWithFile(program['video'])
   if (!video) throw new Error('Video not found.')
 
   const dataInput = {
index 5722838685d2277da1a112a14051482786e982da..b00f2093426ddcfac3f909afe9a8f5bdffc9606d 100755 (executable)
@@ -56,7 +56,7 @@ async function pruneDirectory (directory: string) {
     const uuid = getUUIDFromFilename(file)
     let video: VideoModel
 
-    if (uuid) video = await VideoModel.loadByUUID(uuid)
+    if (uuid) video = await VideoModel.loadByUUIDWithFile(uuid)
 
     if (!uuid || !video) toDelete.push(join(directory, file))
   }
index 2e168ea78812c6876fa158e685ca60f09f72daa7..6229c44aa0b5f144a8a0016efbd4417a6edcbb10 100644 (file)
@@ -6,7 +6,13 @@ import { CONFIG, ROUTE_CACHE_LIFETIME } from '../../initializers'
 import { buildAnnounceWithVideoAudience } from '../../lib/activitypub/send'
 import { audiencify, getAudience } from '../../lib/activitypub/audience'
 import { buildCreateActivity } from '../../lib/activitypub/send/send-create'
-import { asyncMiddleware, executeIfActivityPub, localAccountValidator, localVideoChannelValidator } from '../../middlewares'
+import {
+  asyncMiddleware,
+  executeIfActivityPub,
+  localAccountValidator,
+  localVideoChannelValidator,
+  videosCustomGetValidator
+} from '../../middlewares'
 import { videosGetValidator, videosShareValidator } from '../../middlewares/validators'
 import { videoCommentGetValidator } from '../../middlewares/validators/video-comments'
 import { AccountModel } from '../../models/account/account'
@@ -54,7 +60,7 @@ activityPubClientRouter.get('/videos/watch/:id/activity',
   executeIfActivityPub(asyncMiddleware(videoController))
 )
 activityPubClientRouter.get('/videos/watch/:id/announces',
-  executeIfActivityPub(asyncMiddleware(videosGetValidator)),
+  executeIfActivityPub(asyncMiddleware(videosCustomGetValidator('only-video'))),
   executeIfActivityPub(asyncMiddleware(videoAnnouncesController))
 )
 activityPubClientRouter.get('/videos/watch/:id/announces/:accountId',
@@ -62,15 +68,15 @@ activityPubClientRouter.get('/videos/watch/:id/announces/:accountId',
   executeIfActivityPub(asyncMiddleware(videoAnnounceController))
 )
 activityPubClientRouter.get('/videos/watch/:id/likes',
-  executeIfActivityPub(asyncMiddleware(videosGetValidator)),
+  executeIfActivityPub(asyncMiddleware(videosCustomGetValidator('only-video'))),
   executeIfActivityPub(asyncMiddleware(videoLikesController))
 )
 activityPubClientRouter.get('/videos/watch/:id/dislikes',
-  executeIfActivityPub(asyncMiddleware(videosGetValidator)),
+  executeIfActivityPub(asyncMiddleware(videosCustomGetValidator('only-video'))),
   executeIfActivityPub(asyncMiddleware(videoDislikesController))
 )
 activityPubClientRouter.get('/videos/watch/:id/comments',
-  executeIfActivityPub(asyncMiddleware(videosGetValidator)),
+  executeIfActivityPub(asyncMiddleware(videosCustomGetValidator('only-video'))),
   executeIfActivityPub(asyncMiddleware(videoCommentsController))
 )
 activityPubClientRouter.get('/videos/watch/:videoId/comments/:commentId',
index 20bd20ed451e3d095b79fe2807f2e5eea070254e..738d155eb5a89269331ee093b3e590b3d9db452b 100644 (file)
@@ -7,6 +7,8 @@ import { asyncMiddleware, checkSignature, localAccountValidator, localVideoChann
 import { activityPubValidator } from '../../middlewares/validators/activitypub/activity'
 import { VideoChannelModel } from '../../models/video/video-channel'
 import { AccountModel } from '../../models/account/account'
+import { queue } from 'async'
+import { ActorModel } from '../../models/activitypub/actor'
 
 const inboxRouter = express.Router()
 
@@ -14,7 +16,7 @@ inboxRouter.post('/inbox',
   signatureValidator,
   asyncMiddleware(checkSignature),
   asyncMiddleware(activityPubValidator),
-  asyncMiddleware(inboxController)
+  inboxController
 )
 
 inboxRouter.post('/accounts/:name/inbox',
@@ -22,14 +24,14 @@ inboxRouter.post('/accounts/:name/inbox',
   asyncMiddleware(checkSignature),
   asyncMiddleware(localAccountValidator),
   asyncMiddleware(activityPubValidator),
-  asyncMiddleware(inboxController)
+  inboxController
 )
 inboxRouter.post('/video-channels/:name/inbox',
   signatureValidator,
   asyncMiddleware(checkSignature),
   asyncMiddleware(localVideoChannelValidator),
   asyncMiddleware(activityPubValidator),
-  asyncMiddleware(inboxController)
+  inboxController
 )
 
 // ---------------------------------------------------------------------------
@@ -40,7 +42,12 @@ export {
 
 // ---------------------------------------------------------------------------
 
-async function inboxController (req: express.Request, res: express.Response, next: express.NextFunction) {
+const inboxQueue = queue<{ activities: Activity[], signatureActor?: ActorModel, inboxActor?: ActorModel }, Error>((task, cb) => {
+  processActivities(task.activities, task.signatureActor, task.inboxActor)
+    .then(() => cb())
+})
+
+function inboxController (req: express.Request, res: express.Response, next: express.NextFunction) {
   const rootActivity: RootActivity = req.body
   let activities: Activity[] = []
 
@@ -66,7 +73,11 @@ async function inboxController (req: express.Request, res: express.Response, nex
 
   logger.info('Receiving inbox requests for %d activities by %s.', activities.length, res.locals.signature.actor.url)
 
-  await processActivities(activities, res.locals.signature.actor, accountOrChannel ? accountOrChannel.Actor : undefined)
+  inboxQueue.push({
+    activities,
+    signatureActor: res.locals.signature.actor,
+    inboxActor: accountOrChannel ? accountOrChannel.Actor : undefined
+  })
 
-  res.status(204).end()
+  return res.status(204).end()
 }
index 6edbe4820a32ee7f2e02a662671ea75ef66d447a..95549b7240fa64d90b0fa9c702f6dbdf7493c4b1 100644 (file)
@@ -8,7 +8,7 @@ import { CONFIG, CONSTRAINTS_FIELDS, reloadConfig } from '../../initializers'
 import { asyncMiddleware, authenticate, ensureUserHasRight } from '../../middlewares'
 import { customConfigUpdateValidator } from '../../middlewares/validators/config'
 import { ClientHtml } from '../../lib/client-html'
-import { auditLoggerFactory, CustomConfigAuditView } from '../../helpers/audit-logger'
+import { auditLoggerFactory, CustomConfigAuditView, getAuditIdFromRes } from '../../helpers/audit-logger'
 import { remove, writeJSON } from 'fs-extra'
 
 const packageJSON = require('../../../../package.json')
@@ -134,10 +134,7 @@ async function getCustomConfig (req: express.Request, res: express.Response, nex
 async function deleteCustomConfig (req: express.Request, res: express.Response, next: express.NextFunction) {
   await remove(CONFIG.CUSTOM_FILE)
 
-  auditLogger.delete(
-    res.locals.oauth.token.User.Account.Actor.getIdentifier(),
-    new CustomConfigAuditView(customConfig())
-  )
+  auditLogger.delete(getAuditIdFromRes(res), new CustomConfigAuditView(customConfig()))
 
   reloadConfig()
   ClientHtml.invalidCache()
@@ -183,7 +180,7 @@ async function updateCustomConfig (req: express.Request, res: express.Response,
   const data = customConfig()
 
   auditLogger.update(
-    res.locals.oauth.token.User.Account.Actor.getIdentifier(),
+    getAuditIdFromRes(res),
     new CustomConfigAuditView(data),
     oldCustomConfigAuditKeys
   )
index da941c0ac7a86030378199d0d16512c8c259dbd4..8b67730565d99224d68d290e3b0e99ad24e4a18f 100644 (file)
@@ -4,8 +4,9 @@ import { VideoModel } from '../../models/video/video'
 import { asyncMiddleware } from '../../middlewares'
 import { TagModel } from '../../models/video/tag'
 import { VideosOverview } from '../../../shared/models/overviews'
-import { OVERVIEWS, ROUTE_CACHE_LIFETIME } from '../../initializers'
+import { MEMOIZE_TTL, OVERVIEWS, ROUTE_CACHE_LIFETIME } from '../../initializers'
 import { cacheRoute } from '../../middlewares/cache'
+import * as memoizee from 'memoizee'
 
 const overviewsRouter = express.Router()
 
@@ -20,13 +21,30 @@ export { overviewsRouter }
 
 // ---------------------------------------------------------------------------
 
+const buildSamples = memoizee(async function () {
+  const [ categories, channels, tags ] = await Promise.all([
+    VideoModel.getRandomFieldSamples('category', OVERVIEWS.VIDEOS.SAMPLE_THRESHOLD, OVERVIEWS.VIDEOS.SAMPLES_COUNT),
+    VideoModel.getRandomFieldSamples('channelId', OVERVIEWS.VIDEOS.SAMPLE_THRESHOLD ,OVERVIEWS.VIDEOS.SAMPLES_COUNT),
+    TagModel.getRandomSamples(OVERVIEWS.VIDEOS.SAMPLE_THRESHOLD, OVERVIEWS.VIDEOS.SAMPLES_COUNT)
+  ])
+
+  return { categories, channels, tags }
+}, { maxAge: MEMOIZE_TTL.OVERVIEWS_SAMPLE })
+
 // This endpoint could be quite long, but we cache it
 async function getVideosOverview (req: express.Request, res: express.Response) {
   const attributes = await buildSamples()
+
+  const [ categories, channels, tags ] = await Promise.all([
+    Promise.all(attributes.categories.map(c => getVideosByCategory(c, res))),
+    Promise.all(attributes.channels.map(c => getVideosByChannel(c, res))),
+    Promise.all(attributes.tags.map(t => getVideosByTag(t, res)))
+  ])
+
   const result: VideosOverview = {
-    categories: await Promise.all(attributes.categories.map(c => getVideosByCategory(c, res))),
-    channels: await Promise.all(attributes.channels.map(c => getVideosByChannel(c, res))),
-    tags: await Promise.all(attributes.tags.map(t => getVideosByTag(t, res)))
+    categories,
+    channels,
+    tags
   }
 
   // Cleanup our object
@@ -37,16 +55,6 @@ async function getVideosOverview (req: express.Request, res: express.Response) {
   return res.json(result)
 }
 
-async function buildSamples () {
-  const [ categories, channels, tags ] = await Promise.all([
-    VideoModel.getRandomFieldSamples('category', OVERVIEWS.VIDEOS.SAMPLE_THRESHOLD, OVERVIEWS.VIDEOS.SAMPLES_COUNT),
-    VideoModel.getRandomFieldSamples('channelId', OVERVIEWS.VIDEOS.SAMPLE_THRESHOLD ,OVERVIEWS.VIDEOS.SAMPLES_COUNT),
-    TagModel.getRandomSamples(OVERVIEWS.VIDEOS.SAMPLE_THRESHOLD, OVERVIEWS.VIDEOS.SAMPLES_COUNT)
-  ])
-
-  return { categories, channels, tags }
-}
-
 async function getVideosByTag (tag: string, res: express.Response) {
   const videos = await getVideos(res, { tagsOneOf: [ tag ] })
 
@@ -84,14 +92,16 @@ async function getVideos (
   res: express.Response,
   where: { videoChannelId?: number, tagsOneOf?: string[], categoryOneOf?: number[] }
 ) {
-  const { data } = await VideoModel.listForApi(Object.assign({
+  const query = Object.assign({
     start: 0,
     count: 10,
     sort: '-createdAt',
     includeLocalVideos: true,
     nsfw: buildNSFWFilter(res),
     withFiles: false
-  }, where))
+  }, where)
+
+  const { data } = await VideoModel.listForApi(query, false)
 
   return data.map(d => d.toFormattedJSON())
 }
index 28a7a04cadc461142e1a5e13841ad7f02c827ef0..fd4db7a543869f187a445c42ed6760b2985559cb 100644 (file)
@@ -56,6 +56,9 @@ function searchVideoChannels (req: express.Request, res: express.Response) {
   const isURISearch = search.startsWith('http://') || search.startsWith('https://')
 
   const parts = search.split('@')
+
+  // Handle strings like @toto@example.com
+  if (parts.length === 3 && parts[0].length === 0) parts.shift()
   const isWebfingerSearch = parts.length === 2 && parts.every(p => p.indexOf(' ') === -1)
 
   if (isURISearch || isWebfingerSearch) return searchVideoChannelURI(search, isWebfingerSearch, res)
@@ -86,7 +89,7 @@ async function searchVideoChannelURI (search: string, isWebfingerSearch: boolean
 
   if (isUserAbleToSearchRemoteURI(res)) {
     try {
-      const actor = await getOrCreateActorAndServerAndModel(uri, true, true)
+      const actor = await getOrCreateActorAndServerAndModel(uri, 'all', true, true)
       videoChannel = actor.VideoChannel
     } catch (err) {
       logger.info('Cannot search remote video channel %s.', uri, { err })
@@ -136,7 +139,7 @@ async function searchVideoURI (url: string, res: express.Response) {
         refreshVideo: false
       }
 
-      const result = await getOrCreateVideoAndAccountAndChannel(url, syncParam)
+      const result = await getOrCreateVideoAndAccountAndChannel({ videoObject: url, syncParam })
       video = result ? result.video : undefined
     } catch (err) {
       logger.info('Cannot search remote video %s.', url, { err })
index 6f4fe938c3c99a31d63641db029270ae0d9f9088..85803f69ee87e4559f4dbe56bf14f1688122e649 100644 (file)
@@ -5,10 +5,14 @@ import { UserModel } from '../../../models/account/user'
 import { ActorFollowModel } from '../../../models/activitypub/actor-follow'
 import { VideoModel } from '../../../models/video/video'
 import { VideoCommentModel } from '../../../models/video/video-comment'
+import { VideoRedundancyModel } from '../../../models/redundancy/video-redundancy'
+import { CONFIG, ROUTE_CACHE_LIFETIME } from '../../../initializers/constants'
+import { cacheRoute } from '../../../middlewares/cache'
 
 const statsRouter = express.Router()
 
 statsRouter.get('/stats',
+  asyncMiddleware(cacheRoute(ROUTE_CACHE_LIFETIME.STATS)),
   asyncMiddleware(getStats)
 )
 
@@ -18,6 +22,13 @@ async function getStats (req: express.Request, res: express.Response, next: expr
   const { totalUsers } = await UserModel.getStats()
   const { totalInstanceFollowers, totalInstanceFollowing } = await ActorFollowModel.getStats()
 
+  const videosRedundancyStats = await Promise.all(
+    CONFIG.REDUNDANCY.VIDEOS.STRATEGIES.map(r => {
+      return VideoRedundancyModel.getStats(r.strategy)
+        .then(stats => Object.assign(stats, { strategy: r.strategy, totalSize: r.size }))
+    })
+  )
+
   const data: ServerStats = {
     totalLocalVideos,
     totalLocalVideoViews,
@@ -26,7 +37,8 @@ async function getStats (req: express.Request, res: express.Response, next: expr
     totalVideoComments,
     totalUsers,
     totalInstanceFollowers,
-    totalInstanceFollowing
+    totalInstanceFollowing,
+    videosRedundancy: videosRedundancyStats
   }
 
   return res.json(data).end()
index 07edf3727117075bd9136382c53d7356d08b8c1e..8b8ebcd234ead49b36991125fc9fda579a388017 100644 (file)
@@ -27,13 +27,17 @@ import {
   usersUpdateValidator
 } from '../../../middlewares'
 import {
-  usersAskResetPasswordValidator, usersBlockingValidator, usersResetPasswordValidator,
-  usersAskSendVerifyEmailValidator, usersVerifyEmailValidator
+  usersAskResetPasswordValidator,
+  usersAskSendVerifyEmailValidator,
+  usersBlockingValidator,
+  usersResetPasswordValidator,
+  usersVerifyEmailValidator
 } from '../../../middlewares/validators'
 import { UserModel } from '../../../models/account/user'
 import { OAuthTokenModel } from '../../../models/oauth/oauth-token'
-import { auditLoggerFactory, UserAuditView } from '../../../helpers/audit-logger'
+import { auditLoggerFactory, getAuditIdFromRes, UserAuditView } from '../../../helpers/audit-logger'
 import { meRouter } from './me'
+import { deleteUserToken } from '../../../lib/oauth-model'
 
 const auditLogger = auditLoggerFactory('users')
 
@@ -166,7 +170,7 @@ async function createUser (req: express.Request, res: express.Response) {
 
   const { user, account } = await createUserAccountAndChannel(userToCreate)
 
-  auditLogger.create(res.locals.oauth.token.User.Account.Actor.getIdentifier(), new UserAuditView(user.toFormattedJSON()))
+  auditLogger.create(getAuditIdFromRes(res), new UserAuditView(user.toFormattedJSON()))
   logger.info('User %s with its channel and account created.', body.username)
 
   return res.json({
@@ -245,7 +249,7 @@ async function removeUser (req: express.Request, res: express.Response, next: ex
 
   await user.destroy()
 
-  auditLogger.delete(res.locals.oauth.token.User.Account.Actor.getIdentifier(), new UserAuditView(user.toFormattedJSON()))
+  auditLogger.delete(getAuditIdFromRes(res), new UserAuditView(user.toFormattedJSON()))
 
   return res.sendStatus(204)
 }
@@ -264,15 +268,9 @@ async function updateUser (req: express.Request, res: express.Response, next: ex
   const user = await userToUpdate.save()
 
   // Destroy user token to refresh rights
-  if (roleChanged) {
-    await OAuthTokenModel.deleteUserToken(userToUpdate.id)
-  }
+  if (roleChanged) await deleteUserToken(userToUpdate.id)
 
-  auditLogger.update(
-    res.locals.oauth.token.User.Account.Actor.getIdentifier(),
-    new UserAuditView(user.toFormattedJSON()),
-    oldUserAuditView
-  )
+  auditLogger.update(getAuditIdFromRes(res), new UserAuditView(user.toFormattedJSON()), oldUserAuditView)
 
   // Don't need to send this update to followers, these attributes are not propagated
 
@@ -333,16 +331,12 @@ async function changeUserBlock (res: express.Response, user: UserModel, block: b
   user.blockedReason = reason || null
 
   await sequelizeTypescript.transaction(async t => {
-    await OAuthTokenModel.deleteUserToken(user.id, t)
+    await deleteUserToken(user.id, t)
 
     await user.save({ transaction: t })
   })
 
   await Emailer.Instance.addUserBlockJob(user, block, reason)
 
-  auditLogger.update(
-    res.locals.oauth.token.User.Account.Actor.getIdentifier(),
-    new UserAuditView(user.toFormattedJSON()),
-    oldUserAuditView
-  )
+  auditLogger.update(getAuditIdFromRes(res), new UserAuditView(user.toFormattedJSON()), oldUserAuditView)
 }
index e886d4b2ad8e34935b6e1881476145ae3f1a19af..ff3a87b7f181d7d30fb9aa3065057ab11c6c76f1 100644 (file)
@@ -5,7 +5,8 @@ import { getFormattedObjects } from '../../../helpers/utils'
 import { CONFIG, IMAGE_MIMETYPE_EXT, sequelizeTypescript } from '../../../initializers'
 import { sendUpdateActor } from '../../../lib/activitypub/send'
 import {
-  asyncMiddleware, asyncRetryTransactionMiddleware,
+  asyncMiddleware,
+  asyncRetryTransactionMiddleware,
   authenticate,
   commonVideosFiltersValidator,
   paginationValidator,
@@ -17,11 +18,11 @@ import {
   usersVideoRatingValidator
 } from '../../../middlewares'
 import {
+  areSubscriptionsExistValidator,
   deleteMeValidator,
   userSubscriptionsSortValidator,
   videoImportsSortValidator,
-  videosSortValidator,
-  areSubscriptionsExistValidator
+  videosSortValidator
 } from '../../../middlewares/validators'
 import { AccountVideoRateModel } from '../../../models/account/account-video-rate'
 import { UserModel } from '../../../models/account/user'
@@ -31,12 +32,13 @@ import { buildNSFWFilter, createReqFiles } from '../../../helpers/express-utils'
 import { UserVideoQuota } from '../../../../shared/models/users/user-video-quota.model'
 import { updateAvatarValidator } from '../../../middlewares/validators/avatar'
 import { updateActorAvatarFile } from '../../../lib/avatar'
-import { auditLoggerFactory, UserAuditView } from '../../../helpers/audit-logger'
+import { auditLoggerFactory, getAuditIdFromRes, UserAuditView } from '../../../helpers/audit-logger'
 import { VideoImportModel } from '../../../models/video/video-import'
 import { VideoFilter } from '../../../../shared/models/videos/video-query.type'
 import { ActorFollowModel } from '../../../models/activitypub/actor-follow'
 import { JobQueue } from '../../../lib/job-queue'
 import { logger } from '../../../helpers/logger'
+import { AccountModel } from '../../../models/account/account'
 
 const auditLogger = auditLoggerFactory('users-me')
 
@@ -293,7 +295,7 @@ async function getUserVideoQuotaUsed (req: express.Request, res: express.Respons
 }
 
 async function getUserVideoRating (req: express.Request, res: express.Response, next: express.NextFunction) {
-  const videoId = +req.params.videoId
+  const videoId = res.locals.video.id
   const accountId = +res.locals.oauth.token.User.Account.id
 
   const ratingObj = await AccountVideoRateModel.load(accountId, videoId, null)
@@ -311,7 +313,7 @@ async function deleteMe (req: express.Request, res: express.Response) {
 
   await user.destroy()
 
-  auditLogger.delete(res.locals.oauth.token.User.Account.Actor.getIdentifier(), new UserAuditView(user.toFormattedJSON()))
+  auditLogger.delete(getAuditIdFromRes(res), new UserAuditView(user.toFormattedJSON()))
 
   return res.sendStatus(204)
 }
@@ -328,19 +330,17 @@ async function updateMe (req: express.Request, res: express.Response, next: expr
   if (body.autoPlayVideo !== undefined) user.autoPlayVideo = body.autoPlayVideo
 
   await sequelizeTypescript.transaction(async t => {
+    const userAccount = await AccountModel.load(user.Account.id)
+
     await user.save({ transaction: t })
 
-    if (body.displayName !== undefined) user.Account.name = body.displayName
-    if (body.description !== undefined) user.Account.description = body.description
-    await user.Account.save({ transaction: t })
+    if (body.displayName !== undefined) userAccount.name = body.displayName
+    if (body.description !== undefined) userAccount.description = body.description
+    await userAccount.save({ transaction: t })
 
-    await sendUpdateActor(user.Account, t)
+    await sendUpdateActor(userAccount, t)
 
-    auditLogger.update(
-      res.locals.oauth.token.User.Account.Actor.getIdentifier(),
-      new UserAuditView(user.toFormattedJSON()),
-      oldUserAuditView
-    )
+    auditLogger.update(getAuditIdFromRes(res), new UserAuditView(user.toFormattedJSON()), oldUserAuditView)
   })
 
   return res.sendStatus(204)
@@ -350,15 +350,12 @@ async function updateMyAvatar (req: express.Request, res: express.Response, next
   const avatarPhysicalFile = req.files[ 'avatarfile' ][ 0 ]
   const user: UserModel = res.locals.oauth.token.user
   const oldUserAuditView = new UserAuditView(user.toFormattedJSON())
-  const account = user.Account
 
-  const avatar = await updateActorAvatarFile(avatarPhysicalFile, account.Actor, account)
+  const userAccount = await AccountModel.load(user.Account.id)
 
-  auditLogger.update(
-    res.locals.oauth.token.User.Account.Actor.getIdentifier(),
-    new UserAuditView(user.toFormattedJSON()),
-    oldUserAuditView
-  )
+  const avatar = await updateActorAvatarFile(avatarPhysicalFile, userAccount)
+
+  auditLogger.update(getAuditIdFromRes(res), new UserAuditView(user.toFormattedJSON()), oldUserAuditView)
 
   return res.json({ avatar: avatar.toFormattedJSON() })
 }
index a7a36080b7f242f51de1cf5e4e498dca9ce0073d..ff6bbe44c0b068ba533fbac53cc283a6b01ae7d7 100644 (file)
@@ -27,8 +27,9 @@ import { logger } from '../../helpers/logger'
 import { VideoModel } from '../../models/video/video'
 import { updateAvatarValidator } from '../../middlewares/validators/avatar'
 import { updateActorAvatarFile } from '../../lib/avatar'
-import { auditLoggerFactory, VideoChannelAuditView } from '../../helpers/audit-logger'
+import { auditLoggerFactory, getAuditIdFromRes, VideoChannelAuditView } from '../../helpers/audit-logger'
 import { resetSequelizeInstance } from '../../helpers/database-utils'
+import { UserModel } from '../../models/account/user'
 
 const auditLogger = auditLoggerFactory('channels')
 const reqAvatarFile = createReqFiles([ 'avatarfile' ], IMAGE_MIMETYPE_EXT, { avatarfile: CONFIG.STORAGE.AVATARS_DIR })
@@ -55,7 +56,7 @@ videoChannelRouter.post('/:nameWithHost/avatar/pick',
   // Check the rights
   asyncMiddleware(videoChannelsUpdateValidator),
   updateAvatarValidator,
-  asyncMiddleware(updateVideoChannelAvatar)
+  asyncRetryTransactionMiddleware(updateVideoChannelAvatar)
 )
 
 videoChannelRouter.put('/:nameWithHost',
@@ -106,13 +107,9 @@ async function updateVideoChannelAvatar (req: express.Request, res: express.Resp
   const videoChannel = res.locals.videoChannel as VideoChannelModel
   const oldVideoChannelAuditKeys = new VideoChannelAuditView(videoChannel.toFormattedJSON())
 
-  const avatar = await updateActorAvatarFile(avatarPhysicalFile, videoChannel.Actor, videoChannel)
+  const avatar = await updateActorAvatarFile(avatarPhysicalFile, videoChannel)
 
-  auditLogger.update(
-    res.locals.oauth.token.User.Account.Actor.getIdentifier(),
-    new VideoChannelAuditView(videoChannel.toFormattedJSON()),
-    oldVideoChannelAuditKeys
-  )
+  auditLogger.update(getAuditIdFromRes(res), new VideoChannelAuditView(videoChannel.toFormattedJSON()), oldVideoChannelAuditKeys)
 
   return res
     .json({
@@ -123,19 +120,17 @@ async function updateVideoChannelAvatar (req: express.Request, res: express.Resp
 
 async function addVideoChannel (req: express.Request, res: express.Response) {
   const videoChannelInfo: VideoChannelCreate = req.body
-  const account: AccountModel = res.locals.oauth.token.User.Account
 
   const videoChannelCreated: VideoChannelModel = await sequelizeTypescript.transaction(async t => {
+    const account = await AccountModel.load((res.locals.oauth.token.User as UserModel).Account.id, t)
+
     return createVideoChannel(videoChannelInfo, account, t)
   })
 
   setAsyncActorKeys(videoChannelCreated.Actor)
     .catch(err => logger.error('Cannot set async actor keys for account %s.', videoChannelCreated.Actor.uuid, { err }))
 
-  auditLogger.create(
-    res.locals.oauth.token.User.Account.Actor.getIdentifier(),
-    new VideoChannelAuditView(videoChannelCreated.toFormattedJSON())
-  )
+  auditLogger.create(getAuditIdFromRes(res), new VideoChannelAuditView(videoChannelCreated.toFormattedJSON()))
   logger.info('Video channel with uuid %s created.', videoChannelCreated.Actor.uuid)
 
   return res.json({
@@ -166,7 +161,7 @@ async function updateVideoChannel (req: express.Request, res: express.Response)
       await sendUpdateActor(videoChannelInstanceUpdated, t)
 
       auditLogger.update(
-        res.locals.oauth.token.User.Account.Actor.getIdentifier(),
+        getAuditIdFromRes(res),
         new VideoChannelAuditView(videoChannelInstanceUpdated.toFormattedJSON()),
         oldVideoChannelAuditKeys
       )
@@ -192,10 +187,7 @@ async function removeVideoChannel (req: express.Request, res: express.Response)
   await sequelizeTypescript.transaction(async t => {
     await videoChannelInstance.destroy({ transaction: t })
 
-    auditLogger.delete(
-      res.locals.oauth.token.User.Account.Actor.getIdentifier(),
-      new VideoChannelAuditView(videoChannelInstance.toFormattedJSON())
-    )
+    auditLogger.delete(getAuditIdFromRes(res), new VideoChannelAuditView(videoChannelInstance.toFormattedJSON()))
     logger.info('Video channel with name %s and uuid %s deleted.', videoChannelInstance.name, videoChannelInstance.Actor.uuid)
   })
 
index 08e11b00bd930e5b0e604be68f7d3e73a454f179..d0c81804bfe538036938e69bb4432880704554da 100644 (file)
@@ -21,6 +21,7 @@ import { AccountModel } from '../../../models/account/account'
 import { VideoModel } from '../../../models/video/video'
 import { VideoAbuseModel } from '../../../models/video/video-abuse'
 import { auditLoggerFactory, VideoAbuseAuditView } from '../../../helpers/audit-logger'
+import { UserModel } from '../../../models/account/user'
 
 const auditLogger = auditLoggerFactory('abuse')
 const abuseVideoRouter = express.Router()
@@ -95,17 +96,18 @@ async function deleteVideoAbuse (req: express.Request, res: express.Response) {
 
 async function reportVideoAbuse (req: express.Request, res: express.Response) {
   const videoInstance = res.locals.video as VideoModel
-  const reporterAccount = res.locals.oauth.token.User.Account as AccountModel
   const body: VideoAbuseCreate = req.body
 
-  const abuseToCreate = {
-    reporterAccountId: reporterAccount.id,
-    reason: body.reason,
-    videoId: videoInstance.id,
-    state: VideoAbuseState.PENDING
-  }
-
   const videoAbuse: VideoAbuseModel = await sequelizeTypescript.transaction(async t => {
+    const reporterAccount = await AccountModel.load((res.locals.oauth.token.User as UserModel).Account.id, t)
+
+    const abuseToCreate = {
+      reporterAccountId: reporterAccount.id,
+      reason: body.reason,
+      videoId: videoInstance.id,
+      state: VideoAbuseState.PENDING
+    }
+
     const videoAbuseInstance = await VideoAbuseModel.create(abuseToCreate, { transaction: t })
     videoAbuseInstance.Video = videoInstance
     videoAbuseInstance.Account = reporterAccount
@@ -121,7 +123,6 @@ async function reportVideoAbuse (req: express.Request, res: express.Response) {
   })
 
   logger.info('Abuse report for video %s created.', videoInstance.name)
-  return res.json({
-    videoAbuse: videoAbuse.toFormattedJSON()
-  }).end()
+
+  return res.json({ videoAbuse: videoAbuse.toFormattedJSON() }).end()
 }
index e35247829b249c7ae62899908b0373a31a8c6967..dc25e1e859502b820692bf50e7a138be326f8cd1 100644 (file)
@@ -23,7 +23,9 @@ import {
 } from '../../../middlewares/validators/video-comments'
 import { VideoModel } from '../../../models/video/video'
 import { VideoCommentModel } from '../../../models/video/video-comment'
-import { auditLoggerFactory, CommentAuditView } from '../../../helpers/audit-logger'
+import { auditLoggerFactory, CommentAuditView, getAuditIdFromRes } from '../../../helpers/audit-logger'
+import { AccountModel } from '../../../models/account/account'
+import { UserModel } from '../../../models/account/user'
 
 const auditLogger = auditLoggerFactory('comments')
 const videoCommentRouter = express.Router()
@@ -86,7 +88,7 @@ async function listVideoThreadComments (req: express.Request, res: express.Respo
   let resultList: ResultList<VideoCommentModel>
 
   if (video.commentsEnabled === true) {
-    resultList = await VideoCommentModel.listThreadCommentsForApi(res.locals.video.id, res.locals.videoCommentThread.id)
+    resultList = await VideoCommentModel.listThreadCommentsForApi(video.id, res.locals.videoCommentThread.id)
   } else {
     resultList = {
       total: 0,
@@ -101,15 +103,17 @@ async function addVideoCommentThread (req: express.Request, res: express.Respons
   const videoCommentInfo: VideoCommentCreate = req.body
 
   const comment = await sequelizeTypescript.transaction(async t => {
+    const account = await AccountModel.load((res.locals.oauth.token.User as UserModel).Account.id, t)
+
     return createVideoComment({
       text: videoCommentInfo.text,
       inReplyToComment: null,
       video: res.locals.video,
-      account: res.locals.oauth.token.User.Account
+      account
     }, t)
   })
 
-  auditLogger.create(res.locals.oauth.token.User.Account.Actor.getIdentifier(), new CommentAuditView(comment.toFormattedJSON()))
+  auditLogger.create(getAuditIdFromRes(res), new CommentAuditView(comment.toFormattedJSON()))
 
   return res.json({
     comment: comment.toFormattedJSON()
@@ -120,19 +124,19 @@ async function addVideoCommentReply (req: express.Request, res: express.Response
   const videoCommentInfo: VideoCommentCreate = req.body
 
   const comment = await sequelizeTypescript.transaction(async t => {
+    const account = await AccountModel.load((res.locals.oauth.token.User as UserModel).Account.id, t)
+
     return createVideoComment({
       text: videoCommentInfo.text,
       inReplyToComment: res.locals.videoComment,
       video: res.locals.video,
-      account: res.locals.oauth.token.User.Account
+      account
     }, t)
   })
 
-  auditLogger.create(res.locals.oauth.token.User.Account.Actor.getIdentifier(), new CommentAuditView(comment.toFormattedJSON()))
+  auditLogger.create(getAuditIdFromRes(res), new CommentAuditView(comment.toFormattedJSON()))
 
-  return res.json({
-    comment: comment.toFormattedJSON()
-  }).end()
+  return res.json({ comment: comment.toFormattedJSON() }).end()
 }
 
 async function removeVideoComment (req: express.Request, res: express.Response) {
@@ -143,7 +147,7 @@ async function removeVideoComment (req: express.Request, res: express.Response)
   })
 
   auditLogger.delete(
-    res.locals.oauth.token.User.Account.Actor.getIdentifier(),
+    getAuditIdFromRes(res),
     new CommentAuditView(videoCommentInstance.toFormattedJSON())
   )
   logger.info('Video comment %d deleted.', videoCommentInstance.id)
index 44f15ef74bbe9d286e1e7d6272ce0c0e3a6f9338..398fd5a7f68e9379a0cfb1f6091ed38d2e3dabda 100644 (file)
@@ -1,7 +1,7 @@
 import * as express from 'express'
 import * as magnetUtil from 'magnet-uri'
 import 'multer'
-import { auditLoggerFactory, VideoImportAuditView } from '../../../helpers/audit-logger'
+import { auditLoggerFactory, getAuditIdFromRes, VideoImportAuditView } from '../../../helpers/audit-logger'
 import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, videoImportAddValidator } from '../../../middlewares'
 import {
   CONFIG,
@@ -114,7 +114,7 @@ async function addTorrentImport (req: express.Request, res: express.Response, to
   }
   await JobQueue.Instance.createJob({ type: 'video-import', payload })
 
-  auditLogger.create(res.locals.oauth.token.User.Account.Actor.getIdentifier(), new VideoImportAuditView(videoImport.toFormattedJSON()))
+  auditLogger.create(getAuditIdFromRes(res), new VideoImportAuditView(videoImport.toFormattedJSON()))
 
   return res.json(videoImport.toFormattedJSON()).end()
 }
@@ -158,7 +158,7 @@ async function addYoutubeDLImport (req: express.Request, res: express.Response)
   }
   await JobQueue.Instance.createJob({ type: 'video-import', payload })
 
-  auditLogger.create(res.locals.oauth.token.User.Account.Actor.getIdentifier(), new VideoImportAuditView(videoImport.toFormattedJSON()))
+  auditLogger.create(getAuditIdFromRes(res), new VideoImportAuditView(videoImport.toFormattedJSON()))
 
   return res.json(videoImport.toFormattedJSON()).end()
 }
index 0c9e6c2d19d368a9d5e32924233c5781505aecd6..581046782e775b68c3bfcd4218d04d1e10153cee 100644 (file)
@@ -4,7 +4,7 @@ import { VideoCreate, VideoPrivacy, VideoState, VideoUpdate } from '../../../../
 import { getVideoFileFPS, getVideoFileResolution } from '../../../helpers/ffmpeg-utils'
 import { processImage } from '../../../helpers/image-utils'
 import { logger } from '../../../helpers/logger'
-import { auditLoggerFactory, VideoAuditView } from '../../../helpers/audit-logger'
+import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger'
 import { getFormattedObjects, getServerActor } from '../../../helpers/utils'
 import {
   CONFIG,
@@ -253,7 +253,7 @@ async function addVideo (req: express.Request, res: express.Response) {
 
     await federateVideoIfNeeded(video, true, t)
 
-    auditLogger.create(res.locals.oauth.token.User.Account.Actor.getIdentifier(), new VideoAuditView(videoCreated.toFormattedDetailsJSON()))
+    auditLogger.create(getAuditIdFromRes(res), new VideoAuditView(videoCreated.toFormattedDetailsJSON()))
     logger.info('Video with name %s and uuid %s created.', videoInfo.name, videoCreated.uuid)
 
     return videoCreated
@@ -354,7 +354,7 @@ async function updateVideo (req: express.Request, res: express.Response) {
       await federateVideoIfNeeded(videoInstanceUpdated, isNewVideo, t)
 
       auditLogger.update(
-        res.locals.oauth.token.User.Account.Actor.getIdentifier(),
+        getAuditIdFromRes(res),
         new VideoAuditView(videoInstanceUpdated.toFormattedDetailsJSON()),
         oldVideoAuditView
       )
@@ -393,9 +393,9 @@ async function viewVideo (req: express.Request, res: express.Response) {
     Redis.Instance.setIPVideoView(ip, videoInstance.uuid)
   ])
 
-  const serverAccount = await getServerActor()
+  const serverActor = await getServerActor()
 
-  await sendCreateView(serverAccount, videoInstance, undefined)
+  await sendCreateView(serverActor, videoInstance, undefined)
 
   return res.status(204).end()
 }
@@ -439,7 +439,7 @@ async function removeVideo (req: express.Request, res: express.Response) {
     await videoInstance.destroy({ transaction: t })
   })
 
-  auditLogger.delete(res.locals.oauth.token.User.Account.Actor.getIdentifier(), new VideoAuditView(videoInstance.toFormattedDetailsJSON()))
+  auditLogger.delete(getAuditIdFromRes(res), new VideoAuditView(videoInstance.toFormattedDetailsJSON()))
   logger.info('Video with name %s and uuid %s deleted.', videoInstance.name, videoInstance.uuid)
 
   return res.type('json').status(204).end()
index d26ed6cfc39adb8385f5e9e0d5504ec86b8a4a32..5ea7d7c6a3d72428be0f968581bfd9ef710eff8c 100644 (file)
@@ -19,6 +19,7 @@ import { VideoChannelModel } from '../../../models/video/video-channel'
 import { getFormattedObjects } from '../../../helpers/utils'
 import { changeVideoChannelShare } from '../../../lib/activitypub'
 import { sendUpdateVideo } from '../../../lib/activitypub/send'
+import { UserModel } from '../../../models/account/user'
 
 const ownershipVideoRouter = express.Router()
 
@@ -58,26 +59,25 @@ export {
 
 async function giveVideoOwnership (req: express.Request, res: express.Response) {
   const videoInstance = res.locals.video as VideoModel
-  const initiatorAccount = res.locals.oauth.token.User.Account as AccountModel
+  const initiatorAccountId = (res.locals.oauth.token.User as UserModel).Account.id
   const nextOwner = res.locals.nextOwner as AccountModel
 
   await sequelizeTypescript.transaction(t => {
     return VideoChangeOwnershipModel.findOrCreate({
       where: {
-        initiatorAccountId: initiatorAccount.id,
+        initiatorAccountId,
         nextOwnerAccountId: nextOwner.id,
         videoId: videoInstance.id,
         status: VideoChangeOwnershipStatus.WAITING
       },
       defaults: {
-        initiatorAccountId: initiatorAccount.id,
+        initiatorAccountId,
         nextOwnerAccountId: nextOwner.id,
         videoId: videoInstance.id,
         status: VideoChangeOwnershipStatus.WAITING
       },
       transaction: t
     })
-
   })
 
   logger.info('Ownership change for video %s created.', videoInstance.name)
@@ -85,9 +85,10 @@ async function giveVideoOwnership (req: express.Request, res: express.Response)
 }
 
 async function listVideoOwnership (req: express.Request, res: express.Response) {
-  const currentAccount = res.locals.oauth.token.User.Account as AccountModel
+  const currentAccountId = (res.locals.oauth.token.User as UserModel).Account.id
+
   const resultList = await VideoChangeOwnershipModel.listForApi(
-    currentAccount.id,
+    currentAccountId,
     req.query.start || 0,
     req.query.count || 10,
     req.query.sort || 'createdAt'
index b1732837d1518c08f7e58a85b2863e5a0e91ffe1..dc322bb0c070c0ad3ac9353cab8232783e4dc066 100644 (file)
@@ -28,10 +28,11 @@ async function rateVideo (req: express.Request, res: express.Response) {
   const body: UserVideoRateUpdate = req.body
   const rateType = body.rating
   const videoInstance: VideoModel = res.locals.video
-  const accountInstance: AccountModel = res.locals.oauth.token.User.Account
 
   await sequelizeTypescript.transaction(async t => {
     const sequelizeOptions = { transaction: t }
+
+    const accountInstance = await AccountModel.load(res.locals.oauth.token.User.Account.id, t)
     const previousRate = await AccountVideoRateModel.load(accountInstance.id, videoInstance.id, t)
 
     let likesToIncrement = 0
@@ -47,10 +48,10 @@ async function rateVideo (req: express.Request, res: express.Response) {
       else if (previousRate.type === VIDEO_RATE_TYPES.DISLIKE) dislikesToIncrement--
 
       if (rateType === 'none') { // Destroy previous rate
-        await previousRate.destroy({ transaction: t })
+        await previousRate.destroy(sequelizeOptions)
       } else { // Update previous rate
         previousRate.type = rateType
-        await previousRate.save({ transaction: t })
+        await previousRate.save(sequelizeOptions)
       }
     } else if (rateType !== 'none') { // There was not a previous rate, insert a new one if there is a rate
       const query = {
@@ -70,9 +71,9 @@ async function rateVideo (req: express.Request, res: express.Response) {
     await videoInstance.increment(incrementQuery, sequelizeOptions)
 
     await sendVideoRateChange(accountInstance, videoInstance, likesToIncrement, dislikesToIncrement, t)
-  })
 
-  logger.info('Account video rate for video %s of account %s updated.', videoInstance.name, accountInstance.name)
+    logger.info('Account video rate for video %s of account %s updated.', videoInstance.name, accountInstance.name)
+  })
 
   return res.type('json').status(204).end()
 }
index c33061289c947487a33a5e23e91ed1bf9cfce74f..73b40cf6513c3941fe5fe9ef1b76c9691db0a456 100644 (file)
@@ -35,7 +35,7 @@ clientsRouter.use('' +
 // Static HTML/CSS/JS client files
 
 const staticClientFiles = [
-  'manifest.json',
+  'manifest.webmanifest',
   'ngsw-worker.js',
   'ngsw.json'
 ]
diff --git a/server/helpers/actor.ts b/server/helpers/actor.ts
new file mode 100644 (file)
index 0000000..12a7ace
--- /dev/null
@@ -0,0 +1,13 @@
+import { ActorModel } from '../models/activitypub/actor'
+
+type ActorFetchByUrlType = 'all' | 'actor-and-association-ids'
+function fetchActorByUrl (url: string, fetchType: ActorFetchByUrlType) {
+  if (fetchType === 'all') return ActorModel.loadByUrlAndPopulateAccountAndChannel(url)
+
+  if (fetchType === 'actor-and-association-ids') return ActorModel.loadByUrl(url)
+}
+
+export {
+  ActorFetchByUrlType,
+  fetchActorByUrl
+}
index 7db72b69c5226f537beda46a4e532120fc60edb6..00311fce13dbf7b6a5b0a81e15cd487932845ae6 100644 (file)
@@ -1,4 +1,5 @@
 import * as path from 'path'
+import * as express from 'express'
 import { diff } from 'deep-object-diff'
 import { chain } from 'lodash'
 import * as flatten from 'flat'
@@ -8,6 +9,11 @@ import { jsonLoggerFormat, labelFormatter } from './logger'
 import { VideoDetails, User, VideoChannel, VideoAbuse, VideoImport } from '../../shared'
 import { VideoComment } from '../../shared/models/videos/video-comment.model'
 import { CustomConfig } from '../../shared/models/server/custom-config.model'
+import { UserModel } from '../models/account/user'
+
+function getAuditIdFromRes (res: express.Response) {
+  return (res.locals.oauth.token.User as UserModel).username
+}
 
 enum AUDIT_TYPE {
   CREATE = 'create',
@@ -255,6 +261,8 @@ class CustomConfigAuditView extends EntityAuditView {
 }
 
 export {
+  getAuditIdFromRes,
+
   auditLoggerFactory,
   VideoImportAuditView,
   VideoChannelAuditView,
index f76eba47482ba717c16a9fe935e4e27524f8ac4a..8772e74cfef2630814ab6ddca3002fb76e9540a7 100644 (file)
@@ -171,5 +171,3 @@ function setRemoteVideoTruncatedContent (video: any) {
 
   return true
 }
-
-
index aaa0c736b34c4e73e69270a6bbf40295061960ff..a7771e07b2e0bf6b1266e42407e27588f90e1b75 100644 (file)
@@ -31,7 +31,7 @@ export function checkUserCanTerminateOwnershipChange (
   videoChangeOwnership: VideoChangeOwnershipModel,
   res: Response
 ): boolean {
-  if (videoChangeOwnership.NextOwner.userId === user.Account.userId) {
+  if (videoChangeOwnership.NextOwner.userId === user.id) {
     return true
   }
 
index edafba6e2d17378506d69d1702be8cf39e30574a..9875c68bdfc63a120f2a7aecc27e85d236a4dee8 100644 (file)
@@ -18,6 +18,7 @@ import { exists, isArray, isFileValid } from './misc'
 import { VideoChannelModel } from '../../models/video/video-channel'
 import { UserModel } from '../../models/account/user'
 import * as magnetUtil from 'magnet-uri'
+import { fetchVideo, VideoFetchType } from '../video'
 
 const VIDEOS_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.VIDEOS
 
@@ -152,14 +153,8 @@ function checkUserCanManageVideo (user: UserModel, video: VideoModel, right: Use
   return true
 }
 
-async function isVideoExist (id: string, res: Response) {
-  let video: VideoModel | null
-
-  if (validator.isInt(id)) {
-    video = await VideoModel.loadAndPopulateAccountAndServerAndTags(+id)
-  } else { // UUID
-    video = await VideoModel.loadByUUIDAndPopulateAccountAndServerAndTags(id)
-  }
+async function isVideoExist (id: string, res: Response, fetchType: VideoFetchType = 'all') {
+  const video = await fetchVideo(id, fetchType)
 
   if (video === null) {
     res.status(404)
@@ -169,7 +164,7 @@ async function isVideoExist (id: string, res: Response) {
     return false
   }
 
-  res.locals.video = video
+  if (fetchType !== 'none') res.locals.video = video
   return true
 }
 
index a1ed8e72df461650e7f697c7bebf8cc94b62a5a2..a42474417769c4aeb97f9c8b5cf0b3e8757d7afc 100644 (file)
@@ -1,12 +1,12 @@
 import { ResultList } from '../../shared'
 import { CONFIG } from '../initializers'
-import { ActorModel } from '../models/activitypub/actor'
 import { ApplicationModel } from '../models/application/application'
 import { pseudoRandomBytesPromise, sha256 } from './core-utils'
 import { logger } from './logger'
 import { join } from 'path'
 import { Instance as ParseTorrent } from 'parse-torrent'
 import { remove } from 'fs-extra'
+import * as memoizee from 'memoizee'
 
 function deleteFileAsync (path: string) {
   remove(path)
@@ -36,24 +36,12 @@ function getFormattedObjects<U, T extends FormattableToJSON> (objects: T[], obje
   } as ResultList<U>
 }
 
-async function getServerActor () {
-  if (getServerActor.serverActor === undefined) {
-    const application = await ApplicationModel.load()
-    if (!application) throw Error('Could not load Application from database.')
+const getServerActor = memoizee(async function () {
+  const application = await ApplicationModel.load()
+  if (!application) throw Error('Could not load Application from database.')
 
-    getServerActor.serverActor = application.Account.Actor
-  }
-
-  if (!getServerActor.serverActor) {
-    logger.error('Cannot load server actor.')
-    process.exit(0)
-  }
-
-  return Promise.resolve(getServerActor.serverActor)
-}
-namespace getServerActor {
-  export let serverActor: ActorModel
-}
+  return application.Account.Actor
+})
 
 function generateVideoTmpPath (target: string | ParseTorrent) {
   const id = typeof target === 'string' ? target : target.infoHash
diff --git a/server/helpers/video.ts b/server/helpers/video.ts
new file mode 100644 (file)
index 0000000..b1577a6
--- /dev/null
@@ -0,0 +1,25 @@
+import { VideoModel } from '../models/video/video'
+
+type VideoFetchType = 'all' | 'only-video' | 'id' | 'none'
+
+function fetchVideo (id: number | string, fetchType: VideoFetchType) {
+  if (fetchType === 'all') return VideoModel.loadAndPopulateAccountAndServerAndTags(id)
+
+  if (fetchType === 'only-video') return VideoModel.load(id)
+
+  if (fetchType === 'id' || fetchType === 'none') return VideoModel.loadOnlyId(id)
+}
+
+type VideoFetchByUrlType = 'all' | 'only-video'
+function fetchVideoByUrl (url: string, fetchType: VideoFetchByUrlType) {
+  if (fetchType === 'all') return VideoModel.loadByUrlAndPopulateAccount(url)
+
+  if (fetchType === 'only-video') return VideoModel.loadByUrl(url)
+}
+
+export {
+  VideoFetchType,
+  VideoFetchByUrlType,
+  fetchVideo,
+  fetchVideoByUrl
+}
index 10fcec4622c9f1b6429cd0a048d6d28ab31cd1bc..156376943dafee006e73d8ff5d97dfd34681814c 100644 (file)
@@ -12,7 +12,10 @@ const webfinger = new WebFinger({
   request_timeout: 3000
 })
 
-async function loadActorUrlOrGetFromWebfinger (uri: string) {
+async function loadActorUrlOrGetFromWebfinger (uriArg: string) {
+  // Handle strings like @toto@example.com
+  const uri = uriArg.startsWith('@') ? uriArg.slice(1) : uriArg
+
   const [ name, host ] = uri.split('@')
   let actor: ActorModel
 
index 2fdfd18760d2d54616b841926d621b4db7b0d28e..f4b44bc4ff6c452ca352cd77ccba41ac1580dba5 100644 (file)
@@ -24,7 +24,7 @@ function downloadWebTorrentVideo (target: { magnetUri: string, torrentName?: str
         if (timer) clearTimeout(timer)
 
         return safeWebtorrentDestroy(webtorrent, torrentId, file.name, target.torrentName)
-          .then(() => rej(new Error('The number of files is not equal to 1 for ' + torrentId)))
+          .then(() => rej(new Error('Cannot import torrent ' + torrentId + ': there are multiple files in it')))
       }
 
       file = torrent.files[ 0 ]
index 8b2bc17824f511866ffc1509dc7fa8afc71b7b43..25e719cc3654d65e01ceedbfef03b96f3fd7e239 100644 (file)
@@ -2,7 +2,11 @@ import { truncate } from 'lodash'
 import { CONSTRAINTS_FIELDS, VIDEO_CATEGORIES } from '../initializers'
 import { logger } from './logger'
 import { generateVideoTmpPath } from './utils'
-import { YoutubeDlUpdateScheduler } from '../lib/schedulers/youtube-dl-update-scheduler'
+import { join } from 'path'
+import { root } from './core-utils'
+import { ensureDir, writeFile } from 'fs-extra'
+import * as request from 'request'
+import { createWriteStream } from 'fs'
 
 export type YoutubeDLInfo = {
   name?: string
@@ -40,7 +44,7 @@ function downloadYoutubeDLVideo (url: string) {
 
   return new Promise<string>(async (res, rej) => {
     const youtubeDL = await safeGetYoutubeDL()
-    youtubeDL.exec(url, options, async (err, output) => {
+    youtubeDL.exec(url, options, err => {
       if (err) return rej(err)
 
       return res(path)
@@ -48,6 +52,64 @@ function downloadYoutubeDLVideo (url: string) {
   })
 }
 
+// Thanks: https://github.com/przemyslawpluta/node-youtube-dl/blob/master/lib/downloader.js
+// We rewrote it to avoid sync calls
+async function updateYoutubeDLBinary () {
+  logger.info('Updating youtubeDL binary.')
+
+  const binDirectory = join(root(), 'node_modules', 'youtube-dl', 'bin')
+  const bin = join(binDirectory, 'youtube-dl')
+  const detailsPath = join(binDirectory, 'details')
+  const url = 'https://yt-dl.org/downloads/latest/youtube-dl'
+
+  await ensureDir(binDirectory)
+
+  return new Promise(res => {
+    request.get(url, { followRedirect: false }, (err, result) => {
+      if (err) {
+        logger.error('Cannot update youtube-dl.', { err })
+        return res()
+      }
+
+      if (result.statusCode !== 302) {
+        logger.error('youtube-dl update error: did not get redirect for the latest version link. Status %d', result.statusCode)
+        return res()
+      }
+
+      const url = result.headers.location
+      const downloadFile = request.get(url)
+      const newVersion = /yt-dl\.org\/downloads\/(\d{4}\.\d\d\.\d\d(\.\d)?)\/youtube-dl/.exec(url)[ 1 ]
+
+      downloadFile.on('response', result => {
+        if (result.statusCode !== 200) {
+          logger.error('Cannot update youtube-dl: new version response is not 200, it\'s %d.', result.statusCode)
+          return res()
+        }
+
+        downloadFile.pipe(createWriteStream(bin, { mode: 493 }))
+      })
+
+      downloadFile.on('error', err => {
+        logger.error('youtube-dl update error.', { err })
+        return res()
+      })
+
+      downloadFile.on('end', () => {
+        const details = JSON.stringify({ version: newVersion, path: bin, exec: 'youtube-dl' })
+        writeFile(detailsPath, details, { encoding: 'utf8' }, err => {
+          if (err) {
+            logger.error('youtube-dl update error: cannot write details.', { err })
+            return res()
+          }
+
+          logger.info('youtube-dl updated to version %s.', newVersion)
+          return res()
+        })
+      })
+    })
+  })
+}
+
 async function safeGetYoutubeDL () {
   let youtubeDL
 
@@ -55,7 +117,7 @@ async function safeGetYoutubeDL () {
     youtubeDL = require('youtube-dl')
   } catch (e) {
     // Download binary
-    await YoutubeDlUpdateScheduler.Instance.execute()
+    await updateYoutubeDLBinary()
     youtubeDL = require('youtube-dl')
   }
 
@@ -65,6 +127,7 @@ async function safeGetYoutubeDL () {
 // ---------------------------------------------------------------------------
 
 export {
+  updateYoutubeDLBinary,
   downloadYoutubeDLVideo,
   getYoutubeDLInfo,
   safeGetYoutubeDL
index 6a2badd3580e4c7b4c4b36d260966bde5df9d5c4..a54f6155be410612129293efe2f765387984dd36 100644 (file)
@@ -7,7 +7,7 @@ import { parse } from 'url'
 import { CONFIG } from './constants'
 import { logger } from '../helpers/logger'
 import { getServerActor } from '../helpers/utils'
-import { VideosRedundancy } from '../../shared/models/redundancy'
+import { RecentlyAddedStrategy, VideosRedundancy } from '../../shared/models/redundancy'
 import { isArray } from '../helpers/custom-validators/misc'
 import { uniq } from 'lodash'
 
@@ -34,21 +34,28 @@ async function checkActivityPubUrls () {
 function checkConfig () {
   const defaultNSFWPolicy = config.get<string>('instance.default_nsfw_policy')
 
+  // NSFW policy
   if ([ 'do_not_list', 'blur', 'display' ].indexOf(defaultNSFWPolicy) === -1) {
     return 'NSFW policy setting should be "do_not_list" or "blur" or "display" instead of ' + defaultNSFWPolicy
   }
 
-  const redundancyVideos = config.get<VideosRedundancy[]>('redundancy.videos')
+  // Redundancies
+  const redundancyVideos = config.get<VideosRedundancy[]>('redundancy.videos.strategies')
   if (isArray(redundancyVideos)) {
     for (const r of redundancyVideos) {
-      if ([ 'most-views' ].indexOf(r.strategy) === -1) {
+      if ([ 'most-views', 'trending', 'recently-added' ].indexOf(r.strategy) === -1) {
         return 'Redundancy video entries should have "most-views" strategy instead of ' + r.strategy
       }
     }
 
     const filtered = uniq(redundancyVideos.map(r => r.strategy))
     if (filtered.length !== redundancyVideos.length) {
-      return 'Redundancy video entries should have uniq strategies'
+      return 'Redundancy video entries should have unique strategies'
+    }
+
+    const recentlyAddedStrategy = redundancyVideos.find(r => r.strategy === 'recently-added') as RecentlyAddedStrategy
+    if (recentlyAddedStrategy && isNaN(recentlyAddedStrategy.minViews)) {
+      return 'Min views in recently added strategy is not a number'
     }
   }
 
@@ -68,6 +75,7 @@ function checkMissedConfig () {
     'cache.previews.size', 'admin.email',
     'signup.enabled', 'signup.limit', 'signup.requires_email_verification',
     'signup.filters.cidr.whitelist', 'signup.filters.cidr.blacklist',
+    'redundancy.videos.strategies', 'redundancy.videos.check_interval',
     'transcoding.enabled', 'transcoding.threads',
     'import.videos.http.enabled', 'import.videos.torrent.enabled',
     'trending.videos.interval_days',
index 6b4afbfd8a1e6f42ab4a477b306cc00076afc922..03424ffb8f4d2ffc4f9adea006d728c14673c33f 100644 (file)
@@ -1,11 +1,11 @@
 import { IConfig } from 'config'
 import { dirname, join } from 'path'
-import { JobType, VideoRateType, VideoRedundancyStrategy, VideoState, VideosRedundancy } from '../../shared/models'
+import { JobType, VideoRateType, VideoState, VideosRedundancy } from '../../shared/models'
 import { ActivityPubActorType } from '../../shared/models/activitypub'
 import { FollowState } from '../../shared/models/actors'
 import { VideoAbuseState, VideoImportState, VideoPrivacy } from '../../shared/models/videos'
 // Do not use barrels, remain constants as independent as possible
-import { buildPath, isTestInstance, root, sanitizeHost, sanitizeUrl } from '../helpers/core-utils'
+import { buildPath, isTestInstance, parseDuration, root, sanitizeHost, sanitizeUrl } from '../helpers/core-utils'
 import { NSFWPolicyType } from '../../shared/models/videos/nsfw-policy.type'
 import { invert } from 'lodash'
 import { CronRepeatOptions, EveryRepeatOptions } from 'bull'
@@ -66,7 +66,8 @@ const ROUTE_CACHE_LIFETIME = {
   },
   ACTIVITY_PUB: {
     VIDEOS: '1 second' // 1 second, cache concurrent requests after a broadcast for example
-  }
+  },
+  STATS: '4 hours'
 }
 
 // ---------------------------------------------------------------------------
@@ -138,8 +139,7 @@ let SCHEDULER_INTERVALS_MS = {
   badActorFollow: 60000 * 60, // 1 hour
   removeOldJobs: 60000 * 60, // 1 hour
   updateVideos: 60000, // 1 minute
-  youtubeDLUpdate: 60000 * 60 * 24, // 1 day
-  videosRedundancy: 60000 * 2 // 2 hours
+  youtubeDLUpdate: 60000 * 60 * 24 // 1 day
 }
 
 // ---------------------------------------------------------------------------
@@ -211,7 +211,10 @@ const CONFIG = {
     }
   },
   REDUNDANCY: {
-    VIDEOS: buildVideosRedundancy(config.get<any[]>('redundancy.videos'))
+    VIDEOS: {
+      CHECK_INTERVAL: parseDuration(config.get<string>('redundancy.videos.check_interval')),
+      STRATEGIES: buildVideosRedundancy(config.get<any[]>('redundancy.videos.strategies'))
+    }
   },
   ADMIN: {
     get EMAIL () { return config.get<string>('admin.email') }
@@ -592,6 +595,10 @@ const CACHE = {
   }
 }
 
+const MEMOIZE_TTL = {
+  OVERVIEWS_SAMPLE: 1000 * 3600 * 4 // 4 hours
+}
+
 const REDUNDANCY = {
   VIDEOS: {
     EXPIRES_AFTER_MS: 48 * 3600 * 1000, // 2 days
@@ -644,7 +651,6 @@ if (isTestInstance() === true) {
   SCHEDULER_INTERVALS_MS.badActorFollow = 10000
   SCHEDULER_INTERVALS_MS.removeOldJobs = 10000
   SCHEDULER_INTERVALS_MS.updateVideos = 5000
-  SCHEDULER_INTERVALS_MS.videosRedundancy = 5000
   REPEAT_JOBS['videos-views'] = { every: 5000 }
 
   REDUNDANCY.VIDEOS.RANDOMIZED_FACTOR = 1
@@ -654,6 +660,8 @@ if (isTestInstance() === true) {
   JOB_ATTEMPTS['email'] = 1
 
   CACHE.VIDEO_CAPTIONS.MAX_AGE = 3000
+  MEMOIZE_TTL.OVERVIEWS_SAMPLE = 1
+  ROUTE_CACHE_LIFETIME.OVERVIEWS.VIDEOS = '0ms'
 }
 
 updateWebserverConfig()
@@ -708,6 +716,7 @@ export {
   VIDEO_ABUSE_STATES,
   JOB_REQUEST_TIMEOUT,
   USER_PASSWORD_RESET_LIFETIME,
+  MEMOIZE_TTL,
   USER_EMAIL_VERIFY_LIFETIME,
   IMAGE_MIMETYPE_EXT,
   OVERVIEWS,
@@ -741,15 +750,10 @@ function updateWebserverConfig () {
   CONFIG.WEBSERVER.HOST = sanitizeHost(CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT, REMOTE_SCHEME.HTTP)
 }
 
-function buildVideosRedundancy (objs: { strategy: VideoRedundancyStrategy, size: string }[]): VideosRedundancy[] {
+function buildVideosRedundancy (objs: VideosRedundancy[]): VideosRedundancy[] {
   if (!objs) return []
 
-  return objs.map(obj => {
-    return {
-      strategy: obj.strategy,
-      size: bytes.parse(obj.size)
-    }
-  })
+  return objs.map(obj => Object.assign(obj, { size: bytes.parse(obj.size) }))
 }
 
 function buildLanguages () {
index 3464add0311e2c6410d22f45a9ac1e66a50ac150..d37a695a725dc7c3abf44d889382a9dea8b18d76 100644 (file)
@@ -21,6 +21,7 @@ import { ServerModel } from '../../models/server/server'
 import { VideoChannelModel } from '../../models/video/video-channel'
 import { JobQueue } from '../job-queue'
 import { getServerActor } from '../../helpers/utils'
+import { ActorFetchByUrlType, fetchActorByUrl } from '../../helpers/actor'
 
 // Set account keys, this could be long so process after the account creation and do not block the client
 function setAsyncActorKeys (actor: ActorModel) {
@@ -38,13 +39,14 @@ function setAsyncActorKeys (actor: ActorModel) {
 
 async function getOrCreateActorAndServerAndModel (
   activityActor: string | ActivityPubActor,
+  fetchType: ActorFetchByUrlType = 'actor-and-association-ids',
   recurseIfNeeded = true,
   updateCollections = false
 ) {
   const actorUrl = getActorUrl(activityActor)
   let created = false
 
-  let actor = await ActorModel.loadByUrl(actorUrl)
+  let actor = await fetchActorByUrl(actorUrl, fetchType)
   // Orphan actor (not associated to an account of channel) so recreate it
   if (actor && (!actor.Account && !actor.VideoChannel)) {
     await actor.destroy()
@@ -65,7 +67,7 @@ async function getOrCreateActorAndServerAndModel (
 
       try {
         // Assert we don't recurse another time
-        ownerActor = await getOrCreateActorAndServerAndModel(accountAttributedTo.id, false)
+        ownerActor = await getOrCreateActorAndServerAndModel(accountAttributedTo.id, 'all', false)
       } catch (err) {
         logger.error('Cannot get or create account attributed to video channel ' + actor.url)
         throw new Error(err)
@@ -79,7 +81,7 @@ async function getOrCreateActorAndServerAndModel (
   if (actor.Account) actor.Account.Actor = actor
   if (actor.VideoChannel) actor.VideoChannel.Actor = actor
 
-  const { actor: actorRefreshed, refreshed } = await retryTransactionWrapper(refreshActorIfNeeded, actor)
+  const { actor: actorRefreshed, refreshed } = await retryTransactionWrapper(refreshActorIfNeeded, actor, fetchType)
   if (!actorRefreshed) throw new Error('Actor ' + actorRefreshed.url + ' does not exist anymore.')
 
   if ((created === true || refreshed === true) && updateCollections === true) {
@@ -370,8 +372,14 @@ async function saveVideoChannel (actor: ActorModel, result: FetchRemoteActorResu
   return videoChannelCreated
 }
 
-async function refreshActorIfNeeded (actor: ActorModel): Promise<{ actor: ActorModel, refreshed: boolean }> {
-  if (!actor.isOutdated()) return { actor, refreshed: false }
+async function refreshActorIfNeeded (
+  actorArg: ActorModel,
+  fetchedType: ActorFetchByUrlType
+): Promise<{ actor: ActorModel, refreshed: boolean }> {
+  if (!actorArg.isOutdated()) return { actor: actorArg, refreshed: false }
+
+  // We need more attributes
+  const actor = fetchedType === 'all' ? actorArg : await ActorModel.loadByUrlAndPopulateAccountAndChannel(actorArg.url)
 
   try {
     const actorUrl = await getUrlFromWebfinger(actor.preferredUsername + '@' + actor.getHost())
index 7b4067c11c62eeee8318f8667274e5fbb8211729..a86428461c365ca35217acd2f3fe266e859348be 100644 (file)
@@ -6,7 +6,7 @@ import { VideoModel } from '../../models/video/video'
 import { VideoCommentModel } from '../../models/video/video-comment'
 import { VideoShareModel } from '../../models/video/video-share'
 
-function getVideoAudience (video: VideoModel, actorsInvolvedInVideo: ActorModel[]) {
+function getRemoteVideoAudience (video: VideoModel, actorsInvolvedInVideo: ActorModel[]): ActivityAudience {
   return {
     to: [ video.VideoChannel.Account.Actor.url ],
     cc: actorsInvolvedInVideo.map(a => a.followersUrl)
@@ -18,7 +18,7 @@ function getVideoCommentAudience (
   threadParentComments: VideoCommentModel[],
   actorsInvolvedInVideo: ActorModel[],
   isOrigin = false
-) {
+): ActivityAudience {
   const to = [ ACTIVITY_PUB.PUBLIC ]
   const cc: string[] = []
 
@@ -41,7 +41,7 @@ function getVideoCommentAudience (
   }
 }
 
-function getObjectFollowersAudience (actorsInvolvedInObject: ActorModel[]) {
+function getAudienceFromFollowersOf (actorsInvolvedInObject: ActorModel[]): ActivityAudience {
   return {
     to: [ ACTIVITY_PUB.PUBLIC ].concat(actorsInvolvedInObject.map(a => a.followersUrl)),
     cc: []
@@ -83,9 +83,9 @@ function audiencify<T> (object: T, audience: ActivityAudience) {
 export {
   buildAudience,
   getAudience,
-  getVideoAudience,
+  getRemoteVideoAudience,
   getActorsInvolvedInVideo,
-  getObjectFollowersAudience,
+  getAudienceFromFollowersOf,
   audiencify,
   getVideoCommentAudience
 }
index 7325ddcb66f626849545e6a8a6dc5666bce47e1d..87f8a4162b0a7770d4327499486ab5dc9c0474d2 100644 (file)
@@ -1,10 +1,9 @@
 import { CacheFileObject } from '../../../shared/index'
 import { VideoModel } from '../../models/video/video'
-import { ActorModel } from '../../models/activitypub/actor'
 import { sequelizeTypescript } from '../../initializers'
 import { VideoRedundancyModel } from '../../models/redundancy/video-redundancy'
 
-function cacheFileActivityObjectToDBAttributes (cacheFileObject: CacheFileObject, video: VideoModel, byActor: ActorModel) {
+function cacheFileActivityObjectToDBAttributes (cacheFileObject: CacheFileObject, video: VideoModel, byActor: { id?: number }) {
   const url = cacheFileObject.url
 
   const videoFile = video.VideoFiles.find(f => {
@@ -23,7 +22,7 @@ function cacheFileActivityObjectToDBAttributes (cacheFileObject: CacheFileObject
   }
 }
 
-function createCacheFile (cacheFileObject: CacheFileObject, video: VideoModel, byActor: ActorModel) {
+function createCacheFile (cacheFileObject: CacheFileObject, video: VideoModel, byActor: { id?: number }) {
   return sequelizeTypescript.transaction(async t => {
     const attributes = cacheFileActivityObjectToDBAttributes(cacheFileObject, video, byActor)
 
@@ -31,7 +30,11 @@ function createCacheFile (cacheFileObject: CacheFileObject, video: VideoModel, b
   })
 }
 
-function updateCacheFile (cacheFileObject: CacheFileObject, redundancyModel: VideoRedundancyModel, byActor: ActorModel) {
+function updateCacheFile (cacheFileObject: CacheFileObject, redundancyModel: VideoRedundancyModel, byActor: { id?: number }) {
+  if (redundancyModel.actorId !== byActor.id) {
+    throw new Error('Cannot update redundancy ' + redundancyModel.url + ' of another actor.')
+  }
+
   const attributes = cacheFileActivityObjectToDBAttributes(cacheFileObject, redundancyModel.VideoFile.Video, byActor)
 
   redundancyModel.set('expires', attributes.expiresOn)
index 046370b79dc0124366276903110d43c7657ff995..89bda9c32a531c4d49472702e59801ccae403582 100644 (file)
@@ -1,15 +1,11 @@
 import { ActivityAccept } from '../../../../shared/models/activitypub'
-import { getActorUrl } from '../../../helpers/activitypub'
 import { ActorModel } from '../../../models/activitypub/actor'
 import { ActorFollowModel } from '../../../models/activitypub/actor-follow'
 import { addFetchOutboxJob } from '../actor'
 
-async function processAcceptActivity (activity: ActivityAccept, inboxActor?: ActorModel) {
+async function processAcceptActivity (activity: ActivityAccept, targetActor: ActorModel, inboxActor?: ActorModel) {
   if (inboxActor === undefined) throw new Error('Need to accept on explicit inbox.')
 
-  const actorUrl = getActorUrl(activity.actor)
-  const targetActor = await ActorModel.loadByUrl(actorUrl)
-
   return processAccept(inboxActor, targetActor)
 }
 
index 814556817314410652cf8e43e89d12cdc3422339..cc88b5423702f0be03dfd8d563b054e3b07c95e2 100644 (file)
@@ -2,15 +2,11 @@ import { ActivityAnnounce } from '../../../../shared/models/activitypub'
 import { retryTransactionWrapper } from '../../../helpers/database-utils'
 import { sequelizeTypescript } from '../../../initializers'
 import { ActorModel } from '../../../models/activitypub/actor'
-import { VideoModel } from '../../../models/video/video'
 import { VideoShareModel } from '../../../models/video/video-share'
-import { getOrCreateActorAndServerAndModel } from '../actor'
 import { forwardVideoRelatedActivity } from '../send/utils'
 import { getOrCreateVideoAndAccountAndChannel } from '../videos'
 
-async function processAnnounceActivity (activity: ActivityAnnounce) {
-  const actorAnnouncer = await getOrCreateActorAndServerAndModel(activity.actor)
-
+async function processAnnounceActivity (activity: ActivityAnnounce, actorAnnouncer: ActorModel) {
   return retryTransactionWrapper(processVideoShare, actorAnnouncer, activity)
 }
 
@@ -25,7 +21,7 @@ export {
 async function processVideoShare (actorAnnouncer: ActorModel, activity: ActivityAnnounce) {
   const objectUri = typeof activity.object === 'string' ? activity.object : activity.object.id
 
-  const { video } = await getOrCreateVideoAndAccountAndChannel(objectUri)
+  const { video } = await getOrCreateVideoAndAccountAndChannel({ videoObject: objectUri })
 
   return sequelizeTypescript.transaction(async t => {
     // Add share entry
index 32e555acf6cb209f47529bf4754cf09610c33480..5197dac73455bef70c8d2cff984775c88404e713 100644 (file)
@@ -7,30 +7,28 @@ import { sequelizeTypescript } from '../../../initializers'
 import { AccountVideoRateModel } from '../../../models/account/account-video-rate'
 import { ActorModel } from '../../../models/activitypub/actor'
 import { VideoAbuseModel } from '../../../models/video/video-abuse'
-import { getOrCreateActorAndServerAndModel } from '../actor'
 import { addVideoComment, resolveThread } from '../video-comments'
 import { getOrCreateVideoAndAccountAndChannel } from '../videos'
 import { forwardActivity, forwardVideoRelatedActivity } from '../send/utils'
 import { Redis } from '../../redis'
 import { createCacheFile } from '../cache-file'
 
-async function processCreateActivity (activity: ActivityCreate) {
+async function processCreateActivity (activity: ActivityCreate, byActor: ActorModel) {
   const activityObject = activity.object
   const activityType = activityObject.type
-  const actor = await getOrCreateActorAndServerAndModel(activity.actor)
 
   if (activityType === 'View') {
-    return processCreateView(actor, activity)
+    return processCreateView(byActor, activity)
   } else if (activityType === 'Dislike') {
-    return retryTransactionWrapper(processCreateDislike, actor, activity)
+    return retryTransactionWrapper(processCreateDislike, byActor, activity)
   } else if (activityType === 'Video') {
     return processCreateVideo(activity)
   } else if (activityType === 'Flag') {
-    return retryTransactionWrapper(processCreateVideoAbuse, actor, activityObject as VideoAbuseObject)
+    return retryTransactionWrapper(processCreateVideoAbuse, byActor, activityObject as VideoAbuseObject)
   } else if (activityType === 'Note') {
-    return retryTransactionWrapper(processCreateVideoComment, actor, activity)
+    return retryTransactionWrapper(processCreateVideoComment, byActor, activity)
   } else if (activityType === 'CacheFile') {
-    return retryTransactionWrapper(processCacheFile, actor, activity)
+    return retryTransactionWrapper(processCacheFile, byActor, activity)
   }
 
   logger.warn('Unknown activity object type %s when creating activity.', activityType, { activity: activity.id })
@@ -48,7 +46,7 @@ export {
 async function processCreateVideo (activity: ActivityCreate) {
   const videoToCreateData = activity.object as VideoTorrentObject
 
-  const { video } = await getOrCreateVideoAndAccountAndChannel(videoToCreateData)
+  const { video } = await getOrCreateVideoAndAccountAndChannel({ videoObject: videoToCreateData })
 
   return video
 }
@@ -59,7 +57,7 @@ async function processCreateDislike (byActor: ActorModel, activity: ActivityCrea
 
   if (!byAccount) throw new Error('Cannot create dislike with the non account actor ' + byActor.url)
 
-  const { video } = await getOrCreateVideoAndAccountAndChannel(dislike.object)
+  const { video } = await getOrCreateVideoAndAccountAndChannel({ videoObject: dislike.object })
 
   return sequelizeTypescript.transaction(async t => {
     const rate = {
@@ -86,10 +84,14 @@ async function processCreateDislike (byActor: ActorModel, activity: ActivityCrea
 async function processCreateView (byActor: ActorModel, activity: ActivityCreate) {
   const view = activity.object as ViewObject
 
-  const { video } = await getOrCreateVideoAndAccountAndChannel(view.object)
+  const options = {
+    videoObject: view.object,
+    fetchType: 'only-video' as 'only-video'
+  }
+  const { video } = await getOrCreateVideoAndAccountAndChannel(options)
 
-  const actor = await ActorModel.loadByUrl(view.actor)
-  if (!actor) throw new Error('Unknown actor ' + view.actor)
+  const actorExists = await ActorModel.isActorUrlExist(view.actor)
+  if (actorExists === false) throw new Error('Unknown actor ' + view.actor)
 
   await Redis.Instance.addVideoView(video.id)
 
@@ -103,7 +105,7 @@ async function processCreateView (byActor: ActorModel, activity: ActivityCreate)
 async function processCacheFile (byActor: ActorModel, activity: ActivityCreate) {
   const cacheFile = activity.object as CacheFileObject
 
-  const { video } = await getOrCreateVideoAndAccountAndChannel(cacheFile.object)
+  const { video } = await getOrCreateVideoAndAccountAndChannel({ videoObject: cacheFile.object })
 
   await createCacheFile(cacheFile, video, byActor)
 
@@ -114,13 +116,13 @@ async function processCacheFile (byActor: ActorModel, activity: ActivityCreate)
   }
 }
 
-async function processCreateVideoAbuse (actor: ActorModel, videoAbuseToCreateData: VideoAbuseObject) {
+async function processCreateVideoAbuse (byActor: ActorModel, videoAbuseToCreateData: VideoAbuseObject) {
   logger.debug('Reporting remote abuse for video %s.', videoAbuseToCreateData.object)
 
-  const account = actor.Account
-  if (!account) throw new Error('Cannot create dislike with the non account actor ' + actor.url)
+  const account = byActor.Account
+  if (!account) throw new Error('Cannot create dislike with the non account actor ' + byActor.url)
 
-  const { video } = await getOrCreateVideoAndAccountAndChannel(videoAbuseToCreateData.object)
+  const { video } = await getOrCreateVideoAndAccountAndChannel({ videoObject: videoAbuseToCreateData.object })
 
   return sequelizeTypescript.transaction(async t => {
     const videoAbuseData = {
index 3c830abea7a529b11a06bc000e437750a254709d..038d8c4d30dcadde3bb2d5d2a187afe4f077f815 100644 (file)
@@ -7,41 +7,41 @@ import { ActorModel } from '../../../models/activitypub/actor'
 import { VideoModel } from '../../../models/video/video'
 import { VideoChannelModel } from '../../../models/video/video-channel'
 import { VideoCommentModel } from '../../../models/video/video-comment'
-import { getOrCreateActorAndServerAndModel } from '../actor'
 import { forwardActivity } from '../send/utils'
 
-async function processDeleteActivity (activity: ActivityDelete) {
+async function processDeleteActivity (activity: ActivityDelete, byActor: ActorModel) {
   const objectUrl = typeof activity.object === 'string' ? activity.object : activity.object.id
 
   if (activity.actor === objectUrl) {
-    let actor = await ActorModel.loadByUrl(activity.actor)
-    if (!actor) return undefined
+    // We need more attributes (all the account and channel)
+    const byActorFull = await ActorModel.loadByUrlAndPopulateAccountAndChannel(byActor.url)
 
-    if (actor.type === 'Person') {
-      if (!actor.Account) throw new Error('Actor ' + actor.url + ' is a person but we cannot find it in database.')
+    if (byActorFull.type === 'Person') {
+      if (!byActorFull.Account) throw new Error('Actor ' + byActorFull.url + ' is a person but we cannot find it in database.')
 
-      actor.Account.Actor = await actor.Account.$get('Actor') as ActorModel
-      return retryTransactionWrapper(processDeleteAccount, actor.Account)
-    } else if (actor.type === 'Group') {
-      if (!actor.VideoChannel) throw new Error('Actor ' + actor.url + ' is a group but we cannot find it in database.')
+      byActorFull.Account.Actor = await byActorFull.Account.$get('Actor') as ActorModel
+      return retryTransactionWrapper(processDeleteAccount, byActorFull.Account)
+    } else if (byActorFull.type === 'Group') {
+      if (!byActorFull.VideoChannel) throw new Error('Actor ' + byActorFull.url + ' is a group but we cannot find it in database.')
 
-      actor.VideoChannel.Actor = await actor.VideoChannel.$get('Actor') as ActorModel
-      return retryTransactionWrapper(processDeleteVideoChannel, actor.VideoChannel)
+      byActorFull.VideoChannel.Actor = await byActorFull.VideoChannel.$get('Actor') as ActorModel
+      return retryTransactionWrapper(processDeleteVideoChannel, byActorFull.VideoChannel)
     }
   }
 
-  const actor = await getOrCreateActorAndServerAndModel(activity.actor)
   {
     const videoCommentInstance = await VideoCommentModel.loadByUrlAndPopulateAccount(objectUrl)
     if (videoCommentInstance) {
-      return retryTransactionWrapper(processDeleteVideoComment, actor, videoCommentInstance, activity)
+      return retryTransactionWrapper(processDeleteVideoComment, byActor, videoCommentInstance, activity)
     }
   }
 
   {
     const videoInstance = await VideoModel.loadByUrlAndPopulateAccount(objectUrl)
     if (videoInstance) {
-      return retryTransactionWrapper(processDeleteVideo, actor, videoInstance)
+      if (videoInstance.isOwned()) throw new Error(`Remote instance cannot delete owned video ${videoInstance.url}.`)
+
+      return retryTransactionWrapper(processDeleteVideo, byActor, videoInstance)
     }
   }
 
@@ -94,6 +94,10 @@ function processDeleteVideoComment (byActor: ActorModel, videoComment: VideoComm
   logger.debug('Removing remote video comment "%s".', videoComment.url)
 
   return sequelizeTypescript.transaction(async t => {
+    if (videoComment.Account.id !== byActor.Account.id) {
+      throw new Error('Account ' + byActor.url + ' does not own video comment ' + videoComment.url)
+    }
+
     await videoComment.destroy({ transaction: t })
 
     if (videoComment.Video.isOwned()) {
index f34fd66cc24e953882dfb0d94d76391db3b3b1af..24c9085f7ac77520a537e713e8c09a336518341c 100644 (file)
@@ -4,14 +4,12 @@ import { logger } from '../../../helpers/logger'
 import { sequelizeTypescript } from '../../../initializers'
 import { ActorModel } from '../../../models/activitypub/actor'
 import { ActorFollowModel } from '../../../models/activitypub/actor-follow'
-import { getOrCreateActorAndServerAndModel } from '../actor'
 import { sendAccept } from '../send'
 
-async function processFollowActivity (activity: ActivityFollow) {
+async function processFollowActivity (activity: ActivityFollow, byActor: ActorModel) {
   const activityObject = activity.object
-  const actor = await getOrCreateActorAndServerAndModel(activity.actor)
 
-  return retryTransactionWrapper(processFollow, actor, activityObject)
+  return retryTransactionWrapper(processFollow, byActor, activityObject)
 }
 
 // ---------------------------------------------------------------------------
@@ -24,7 +22,7 @@ export {
 
 async function processFollow (actor: ActorModel, targetActorURL: string) {
   await sequelizeTypescript.transaction(async t => {
-    const targetActor = await ActorModel.loadByUrl(targetActorURL, t)
+    const targetActor = await ActorModel.loadByUrlAndPopulateAccountAndChannel(targetActorURL, t)
 
     if (!targetActor) throw new Error('Unknown actor')
     if (targetActor.isOwned() === false) throw new Error('This is not a local actor.')
index 9e1664fd8ed8040d1343251b32dd7510ad923a70..f7200db6187be4efb649b9ee25d4c0256eecf499 100644 (file)
@@ -3,14 +3,11 @@ import { retryTransactionWrapper } from '../../../helpers/database-utils'
 import { sequelizeTypescript } from '../../../initializers'
 import { AccountVideoRateModel } from '../../../models/account/account-video-rate'
 import { ActorModel } from '../../../models/activitypub/actor'
-import { getOrCreateActorAndServerAndModel } from '../actor'
 import { forwardVideoRelatedActivity } from '../send/utils'
 import { getOrCreateVideoAndAccountAndChannel } from '../videos'
 
-async function processLikeActivity (activity: ActivityLike) {
-  const actor = await getOrCreateActorAndServerAndModel(activity.actor)
-
-  return retryTransactionWrapper(processLikeVideo, actor, activity)
+async function processLikeActivity (activity: ActivityLike, byActor: ActorModel) {
+  return retryTransactionWrapper(processLikeVideo, byActor, activity)
 }
 
 // ---------------------------------------------------------------------------
@@ -27,7 +24,7 @@ async function processLikeVideo (byActor: ActorModel, activity: ActivityLike) {
   const byAccount = byActor.Account
   if (!byAccount) throw new Error('Cannot create like with the non account actor ' + byActor.url)
 
-  const { video } = await getOrCreateVideoAndAccountAndChannel(videoUrl)
+  const { video } = await getOrCreateVideoAndAccountAndChannel({ videoObject: videoUrl })
 
   return sequelizeTypescript.transaction(async t => {
     const rate = {
index f06b03772d08c6135d9d6dc90ce42b209a54eb2c..709a6509628d5a867f2418e3896010a12ad6f1ce 100644 (file)
@@ -1,15 +1,11 @@
 import { ActivityReject } from '../../../../shared/models/activitypub/activity'
-import { getActorUrl } from '../../../helpers/activitypub'
 import { sequelizeTypescript } from '../../../initializers'
 import { ActorModel } from '../../../models/activitypub/actor'
 import { ActorFollowModel } from '../../../models/activitypub/actor-follow'
 
-async function processRejectActivity (activity: ActivityReject, inboxActor?: ActorModel) {
+async function processRejectActivity (activity: ActivityReject, targetActor: ActorModel, inboxActor?: ActorModel) {
   if (inboxActor === undefined) throw new Error('Need to reject on explicit inbox.')
 
-  const actorUrl = getActorUrl(activity.actor)
-  const targetActor = await ActorModel.loadByUrl(actorUrl)
-
   return processReject(inboxActor, targetActor)
 }
 
@@ -21,11 +17,11 @@ export {
 
 // ---------------------------------------------------------------------------
 
-async function processReject (actor: ActorModel, targetActor: ActorModel) {
+async function processReject (follower: ActorModel, targetActor: ActorModel) {
   return sequelizeTypescript.transaction(async t => {
-    const actorFollow = await ActorFollowModel.loadByActorAndTarget(actor.id, targetActor.id, t)
+    const actorFollow = await ActorFollowModel.loadByActorAndTarget(follower.id, targetActor.id, t)
 
-    if (!actorFollow) throw new Error(`'Unknown actor follow ${actor.id} -> ${targetActor.id}.`)
+    if (!actorFollow) throw new Error(`'Unknown actor follow ${follower.id} -> ${targetActor.id}.`)
 
     await actorFollow.destroy({ transaction: t })
 
index 0eb5fa392161f30ad31283218bcfe6568f4d454f..73ca0a17c221e75a83e3f0539eeeba6644cc9a64 100644 (file)
@@ -1,10 +1,8 @@
 import { ActivityAnnounce, ActivityFollow, ActivityLike, ActivityUndo, CacheFileObject } from '../../../../shared/models/activitypub'
 import { DislikeObject } from '../../../../shared/models/activitypub/objects'
-import { getActorUrl } from '../../../helpers/activitypub'
 import { retryTransactionWrapper } from '../../../helpers/database-utils'
 import { logger } from '../../../helpers/logger'
 import { sequelizeTypescript } from '../../../initializers'
-import { AccountModel } from '../../../models/account/account'
 import { AccountVideoRateModel } from '../../../models/account/account-video-rate'
 import { ActorModel } from '../../../models/activitypub/actor'
 import { ActorFollowModel } from '../../../models/activitypub/actor-follow'
@@ -13,29 +11,27 @@ import { getOrCreateVideoAndAccountAndChannel } from '../videos'
 import { VideoShareModel } from '../../../models/video/video-share'
 import { VideoRedundancyModel } from '../../../models/redundancy/video-redundancy'
 
-async function processUndoActivity (activity: ActivityUndo) {
+async function processUndoActivity (activity: ActivityUndo, byActor: ActorModel) {
   const activityToUndo = activity.object
 
-  const actorUrl = getActorUrl(activity.actor)
-
   if (activityToUndo.type === 'Like') {
-    return retryTransactionWrapper(processUndoLike, actorUrl, activity)
+    return retryTransactionWrapper(processUndoLike, byActor, activity)
   }
 
   if (activityToUndo.type === 'Create') {
     if (activityToUndo.object.type === 'Dislike') {
-      return retryTransactionWrapper(processUndoDislike, actorUrl, activity)
+      return retryTransactionWrapper(processUndoDislike, byActor, activity)
     } else if (activityToUndo.object.type === 'CacheFile') {
-      return retryTransactionWrapper(processUndoCacheFile, actorUrl, activity)
+      return retryTransactionWrapper(processUndoCacheFile, byActor, activity)
     }
   }
 
   if (activityToUndo.type === 'Follow') {
-    return retryTransactionWrapper(processUndoFollow, actorUrl, activityToUndo)
+    return retryTransactionWrapper(processUndoFollow, byActor, activityToUndo)
   }
 
   if (activityToUndo.type === 'Announce') {
-    return retryTransactionWrapper(processUndoAnnounce, actorUrl, activityToUndo)
+    return retryTransactionWrapper(processUndoAnnounce, byActor, activityToUndo)
   }
 
   logger.warn('Unknown activity object type %s -> %s when undo activity.', activityToUndo.type, { activity: activity.id })
@@ -51,66 +47,63 @@ export {
 
 // ---------------------------------------------------------------------------
 
-async function processUndoLike (actorUrl: string, activity: ActivityUndo) {
+async function processUndoLike (byActor: ActorModel, activity: ActivityUndo) {
   const likeActivity = activity.object as ActivityLike
 
-  const { video } = await getOrCreateVideoAndAccountAndChannel(likeActivity.object)
+  const { video } = await getOrCreateVideoAndAccountAndChannel({ videoObject: likeActivity.object })
 
   return sequelizeTypescript.transaction(async t => {
-    const byAccount = await AccountModel.loadByUrl(actorUrl, t)
-    if (!byAccount) throw new Error('Unknown account ' + actorUrl)
+    if (!byActor.Account) throw new Error('Unknown account ' + byActor.url)
 
-    const rate = await AccountVideoRateModel.load(byAccount.id, video.id, t)
-    if (!rate) throw new Error(`Unknown rate by account ${byAccount.id} for video ${video.id}.`)
+    const rate = await AccountVideoRateModel.load(byActor.Account.id, video.id, t)
+    if (!rate) throw new Error(`Unknown rate by account ${byActor.Account.id} for video ${video.id}.`)
 
     await rate.destroy({ transaction: t })
     await video.decrement('likes', { transaction: t })
 
     if (video.isOwned()) {
       // Don't resend the activity to the sender
-      const exceptions = [ byAccount.Actor ]
+      const exceptions = [ byActor ]
 
       await forwardVideoRelatedActivity(activity, t, exceptions, video)
     }
   })
 }
 
-async function processUndoDislike (actorUrl: string, activity: ActivityUndo) {
+async function processUndoDislike (byActor: ActorModel, activity: ActivityUndo) {
   const dislike = activity.object.object as DislikeObject
 
-  const { video } = await getOrCreateVideoAndAccountAndChannel(dislike.object)
+  const { video } = await getOrCreateVideoAndAccountAndChannel({ videoObject: dislike.object })
 
   return sequelizeTypescript.transaction(async t => {
-    const byAccount = await AccountModel.loadByUrl(actorUrl, t)
-    if (!byAccount) throw new Error('Unknown account ' + actorUrl)
+    if (!byActor.Account) throw new Error('Unknown account ' + byActor.url)
 
-    const rate = await AccountVideoRateModel.load(byAccount.id, video.id, t)
-    if (!rate) throw new Error(`Unknown rate by account ${byAccount.id} for video ${video.id}.`)
+    const rate = await AccountVideoRateModel.load(byActor.Account.id, video.id, t)
+    if (!rate) throw new Error(`Unknown rate by account ${byActor.Account.id} for video ${video.id}.`)
 
     await rate.destroy({ transaction: t })
     await video.decrement('dislikes', { transaction: t })
 
     if (video.isOwned()) {
       // Don't resend the activity to the sender
-      const exceptions = [ byAccount.Actor ]
+      const exceptions = [ byActor ]
 
       await forwardVideoRelatedActivity(activity, t, exceptions, video)
     }
   })
 }
 
-async function processUndoCacheFile (actorUrl: string, activity: ActivityUndo) {
+async function processUndoCacheFile (byActor: ActorModel, activity: ActivityUndo) {
   const cacheFileObject = activity.object.object as CacheFileObject
 
-  const { video } = await getOrCreateVideoAndAccountAndChannel(cacheFileObject.object)
+  const { video } = await getOrCreateVideoAndAccountAndChannel({ videoObject: cacheFileObject.object })
 
   return sequelizeTypescript.transaction(async t => {
-    const byActor = await ActorModel.loadByUrl(actorUrl)
-    if (!byActor) throw new Error('Unknown actor ' + actorUrl)
-
     const cacheFile = await VideoRedundancyModel.loadByUrl(cacheFileObject.id)
     if (!cacheFile) throw new Error('Unknown video cache ' + cacheFile.url)
 
+    if (cacheFile.actorId !== byActor.id) throw new Error('Cannot delete redundancy ' + cacheFile.url + ' of another actor.')
+
     await cacheFile.destroy()
 
     if (video.isOwned()) {
@@ -122,10 +115,9 @@ async function processUndoCacheFile (actorUrl: string, activity: ActivityUndo) {
   })
 }
 
-function processUndoFollow (actorUrl: string, followActivity: ActivityFollow) {
+function processUndoFollow (follower: ActorModel, followActivity: ActivityFollow) {
   return sequelizeTypescript.transaction(async t => {
-    const follower = await ActorModel.loadByUrl(actorUrl, t)
-    const following = await ActorModel.loadByUrl(followActivity.object, t)
+    const following = await ActorModel.loadByUrlAndPopulateAccountAndChannel(followActivity.object, t)
     const actorFollow = await ActorFollowModel.loadByActorAndTarget(follower.id, following.id, t)
 
     if (!actorFollow) throw new Error(`'Unknown actor follow ${follower.id} -> ${following.id}.`)
@@ -136,11 +128,8 @@ function processUndoFollow (actorUrl: string, followActivity: ActivityFollow) {
   })
 }
 
-function processUndoAnnounce (actorUrl: string, announceActivity: ActivityAnnounce) {
+function processUndoAnnounce (byActor: ActorModel, announceActivity: ActivityAnnounce) {
   return sequelizeTypescript.transaction(async t => {
-    const byActor = await ActorModel.loadByUrl(actorUrl, t)
-    if (!byActor) throw new Error('Unknown actor ' + actorUrl)
-
     const share = await VideoShareModel.loadByUrl(announceActivity.id, t)
     if (!share) throw new Error(`Unknown video share ${announceActivity.id}.`)
 
index d3af1a181b0885b1c192f5471ee4dc654a3228d7..ed3489ebfe0ab52b25f9ce6bdc9a2c0a2853b2af 100644 (file)
@@ -6,27 +6,30 @@ import { sequelizeTypescript } from '../../../initializers'
 import { AccountModel } from '../../../models/account/account'
 import { ActorModel } from '../../../models/activitypub/actor'
 import { VideoChannelModel } from '../../../models/video/video-channel'
-import { fetchAvatarIfExists, getOrCreateActorAndServerAndModel, updateActorAvatarInstance, updateActorInstance } from '../actor'
-import { getOrCreateVideoAndAccountAndChannel, updateVideoFromAP, getOrCreateVideoChannelFromVideoObject } from '../videos'
+import { fetchAvatarIfExists, updateActorAvatarInstance, updateActorInstance } from '../actor'
+import { getOrCreateVideoAndAccountAndChannel, getOrCreateVideoChannelFromVideoObject, updateVideoFromAP } from '../videos'
 import { sanitizeAndCheckVideoTorrentObject } from '../../../helpers/custom-validators/activitypub/videos'
 import { isCacheFileObjectValid } from '../../../helpers/custom-validators/activitypub/cache-file'
 import { VideoRedundancyModel } from '../../../models/redundancy/video-redundancy'
 import { createCacheFile, updateCacheFile } from '../cache-file'
 
-async function processUpdateActivity (activity: ActivityUpdate) {
-  const actor = await getOrCreateActorAndServerAndModel(activity.actor)
+async function processUpdateActivity (activity: ActivityUpdate, byActor: ActorModel) {
   const objectType = activity.object.type
 
   if (objectType === 'Video') {
-    return retryTransactionWrapper(processUpdateVideo, actor, activity)
+    return retryTransactionWrapper(processUpdateVideo, byActor, activity)
   }
 
   if (objectType === 'Person' || objectType === 'Application' || objectType === 'Group') {
-    return retryTransactionWrapper(processUpdateActor, actor, activity)
+    // We need more attributes
+    const byActorFull = await ActorModel.loadByUrlAndPopulateAccountAndChannel(byActor.url)
+    return retryTransactionWrapper(processUpdateActor, byActorFull, activity)
   }
 
   if (objectType === 'CacheFile') {
-    return retryTransactionWrapper(processUpdateCacheFile, actor, activity)
+    // We need more attributes
+    const byActorFull = await ActorModel.loadByUrlAndPopulateAccountAndChannel(byActor.url)
+    return retryTransactionWrapper(processUpdateCacheFile, byActorFull, activity)
   }
 
   return undefined
@@ -48,10 +51,18 @@ async function processUpdateVideo (actor: ActorModel, activity: ActivityUpdate)
     return undefined
   }
 
-  const { video } = await getOrCreateVideoAndAccountAndChannel(videoObject.id)
+  const { video } = await getOrCreateVideoAndAccountAndChannel({ videoObject: videoObject.id })
   const channelActor = await getOrCreateVideoChannelFromVideoObject(videoObject)
 
-  return updateVideoFromAP(video, videoObject, actor.Account, channelActor.VideoChannel, activity.to)
+  const updateOptions = {
+    video,
+    videoObject,
+    account: actor.Account,
+    channel: channelActor.VideoChannel,
+    updateViews: true,
+    overrideTo: activity.to
+  }
+  return updateVideoFromAP(updateOptions)
 }
 
 async function processUpdateCacheFile (byActor: ActorModel, activity: ActivityUpdate) {
@@ -64,7 +75,7 @@ async function processUpdateCacheFile (byActor: ActorModel, activity: ActivityUp
 
   const redundancyModel = await VideoRedundancyModel.loadByUrl(cacheFileObject.id)
   if (!redundancyModel) {
-    const { video } = await getOrCreateVideoAndAccountAndChannel(cacheFileObject.id)
+    const { video } = await getOrCreateVideoAndAccountAndChannel({ videoObject: cacheFileObject.id })
     return createCacheFile(cacheFileObject, video, byActor)
   }
 
index da91675ced3d7622f5fdb7505837a775aa043b4a..b263f1ea22c91007d02ee70cba861790b8fd3457 100644 (file)
@@ -11,8 +11,9 @@ import { processLikeActivity } from './process-like'
 import { processRejectActivity } from './process-reject'
 import { processUndoActivity } from './process-undo'
 import { processUpdateActivity } from './process-update'
+import { getOrCreateActorAndServerAndModel } from '../actor'
 
-const processActivity: { [ P in ActivityType ]: (activity: Activity, inboxActor?: ActorModel) => Promise<any> } = {
+const processActivity: { [ P in ActivityType ]: (activity: Activity, byActor: ActorModel, inboxActor?: ActorModel) => Promise<any> } = {
   Create: processCreateActivity,
   Update: processUpdateActivity,
   Delete: processDeleteActivity,
@@ -25,7 +26,14 @@ const processActivity: { [ P in ActivityType ]: (activity: Activity, inboxActor?
 }
 
 async function processActivities (activities: Activity[], signatureActor?: ActorModel, inboxActor?: ActorModel) {
+  const actorsCache: { [ url: string ]: ActorModel } = {}
+
   for (const activity of activities) {
+    if (!signatureActor && [ 'Create', 'Announce', 'Like' ].indexOf(activity.type) === -1) {
+      logger.error('Cannot process activity %s (type: %s) without the actor signature.', activity.id, activity.type)
+      continue
+    }
+
     const actorUrl = getActorUrl(activity.actor)
 
     // When we fetch remote data, we don't have signature
@@ -34,6 +42,9 @@ async function processActivities (activities: Activity[], signatureActor?: Actor
       continue
     }
 
+    const byActor = signatureActor || actorsCache[actorUrl] || await getOrCreateActorAndServerAndModel(actorUrl)
+    actorsCache[actorUrl] = byActor
+
     const activityProcessor = processActivity[activity.type]
     if (activityProcessor === undefined) {
       logger.warn('Unknown activity type %s.', activity.type, { activityId: activity.id })
@@ -41,7 +52,7 @@ async function processActivities (activities: Activity[], signatureActor?: Actor
     }
 
     try {
-      await activityProcessor(activity, inboxActor)
+      await activityProcessor(activity, byActor, inboxActor)
     } catch (err) {
       logger.warn('Cannot process activity %s.', activity.type, { err })
     }
index f137217f819201bdf3d4c6337d806fb863085299..cd0cab7ee5a034a62c7658c63ee566a47aeeb467 100644 (file)
@@ -4,14 +4,14 @@ import { ActorModel } from '../../../models/activitypub/actor'
 import { VideoModel } from '../../../models/video/video'
 import { VideoShareModel } from '../../../models/video/video-share'
 import { broadcastToFollowers } from './utils'
-import { audiencify, getActorsInvolvedInVideo, getAudience, getObjectFollowersAudience } from '../audience'
+import { audiencify, getActorsInvolvedInVideo, getAudience, getAudienceFromFollowersOf } from '../audience'
 import { logger } from '../../../helpers/logger'
 
 async function buildAnnounceWithVideoAudience (byActor: ActorModel, videoShare: VideoShareModel, video: VideoModel, t: Transaction) {
   const announcedObject = video.url
 
   const actorsInvolvedInVideo = await getActorsInvolvedInVideo(video, t)
-  const audience = getObjectFollowersAudience(actorsInvolvedInVideo)
+  const audience = getAudienceFromFollowersOf(actorsInvolvedInVideo)
 
   const activity = buildAnnounceActivity(videoShare.url, byActor, announcedObject, audience)
 
index 6f89b1a2291a14226a9e2dbcc24561834977df2c..285edba3b4862e19f21cee5e36c1266d0d80f2f6 100644 (file)
@@ -1,21 +1,13 @@
 import { Transaction } from 'sequelize'
 import { ActivityAudience, ActivityCreate } from '../../../../shared/models/activitypub'
 import { VideoPrivacy } from '../../../../shared/models/videos'
-import { getServerActor } from '../../../helpers/utils'
 import { ActorModel } from '../../../models/activitypub/actor'
 import { VideoModel } from '../../../models/video/video'
 import { VideoAbuseModel } from '../../../models/video/video-abuse'
 import { VideoCommentModel } from '../../../models/video/video-comment'
 import { getVideoAbuseActivityPubUrl, getVideoDislikeActivityPubUrl, getVideoViewActivityPubUrl } from '../url'
-import { broadcastToActors, broadcastToFollowers, unicastTo } from './utils'
-import {
-  audiencify,
-  getActorsInvolvedInVideo,
-  getAudience,
-  getObjectFollowersAudience,
-  getVideoAudience,
-  getVideoCommentAudience
-} from '../audience'
+import { broadcastToActors, broadcastToFollowers, sendVideoRelatedActivity, unicastTo } from './utils'
+import { audiencify, getActorsInvolvedInVideo, getAudience, getAudienceFromFollowersOf, getVideoCommentAudience } from '../audience'
 import { logger } from '../../../helpers/logger'
 import { VideoRedundancyModel } from '../../../models/redundancy/video-redundancy'
 
@@ -40,6 +32,7 @@ async function sendVideoAbuse (byActor: ActorModel, videoAbuse: VideoAbuseModel,
 
   logger.info('Creating job to send video abuse %s.', url)
 
+  // Custom audience, we only send the abuse to the origin instance
   const audience = { to: [ video.VideoChannel.Account.Actor.url ], cc: [] }
   const createActivity = buildCreateActivity(url, byActor, videoAbuse.toActivityPubObject(), audience)
 
@@ -49,15 +42,15 @@ async function sendVideoAbuse (byActor: ActorModel, videoAbuse: VideoAbuseModel,
 async function sendCreateCacheFile (byActor: ActorModel, fileRedundancy: VideoRedundancyModel) {
   logger.info('Creating job to send file cache of %s.', fileRedundancy.url)
 
-  const redundancyObject = fileRedundancy.toActivityPubObject()
-
   const video = await VideoModel.loadAndPopulateAccountAndServerAndTags(fileRedundancy.VideoFile.Video.id)
-  const actorsInvolvedInVideo = await getActorsInvolvedInVideo(video, undefined)
-
-  const audience = getVideoAudience(video, actorsInvolvedInVideo)
-  const createActivity = buildCreateActivity(fileRedundancy.url, byActor, redundancyObject, audience)
+  const redundancyObject = fileRedundancy.toActivityPubObject()
 
-  return unicastTo(createActivity, byActor, video.VideoChannel.Account.Actor.sharedInboxUrl)
+  return sendVideoRelatedCreateActivity({
+    byActor,
+    video,
+    url: fileRedundancy.url,
+    object: redundancyObject
+  })
 }
 
 async function sendCreateVideoComment (comment: VideoCommentModel, t: Transaction) {
@@ -70,6 +63,7 @@ async function sendCreateVideoComment (comment: VideoCommentModel, t: Transactio
   const commentObject = comment.toActivityPubObject(threadParentComments)
 
   const actorsInvolvedInComment = await getActorsInvolvedInVideo(comment.Video, t)
+  // Add the actor that commented too
   actorsInvolvedInComment.push(byActor)
 
   const parentsCommentActors = threadParentComments.map(c => c.Account.Actor)
@@ -78,7 +72,7 @@ async function sendCreateVideoComment (comment: VideoCommentModel, t: Transactio
   if (isOrigin) {
     audience = getVideoCommentAudience(comment, threadParentComments, actorsInvolvedInComment, isOrigin)
   } else {
-    audience = getObjectFollowersAudience(actorsInvolvedInComment.concat(parentsCommentActors))
+    audience = getAudienceFromFollowersOf(actorsInvolvedInComment.concat(parentsCommentActors))
   }
 
   const createActivity = buildCreateActivity(comment.url, byActor, commentObject, audience)
@@ -103,24 +97,14 @@ async function sendCreateView (byActor: ActorModel, video: VideoModel, t: Transa
   const url = getVideoViewActivityPubUrl(byActor, video)
   const viewActivity = buildViewActivity(byActor, video)
 
-  const actorsInvolvedInVideo = await getActorsInvolvedInVideo(video, t)
-
-  // Send to origin
-  if (video.isOwned() === false) {
-    const audience = getVideoAudience(video, actorsInvolvedInVideo)
-    const createActivity = buildCreateActivity(url, byActor, viewActivity, audience)
-
-    return unicastTo(createActivity, byActor, video.VideoChannel.Account.Actor.sharedInboxUrl)
-  }
-
-  // Send to followers
-  const audience = getObjectFollowersAudience(actorsInvolvedInVideo)
-  const createActivity = buildCreateActivity(url, byActor, viewActivity, audience)
-
-  // Use the server actor to send the view
-  const serverActor = await getServerActor()
-  const actorsException = [ byActor ]
-  return broadcastToFollowers(createActivity, serverActor, actorsInvolvedInVideo, t, actorsException)
+  return sendVideoRelatedCreateActivity({
+    // Use the server actor to send the view
+    byActor,
+    video,
+    url,
+    object: viewActivity,
+    transaction: t
+  })
 }
 
 async function sendCreateDislike (byActor: ActorModel, video: VideoModel, t: Transaction) {
@@ -129,22 +113,13 @@ async function sendCreateDislike (byActor: ActorModel, video: VideoModel, t: Tra
   const url = getVideoDislikeActivityPubUrl(byActor, video)
   const dislikeActivity = buildDislikeActivity(byActor, video)
 
-  const actorsInvolvedInVideo = await getActorsInvolvedInVideo(video, t)
-
-  // Send to origin
-  if (video.isOwned() === false) {
-    const audience = getVideoAudience(video, actorsInvolvedInVideo)
-    const createActivity = buildCreateActivity(url, byActor, dislikeActivity, audience)
-
-    return unicastTo(createActivity, byActor, video.VideoChannel.Account.Actor.sharedInboxUrl)
-  }
-
-  // Send to followers
-  const audience = getObjectFollowersAudience(actorsInvolvedInVideo)
-  const createActivity = buildCreateActivity(url, byActor, dislikeActivity, audience)
-
-  const actorsException = [ byActor ]
-  return broadcastToFollowers(createActivity, byActor, actorsInvolvedInVideo, t, actorsException)
+  return sendVideoRelatedCreateActivity({
+    byActor,
+    video,
+    url,
+    object: dislikeActivity,
+    transaction: t
+  })
 }
 
 function buildCreateActivity (url: string, byActor: ActorModel, object: any, audience?: ActivityAudience): ActivityCreate {
@@ -189,3 +164,19 @@ export {
   sendCreateVideoComment,
   sendCreateCacheFile
 }
+
+// ---------------------------------------------------------------------------
+
+async function sendVideoRelatedCreateActivity (options: {
+  byActor: ActorModel,
+  video: VideoModel,
+  url: string,
+  object: any,
+  transaction?: Transaction
+}) {
+  const activityBuilder = (audience: ActivityAudience) => {
+    return buildCreateActivity(options.url, options.byActor, options.object, audience)
+  }
+
+  return sendVideoRelatedActivity(activityBuilder, options)
+}
index 47918254362d50f5dd1ecbf159118f46387014cc..18969433a8db3ba460363415f65f7924b83fa22e 100644 (file)
@@ -5,21 +5,22 @@ import { VideoModel } from '../../../models/video/video'
 import { VideoCommentModel } from '../../../models/video/video-comment'
 import { VideoShareModel } from '../../../models/video/video-share'
 import { getDeleteActivityPubUrl } from '../url'
-import { broadcastToActors, broadcastToFollowers, unicastTo } from './utils'
+import { broadcastToActors, broadcastToFollowers, sendVideoRelatedActivity, unicastTo } from './utils'
 import { audiencify, getActorsInvolvedInVideo, getVideoCommentAudience } from '../audience'
 import { logger } from '../../../helpers/logger'
 
-async function sendDeleteVideo (video: VideoModel, t: Transaction) {
+async function sendDeleteVideo (video: VideoModel, transaction: Transaction) {
   logger.info('Creating job to broadcast delete of video %s.', video.url)
 
-  const url = getDeleteActivityPubUrl(video.url)
   const byActor = video.VideoChannel.Account.Actor
 
-  const activity = buildDeleteActivity(url, video.url, byActor)
+  const activityBuilder = (audience: ActivityAudience) => {
+    const url = getDeleteActivityPubUrl(video.url)
 
-  const actorsInvolved = await getActorsInvolvedInVideo(video, t)
+    return buildDeleteActivity(url, video.url, byActor, audience)
+  }
 
-  return broadcastToFollowers(activity, byActor, actorsInvolved, t)
+  return sendVideoRelatedActivity(activityBuilder, { byActor, video, transaction })
 }
 
 async function sendDeleteActor (byActor: ActorModel, t: Transaction) {
index a5408ac6a2acbcd4975fb505b1da78eb49b0112f..89307acc6677e86fd082f8491c78635bb3eb09c6 100644 (file)
@@ -3,31 +3,20 @@ import { ActivityAudience, ActivityLike } from '../../../../shared/models/activi
 import { ActorModel } from '../../../models/activitypub/actor'
 import { VideoModel } from '../../../models/video/video'
 import { getVideoLikeActivityPubUrl } from '../url'
-import { broadcastToFollowers, unicastTo } from './utils'
-import { audiencify, getActorsInvolvedInVideo, getAudience, getObjectFollowersAudience, getVideoAudience } from '../audience'
+import { sendVideoRelatedActivity } from './utils'
+import { audiencify, getAudience } from '../audience'
 import { logger } from '../../../helpers/logger'
 
 async function sendLike (byActor: ActorModel, video: VideoModel, t: Transaction) {
   logger.info('Creating job to like %s.', video.url)
 
-  const url = getVideoLikeActivityPubUrl(byActor, video)
+  const activityBuilder = (audience: ActivityAudience) => {
+    const url = getVideoLikeActivityPubUrl(byActor, video)
 
-  const accountsInvolvedInVideo = await getActorsInvolvedInVideo(video, t)
-
-  // Send to origin
-  if (video.isOwned() === false) {
-    const audience = getVideoAudience(video, accountsInvolvedInVideo)
-    const data = buildLikeActivity(url, byActor, video, audience)
-
-    return unicastTo(data, byActor, video.VideoChannel.Account.Actor.sharedInboxUrl)
+    return buildLikeActivity(url, byActor, video, audience)
   }
 
-  // Send to followers
-  const audience = getObjectFollowersAudience(accountsInvolvedInVideo)
-  const activity = buildLikeActivity(url, byActor, video, audience)
-
-  const followersException = [ byActor ]
-  return broadcastToFollowers(activity, byActor, accountsInvolvedInVideo, t, followersException)
+  return sendVideoRelatedActivity(activityBuilder, { byActor, video, transaction: t })
 }
 
 function buildLikeActivity (url: string, byActor: ActorModel, video: VideoModel, audience?: ActivityAudience): ActivityLike {
index a50673c7975cb042f1a8d2d5fa4dce467ce4d2fa..5236d2cb31717c31437a4c101fd3ce0b68e988f0 100644 (file)
@@ -11,8 +11,8 @@ import { ActorModel } from '../../../models/activitypub/actor'
 import { ActorFollowModel } from '../../../models/activitypub/actor-follow'
 import { VideoModel } from '../../../models/video/video'
 import { getActorFollowActivityPubUrl, getUndoActivityPubUrl, getVideoDislikeActivityPubUrl, getVideoLikeActivityPubUrl } from '../url'
-import { broadcastToFollowers, unicastTo } from './utils'
-import { audiencify, getActorsInvolvedInVideo, getAudience, getObjectFollowersAudience, getVideoAudience } from '../audience'
+import { broadcastToFollowers, sendVideoRelatedActivity, unicastTo } from './utils'
+import { audiencify, getAudience } from '../audience'
 import { buildCreateActivity, buildDislikeActivity } from './send-create'
 import { buildFollowActivity } from './send-follow'
 import { buildLikeActivity } from './send-like'
@@ -39,79 +39,44 @@ async function sendUndoFollow (actorFollow: ActorFollowModel, t: Transaction) {
   return unicastTo(undoActivity, me, following.inboxUrl)
 }
 
-async function sendUndoLike (byActor: ActorModel, video: VideoModel, t: Transaction) {
-  logger.info('Creating job to undo a like of video %s.', video.url)
+async function sendUndoAnnounce (byActor: ActorModel, videoShare: VideoShareModel, video: VideoModel, t: Transaction) {
+  logger.info('Creating job to undo announce %s.', videoShare.url)
 
-  const likeUrl = getVideoLikeActivityPubUrl(byActor, video)
-  const undoUrl = getUndoActivityPubUrl(likeUrl)
+  const undoUrl = getUndoActivityPubUrl(videoShare.url)
 
-  const actorsInvolvedInVideo = await getActorsInvolvedInVideo(video, t)
-  const likeActivity = buildLikeActivity(likeUrl, byActor, video)
+  const { activity: announceActivity, actorsInvolvedInVideo } = await buildAnnounceWithVideoAudience(byActor, videoShare, video, t)
+  const undoActivity = undoActivityData(undoUrl, byActor, announceActivity)
 
-  // Send to origin
-  if (video.isOwned() === false) {
-    const audience = getVideoAudience(video, actorsInvolvedInVideo)
-    const undoActivity = undoActivityData(undoUrl, byActor, likeActivity, audience)
+  const followersException = [ byActor ]
+  return broadcastToFollowers(undoActivity, byActor, actorsInvolvedInVideo, t, followersException)
+}
 
-    return unicastTo(undoActivity, byActor, video.VideoChannel.Account.Actor.sharedInboxUrl)
-  }
+async function sendUndoLike (byActor: ActorModel, video: VideoModel, t: Transaction) {
+  logger.info('Creating job to undo a like of video %s.', video.url)
 
-  const audience = getObjectFollowersAudience(actorsInvolvedInVideo)
-  const undoActivity = undoActivityData(undoUrl, byActor, likeActivity, audience)
+  const likeUrl = getVideoLikeActivityPubUrl(byActor, video)
+  const likeActivity = buildLikeActivity(likeUrl, byActor, video)
 
-  const followersException = [ byActor ]
-  return broadcastToFollowers(undoActivity, byActor, actorsInvolvedInVideo, t, followersException)
+  return sendUndoVideoRelatedActivity({ byActor, video, url: likeUrl, activity: likeActivity, transaction: t })
 }
 
 async function sendUndoDislike (byActor: ActorModel, video: VideoModel, t: Transaction) {
   logger.info('Creating job to undo a dislike of video %s.', video.url)
 
   const dislikeUrl = getVideoDislikeActivityPubUrl(byActor, video)
-  const undoUrl = getUndoActivityPubUrl(dislikeUrl)
-
-  const actorsInvolvedInVideo = await getActorsInvolvedInVideo(video, t)
   const dislikeActivity = buildDislikeActivity(byActor, video)
   const createDislikeActivity = buildCreateActivity(dislikeUrl, byActor, dislikeActivity)
 
-  if (video.isOwned() === false) {
-    const audience = getVideoAudience(video, actorsInvolvedInVideo)
-    const undoActivity = undoActivityData(undoUrl, byActor, createDislikeActivity, audience)
-
-    return unicastTo(undoActivity, byActor, video.VideoChannel.Account.Actor.sharedInboxUrl)
-  }
-
-  const undoActivity = undoActivityData(undoUrl, byActor, createDislikeActivity)
-
-  const followersException = [ byActor ]
-  return broadcastToFollowers(undoActivity, byActor, actorsInvolvedInVideo, t, followersException)
-}
-
-async function sendUndoAnnounce (byActor: ActorModel, videoShare: VideoShareModel, video: VideoModel, t: Transaction) {
-  logger.info('Creating job to undo announce %s.', videoShare.url)
-
-  const undoUrl = getUndoActivityPubUrl(videoShare.url)
-
-  const { activity: announceActivity, actorsInvolvedInVideo } = await buildAnnounceWithVideoAudience(byActor, videoShare, video, t)
-  const undoActivity = undoActivityData(undoUrl, byActor, announceActivity)
-
-  const followersException = [ byActor ]
-  return broadcastToFollowers(undoActivity, byActor, actorsInvolvedInVideo, t, followersException)
+  return sendUndoVideoRelatedActivity({ byActor, video, url: dislikeUrl, activity: createDislikeActivity, transaction: t })
 }
 
 async function sendUndoCacheFile (byActor: ActorModel, redundancyModel: VideoRedundancyModel, t: Transaction) {
   logger.info('Creating job to undo cache file %s.', redundancyModel.url)
 
-  const undoUrl = getUndoActivityPubUrl(redundancyModel.url)
-
   const video = await VideoModel.loadAndPopulateAccountAndServerAndTags(redundancyModel.VideoFile.Video.id)
-  const actorsInvolvedInVideo = await getActorsInvolvedInVideo(video, t)
-
-  const audience = getVideoAudience(video, actorsInvolvedInVideo)
   const createActivity = buildCreateActivity(redundancyModel.url, byActor, redundancyModel.toActivityPubObject())
 
-  const undoActivity = undoActivityData(undoUrl, byActor, createActivity, audience)
-
-  return unicastTo(undoActivity, byActor, video.VideoChannel.Account.Actor.sharedInboxUrl)
+  return sendUndoVideoRelatedActivity({ byActor, video, url: redundancyModel.url, activity: createActivity, transaction: t })
 }
 
 // ---------------------------------------------------------------------------
@@ -144,3 +109,19 @@ function undoActivityData (
     audience
   )
 }
+
+async function sendUndoVideoRelatedActivity (options: {
+  byActor: ActorModel,
+  video: VideoModel,
+  url: string,
+  activity: ActivityFollow | ActivityLike | ActivityCreate | ActivityAnnounce,
+  transaction: Transaction
+}) {
+  const activityBuilder = (audience: ActivityAudience) => {
+    const undoUrl = getUndoActivityPubUrl(options.url)
+
+    return undoActivityData(undoUrl, options.byActor, options.activity, audience)
+  }
+
+  return sendVideoRelatedActivity(activityBuilder, options)
+}
index 605473338f340413730bec2f512d30a421ede3ba..ec46789b7d6d2a53025d3c7a03324dca11e92aa8 100644 (file)
@@ -7,8 +7,8 @@ import { VideoModel } from '../../../models/video/video'
 import { VideoChannelModel } from '../../../models/video/video-channel'
 import { VideoShareModel } from '../../../models/video/video-share'
 import { getUpdateActivityPubUrl } from '../url'
-import { broadcastToFollowers, unicastTo } from './utils'
-import { audiencify, getActorsInvolvedInVideo, getAudience, getObjectFollowersAudience } from '../audience'
+import { broadcastToFollowers, sendVideoRelatedActivity, unicastTo } from './utils'
+import { audiencify, getActorsInvolvedInVideo, getAudience, getAudienceFromFollowersOf } from '../audience'
 import { logger } from '../../../helpers/logger'
 import { VideoCaptionModel } from '../../../models/video/video-caption'
 import { VideoRedundancyModel } from '../../../models/redundancy/video-redundancy'
@@ -61,16 +61,16 @@ async function sendUpdateActor (accountOrChannel: AccountModel | VideoChannelMod
 async function sendUpdateCacheFile (byActor: ActorModel, redundancyModel: VideoRedundancyModel) {
   logger.info('Creating job to update cache file %s.', redundancyModel.url)
 
-  const url = getUpdateActivityPubUrl(redundancyModel.url, redundancyModel.updatedAt.toISOString())
   const video = await VideoModel.loadAndPopulateAccountAndServerAndTags(redundancyModel.VideoFile.Video.id)
 
-  const redundancyObject = redundancyModel.toActivityPubObject()
+  const activityBuilder = (audience: ActivityAudience) => {
+    const redundancyObject = redundancyModel.toActivityPubObject()
+    const url = getUpdateActivityPubUrl(redundancyModel.url, redundancyModel.updatedAt.toISOString())
 
-  const accountsInvolvedInVideo = await getActorsInvolvedInVideo(video, undefined)
-  const audience = getObjectFollowersAudience(accountsInvolvedInVideo)
+    return buildUpdateActivity(url, byActor, redundancyObject, audience)
+  }
 
-  const updateActivity = buildUpdateActivity(url, byActor, redundancyObject, audience)
-  return unicastTo(updateActivity, byActor, video.VideoChannel.Account.Actor.sharedInboxUrl)
+  return sendVideoRelatedActivity(activityBuilder, { byActor, video })
 }
 
 // ---------------------------------------------------------------------------
index c20c156331a4be9ac0f27c3eac77d3d35323effb..69706e620ebcd180a85f7b7a3a6a9e51ee6cdd87 100644 (file)
@@ -1,13 +1,36 @@
 import { Transaction } from 'sequelize'
-import { Activity } from '../../../../shared/models/activitypub'
+import { Activity, ActivityAudience } from '../../../../shared/models/activitypub'
 import { logger } from '../../../helpers/logger'
 import { ActorModel } from '../../../models/activitypub/actor'
 import { ActorFollowModel } from '../../../models/activitypub/actor-follow'
 import { JobQueue } from '../../job-queue'
 import { VideoModel } from '../../../models/video/video'
-import { getActorsInvolvedInVideo } from '../audience'
+import { getActorsInvolvedInVideo, getAudienceFromFollowersOf, getRemoteVideoAudience } from '../audience'
 import { getServerActor } from '../../../helpers/utils'
 
+async function sendVideoRelatedActivity (activityBuilder: (audience: ActivityAudience) => Activity, options: {
+  byActor: ActorModel,
+  video: VideoModel,
+  transaction?: Transaction
+}) {
+  const actorsInvolvedInVideo = await getActorsInvolvedInVideo(options.video, options.transaction)
+
+  // Send to origin
+  if (options.video.isOwned() === false) {
+    const audience = getRemoteVideoAudience(options.video, actorsInvolvedInVideo)
+    const activity = activityBuilder(audience)
+
+    return unicastTo(activity, options.byActor, options.video.VideoChannel.Account.Actor.sharedInboxUrl)
+  }
+
+  // Send to followers
+  const audience = getAudienceFromFollowersOf(actorsInvolvedInVideo)
+  const activity = activityBuilder(audience)
+
+  const actorsException = [ options.byActor ]
+  return broadcastToFollowers(activity, options.byActor, actorsInvolvedInVideo, options.transaction, actorsException)
+}
+
 async function forwardVideoRelatedActivity (
   activity: Activity,
   t: Transaction,
@@ -110,7 +133,8 @@ export {
   unicastTo,
   forwardActivity,
   broadcastToActors,
-  forwardVideoRelatedActivity
+  forwardVideoRelatedActivity,
+  sendVideoRelatedActivity
 }
 
 // ---------------------------------------------------------------------------
index ffbd3a64e606f22de754400ebde8019d1f518d64..4ca8bf6595796431dd1396dbd46d712f740ddc3e 100644 (file)
@@ -94,7 +94,7 @@ async function resolveThread (url: string, comments: VideoCommentModel[] = []) {
   try {
     // Maybe it's a reply to a video?
     // If yes, it's done: we resolved all the thread
-    const { video } = await getOrCreateVideoAndAccountAndChannel(url)
+    const { video } = await getOrCreateVideoAndAccountAndChannel({ videoObject: url })
 
     if (comments.length !== 0) {
       const firstReply = comments[ comments.length - 1 ]
index 783f78d3ec8417ec0bcee5b96835482e3e57b8fb..48c0e0a5ca9c7217e19713daeb55b1531125ef0e 100644 (file)
@@ -3,7 +3,7 @@ import * as sequelize from 'sequelize'
 import * as magnetUtil from 'magnet-uri'
 import { join } from 'path'
 import * as request from 'request'
-import { ActivityIconObject, ActivityVideoUrlObject, VideoState, ActivityUrlObject } from '../../../shared/index'
+import { ActivityIconObject, ActivityUrlObject, ActivityVideoUrlObject, VideoState } from '../../../shared/index'
 import { VideoTorrentObject } from '../../../shared/models/activitypub/objects'
 import { VideoPrivacy } from '../../../shared/models/videos'
 import { sanitizeAndCheckVideoTorrentObject } from '../../helpers/custom-validators/activitypub/videos'
@@ -28,6 +28,7 @@ import { ActivitypubHttpFetcherPayload } from '../job-queue/handlers/activitypub
 import { createRates } from './video-rates'
 import { addVideoShares, shareVideoByServerAndChannel } from './share'
 import { AccountModel } from '../../models/account/account'
+import { fetchVideoByUrl, VideoFetchByUrlType } from '../../helpers/video'
 
 async function federateVideoIfNeeded (video: VideoModel, isNewVideo: boolean, transaction?: sequelize.Transaction) {
   // If the video is not private and published, we federate it
@@ -50,18 +51,29 @@ async function federateVideoIfNeeded (video: VideoModel, isNewVideo: boolean, tr
   }
 }
 
-function fetchRemoteVideoStaticFile (video: VideoModel, path: string, reject: Function) {
-  const host = video.VideoChannel.Account.Actor.Server.host
+async function fetchRemoteVideo (videoUrl: string): Promise<{ response: request.RequestResponse, videoObject: VideoTorrentObject }> {
+  const options = {
+    uri: videoUrl,
+    method: 'GET',
+    json: true,
+    activityPub: true
+  }
 
-  // We need to provide a callback, if no we could have an uncaught exception
-  return request.get(REMOTE_SCHEME.HTTP + '://' + host + path, err => {
-    if (err) reject(err)
-  })
+  logger.info('Fetching remote video %s.', videoUrl)
+
+  const { response, body } = await doRequest(options)
+
+  if (sanitizeAndCheckVideoTorrentObject(body) === false) {
+    logger.debug('Remote video JSON is not valid.', { body })
+    return { response, videoObject: undefined }
+  }
+
+  return { response, videoObject: body }
 }
 
 async function fetchRemoteVideoDescription (video: VideoModel) {
   const host = video.VideoChannel.Account.Actor.Server.host
-  const path = video.getDescriptionPath()
+  const path = video.getDescriptionAPIPath()
   const options = {
     uri: REMOTE_SCHEME.HTTP + '://' + host + path,
     json: true
@@ -71,6 +83,15 @@ async function fetchRemoteVideoDescription (video: VideoModel) {
   return body.description ? body.description : ''
 }
 
+function fetchRemoteVideoStaticFile (video: VideoModel, path: string, reject: Function) {
+  const host = video.VideoChannel.Account.Actor.Server.host
+
+  // We need to provide a callback, if no we could have an uncaught exception
+  return request.get(REMOTE_SCHEME.HTTP + '://' + host + path, err => {
+    if (err) reject(err)
+  })
+}
+
 function generateThumbnailFromUrl (video: VideoModel, icon: ActivityIconObject) {
   const thumbnailName = video.getThumbnailName()
   const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, thumbnailName)
@@ -82,144 +103,11 @@ function generateThumbnailFromUrl (video: VideoModel, icon: ActivityIconObject)
   return doRequestAndSaveToFile(options, thumbnailPath)
 }
 
-async function videoActivityObjectToDBAttributes (
-  videoChannel: VideoChannelModel,
-  videoObject: VideoTorrentObject,
-  to: string[] = []
-) {
-  const privacy = to.indexOf(ACTIVITY_PUB.PUBLIC) !== -1 ? VideoPrivacy.PUBLIC : VideoPrivacy.UNLISTED
-  const duration = videoObject.duration.replace(/[^\d]+/, '')
-
-  let language: string | undefined
-  if (videoObject.language) {
-    language = videoObject.language.identifier
-  }
-
-  let category: number | undefined
-  if (videoObject.category) {
-    category = parseInt(videoObject.category.identifier, 10)
-  }
-
-  let licence: number | undefined
-  if (videoObject.licence) {
-    licence = parseInt(videoObject.licence.identifier, 10)
-  }
-
-  const description = videoObject.content || null
-  const support = videoObject.support || null
-
-  return {
-    name: videoObject.name,
-    uuid: videoObject.uuid,
-    url: videoObject.id,
-    category,
-    licence,
-    language,
-    description,
-    support,
-    nsfw: videoObject.sensitive,
-    commentsEnabled: videoObject.commentsEnabled,
-    waitTranscoding: videoObject.waitTranscoding,
-    state: videoObject.state,
-    channelId: videoChannel.id,
-    duration: parseInt(duration, 10),
-    createdAt: new Date(videoObject.published),
-    publishedAt: new Date(videoObject.published),
-    // FIXME: updatedAt does not seems to be considered by Sequelize
-    updatedAt: new Date(videoObject.updated),
-    views: videoObject.views,
-    likes: 0,
-    dislikes: 0,
-    remote: true,
-    privacy
-  }
-}
-
-function videoFileActivityUrlToDBAttributes (videoCreated: VideoModel, videoObject: VideoTorrentObject) {
-  const fileUrls = videoObject.url.filter(u => isActivityVideoUrlObject(u)) as ActivityVideoUrlObject[]
-
-  if (fileUrls.length === 0) {
-    throw new Error('Cannot find video files for ' + videoCreated.url)
-  }
-
-  const attributes: VideoFileModel[] = []
-  for (const fileUrl of fileUrls) {
-    // Fetch associated magnet uri
-    const magnet = videoObject.url.find(u => {
-      return u.mimeType === 'application/x-bittorrent;x-scheme-handler/magnet' && u.height === fileUrl.height
-    })
-
-    if (!magnet) throw new Error('Cannot find associated magnet uri for file ' + fileUrl.href)
-
-    const parsed = magnetUtil.decode(magnet.href)
-    if (!parsed || isVideoFileInfoHashValid(parsed.infoHash) === false) {
-      throw new Error('Cannot parse magnet URI ' + magnet.href)
-    }
-
-    const attribute = {
-      extname: VIDEO_MIMETYPE_EXT[ fileUrl.mimeType ],
-      infoHash: parsed.infoHash,
-      resolution: fileUrl.height,
-      size: fileUrl.size,
-      videoId: videoCreated.id,
-      fps: fileUrl.fps
-    } as VideoFileModel
-    attributes.push(attribute)
-  }
-
-  return attributes
-}
-
 function getOrCreateVideoChannelFromVideoObject (videoObject: VideoTorrentObject) {
   const channel = videoObject.attributedTo.find(a => a.type === 'Group')
   if (!channel) throw new Error('Cannot find associated video channel to video ' + videoObject.url)
 
-  return getOrCreateActorAndServerAndModel(channel.id)
-}
-
-async function createVideo (videoObject: VideoTorrentObject, channelActor: ActorModel, waitThumbnail = false) {
-  logger.debug('Adding remote video %s.', videoObject.id)
-
-  const videoCreated: VideoModel = await sequelizeTypescript.transaction(async t => {
-    const sequelizeOptions = { transaction: t }
-
-    const videoData = await videoActivityObjectToDBAttributes(channelActor.VideoChannel, videoObject, videoObject.to)
-    const video = VideoModel.build(videoData)
-
-    const videoCreated = await video.save(sequelizeOptions)
-
-    // Process files
-    const videoFileAttributes = videoFileActivityUrlToDBAttributes(videoCreated, videoObject)
-    if (videoFileAttributes.length === 0) {
-      throw new Error('Cannot find valid files for video %s ' + videoObject.url)
-    }
-
-    const videoFilePromises = videoFileAttributes.map(f => VideoFileModel.create(f, { transaction: t }))
-    await Promise.all(videoFilePromises)
-
-    // Process tags
-    const tags = videoObject.tag.map(t => t.name)
-    const tagInstances = await TagModel.findOrCreateTags(tags, t)
-    await videoCreated.$set('Tags', tagInstances, sequelizeOptions)
-
-    // Process captions
-    const videoCaptionsPromises = videoObject.subtitleLanguage.map(c => {
-      return VideoCaptionModel.insertOrReplaceLanguage(videoCreated.id, c.identifier, t)
-    })
-    await Promise.all(videoCaptionsPromises)
-
-    logger.info('Remote video with uuid %s inserted.', videoObject.uuid)
-
-    videoCreated.VideoChannel = channelActor.VideoChannel
-    return videoCreated
-  })
-
-  const p = generateThumbnailFromUrl(videoCreated, videoObject.icon)
-    .catch(err => logger.warn('Cannot generate thumbnail of %s.', videoObject.id, { err }))
-
-  if (waitThumbnail === true) await p
-
-  return videoCreated
+  return getOrCreateActorAndServerAndModel(channel.id, 'all')
 }
 
 type SyncParam = {
@@ -230,28 +118,7 @@ type SyncParam = {
   thumbnail: boolean
   refreshVideo: boolean
 }
-async function getOrCreateVideoAndAccountAndChannel (
-  videoObject: VideoTorrentObject | string,
-  syncParam: SyncParam = { likes: true, dislikes: true, shares: true, comments: true, thumbnail: true, refreshVideo: false }
-) {
-  const videoUrl = typeof videoObject === 'string' ? videoObject : videoObject.id
-
-  let videoFromDatabase = await VideoModel.loadByUrlAndPopulateAccount(videoUrl)
-  if (videoFromDatabase) {
-    const p = retryTransactionWrapper(refreshVideoIfNeeded, videoFromDatabase)
-    if (syncParam.refreshVideo === true) videoFromDatabase = await p
-
-    return { video: videoFromDatabase }
-  }
-
-  const { videoObject: fetchedVideo } = await fetchRemoteVideo(videoUrl)
-  if (!fetchedVideo) throw new Error('Cannot fetch remote video with url: ' + videoUrl)
-
-  const channelActor = await getOrCreateVideoChannelFromVideoObject(fetchedVideo)
-  const video = await retryTransactionWrapper(createVideo, fetchedVideo, channelActor, syncParam.thumbnail)
-
-  // Process outside the transaction because we could fetch remote data
-
+async function syncVideoExternalAttributes (video: VideoModel, fetchedVideo: VideoTorrentObject, syncParam: SyncParam) {
   logger.info('Adding likes/dislikes/shares/comments of video %s.', video.uuid)
 
   const jobPayloads: ActivitypubHttpFetcherPayload[] = []
@@ -285,64 +152,56 @@ async function getOrCreateVideoAndAccountAndChannel (
   }
 
   await Bluebird.map(jobPayloads, payload => JobQueue.Instance.createJob({ type: 'activitypub-http-fetcher', payload }))
-
-  return { video }
 }
 
-async function fetchRemoteVideo (videoUrl: string): Promise<{ response: request.RequestResponse, videoObject: VideoTorrentObject }> {
-  const options = {
-    uri: videoUrl,
-    method: 'GET',
-    json: true,
-    activityPub: true
-  }
-
-  logger.info('Fetching remote video %s.', videoUrl)
-
-  const { response, body } = await doRequest(options)
+async function getOrCreateVideoAndAccountAndChannel (options: {
+  videoObject: VideoTorrentObject | string,
+  syncParam?: SyncParam,
+  fetchType?: VideoFetchByUrlType,
+  refreshViews?: boolean
+}) {
+  // Default params
+  const syncParam = options.syncParam || { likes: true, dislikes: true, shares: true, comments: true, thumbnail: true, refreshVideo: false }
+  const fetchType = options.fetchType || 'all'
+  const refreshViews = options.refreshViews || false
+
+  // Get video url
+  const videoUrl = typeof options.videoObject === 'string' ? options.videoObject : options.videoObject.id
+
+  let videoFromDatabase = await fetchVideoByUrl(videoUrl, fetchType)
+  if (videoFromDatabase) {
+    const refreshOptions = {
+      video: videoFromDatabase,
+      fetchedType: fetchType,
+      syncParam,
+      refreshViews
+    }
+    const p = retryTransactionWrapper(refreshVideoIfNeeded, refreshOptions)
+    if (syncParam.refreshVideo === true) videoFromDatabase = await p
 
-  if (sanitizeAndCheckVideoTorrentObject(body) === false) {
-    logger.debug('Remote video JSON is not valid.', { body })
-    return { response, videoObject: undefined }
+    return { video: videoFromDatabase }
   }
 
-  return { response, videoObject: body }
-}
-
-async function refreshVideoIfNeeded (video: VideoModel): Promise<VideoModel> {
-  if (!video.isOutdated()) return video
-
-  try {
-    const { response, videoObject } = await fetchRemoteVideo(video.url)
-    if (response.statusCode === 404) {
-      // Video does not exist anymore
-      await video.destroy()
-      return undefined
-    }
+  const { videoObject: fetchedVideo } = await fetchRemoteVideo(videoUrl)
+  if (!fetchedVideo) throw new Error('Cannot fetch remote video with url: ' + videoUrl)
 
-    if (videoObject === undefined) {
-      logger.warn('Cannot refresh remote video: invalid body.')
-      return video
-    }
+  const channelActor = await getOrCreateVideoChannelFromVideoObject(fetchedVideo)
+  const video = await retryTransactionWrapper(createVideo, fetchedVideo, channelActor, syncParam.thumbnail)
 
-    const channelActor = await getOrCreateVideoChannelFromVideoObject(videoObject)
-    const account = await AccountModel.load(channelActor.VideoChannel.accountId)
+  await syncVideoExternalAttributes(video, fetchedVideo, syncParam)
 
-    return updateVideoFromAP(video, videoObject, account, channelActor.VideoChannel)
-  } catch (err) {
-    logger.warn('Cannot refresh video.', { err })
-    return video
-  }
+  return { video }
 }
 
-async function updateVideoFromAP (
+async function updateVideoFromAP (options: {
   video: VideoModel,
   videoObject: VideoTorrentObject,
   account: AccountModel,
   channel: VideoChannelModel,
+  updateViews: boolean,
   overrideTo?: string[]
-) {
-  logger.debug('Updating remote video "%s".', videoObject.uuid)
+}) {
+  logger.debug('Updating remote video "%s".', options.videoObject.uuid)
   let videoFieldsSave: any
 
   try {
@@ -351,72 +210,72 @@ async function updateVideoFromAP (
         transaction: t
       }
 
-      videoFieldsSave = video.toJSON()
+      videoFieldsSave = options.video.toJSON()
 
       // Check actor has the right to update the video
-      const videoChannel = video.VideoChannel
-      if (videoChannel.Account.id !== account.id) {
-        throw new Error('Account ' + account.Actor.url + ' does not own video channel ' + videoChannel.Actor.url)
+      const videoChannel = options.video.VideoChannel
+      if (videoChannel.Account.id !== options.account.id) {
+        throw new Error('Account ' + options.account.Actor.url + ' does not own video channel ' + videoChannel.Actor.url)
       }
 
-      const to = overrideTo ? overrideTo : videoObject.to
-      const videoData = await videoActivityObjectToDBAttributes(channel, videoObject, to)
-      video.set('name', videoData.name)
-      video.set('uuid', videoData.uuid)
-      video.set('url', videoData.url)
-      video.set('category', videoData.category)
-      video.set('licence', videoData.licence)
-      video.set('language', videoData.language)
-      video.set('description', videoData.description)
-      video.set('support', videoData.support)
-      video.set('nsfw', videoData.nsfw)
-      video.set('commentsEnabled', videoData.commentsEnabled)
-      video.set('waitTranscoding', videoData.waitTranscoding)
-      video.set('state', videoData.state)
-      video.set('duration', videoData.duration)
-      video.set('createdAt', videoData.createdAt)
-      video.set('publishedAt', videoData.publishedAt)
-      video.set('views', videoData.views)
-      video.set('privacy', videoData.privacy)
-      video.set('channelId', videoData.channelId)
-
-      await video.save(sequelizeOptions)
+      const to = options.overrideTo ? options.overrideTo : options.videoObject.to
+      const videoData = await videoActivityObjectToDBAttributes(options.channel, options.videoObject, to)
+      options.video.set('name', videoData.name)
+      options.video.set('uuid', videoData.uuid)
+      options.video.set('url', videoData.url)
+      options.video.set('category', videoData.category)
+      options.video.set('licence', videoData.licence)
+      options.video.set('language', videoData.language)
+      options.video.set('description', videoData.description)
+      options.video.set('support', videoData.support)
+      options.video.set('nsfw', videoData.nsfw)
+      options.video.set('commentsEnabled', videoData.commentsEnabled)
+      options.video.set('waitTranscoding', videoData.waitTranscoding)
+      options.video.set('state', videoData.state)
+      options.video.set('duration', videoData.duration)
+      options.video.set('createdAt', videoData.createdAt)
+      options.video.set('publishedAt', videoData.publishedAt)
+      options.video.set('privacy', videoData.privacy)
+      options.video.set('channelId', videoData.channelId)
+
+      if (options.updateViews === true) options.video.set('views', videoData.views)
+      await options.video.save(sequelizeOptions)
 
       // Don't block on request
-      generateThumbnailFromUrl(video, videoObject.icon)
-        .catch(err => logger.warn('Cannot generate thumbnail of %s.', videoObject.id, { err }))
+      generateThumbnailFromUrl(options.video, options.videoObject.icon)
+        .catch(err => logger.warn('Cannot generate thumbnail of %s.', options.videoObject.id, { err }))
 
       // Remove old video files
       const videoFileDestroyTasks: Bluebird<void>[] = []
-      for (const videoFile of video.VideoFiles) {
+      for (const videoFile of options.video.VideoFiles) {
         videoFileDestroyTasks.push(videoFile.destroy(sequelizeOptions))
       }
       await Promise.all(videoFileDestroyTasks)
 
-      const videoFileAttributes = videoFileActivityUrlToDBAttributes(video, videoObject)
+      const videoFileAttributes = videoFileActivityUrlToDBAttributes(options.video, options.videoObject)
       const tasks = videoFileAttributes.map(f => VideoFileModel.create(f, sequelizeOptions))
       await Promise.all(tasks)
 
       // Update Tags
-      const tags = videoObject.tag.map(tag => tag.name)
+      const tags = options.videoObject.tag.map(tag => tag.name)
       const tagInstances = await TagModel.findOrCreateTags(tags, t)
-      await video.$set('Tags', tagInstances, sequelizeOptions)
+      await options.video.$set('Tags', tagInstances, sequelizeOptions)
 
       // Update captions
-      await VideoCaptionModel.deleteAllCaptionsOfRemoteVideo(video.id, t)
+      await VideoCaptionModel.deleteAllCaptionsOfRemoteVideo(options.video.id, t)
 
-      const videoCaptionsPromises = videoObject.subtitleLanguage.map(c => {
-        return VideoCaptionModel.insertOrReplaceLanguage(video.id, c.identifier, t)
+      const videoCaptionsPromises = options.videoObject.subtitleLanguage.map(c => {
+        return VideoCaptionModel.insertOrReplaceLanguage(options.video.id, c.identifier, t)
       })
       await Promise.all(videoCaptionsPromises)
     })
 
-    logger.info('Remote video with uuid %s updated', videoObject.uuid)
+    logger.info('Remote video with uuid %s updated', options.videoObject.uuid)
 
     return updatedVideo
   } catch (err) {
-    if (video !== undefined && videoFieldsSave !== undefined) {
-      resetSequelizeInstance(video, videoFieldsSave)
+    if (options.video !== undefined && videoFieldsSave !== undefined) {
+      resetSequelizeInstance(options.video, videoFieldsSave)
     }
 
     // This is just a debug because we will retry the insert
@@ -433,12 +292,7 @@ export {
   fetchRemoteVideoStaticFile,
   fetchRemoteVideoDescription,
   generateThumbnailFromUrl,
-  videoActivityObjectToDBAttributes,
-  videoFileActivityUrlToDBAttributes,
-  createVideo,
-  getOrCreateVideoChannelFromVideoObject,
-  addVideoShares,
-  createRates
+  getOrCreateVideoChannelFromVideoObject
 }
 
 // ---------------------------------------------------------------------------
@@ -448,3 +302,178 @@ function isActivityVideoUrlObject (url: ActivityUrlObject): url is ActivityVideo
 
   return mimeTypes.indexOf(url.mimeType) !== -1 && url.mimeType.startsWith('video/')
 }
+
+async function createVideo (videoObject: VideoTorrentObject, channelActor: ActorModel, waitThumbnail = false) {
+  logger.debug('Adding remote video %s.', videoObject.id)
+
+  const videoCreated: VideoModel = await sequelizeTypescript.transaction(async t => {
+    const sequelizeOptions = { transaction: t }
+
+    const videoData = await videoActivityObjectToDBAttributes(channelActor.VideoChannel, videoObject, videoObject.to)
+    const video = VideoModel.build(videoData)
+
+    const videoCreated = await video.save(sequelizeOptions)
+
+    // Process files
+    const videoFileAttributes = videoFileActivityUrlToDBAttributes(videoCreated, videoObject)
+    if (videoFileAttributes.length === 0) {
+      throw new Error('Cannot find valid files for video %s ' + videoObject.url)
+    }
+
+    const videoFilePromises = videoFileAttributes.map(f => VideoFileModel.create(f, { transaction: t }))
+    await Promise.all(videoFilePromises)
+
+    // Process tags
+    const tags = videoObject.tag.map(t => t.name)
+    const tagInstances = await TagModel.findOrCreateTags(tags, t)
+    await videoCreated.$set('Tags', tagInstances, sequelizeOptions)
+
+    // Process captions
+    const videoCaptionsPromises = videoObject.subtitleLanguage.map(c => {
+      return VideoCaptionModel.insertOrReplaceLanguage(videoCreated.id, c.identifier, t)
+    })
+    await Promise.all(videoCaptionsPromises)
+
+    logger.info('Remote video with uuid %s inserted.', videoObject.uuid)
+
+    videoCreated.VideoChannel = channelActor.VideoChannel
+    return videoCreated
+  })
+
+  const p = generateThumbnailFromUrl(videoCreated, videoObject.icon)
+    .catch(err => logger.warn('Cannot generate thumbnail of %s.', videoObject.id, { err }))
+
+  if (waitThumbnail === true) await p
+
+  return videoCreated
+}
+
+async function refreshVideoIfNeeded (options: {
+  video: VideoModel,
+  fetchedType: VideoFetchByUrlType,
+  syncParam: SyncParam,
+  refreshViews: boolean
+}): Promise<VideoModel> {
+  if (!options.video.isOutdated()) return options.video
+
+  // We need more attributes if the argument video was fetched with not enough joints
+  const video = options.fetchedType === 'all' ? options.video : await VideoModel.loadByUrlAndPopulateAccount(options.video.url)
+
+  try {
+    const { response, videoObject } = await fetchRemoteVideo(video.url)
+    if (response.statusCode === 404) {
+      // Video does not exist anymore
+      await video.destroy()
+      return undefined
+    }
+
+    if (videoObject === undefined) {
+      logger.warn('Cannot refresh remote video: invalid body.')
+      return video
+    }
+
+    const channelActor = await getOrCreateVideoChannelFromVideoObject(videoObject)
+    const account = await AccountModel.load(channelActor.VideoChannel.accountId)
+
+    const updateOptions = {
+      video,
+      videoObject,
+      account,
+      channel: channelActor.VideoChannel,
+      updateViews: options.refreshViews
+    }
+    await updateVideoFromAP(updateOptions)
+    await syncVideoExternalAttributes(video, videoObject, options.syncParam)
+  } catch (err) {
+    logger.warn('Cannot refresh video.', { err })
+    return video
+  }
+}
+
+async function videoActivityObjectToDBAttributes (
+  videoChannel: VideoChannelModel,
+  videoObject: VideoTorrentObject,
+  to: string[] = []
+) {
+  const privacy = to.indexOf(ACTIVITY_PUB.PUBLIC) !== -1 ? VideoPrivacy.PUBLIC : VideoPrivacy.UNLISTED
+  const duration = videoObject.duration.replace(/[^\d]+/, '')
+
+  let language: string | undefined
+  if (videoObject.language) {
+    language = videoObject.language.identifier
+  }
+
+  let category: number | undefined
+  if (videoObject.category) {
+    category = parseInt(videoObject.category.identifier, 10)
+  }
+
+  let licence: number | undefined
+  if (videoObject.licence) {
+    licence = parseInt(videoObject.licence.identifier, 10)
+  }
+
+  const description = videoObject.content || null
+  const support = videoObject.support || null
+
+  return {
+    name: videoObject.name,
+    uuid: videoObject.uuid,
+    url: videoObject.id,
+    category,
+    licence,
+    language,
+    description,
+    support,
+    nsfw: videoObject.sensitive,
+    commentsEnabled: videoObject.commentsEnabled,
+    waitTranscoding: videoObject.waitTranscoding,
+    state: videoObject.state,
+    channelId: videoChannel.id,
+    duration: parseInt(duration, 10),
+    createdAt: new Date(videoObject.published),
+    publishedAt: new Date(videoObject.published),
+    // FIXME: updatedAt does not seems to be considered by Sequelize
+    updatedAt: new Date(videoObject.updated),
+    views: videoObject.views,
+    likes: 0,
+    dislikes: 0,
+    remote: true,
+    privacy
+  }
+}
+
+function videoFileActivityUrlToDBAttributes (videoCreated: VideoModel, videoObject: VideoTorrentObject) {
+  const fileUrls = videoObject.url.filter(u => isActivityVideoUrlObject(u)) as ActivityVideoUrlObject[]
+
+  if (fileUrls.length === 0) {
+    throw new Error('Cannot find video files for ' + videoCreated.url)
+  }
+
+  const attributes: VideoFileModel[] = []
+  for (const fileUrl of fileUrls) {
+    // Fetch associated magnet uri
+    const magnet = videoObject.url.find(u => {
+      return u.mimeType === 'application/x-bittorrent;x-scheme-handler/magnet' && u.height === fileUrl.height
+    })
+
+    if (!magnet) throw new Error('Cannot find associated magnet uri for file ' + fileUrl.href)
+
+    const parsed = magnetUtil.decode(magnet.href)
+    if (!parsed || isVideoFileInfoHashValid(parsed.infoHash) === false) {
+      throw new Error('Cannot parse magnet URI ' + magnet.href)
+    }
+
+    const attribute = {
+      extname: VIDEO_MIMETYPE_EXT[ fileUrl.mimeType ],
+      infoHash: parsed.infoHash,
+      resolution: fileUrl.height,
+      size: fileUrl.size,
+      videoId: videoCreated.id,
+      fps: fileUrl.fps
+    } as VideoFileModel
+    attributes.push(attribute)
+  }
+
+  return attributes
+}
index 5cfb81fc75e17b8b89f37b28a42deb0c87d89b6d..14f0a05f58829f5d93a175c2e616392ce9cde1ec 100644 (file)
@@ -3,23 +3,18 @@ import { sendUpdateActor } from './activitypub/send'
 import { AVATARS_SIZE, CONFIG, sequelizeTypescript } from '../initializers'
 import { updateActorAvatarInstance } from './activitypub'
 import { processImage } from '../helpers/image-utils'
-import { ActorModel } from '../models/activitypub/actor'
 import { AccountModel } from '../models/account/account'
 import { VideoChannelModel } from '../models/video/video-channel'
 import { extname, join } from 'path'
 
-async function updateActorAvatarFile (
-  avatarPhysicalFile: Express.Multer.File,
-  actor: ActorModel,
-  accountOrChannel: AccountModel | VideoChannelModel
-) {
+async function updateActorAvatarFile (avatarPhysicalFile: Express.Multer.File, accountOrChannel: AccountModel | VideoChannelModel) {
   const extension = extname(avatarPhysicalFile.filename)
-  const avatarName = actor.uuid + extension
+  const avatarName = accountOrChannel.Actor.uuid + extension
   const destination = join(CONFIG.STORAGE.AVATARS_DIR, avatarName)
   await processImage(avatarPhysicalFile, destination, AVATARS_SIZE)
 
   return sequelizeTypescript.transaction(async t => {
-    const updatedActor = await updateActorAvatarInstance(actor, avatarName, t)
+    const updatedActor = await updateActorAvatarInstance(accountOrChannel.Actor, avatarName, t)
     await updatedActor.save({ transaction: t })
 
     await sendUpdateActor(accountOrChannel, t)
index 380d42b2cd8342f57ac90ce467bace4379e5b5bf..f240affbca34cff893e30be1cd90e6495ebdf1cb 100644 (file)
@@ -38,7 +38,7 @@ class VideosCaptionCache extends AbstractVideoStaticFileCache <GetPathParam> {
     if (videoCaption.isOwned()) throw new Error('Cannot load remote caption of owned video.')
 
     // Used to fetch the path
-    const video = await VideoModel.loadByUUIDAndPopulateAccountAndServerAndTags(videoId)
+    const video = await VideoModel.loadAndPopulateAccountAndServerAndTags(videoId)
     if (!video) return undefined
 
     const remoteStaticPath = videoCaption.getCaptionStaticPath()
index 22b6d9cb0db82ecafd74747062ca0db8879fbe36..a5d6f5b627be39c8f619cfccc8fb96401787c85c 100644 (file)
@@ -16,7 +16,7 @@ class VideosPreviewCache extends AbstractVideoStaticFileCache <string> {
   }
 
   async getFilePath (videoUUID: string) {
-    const video = await VideoModel.loadByUUID(videoUUID)
+    const video = await VideoModel.loadByUUIDWithFile(videoUUID)
     if (!video) return undefined
 
     if (video.isOwned()) return join(CONFIG.STORAGE.PREVIEWS_DIR, video.getPreviewName())
@@ -25,7 +25,7 @@ class VideosPreviewCache extends AbstractVideoStaticFileCache <string> {
   }
 
   protected async loadRemoteFile (key: string) {
-    const video = await VideoModel.loadByUUIDAndPopulateAccountAndServerAndTags(key)
+    const video = await VideoModel.loadAndPopulateAccountAndServerAndTags(key)
     if (!video) return undefined
 
     if (video.isOwned()) throw new Error('Cannot load remote preview of owned video.')
index a69e09c32d8df849487d1768b5ed5ac7b9b63152..fc013e0c3bc2601af26d7cf7e171886c62c9ba44 100644 (file)
@@ -8,6 +8,7 @@ import { VideoModel } from '../models/video/video'
 import * as validator from 'validator'
 import { VideoPrivacy } from '../../shared/models/videos'
 import { readFile } from 'fs-extra'
+import { getActivityStreamDuration } from '../models/video/video-format-utils'
 
 export class ClientHtml {
 
@@ -38,10 +39,8 @@ export class ClientHtml {
     let videoPromise: Bluebird<VideoModel>
 
     // Let Angular application handle errors
-    if (validator.isUUID(videoId, 4)) {
-      videoPromise = VideoModel.loadByUUIDAndPopulateAccountAndServerAndTags(videoId)
-    } else if (validator.isInt(videoId)) {
-      videoPromise = VideoModel.loadAndPopulateAccountAndServerAndTags(+videoId)
+    if (validator.isInt(videoId) || validator.isUUID(videoId, 4)) {
+      videoPromise = VideoModel.loadAndPopulateAccountAndServerAndTags(videoId)
     } else {
       return ClientHtml.getIndexHTML(req, res)
     }
@@ -150,7 +149,7 @@ export class ClientHtml {
       description: videoDescriptionEscaped,
       thumbnailUrl: previewUrl,
       uploadDate: video.createdAt.toISOString(),
-      duration: video.getActivityStreamDuration(),
+      duration: getActivityStreamDuration(video.duration),
       contentUrl: videoUrl,
       embedUrl: embedUrl,
       interactionCount: video.views
index 72d670277a3752c90276e736e74db4c9b9f7313a..42217c27caa4dd28c3da03f091e73382f19c40d8 100644 (file)
@@ -1,10 +1,10 @@
 import * as Bull from 'bull'
 import { logger } from '../../../helpers/logger'
 import { processActivities } from '../../activitypub/process'
-import { VideoModel } from '../../../models/video/video'
-import { addVideoShares, createRates } from '../../activitypub/videos'
 import { addVideoComments } from '../../activitypub/video-comments'
 import { crawlCollectionPage } from '../../activitypub/crawl'
+import { VideoModel } from '../../../models/video/video'
+import { addVideoShares, createRates } from '../../activitypub'
 
 type FetchType = 'activity' | 'video-likes' | 'video-dislikes' | 'video-shares' | 'video-comments'
 
index c6308f7a6cbb4740c486844a0975d02ef199b5b7..1463c93fc843e660197c9469bcd65db31575d650 100644 (file)
@@ -8,6 +8,7 @@ import { retryTransactionWrapper } from '../../../helpers/database-utils'
 import { sequelizeTypescript } from '../../../initializers'
 import * as Bluebird from 'bluebird'
 import { computeResolutionsToTranscode } from '../../../helpers/ffmpeg-utils'
+import { importVideoFile, transcodeOriginalVideofile, optimizeOriginalVideofile } from '../../video-transcoding'
 
 export type VideoFilePayload = {
   videoUUID: string
@@ -25,14 +26,14 @@ async function processVideoFileImport (job: Bull.Job) {
   const payload = job.data as VideoFileImportPayload
   logger.info('Processing video file import in job %d.', job.id)
 
-  const video = await VideoModel.loadByUUIDAndPopulateAccountAndServerAndTags(payload.videoUUID)
+  const video = await VideoModel.loadAndPopulateAccountAndServerAndTags(payload.videoUUID)
   // No video, maybe deleted?
   if (!video) {
     logger.info('Do not process job %d, video does not exist.', job.id)
     return undefined
   }
 
-  await video.importVideoFile(payload.filePath)
+  await importVideoFile(video, payload.filePath)
 
   await onVideoFileTranscoderOrImportSuccess(video)
   return video
@@ -42,7 +43,7 @@ async function processVideoFile (job: Bull.Job) {
   const payload = job.data as VideoFilePayload
   logger.info('Processing video file in job %d.', job.id)
 
-  const video = await VideoModel.loadByUUIDAndPopulateAccountAndServerAndTags(payload.videoUUID)
+  const video = await VideoModel.loadAndPopulateAccountAndServerAndTags(payload.videoUUID)
   // No video, maybe deleted?
   if (!video) {
     logger.info('Do not process job %d, video does not exist.', job.id)
@@ -51,11 +52,11 @@ async function processVideoFile (job: Bull.Job) {
 
   // Transcoding in other resolution
   if (payload.resolution) {
-    await video.transcodeOriginalVideofile(payload.resolution, payload.isPortraitMode || false)
+    await transcodeOriginalVideofile(video, payload.resolution, payload.isPortraitMode || false)
 
     await retryTransactionWrapper(onVideoFileTranscoderOrImportSuccess, video)
   } else {
-    await video.optimizeOriginalVideofile()
+    await optimizeOriginalVideofile(video)
 
     await retryTransactionWrapper(onVideoFileOptimizerSuccess, video, payload.isNewVideo)
   }
@@ -68,7 +69,7 @@ async function onVideoFileTranscoderOrImportSuccess (video: VideoModel) {
 
   return sequelizeTypescript.transaction(async t => {
     // Maybe the video changed in database, refresh it
-    let videoDatabase = await VideoModel.loadByUUIDAndPopulateAccountAndServerAndTags(video.uuid, t)
+    let videoDatabase = await VideoModel.loadAndPopulateAccountAndServerAndTags(video.uuid, t)
     // Video does not exist anymore
     if (!videoDatabase) return undefined
 
@@ -98,7 +99,7 @@ async function onVideoFileOptimizerSuccess (video: VideoModel, isNewVideo: boole
 
   return sequelizeTypescript.transaction(async t => {
     // Maybe the video changed in database, refresh it
-    const videoDatabase = await VideoModel.loadByUUIDAndPopulateAccountAndServerAndTags(video.uuid, t)
+    const videoDatabase = await VideoModel.loadAndPopulateAccountAndServerAndTags(video.uuid, t)
     // Video does not exist anymore
     if (!videoDatabase) return undefined
 
index ebcb2090cd42a725ab5e10c4da489533186aa778..9e14e57e6f7b5bd4032537b1f8438130f32da195 100644 (file)
@@ -183,7 +183,7 @@ async function processFile (downloader: () => Promise<string>, videoImport: Vide
       const videoUpdated = await video.save({ transaction: t })
 
       // Now we can federate the video (reload from database, we need more attributes)
-      const videoForFederation = await VideoModel.loadByUUIDAndPopulateAccountAndServerAndTags(video.uuid, t)
+      const videoForFederation = await VideoModel.loadAndPopulateAccountAndServerAndTags(video.uuid, t)
       await federateVideoIfNeeded(videoForFederation, true, t)
 
       // Update video import object
index 2f8667e198bb05086fdf012b5caf439315bf50a1..5cbe60b82c626ca8148ee5545d2da7a05c90fcf3 100644 (file)
@@ -4,15 +4,50 @@ import { UserModel } from '../models/account/user'
 import { OAuthClientModel } from '../models/oauth/oauth-client'
 import { OAuthTokenModel } from '../models/oauth/oauth-token'
 import { CONFIG } from '../initializers/constants'
+import { Transaction } from 'sequelize'
 
 type TokenInfo = { accessToken: string, refreshToken: string, accessTokenExpiresAt: Date, refreshTokenExpiresAt: Date }
+const accessTokenCache: { [ accessToken: string ]: OAuthTokenModel } = {}
+const userHavingToken: { [ userId: number ]: string } = {}
 
 // ---------------------------------------------------------------------------
 
+function deleteUserToken (userId: number, t?: Transaction) {
+  clearCacheByUserId(userId)
+
+  return OAuthTokenModel.deleteUserToken(userId, t)
+}
+
+function clearCacheByUserId (userId: number) {
+  const token = userHavingToken[userId]
+  if (token !== undefined) {
+    accessTokenCache[ token ] = undefined
+    userHavingToken[ userId ] = undefined
+  }
+}
+
+function clearCacheByToken (token: string) {
+  const tokenModel = accessTokenCache[ token ]
+  if (tokenModel !== undefined) {
+    userHavingToken[tokenModel.userId] = undefined
+    accessTokenCache[ token ] = undefined
+  }
+}
+
 function getAccessToken (bearerToken: string) {
   logger.debug('Getting access token (bearerToken: ' + bearerToken + ').')
 
+  if (accessTokenCache[bearerToken] !== undefined) return accessTokenCache[bearerToken]
+
   return OAuthTokenModel.getByTokenAndPopulateUser(bearerToken)
+    .then(tokenModel => {
+      if (tokenModel) {
+        accessTokenCache[ bearerToken ] = tokenModel
+        userHavingToken[ tokenModel.userId ] = tokenModel.accessToken
+      }
+
+      return tokenModel
+    })
 }
 
 function getClient (clientId: string, clientSecret: string) {
@@ -48,6 +83,8 @@ async function getUser (usernameOrEmail: string, password: string) {
 async function revokeToken (tokenInfo: TokenInfo) {
   const token = await OAuthTokenModel.getByRefreshTokenAndPopulateUser(tokenInfo.refreshToken)
   if (token) {
+    clearCacheByToken(token.accessToken)
+
     token.destroy()
          .catch(err => logger.error('Cannot destroy token when revoking token.', { err }))
   }
@@ -85,6 +122,9 @@ async function saveToken (token: TokenInfo, client: OAuthClientModel, user: User
 
 // See https://github.com/oauthjs/node-oauth2-server/wiki/Model-specification for the model specifications
 export {
+  deleteUserToken,
+  clearCacheByUserId,
+  clearCacheByToken,
   getAccessToken,
   getClient,
   getRefreshToken,
index ee9ba1766b4eb0a37d819a7918126527b701e701..96065171298d755fae9a2586673786cdb9ed7909 100644 (file)
@@ -1,10 +1,9 @@
 import { AbstractScheduler } from './abstract-scheduler'
 import { CONFIG, JOB_TTL, REDUNDANCY, SCHEDULER_INTERVALS_MS } from '../../initializers'
 import { logger } from '../../helpers/logger'
-import { VideoRedundancyStrategy } from '../../../shared/models/redundancy'
+import { VideoRedundancyStrategy, VideosRedundancy } from '../../../shared/models/redundancy'
 import { VideoRedundancyModel } from '../../models/redundancy/video-redundancy'
 import { VideoFileModel } from '../../models/video/video-file'
-import { sortBy } from 'lodash'
 import { downloadWebTorrentVideo } from '../../helpers/webtorrent'
 import { join } from 'path'
 import { rename } from 'fs-extra'
@@ -12,7 +11,6 @@ import { getServerActor } from '../../helpers/utils'
 import { sendCreateCacheFile, sendUpdateCacheFile } from '../activitypub/send'
 import { VideoModel } from '../../models/video/video'
 import { getVideoCacheFileActivityPubUrl } from '../activitypub/url'
-import { removeVideoRedundancy } from '../redundancy'
 import { isTestInstance } from '../../helpers/core-utils'
 
 export class VideosRedundancyScheduler extends AbstractScheduler {
@@ -20,7 +18,7 @@ export class VideosRedundancyScheduler extends AbstractScheduler {
   private static instance: AbstractScheduler
   private executing = false
 
-  protected schedulerIntervalMs = SCHEDULER_INTERVALS_MS.videosRedundancy
+  protected schedulerIntervalMs = CONFIG.REDUNDANCY.VIDEOS.CHECK_INTERVAL
 
   private constructor () {
     super()
@@ -31,17 +29,15 @@ export class VideosRedundancyScheduler extends AbstractScheduler {
 
     this.executing = true
 
-    for (const obj of CONFIG.REDUNDANCY.VIDEOS) {
-
+    for (const obj of CONFIG.REDUNDANCY.VIDEOS.STRATEGIES) {
       try {
-        const videoToDuplicate = await this.findVideoToDuplicate(obj.strategy)
+        const videoToDuplicate = await this.findVideoToDuplicate(obj)
         if (!videoToDuplicate) continue
 
         const videoFiles = videoToDuplicate.VideoFiles
         videoFiles.forEach(f => f.Video = videoToDuplicate)
 
-        const videosRedundancy = await VideoRedundancyModel.getVideoFiles(obj.strategy)
-        if (this.isTooHeavy(videosRedundancy, videoFiles, obj.size)) {
+        if (await this.isTooHeavy(obj.strategy, videoFiles, obj.size)) {
           if (!isTestInstance()) logger.info('Video %s is too big for our cache, skipping.', videoToDuplicate.url)
           continue
         }
@@ -54,6 +50,16 @@ export class VideosRedundancyScheduler extends AbstractScheduler {
       }
     }
 
+    await this.removeExpired()
+
+    this.executing = false
+  }
+
+  static get Instance () {
+    return this.instance || (this.instance = new this())
+  }
+
+  private async removeExpired () {
     const expired = await VideoRedundancyModel.listAllExpired()
 
     for (const m of expired) {
@@ -65,16 +71,21 @@ export class VideosRedundancyScheduler extends AbstractScheduler {
         logger.error('Cannot remove %s video from our redundancy system.', this.buildEntryLogId(m))
       }
     }
-
-    this.executing = false
   }
 
-  static get Instance () {
-    return this.instance || (this.instance = new this())
-  }
+  private findVideoToDuplicate (cache: VideosRedundancy) {
+    if (cache.strategy === 'most-views') {
+      return VideoRedundancyModel.findMostViewToDuplicate(REDUNDANCY.VIDEOS.RANDOMIZED_FACTOR)
+    }
+
+    if (cache.strategy === 'trending') {
+      return VideoRedundancyModel.findTrendingToDuplicate(REDUNDANCY.VIDEOS.RANDOMIZED_FACTOR)
+    }
 
-  private findVideoToDuplicate (strategy: VideoRedundancyStrategy) {
-    if (strategy === 'most-views') return VideoRedundancyModel.findMostViewToDuplicate(REDUNDANCY.VIDEOS.RANDOMIZED_FACTOR)
+    if (cache.strategy === 'recently-added') {
+      const minViews = cache.minViews
+      return VideoRedundancyModel.findRecentlyAddedToDuplicate(REDUNDANCY.VIDEOS.RANDOMIZED_FACTOR, minViews)
+    }
   }
 
   private async createVideoRedundancy (strategy: VideoRedundancyStrategy, filesToDuplicate: VideoFileModel[]) {
@@ -120,27 +131,10 @@ export class VideosRedundancyScheduler extends AbstractScheduler {
     }
   }
 
-  // Unused, but could be useful in the future, with a custom strategy
-  private async purgeVideosIfNeeded (videosRedundancy: VideoRedundancyModel[], filesToDuplicate: VideoFileModel[], maxSize: number) {
-    const sortedVideosRedundancy = sortBy(videosRedundancy, 'createdAt')
-
-    while (this.isTooHeavy(sortedVideosRedundancy, filesToDuplicate, maxSize)) {
-      const toDelete = sortedVideosRedundancy.shift()
-
-      const videoFile = toDelete.VideoFile
-      logger.info('Purging video %s (resolution %d) from our redundancy system.', videoFile.Video.url, videoFile.resolution)
-
-      await removeVideoRedundancy(toDelete, undefined)
-    }
-
-    return sortedVideosRedundancy
-  }
-
-  private isTooHeavy (videosRedundancy: VideoRedundancyModel[], filesToDuplicate: VideoFileModel[], maxSizeArg: number) {
+  private async isTooHeavy (strategy: VideoRedundancyStrategy, filesToDuplicate: VideoFileModel[], maxSizeArg: number) {
     const maxSize = maxSizeArg - this.getTotalFileSizes(filesToDuplicate)
 
-    const redundancyReducer = (previous: number, current: VideoRedundancyModel) => previous + current.VideoFile.size
-    const totalDuplicated = videosRedundancy.reduce(redundancyReducer, 0)
+    const totalDuplicated = await VideoRedundancyModel.getTotalDuplicated(strategy)
 
     return totalDuplicated > maxSize
   }
index faadb4334116c0f1429f80824e8b2838512007f2..461cd045ef1256bc9f83a36c334c6d8774ac1085 100644 (file)
@@ -1,13 +1,6 @@
-// Thanks: https://github.com/przemyslawpluta/node-youtube-dl/blob/master/lib/downloader.js
-// We rewrote it to avoid sync calls
-
 import { AbstractScheduler } from './abstract-scheduler'
 import { SCHEDULER_INTERVALS_MS } from '../../initializers'
-import { logger } from '../../helpers/logger'
-import * as request from 'request'
-import { createWriteStream, ensureDir, writeFile } from 'fs-extra'
-import { join } from 'path'
-import { root } from '../../helpers/core-utils'
+import { updateYoutubeDLBinary } from '../../helpers/youtube-dl'
 
 export class YoutubeDlUpdateScheduler extends AbstractScheduler {
 
@@ -19,60 +12,8 @@ export class YoutubeDlUpdateScheduler extends AbstractScheduler {
     super()
   }
 
-  async execute () {
-    logger.info('Updating youtubeDL binary.')
-
-    const binDirectory = join(root(), 'node_modules', 'youtube-dl', 'bin')
-    const bin = join(binDirectory, 'youtube-dl')
-    const detailsPath = join(binDirectory, 'details')
-    const url = 'https://yt-dl.org/downloads/latest/youtube-dl'
-
-    await ensureDir(binDirectory)
-
-    return new Promise(res => {
-      request.get(url, { followRedirect: false }, (err, result) => {
-        if (err) {
-          logger.error('Cannot update youtube-dl.', { err })
-          return res()
-        }
-
-        if (result.statusCode !== 302) {
-          logger.error('youtube-dl update error: did not get redirect for the latest version link. Status %d', result.statusCode)
-          return res()
-        }
-
-        const url = result.headers.location
-        const downloadFile = request.get(url)
-        const newVersion = /yt-dl\.org\/downloads\/(\d{4}\.\d\d\.\d\d(\.\d)?)\/youtube-dl/.exec(url)[ 1 ]
-
-        downloadFile.on('response', result => {
-          if (result.statusCode !== 200) {
-            logger.error('Cannot update youtube-dl: new version response is not 200, it\'s %d.', result.statusCode)
-            return res()
-          }
-
-          downloadFile.pipe(createWriteStream(bin, { mode: 493 }))
-        })
-
-        downloadFile.on('error', err => {
-          logger.error('youtube-dl update error.', { err })
-          return res()
-        })
-
-        downloadFile.on('end', () => {
-          const details = JSON.stringify({ version: newVersion, path: bin, exec: 'youtube-dl' })
-          writeFile(detailsPath, details, { encoding: 'utf8' }, err => {
-            if (err) {
-              logger.error('youtube-dl update error: cannot write details.', { err })
-              return res()
-            }
-
-            logger.info('youtube-dl updated to version %s.', newVersion)
-            return res()
-          })
-        })
-      })
-    })
+  execute () {
+    return updateYoutubeDLBinary()
   }
 
   static get Instance () {
diff --git a/server/lib/video-transcoding.ts b/server/lib/video-transcoding.ts
new file mode 100644 (file)
index 0000000..bf3ff78
--- /dev/null
@@ -0,0 +1,130 @@
+import { CONFIG } from '../initializers'
+import { join, extname } from 'path'
+import { getVideoFileFPS, getVideoFileResolution, transcode } from '../helpers/ffmpeg-utils'
+import { copy, remove, rename, stat } from 'fs-extra'
+import { logger } from '../helpers/logger'
+import { VideoResolution } from '../../shared/models/videos'
+import { VideoFileModel } from '../models/video/video-file'
+import { VideoModel } from '../models/video/video'
+
+async function optimizeOriginalVideofile (video: VideoModel) {
+  const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
+  const newExtname = '.mp4'
+  const inputVideoFile = video.getOriginalFile()
+  const videoInputPath = join(videosDirectory, video.getVideoFilename(inputVideoFile))
+  const videoTranscodedPath = join(videosDirectory, video.id + '-transcoded' + newExtname)
+
+  const transcodeOptions = {
+    inputPath: videoInputPath,
+    outputPath: videoTranscodedPath
+  }
+
+  // Could be very long!
+  await transcode(transcodeOptions)
+
+  try {
+    await remove(videoInputPath)
+
+    // Important to do this before getVideoFilename() to take in account the new file extension
+    inputVideoFile.set('extname', newExtname)
+
+    const videoOutputPath = video.getVideoFilePath(inputVideoFile)
+    await rename(videoTranscodedPath, videoOutputPath)
+    const stats = await stat(videoOutputPath)
+    const fps = await getVideoFileFPS(videoOutputPath)
+
+    inputVideoFile.set('size', stats.size)
+    inputVideoFile.set('fps', fps)
+
+    await video.createTorrentAndSetInfoHash(inputVideoFile)
+    await inputVideoFile.save()
+  } catch (err) {
+    // Auto destruction...
+    video.destroy().catch(err => logger.error('Cannot destruct video after transcoding failure.', { err }))
+
+    throw err
+  }
+}
+
+async function transcodeOriginalVideofile (video: VideoModel, resolution: VideoResolution, isPortraitMode: boolean) {
+  const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
+  const extname = '.mp4'
+
+  // We are sure it's x264 in mp4 because optimizeOriginalVideofile was already executed
+  const videoInputPath = join(videosDirectory, video.getVideoFilename(video.getOriginalFile()))
+
+  const newVideoFile = new VideoFileModel({
+    resolution,
+    extname,
+    size: 0,
+    videoId: video.id
+  })
+  const videoOutputPath = join(videosDirectory, video.getVideoFilename(newVideoFile))
+
+  const transcodeOptions = {
+    inputPath: videoInputPath,
+    outputPath: videoOutputPath,
+    resolution,
+    isPortraitMode
+  }
+
+  await transcode(transcodeOptions)
+
+  const stats = await stat(videoOutputPath)
+  const fps = await getVideoFileFPS(videoOutputPath)
+
+  newVideoFile.set('size', stats.size)
+  newVideoFile.set('fps', fps)
+
+  await video.createTorrentAndSetInfoHash(newVideoFile)
+
+  await newVideoFile.save()
+
+  video.VideoFiles.push(newVideoFile)
+}
+
+async function importVideoFile (video: VideoModel, inputFilePath: string) {
+  const { videoFileResolution } = await getVideoFileResolution(inputFilePath)
+  const { size } = await stat(inputFilePath)
+  const fps = await getVideoFileFPS(inputFilePath)
+
+  let updatedVideoFile = new VideoFileModel({
+    resolution: videoFileResolution,
+    extname: extname(inputFilePath),
+    size,
+    fps,
+    videoId: video.id
+  })
+
+  const currentVideoFile = video.VideoFiles.find(videoFile => videoFile.resolution === updatedVideoFile.resolution)
+
+  if (currentVideoFile) {
+    // Remove old file and old torrent
+    await video.removeFile(currentVideoFile)
+    await video.removeTorrent(currentVideoFile)
+    // Remove the old video file from the array
+    video.VideoFiles = video.VideoFiles.filter(f => f !== currentVideoFile)
+
+    // Update the database
+    currentVideoFile.set('extname', updatedVideoFile.extname)
+    currentVideoFile.set('size', updatedVideoFile.size)
+    currentVideoFile.set('fps', updatedVideoFile.fps)
+
+    updatedVideoFile = currentVideoFile
+  }
+
+  const outputPath = video.getVideoFilePath(updatedVideoFile)
+  await copy(inputFilePath, outputPath)
+
+  await video.createTorrentAndSetInfoHash(updatedVideoFile)
+
+  await updatedVideoFile.save()
+
+  video.VideoFiles.push(updatedVideoFile)
+}
+
+export {
+  optimizeOriginalVideofile,
+  transcodeOriginalVideofile,
+  importVideoFile
+}
index d13c50c84af1f3f5e9248cfc3173000eeccc8d17..d3ba1ae232afa41d2ef6ddea9873ff9c3f0ba723 100644 (file)
@@ -172,7 +172,7 @@ const usersVideoRatingValidator = [
     logger.debug('Checking usersVideoRating parameters', { parameters: req.params })
 
     if (areValidationErrors(req, res)) return
-    if (!await isVideoExist(req.params.videoId, res)) return
+    if (!await isVideoExist(req.params.videoId, res, 'id')) return
 
     return next()
   }
index 4f393ea84e06f83aa018ad9f257a63327c3806b6..51ffd7f3ce698fffab17295c61b1cb5abca2d71c 100644 (file)
@@ -58,7 +58,7 @@ const listVideoCaptionsValidator = [
     logger.debug('Checking listVideoCaptions parameters', { parameters: req.params })
 
     if (areValidationErrors(req, res)) return
-    if (!await isVideoExist(req.params.videoId, res)) return
+    if (!await isVideoExist(req.params.videoId, res, 'id')) return
 
     return next()
   }
index 227bc1fca8f5bd798aeb2818e43f0e671680995a..69385249923ce6214c44dfb8592930a8037dc820 100644 (file)
@@ -17,7 +17,7 @@ const listVideoCommentThreadsValidator = [
     logger.debug('Checking listVideoCommentThreads parameters.', { parameters: req.params })
 
     if (areValidationErrors(req, res)) return
-    if (!await isVideoExist(req.params.videoId, res)) return
+    if (!await isVideoExist(req.params.videoId, res, 'only-video')) return
 
     return next()
   }
@@ -31,7 +31,7 @@ const listVideoThreadCommentsValidator = [
     logger.debug('Checking listVideoThreadComments parameters.', { parameters: req.params })
 
     if (areValidationErrors(req, res)) return
-    if (!await isVideoExist(req.params.videoId, res)) return
+    if (!await isVideoExist(req.params.videoId, res, 'only-video')) return
     if (!await isVideoCommentThreadExist(req.params.threadId, res.locals.video, res)) return
 
     return next()
@@ -78,7 +78,7 @@ const videoCommentGetValidator = [
     logger.debug('Checking videoCommentGetValidator parameters.', { parameters: req.params })
 
     if (areValidationErrors(req, res)) return
-    if (!await isVideoExist(req.params.videoId, res)) return
+    if (!await isVideoExist(req.params.videoId, res, 'id')) return
     if (!await isVideoCommentExist(req.params.commentId, res.locals.video, res)) return
 
     return next()
index 9befbc9ee7e506d195c94b899f1ebc5e939bfe5b..67eabe468268f27e96c4429e9c4df976f32fb75a 100644 (file)
@@ -41,6 +41,7 @@ import { checkUserCanTerminateOwnershipChange, doesChangeVideoOwnershipExist } f
 import { VideoChangeOwnershipAccept } from '../../../shared/models/videos/video-change-ownership-accept.model'
 import { VideoChangeOwnershipModel } from '../../models/video/video-change-ownership'
 import { AccountModel } from '../../models/account/account'
+import { VideoFetchType } from '../../helpers/video'
 
 const videosAddValidator = getCommonVideoAttributes().concat([
   body('videofile')
@@ -128,47 +129,49 @@ const videosUpdateValidator = getCommonVideoAttributes().concat([
   }
 ])
 
-const videosGetValidator = [
-  param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
-
-  async (req: express.Request, res: express.Response, next: express.NextFunction) => {
-    logger.debug('Checking videosGet parameters', { parameters: req.params })
+const videosCustomGetValidator = (fetchType: VideoFetchType) => {
+  return [
+    param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
 
-    if (areValidationErrors(req, res)) return
-    if (!await isVideoExist(req.params.id, res)) return
+    async (req: express.Request, res: express.Response, next: express.NextFunction) => {
+      logger.debug('Checking videosGet parameters', { parameters: req.params })
 
-    const video: VideoModel = res.locals.video
+      if (areValidationErrors(req, res)) return
+      if (!await isVideoExist(req.params.id, res, fetchType)) return
 
-    // Video private or blacklisted
-    if (video.privacy === VideoPrivacy.PRIVATE || video.VideoBlacklist) {
-      return authenticate(req, res, () => {
-        const user: UserModel = res.locals.oauth.token.User
+      const video: VideoModel = res.locals.video
 
-        // Only the owner or a user that have blacklist rights can see the video
-        if (video.VideoChannel.Account.userId !== user.id && !user.hasRight(UserRight.MANAGE_VIDEO_BLACKLIST)) {
-          return res.status(403)
-                    .json({ error: 'Cannot get this private or blacklisted video.' })
-                    .end()
-        }
+      // Video private or blacklisted
+      if (video.privacy === VideoPrivacy.PRIVATE || video.VideoBlacklist) {
+        return authenticate(req, res, () => {
+          const user: UserModel = res.locals.oauth.token.User
 
-        return next()
-      })
+          // Only the owner or a user that have blacklist rights can see the video
+          if (video.VideoChannel.Account.userId !== user.id && !user.hasRight(UserRight.MANAGE_VIDEO_BLACKLIST)) {
+            return res.status(403)
+                      .json({ error: 'Cannot get this private or blacklisted video.' })
+                      .end()
+          }
 
-      return
-    }
+          return next()
+        })
+      }
 
-    // Video is public, anyone can access it
-    if (video.privacy === VideoPrivacy.PUBLIC) return next()
+      // Video is public, anyone can access it
+      if (video.privacy === VideoPrivacy.PUBLIC) return next()
 
-    // Video is unlisted, check we used the uuid to fetch it
-    if (video.privacy === VideoPrivacy.UNLISTED) {
-      if (isUUIDValid(req.params.id)) return next()
+      // Video is unlisted, check we used the uuid to fetch it
+      if (video.privacy === VideoPrivacy.UNLISTED) {
+        if (isUUIDValid(req.params.id)) return next()
 
-      // Don't leak this unlisted video
-      return res.status(404).end()
+        // Don't leak this unlisted video
+        return res.status(404).end()
+      }
     }
-  }
-]
+  ]
+}
+
+const videosGetValidator = videosCustomGetValidator('all')
 
 const videosRemoveValidator = [
   param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
@@ -366,6 +369,7 @@ export {
   videosAddValidator,
   videosUpdateValidator,
   videosGetValidator,
+  videosCustomGetValidator,
   videosRemoveValidator,
   videosShareValidator,
 
index 6bbfc6f4e8d50f223c8eddb45f2f22ad50de9178..580d920ceddad9cd23db6eeb01740eec26822611 100644 (file)
@@ -134,8 +134,8 @@ export class AccountModel extends Model<AccountModel> {
     return undefined
   }
 
-  static load (id: number) {
-    return AccountModel.findById(id)
+  static load (id: number, transaction?: Sequelize.Transaction) {
+    return AccountModel.findById(id, { transaction })
   }
 
   static loadByUUID (uuid: string) {
index 680b1d52d9debb806818162f9de09ebf5972ec38..e56b0bf40b06bb988e8cf9c04f82ace36cd8ec50 100644 (file)
@@ -1,5 +1,7 @@
 import * as Sequelize from 'sequelize'
 import {
+  AfterDelete,
+  AfterUpdate,
   AllowNull,
   BeforeCreate,
   BeforeUpdate,
@@ -39,6 +41,7 @@ import { AccountModel } from './account'
 import { NSFWPolicyType } from '../../../shared/models/videos/nsfw-policy.type'
 import { values } from 'lodash'
 import { NSFW_POLICY_TYPES } from '../../initializers'
+import { clearCacheByUserId } from '../../lib/oauth-model'
 
 enum ScopeNames {
   WITH_VIDEO_CHANNEL = 'WITH_VIDEO_CHANNEL'
@@ -168,6 +171,12 @@ export class UserModel extends Model<UserModel> {
     }
   }
 
+  @AfterUpdate
+  @AfterDelete
+  static removeTokenCache (instance: UserModel) {
+    return clearCacheByUserId(instance.id)
+  }
+
   static countTotal () {
     return this.count()
   }
index ef8dd9f7cbfa8026b00affe99d8c61b9574a2d47..f8bb593239dbbe47792f193ad66766d394f9f945 100644 (file)
@@ -266,6 +266,18 @@ export class ActorModel extends Model<ActorModel> {
     return ActorModel.unscoped().findById(id)
   }
 
+  static isActorUrlExist (url: string) {
+    const query = {
+      raw: true,
+      where: {
+        url
+      }
+    }
+
+    return ActorModel.unscoped().findOne(query)
+      .then(a => !!a)
+  }
+
   static listByFollowersUrls (followersUrls: string[], transaction?: Sequelize.Transaction) {
     const query = {
       where: {
@@ -311,6 +323,29 @@ export class ActorModel extends Model<ActorModel> {
   }
 
   static loadByUrl (url: string, transaction?: Sequelize.Transaction) {
+    const query = {
+      where: {
+        url
+      },
+      transaction,
+      include: [
+        {
+          attributes: [ 'id' ],
+          model: AccountModel.unscoped(),
+          required: false
+        },
+        {
+          attributes: [ 'id' ],
+          model: VideoChannelModel.unscoped(),
+          required: false
+        }
+      ]
+    }
+
+    return ActorModel.unscoped().findOne(query)
+  }
+
+  static loadByUrlAndPopulateAccountAndChannel (url: string, transaction?: Sequelize.Transaction) {
     const query = {
       where: {
         url
index 4c53848dc4aa77220659d436d0b55ad12910b8e4..ef9592c04fc50d9dfbcdf59ce8036c034c41c044 100644 (file)
@@ -1,9 +1,23 @@
-import { AllowNull, BelongsTo, Column, CreatedAt, ForeignKey, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript'
+import {
+  AfterDelete,
+  AfterUpdate,
+  AllowNull,
+  BelongsTo,
+  Column,
+  CreatedAt,
+  ForeignKey,
+  Model,
+  Scopes,
+  Table,
+  UpdatedAt
+} from 'sequelize-typescript'
 import { logger } from '../../helpers/logger'
-import { AccountModel } from '../account/account'
 import { UserModel } from '../account/user'
 import { OAuthClientModel } from './oauth-client'
 import { Transaction } from 'sequelize'
+import { AccountModel } from '../account/account'
+import { ActorModel } from '../activitypub/actor'
+import { clearCacheByToken } from '../../lib/oauth-model'
 
 export type OAuthTokenInfo = {
   refreshToken: string
@@ -17,18 +31,27 @@ export type OAuthTokenInfo = {
 }
 
 enum ScopeNames {
-  WITH_ACCOUNT = 'WITH_ACCOUNT'
+  WITH_USER = 'WITH_USER'
 }
 
 @Scopes({
-  [ScopeNames.WITH_ACCOUNT]: {
+  [ScopeNames.WITH_USER]: {
     include: [
       {
-        model: () => UserModel,
+        model: () => UserModel.unscoped(),
+        required: true,
         include: [
           {
-            model: () => AccountModel,
-            required: true
+            attributes: [ 'id' ],
+            model: () => AccountModel.unscoped(),
+            required: true,
+            include: [
+              {
+                attributes: [ 'id' ],
+                model: () => ActorModel.unscoped(),
+                required: true
+              }
+            ]
           }
         ]
       }
@@ -102,6 +125,12 @@ export class OAuthTokenModel extends Model<OAuthTokenModel> {
   })
   OAuthClients: OAuthClientModel[]
 
+  @AfterUpdate
+  @AfterDelete
+  static removeTokenCache (token: OAuthTokenModel) {
+    return clearCacheByToken(token.accessToken)
+  }
+
   static getByRefreshTokenAndPopulateClient (refreshToken: string) {
     const query = {
       where: {
@@ -138,7 +167,7 @@ export class OAuthTokenModel extends Model<OAuthTokenModel> {
       }
     }
 
-    return OAuthTokenModel.scope(ScopeNames.WITH_ACCOUNT).findOne(query).then(token => {
+    return OAuthTokenModel.scope(ScopeNames.WITH_USER).findOne(query).then(token => {
       if (token) token['user'] = token.User
 
       return token
@@ -152,7 +181,7 @@ export class OAuthTokenModel extends Model<OAuthTokenModel> {
       }
     }
 
-    return OAuthTokenModel.scope(ScopeNames.WITH_ACCOUNT)
+    return OAuthTokenModel.scope(ScopeNames.WITH_USER)
       .findOne(query)
       .then(token => {
         if (token) {
index 48ec772069ed37a02811ca3fce7f805021c95e94..fb07287a84c0f516da91cdebe372cfed77d65324 100644 (file)
@@ -14,11 +14,10 @@ import {
   UpdatedAt
 } from 'sequelize-typescript'
 import { ActorModel } from '../activitypub/actor'
-import { throwIfNotValid } from '../utils'
+import { getVideoSort, throwIfNotValid } from '../utils'
 import { isActivityPubUrlValid, isUrlValid } from '../../helpers/custom-validators/activitypub/misc'
-import { CONSTRAINTS_FIELDS, VIDEO_EXT_MIMETYPE } from '../../initializers'
+import { CONFIG, CONSTRAINTS_FIELDS, VIDEO_EXT_MIMETYPE } from '../../initializers'
 import { VideoFileModel } from '../video/video-file'
-import { isDateValid } from '../../helpers/custom-validators/misc'
 import { getServerActor } from '../../helpers/utils'
 import { VideoModel } from '../video/video'
 import { VideoRedundancyStrategy } from '../../../shared/models/redundancy'
@@ -28,6 +27,7 @@ import { VideoChannelModel } from '../video/video-channel'
 import { ServerModel } from '../server/server'
 import { sample } from 'lodash'
 import { isTestInstance } from '../../helpers/core-utils'
+import * as Bluebird from 'bluebird'
 
 export enum ScopeNames {
   WITH_VIDEO = 'WITH_VIDEO'
@@ -145,65 +145,90 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> {
     return VideoRedundancyModel.findOne(query)
   }
 
+  static async getVideoSample (p: Bluebird<VideoModel[]>) {
+    const rows = await p
+    const ids = rows.map(r => r.id)
+    const id = sample(ids)
+
+    return VideoModel.loadWithFile(id, undefined, !isTestInstance())
+  }
+
   static async findMostViewToDuplicate (randomizedFactor: number) {
     // On VideoModel!
     const query = {
+      attributes: [ 'id', 'views' ],
       logging: !isTestInstance(),
       limit: randomizedFactor,
-      order: [ [ 'views', 'DESC' ] ],
+      order: getVideoSort('-views'),
       include: [
-        {
-          model: VideoFileModel.unscoped(),
-          required: true,
-          where: {
-            id: {
-              [ Sequelize.Op.notIn ]: await VideoRedundancyModel.buildExcludeIn()
-            }
-          }
-        },
-        {
-          attributes: [],
-          model: VideoChannelModel.unscoped(),
-          required: true,
-          include: [
-            {
-              attributes: [],
-              model: ActorModel.unscoped(),
-              required: true,
-              include: [
-                {
-                  attributes: [],
-                  model: ServerModel.unscoped(),
-                  required: true,
-                  where: {
-                    redundancyAllowed: true
-                  }
-                }
-              ]
-            }
-          ]
-        }
+        await VideoRedundancyModel.buildVideoFileForDuplication(),
+        VideoRedundancyModel.buildServerRedundancyInclude()
       ]
     }
 
-    const rows = await VideoModel.unscoped().findAll(query)
+    return VideoRedundancyModel.getVideoSample(VideoModel.unscoped().findAll(query))
+  }
 
-    return sample(rows)
+  static async findTrendingToDuplicate (randomizedFactor: number) {
+    // On VideoModel!
+    const query = {
+      attributes: [ 'id', 'views' ],
+      subQuery: false,
+      logging: !isTestInstance(),
+      group: 'VideoModel.id',
+      limit: randomizedFactor,
+      order: getVideoSort('-trending'),
+      include: [
+        await VideoRedundancyModel.buildVideoFileForDuplication(),
+        VideoRedundancyModel.buildServerRedundancyInclude(),
+
+        VideoModel.buildTrendingQuery(CONFIG.TRENDING.VIDEOS.INTERVAL_DAYS)
+      ]
+    }
+
+    return VideoRedundancyModel.getVideoSample(VideoModel.unscoped().findAll(query))
   }
 
-  static async getVideoFiles (strategy: VideoRedundancyStrategy) {
+  static async findRecentlyAddedToDuplicate (randomizedFactor: number, minViews: number) {
+    // On VideoModel!
+    const query = {
+      attributes: [ 'id', 'publishedAt' ],
+      logging: !isTestInstance(),
+      limit: randomizedFactor,
+      order: getVideoSort('-publishedAt'),
+      where: {
+        views: {
+          [ Sequelize.Op.gte ]: minViews
+        }
+      },
+      include: [
+        await VideoRedundancyModel.buildVideoFileForDuplication(),
+        VideoRedundancyModel.buildServerRedundancyInclude()
+      ]
+    }
+
+    return VideoRedundancyModel.getVideoSample(VideoModel.unscoped().findAll(query))
+  }
+
+  static async getTotalDuplicated (strategy: VideoRedundancyStrategy) {
     const actor = await getServerActor()
 
-    const queryVideoFiles = {
+    const options = {
       logging: !isTestInstance(),
-      where: {
-        actorId: actor.id,
-        strategy
-      }
+      include: [
+        {
+          attributes: [],
+          model: VideoRedundancyModel,
+          required: true,
+          where: {
+            actorId: actor.id,
+            strategy
+          }
+        }
+      ]
     }
 
-    return VideoRedundancyModel.scope(ScopeNames.WITH_VIDEO)
-                               .findAll(queryVideoFiles)
+    return VideoFileModel.sum('size', options)
   }
 
   static listAllExpired () {
@@ -211,7 +236,7 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> {
       logging: !isTestInstance(),
       where: {
         expiresOn: {
-          [Sequelize.Op.lt]: new Date()
+          [ Sequelize.Op.lt ]: new Date()
         }
       }
     }
@@ -220,6 +245,37 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> {
                                .findAll(query)
   }
 
+  static async getStats (strategy: VideoRedundancyStrategy) {
+    const actor = await getServerActor()
+
+    const query = {
+      raw: true,
+      attributes: [
+        [ Sequelize.fn('COALESCE', Sequelize.fn('SUM', Sequelize.col('VideoFile.size')), '0'), 'totalUsed' ],
+        [ Sequelize.fn('COUNT', Sequelize.fn('DISTINCT', 'videoId')), 'totalVideos' ],
+        [ Sequelize.fn('COUNT', 'videoFileId'), 'totalVideoFiles' ]
+      ],
+      where: {
+        strategy,
+        actorId: actor.id
+      },
+      include: [
+        {
+          attributes: [],
+          model: VideoFileModel,
+          required: true
+        }
+      ]
+    }
+
+    return VideoRedundancyModel.find(query as any) // FIXME: typings
+      .then((r: any) => ({
+        totalUsed: parseInt(r.totalUsed.toString(), 10),
+        totalVideos: r.totalVideos,
+        totalVideoFiles: r.totalVideoFiles
+      }))
+  }
+
   toActivityPubObject (): CacheFileObject {
     return {
       id: this.url,
@@ -237,13 +293,50 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> {
     }
   }
 
-  private static async buildExcludeIn () {
+  // Don't include video files we already duplicated
+  private static async buildVideoFileForDuplication () {
     const actor = await getServerActor()
 
-    return Sequelize.literal(
+    const notIn = Sequelize.literal(
       '(' +
         `SELECT "videoFileId" FROM "videoRedundancy" WHERE "actorId" = ${actor.id} AND "expiresOn" >= NOW()` +
       ')'
     )
+
+    return {
+      attributes: [],
+      model: VideoFileModel.unscoped(),
+      required: true,
+      where: {
+        id: {
+          [ Sequelize.Op.notIn ]: notIn
+        }
+      }
+    }
+  }
+
+  private static buildServerRedundancyInclude () {
+    return {
+      attributes: [],
+      model: VideoChannelModel.unscoped(),
+      required: true,
+      include: [
+        {
+          attributes: [],
+          model: ActorModel.unscoped(),
+          required: true,
+          include: [
+            {
+              attributes: [],
+              model: ServerModel.unscoped(),
+              required: true,
+              where: {
+                redundancyAllowed: true
+              }
+            }
+          ]
+        }
+      ]
+    }
   }
 }
index e39a418cdfb0199965bc94ff39c5eb970c45f09a..b39621eaf4a4daf55cdb93a3a5f0fe903674bd7f 100644 (file)
@@ -48,11 +48,10 @@ export class TagModel extends Model<TagModel> {
         },
         defaults: {
           name: tag
-        }
+        },
+        transaction
       }
 
-      if (transaction) query['transaction'] = transaction
-
       const promise = TagModel.findOrCreate(query)
         .then(([ tagInstance ]) => tagInstance)
       tasks.push(promise)
diff --git a/server/models/video/video-format-utils.ts b/server/models/video/video-format-utils.ts
new file mode 100644 (file)
index 0000000..a9a5862
--- /dev/null
@@ -0,0 +1,296 @@
+import { Video, VideoDetails, VideoFile } from '../../../shared/models/videos'
+import { VideoModel } from './video'
+import { VideoFileModel } from './video-file'
+import { ActivityUrlObject, VideoTorrentObject } from '../../../shared/models/activitypub/objects'
+import { CONFIG, THUMBNAILS_SIZE, VIDEO_EXT_MIMETYPE } from '../../initializers'
+import { VideoCaptionModel } from './video-caption'
+import {
+  getVideoCommentsActivityPubUrl,
+  getVideoDislikesActivityPubUrl,
+  getVideoLikesActivityPubUrl,
+  getVideoSharesActivityPubUrl
+} from '../../lib/activitypub'
+
+export type VideoFormattingJSONOptions = {
+  additionalAttributes: {
+    state?: boolean,
+    waitTranscoding?: boolean,
+    scheduledUpdate?: boolean,
+    blacklistInfo?: boolean
+  }
+}
+function videoModelToFormattedJSON (video: VideoModel, options?: VideoFormattingJSONOptions): Video {
+  const formattedAccount = video.VideoChannel.Account.toFormattedJSON()
+  const formattedVideoChannel = video.VideoChannel.toFormattedJSON()
+
+  const videoObject: Video = {
+    id: video.id,
+    uuid: video.uuid,
+    name: video.name,
+    category: {
+      id: video.category,
+      label: VideoModel.getCategoryLabel(video.category)
+    },
+    licence: {
+      id: video.licence,
+      label: VideoModel.getLicenceLabel(video.licence)
+    },
+    language: {
+      id: video.language,
+      label: VideoModel.getLanguageLabel(video.language)
+    },
+    privacy: {
+      id: video.privacy,
+      label: VideoModel.getPrivacyLabel(video.privacy)
+    },
+    nsfw: video.nsfw,
+    description: video.getTruncatedDescription(),
+    isLocal: video.isOwned(),
+    duration: video.duration,
+    views: video.views,
+    likes: video.likes,
+    dislikes: video.dislikes,
+    thumbnailPath: video.getThumbnailStaticPath(),
+    previewPath: video.getPreviewStaticPath(),
+    embedPath: video.getEmbedStaticPath(),
+    createdAt: video.createdAt,
+    updatedAt: video.updatedAt,
+    publishedAt: video.publishedAt,
+    account: {
+      id: formattedAccount.id,
+      uuid: formattedAccount.uuid,
+      name: formattedAccount.name,
+      displayName: formattedAccount.displayName,
+      url: formattedAccount.url,
+      host: formattedAccount.host,
+      avatar: formattedAccount.avatar
+    },
+    channel: {
+      id: formattedVideoChannel.id,
+      uuid: formattedVideoChannel.uuid,
+      name: formattedVideoChannel.name,
+      displayName: formattedVideoChannel.displayName,
+      url: formattedVideoChannel.url,
+      host: formattedVideoChannel.host,
+      avatar: formattedVideoChannel.avatar
+    }
+  }
+
+  if (options) {
+    if (options.additionalAttributes.state === true) {
+      videoObject.state = {
+        id: video.state,
+        label: VideoModel.getStateLabel(video.state)
+      }
+    }
+
+    if (options.additionalAttributes.waitTranscoding === true) {
+      videoObject.waitTranscoding = video.waitTranscoding
+    }
+
+    if (options.additionalAttributes.scheduledUpdate === true && video.ScheduleVideoUpdate) {
+      videoObject.scheduledUpdate = {
+        updateAt: video.ScheduleVideoUpdate.updateAt,
+        privacy: video.ScheduleVideoUpdate.privacy || undefined
+      }
+    }
+
+    if (options.additionalAttributes.blacklistInfo === true) {
+      videoObject.blacklisted = !!video.VideoBlacklist
+      videoObject.blacklistedReason = video.VideoBlacklist ? video.VideoBlacklist.reason : null
+    }
+  }
+
+  return videoObject
+}
+
+function videoModelToFormattedDetailsJSON (video: VideoModel): VideoDetails {
+  const formattedJson = video.toFormattedJSON({
+    additionalAttributes: {
+      scheduledUpdate: true,
+      blacklistInfo: true
+    }
+  })
+
+  const tags = video.Tags ? video.Tags.map(t => t.name) : []
+  const detailsJson = {
+    support: video.support,
+    descriptionPath: video.getDescriptionAPIPath(),
+    channel: video.VideoChannel.toFormattedJSON(),
+    account: video.VideoChannel.Account.toFormattedJSON(),
+    tags,
+    commentsEnabled: video.commentsEnabled,
+    waitTranscoding: video.waitTranscoding,
+    state: {
+      id: video.state,
+      label: VideoModel.getStateLabel(video.state)
+    },
+    files: []
+  }
+
+  // Format and sort video files
+  detailsJson.files = videoFilesModelToFormattedJSON(video, video.VideoFiles)
+
+  return Object.assign(formattedJson, detailsJson)
+}
+
+function videoFilesModelToFormattedJSON (video: VideoModel, videoFiles: VideoFileModel[]): VideoFile[] {
+  const { baseUrlHttp, baseUrlWs } = video.getBaseUrls()
+
+  return videoFiles
+    .map(videoFile => {
+      let resolutionLabel = videoFile.resolution + 'p'
+
+      return {
+        resolution: {
+          id: videoFile.resolution,
+          label: resolutionLabel
+        },
+        magnetUri: video.generateMagnetUri(videoFile, baseUrlHttp, baseUrlWs),
+        size: videoFile.size,
+        fps: videoFile.fps,
+        torrentUrl: video.getTorrentUrl(videoFile, baseUrlHttp),
+        torrentDownloadUrl: video.getTorrentDownloadUrl(videoFile, baseUrlHttp),
+        fileUrl: video.getVideoFileUrl(videoFile, baseUrlHttp),
+        fileDownloadUrl: video.getVideoFileDownloadUrl(videoFile, baseUrlHttp)
+      } as VideoFile
+    })
+    .sort((a, b) => {
+      if (a.resolution.id < b.resolution.id) return 1
+      if (a.resolution.id === b.resolution.id) return 0
+      return -1
+    })
+}
+
+function videoModelToActivityPubObject (video: VideoModel): VideoTorrentObject {
+  const { baseUrlHttp, baseUrlWs } = video.getBaseUrls()
+  if (!video.Tags) video.Tags = []
+
+  const tag = video.Tags.map(t => ({
+    type: 'Hashtag' as 'Hashtag',
+    name: t.name
+  }))
+
+  let language
+  if (video.language) {
+    language = {
+      identifier: video.language,
+      name: VideoModel.getLanguageLabel(video.language)
+    }
+  }
+
+  let category
+  if (video.category) {
+    category = {
+      identifier: video.category + '',
+      name: VideoModel.getCategoryLabel(video.category)
+    }
+  }
+
+  let licence
+  if (video.licence) {
+    licence = {
+      identifier: video.licence + '',
+      name: VideoModel.getLicenceLabel(video.licence)
+    }
+  }
+
+  const url: ActivityUrlObject[] = []
+  for (const file of video.VideoFiles) {
+    url.push({
+      type: 'Link',
+      mimeType: VIDEO_EXT_MIMETYPE[ file.extname ] as any,
+      href: video.getVideoFileUrl(file, baseUrlHttp),
+      height: file.resolution,
+      size: file.size,
+      fps: file.fps
+    })
+
+    url.push({
+      type: 'Link',
+      mimeType: 'application/x-bittorrent' as 'application/x-bittorrent',
+      href: video.getTorrentUrl(file, baseUrlHttp),
+      height: file.resolution
+    })
+
+    url.push({
+      type: 'Link',
+      mimeType: 'application/x-bittorrent;x-scheme-handler/magnet' as 'application/x-bittorrent;x-scheme-handler/magnet',
+      href: video.generateMagnetUri(file, baseUrlHttp, baseUrlWs),
+      height: file.resolution
+    })
+  }
+
+  // Add video url too
+  url.push({
+    type: 'Link',
+    mimeType: 'text/html',
+    href: CONFIG.WEBSERVER.URL + '/videos/watch/' + video.uuid
+  })
+
+  const subtitleLanguage = []
+  for (const caption of video.VideoCaptions) {
+    subtitleLanguage.push({
+      identifier: caption.language,
+      name: VideoCaptionModel.getLanguageLabel(caption.language)
+    })
+  }
+
+  return {
+    type: 'Video' as 'Video',
+    id: video.url,
+    name: video.name,
+    duration: getActivityStreamDuration(video.duration),
+    uuid: video.uuid,
+    tag,
+    category,
+    licence,
+    language,
+    views: video.views,
+    sensitive: video.nsfw,
+    waitTranscoding: video.waitTranscoding,
+    state: video.state,
+    commentsEnabled: video.commentsEnabled,
+    published: video.publishedAt.toISOString(),
+    updated: video.updatedAt.toISOString(),
+    mediaType: 'text/markdown',
+    content: video.getTruncatedDescription(),
+    support: video.support,
+    subtitleLanguage,
+    icon: {
+      type: 'Image',
+      url: video.getThumbnailUrl(baseUrlHttp),
+      mediaType: 'image/jpeg',
+      width: THUMBNAILS_SIZE.width,
+      height: THUMBNAILS_SIZE.height
+    },
+    url,
+    likes: getVideoLikesActivityPubUrl(video),
+    dislikes: getVideoDislikesActivityPubUrl(video),
+    shares: getVideoSharesActivityPubUrl(video),
+    comments: getVideoCommentsActivityPubUrl(video),
+    attributedTo: [
+      {
+        type: 'Person',
+        id: video.VideoChannel.Account.Actor.url
+      },
+      {
+        type: 'Group',
+        id: video.VideoChannel.Actor.url
+      }
+    ]
+  }
+}
+
+function getActivityStreamDuration (duration: number) {
+  // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-duration
+  return 'PT' + duration + 'S'
+}
+
+export {
+  videoModelToFormattedJSON,
+  videoModelToFormattedDetailsJSON,
+  videoFilesModelToFormattedJSON,
+  videoModelToActivityPubObject,
+  getActivityStreamDuration
+}
index 27c631dcd7e071a8f9c9a163feafb4ca0518d803..6c89c16bff39af544ad3c3da0989921ec61519ab 100644 (file)
@@ -1,8 +1,8 @@
 import * as Bluebird from 'bluebird'
-import { map, maxBy } from 'lodash'
+import { maxBy } from 'lodash'
 import * as magnetUtil from 'magnet-uri'
 import * as parseTorrent from 'parse-torrent'
-import { extname, join } from 'path'
+import { join } from 'path'
 import * as Sequelize from 'sequelize'
 import {
   AllowNull,
@@ -27,7 +27,7 @@ import {
   Table,
   UpdatedAt
 } from 'sequelize-typescript'
-import { ActivityUrlObject, VideoPrivacy, VideoResolution, VideoState } from '../../../shared'
+import { VideoPrivacy, VideoState } from '../../../shared'
 import { VideoTorrentObject } from '../../../shared/models/activitypub/objects'
 import { Video, VideoDetails, VideoFile } from '../../../shared/models/videos'
 import { VideoFilter } from '../../../shared/models/videos/video-query.type'
@@ -45,7 +45,7 @@ import {
   isVideoStateValid,
   isVideoSupportValid
 } from '../../helpers/custom-validators/videos'
-import { generateImageFromVideoFile, getVideoFileFPS, getVideoFileResolution, transcode } from '../../helpers/ffmpeg-utils'
+import { generateImageFromVideoFile, getVideoFileResolution } from '../../helpers/ffmpeg-utils'
 import { logger } from '../../helpers/logger'
 import { getServerActor } from '../../helpers/utils'
 import {
@@ -59,18 +59,11 @@ import {
   STATIC_PATHS,
   THUMBNAILS_SIZE,
   VIDEO_CATEGORIES,
-  VIDEO_EXT_MIMETYPE,
   VIDEO_LANGUAGES,
   VIDEO_LICENCES,
   VIDEO_PRIVACIES,
   VIDEO_STATES
 } from '../../initializers'
-import {
-  getVideoCommentsActivityPubUrl,
-  getVideoDislikesActivityPubUrl,
-  getVideoLikesActivityPubUrl,
-  getVideoSharesActivityPubUrl
-} from '../../lib/activitypub'
 import { sendDeleteVideo } from '../../lib/activitypub/send'
 import { AccountModel } from '../account/account'
 import { AccountVideoRateModel } from '../account/account-video-rate'
@@ -88,9 +81,17 @@ import { VideoTagModel } from './video-tag'
 import { ScheduleVideoUpdateModel } from './schedule-video-update'
 import { VideoCaptionModel } from './video-caption'
 import { VideoBlacklistModel } from './video-blacklist'
-import { copy, remove, rename, stat, writeFile } from 'fs-extra'
+import { remove, writeFile } from 'fs-extra'
 import { VideoViewModel } from './video-views'
 import { VideoRedundancyModel } from '../redundancy/video-redundancy'
+import {
+  videoFilesModelToFormattedJSON,
+  VideoFormattingJSONOptions,
+  videoModelToActivityPubObject,
+  videoModelToFormattedDetailsJSON,
+  videoModelToFormattedJSON
+} from './video-format-utils'
+import * as validator from 'validator'
 
 // FIXME: Define indexes here because there is an issue with TS and Sequelize.literal when called directly in the annotation
 const indexes: Sequelize.DefineIndexesOptions[] = [
@@ -221,6 +222,7 @@ type AvailableForListIDsOptions = {
   },
   [ ScopeNames.AVAILABLE_FOR_LIST_IDS ]: (options: AvailableForListIDsOptions) => {
     const query: IFindOptions<VideoModel> = {
+      raw: true,
       attributes: [ 'id' ],
       where: {
         id: {
@@ -387,16 +389,7 @@ type AvailableForListIDsOptions = {
     }
 
     if (options.trendingDays) {
-      query.include.push({
-        attributes: [],
-        model: VideoViewModel,
-        required: false,
-        where: {
-          startDate: {
-            [ Sequelize.Op.gte ]: new Date(new Date().getTime() - (24 * 3600 * 1000) * options.trendingDays)
-          }
-        }
-      })
+      query.include.push(VideoModel.buildTrendingQuery(options.trendingDays))
 
       query.subQuery = false
     }
@@ -474,6 +467,7 @@ type AvailableForListIDsOptions = {
         required: false,
         include: [
           {
+            attributes: [ 'fileUrl' ],
             model: () => VideoRedundancyModel.unscoped(),
             required: false
           }
@@ -937,7 +931,7 @@ export class VideoModel extends Model<VideoModel> {
     videoChannelId?: number,
     actorId?: number
     trendingDays?: number
-  }) {
+  }, countVideos = true) {
     const query: IFindOptions<VideoModel> = {
       offset: options.start,
       limit: options.count,
@@ -970,7 +964,7 @@ export class VideoModel extends Model<VideoModel> {
       trendingDays
     }
 
-    return VideoModel.getAvailableForApi(query, queryOptions)
+    return VideoModel.getAvailableForApi(query, queryOptions, countVideos)
   }
 
   static async searchAndPopulateAccountAndServer (options: {
@@ -1070,41 +1064,34 @@ export class VideoModel extends Model<VideoModel> {
     return VideoModel.getAvailableForApi(query, queryOptions)
   }
 
-  static load (id: number, t?: Sequelize.Transaction) {
-    const options = t ? { transaction: t } : undefined
-
-    return VideoModel.findById(id, options)
-  }
-
-  static loadByUrlAndPopulateAccount (url: string, t?: Sequelize.Transaction) {
-    const query: IFindOptions<VideoModel> = {
-      where: {
-        url
-      }
+  static load (id: number | string, t?: Sequelize.Transaction) {
+    const where = VideoModel.buildWhereIdOrUUID(id)
+    const options = {
+      where,
+      transaction: t
     }
 
-    if (t !== undefined) query.transaction = t
-
-    return VideoModel.scope([ ScopeNames.WITH_ACCOUNT_DETAILS, ScopeNames.WITH_FILES ]).findOne(query)
+    return VideoModel.findOne(options)
   }
 
-  static loadAndPopulateAccountAndServerAndTags (id: number) {
+  static loadOnlyId (id: number | string, t?: Sequelize.Transaction) {
+    const where = VideoModel.buildWhereIdOrUUID(id)
+
     const options = {
-      order: [ [ 'Tags', 'name', 'ASC' ] ]
+      attributes: [ 'id' ],
+      where,
+      transaction: t
     }
 
-    return VideoModel
-      .scope([
-        ScopeNames.WITH_TAGS,
-        ScopeNames.WITH_BLACKLISTED,
-        ScopeNames.WITH_FILES,
-        ScopeNames.WITH_ACCOUNT_DETAILS,
-        ScopeNames.WITH_SCHEDULED_UPDATE
-      ])
-      .findById(id, options)
+    return VideoModel.findOne(options)
   }
 
-  static loadByUUID (uuid: string) {
+  static loadWithFile (id: number, t?: Sequelize.Transaction, logging?: boolean) {
+    return VideoModel.scope(ScopeNames.WITH_FILES)
+                     .findById(id, { transaction: t, logging })
+  }
+
+  static loadByUUIDWithFile (uuid: string) {
     const options = {
       where: {
         uuid
@@ -1116,12 +1103,34 @@ export class VideoModel extends Model<VideoModel> {
       .findOne(options)
   }
 
-  static loadByUUIDAndPopulateAccountAndServerAndTags (uuid: string, t?: Sequelize.Transaction) {
-    const options = {
-      order: [ [ 'Tags', 'name', 'ASC' ] ],
+  static loadByUrl (url: string, transaction?: Sequelize.Transaction) {
+    const query: IFindOptions<VideoModel> = {
       where: {
-        uuid
+        url
+      },
+      transaction
+    }
+
+    return VideoModel.findOne(query)
+  }
+
+  static loadByUrlAndPopulateAccount (url: string, transaction?: Sequelize.Transaction) {
+    const query: IFindOptions<VideoModel> = {
+      where: {
+        url
       },
+      transaction
+    }
+
+    return VideoModel.scope([ ScopeNames.WITH_ACCOUNT_DETAILS, ScopeNames.WITH_FILES ]).findOne(query)
+  }
+
+  static loadAndPopulateAccountAndServerAndTags (id: number | string, t?: Sequelize.Transaction) {
+    const where = VideoModel.buildWhereIdOrUUID(id)
+
+    const options = {
+      order: [ [ 'Tags', 'name', 'ASC' ] ],
+      where,
       transaction: t
     }
 
@@ -1169,7 +1178,14 @@ export class VideoModel extends Model<VideoModel> {
   }
 
   // threshold corresponds to how many video the field should have to be returned
-  static getRandomFieldSamples (field: 'category' | 'channelId', threshold: number, count: number) {
+  static async getRandomFieldSamples (field: 'category' | 'channelId', threshold: number, count: number) {
+    const actorId = (await getServerActor()).id
+
+    const scopeOptions = {
+      actorId,
+      includeLocalVideos: true
+    }
+
     const query: IFindOptions<VideoModel> = {
       attributes: [ field ],
       limit: count,
@@ -1177,20 +1193,28 @@ export class VideoModel extends Model<VideoModel> {
       having: Sequelize.where(Sequelize.fn('COUNT', Sequelize.col(field)), {
         [ Sequelize.Op.gte ]: threshold
       }) as any, // FIXME: typings
-      where: {
-        [ field ]: {
-          [ Sequelize.Op.not ]: null
-        },
-        privacy: VideoPrivacy.PUBLIC,
-        state: VideoState.PUBLISHED
-      },
       order: [ this.sequelize.random() ]
     }
 
-    return VideoModel.findAll(query)
+    return VideoModel.scope({ method: [ ScopeNames.AVAILABLE_FOR_LIST_IDS, scopeOptions ] })
+                     .findAll(query)
                      .then(rows => rows.map(r => r[ field ]))
   }
 
+  static buildTrendingQuery (trendingDays: number) {
+    return {
+      attributes: [],
+      subQuery: false,
+      model: VideoViewModel,
+      required: false,
+      where: {
+        startDate: {
+          [ Sequelize.Op.gte ]: new Date(new Date().getTime() - (24 * 3600 * 1000) * trendingDays)
+        }
+      }
+    }
+  }
+
   private static buildActorWhereWithFilter (filter?: VideoFilter) {
     if (filter && filter === 'local') {
       return {
@@ -1201,7 +1225,7 @@ export class VideoModel extends Model<VideoModel> {
     return {}
   }
 
-  private static async getAvailableForApi (query: IFindOptions<VideoModel>, options: AvailableForListIDsOptions) {
+  private static async getAvailableForApi (query: IFindOptions<VideoModel>, options: AvailableForListIDsOptions, countVideos = true) {
     const idsScope = {
       method: [
         ScopeNames.AVAILABLE_FOR_LIST_IDS, options
@@ -1218,7 +1242,7 @@ export class VideoModel extends Model<VideoModel> {
     }
 
     const [ count, rowsId ] = await Promise.all([
-      VideoModel.scope(countScope).count(countQuery),
+      countVideos ? VideoModel.scope(countScope).count(countQuery) : Promise.resolve(undefined),
       VideoModel.scope(idsScope).findAll(query)
     ])
     const ids = rowsId.map(r => r.id)
@@ -1247,26 +1271,30 @@ export class VideoModel extends Model<VideoModel> {
     }
   }
 
-  private static getCategoryLabel (id: number) {
+  static getCategoryLabel (id: number) {
     return VIDEO_CATEGORIES[ id ] || 'Misc'
   }
 
-  private static getLicenceLabel (id: number) {
+  static getLicenceLabel (id: number) {
     return VIDEO_LICENCES[ id ] || 'Unknown'
   }
 
-  private static getLanguageLabel (id: string) {
+  static getLanguageLabel (id: string) {
     return VIDEO_LANGUAGES[ id ] || 'Unknown'
   }
 
-  private static getPrivacyLabel (id: number) {
+  static getPrivacyLabel (id: number) {
     return VIDEO_PRIVACIES[ id ] || 'Unknown'
   }
 
-  private static getStateLabel (id: number) {
+  static getStateLabel (id: number) {
     return VIDEO_STATES[ id ] || 'Unknown'
   }
 
+  static buildWhereIdOrUUID (id: number | string) {
+    return validator.isInt('' + id) ? { id } : { uuid: id }
+  }
+
   getOriginalFile () {
     if (Array.isArray(this.VideoFiles) === false) return undefined
 
@@ -1359,273 +1387,20 @@ export class VideoModel extends Model<VideoModel> {
     return join(STATIC_PATHS.PREVIEWS, this.getPreviewName())
   }
 
-  toFormattedJSON (options?: {
-    additionalAttributes: {
-      state?: boolean,
-      waitTranscoding?: boolean,
-      scheduledUpdate?: boolean,
-      blacklistInfo?: boolean
-    }
-  }): Video {
-    const formattedAccount = this.VideoChannel.Account.toFormattedJSON()
-    const formattedVideoChannel = this.VideoChannel.toFormattedJSON()
-
-    const videoObject: Video = {
-      id: this.id,
-      uuid: this.uuid,
-      name: this.name,
-      category: {
-        id: this.category,
-        label: VideoModel.getCategoryLabel(this.category)
-      },
-      licence: {
-        id: this.licence,
-        label: VideoModel.getLicenceLabel(this.licence)
-      },
-      language: {
-        id: this.language,
-        label: VideoModel.getLanguageLabel(this.language)
-      },
-      privacy: {
-        id: this.privacy,
-        label: VideoModel.getPrivacyLabel(this.privacy)
-      },
-      nsfw: this.nsfw,
-      description: this.getTruncatedDescription(),
-      isLocal: this.isOwned(),
-      duration: this.duration,
-      views: this.views,
-      likes: this.likes,
-      dislikes: this.dislikes,
-      thumbnailPath: this.getThumbnailStaticPath(),
-      previewPath: this.getPreviewStaticPath(),
-      embedPath: this.getEmbedStaticPath(),
-      createdAt: this.createdAt,
-      updatedAt: this.updatedAt,
-      publishedAt: this.publishedAt,
-      account: {
-        id: formattedAccount.id,
-        uuid: formattedAccount.uuid,
-        name: formattedAccount.name,
-        displayName: formattedAccount.displayName,
-        url: formattedAccount.url,
-        host: formattedAccount.host,
-        avatar: formattedAccount.avatar
-      },
-      channel: {
-        id: formattedVideoChannel.id,
-        uuid: formattedVideoChannel.uuid,
-        name: formattedVideoChannel.name,
-        displayName: formattedVideoChannel.displayName,
-        url: formattedVideoChannel.url,
-        host: formattedVideoChannel.host,
-        avatar: formattedVideoChannel.avatar
-      }
-    }
-
-    if (options) {
-      if (options.additionalAttributes.state === true) {
-        videoObject.state = {
-          id: this.state,
-          label: VideoModel.getStateLabel(this.state)
-        }
-      }
-
-      if (options.additionalAttributes.waitTranscoding === true) {
-        videoObject.waitTranscoding = this.waitTranscoding
-      }
-
-      if (options.additionalAttributes.scheduledUpdate === true && this.ScheduleVideoUpdate) {
-        videoObject.scheduledUpdate = {
-          updateAt: this.ScheduleVideoUpdate.updateAt,
-          privacy: this.ScheduleVideoUpdate.privacy || undefined
-        }
-      }
-
-      if (options.additionalAttributes.blacklistInfo === true) {
-        videoObject.blacklisted = !!this.VideoBlacklist
-        videoObject.blacklistedReason = this.VideoBlacklist ? this.VideoBlacklist.reason : null
-      }
-    }
-
-    return videoObject
+  toFormattedJSON (options?: VideoFormattingJSONOptions): Video {
+    return videoModelToFormattedJSON(this, options)
   }
 
   toFormattedDetailsJSON (): VideoDetails {
-    const formattedJson = this.toFormattedJSON({
-      additionalAttributes: {
-        scheduledUpdate: true,
-        blacklistInfo: true
-      }
-    })
-
-    const detailsJson = {
-      support: this.support,
-      descriptionPath: this.getDescriptionPath(),
-      channel: this.VideoChannel.toFormattedJSON(),
-      account: this.VideoChannel.Account.toFormattedJSON(),
-      tags: map(this.Tags, 'name'),
-      commentsEnabled: this.commentsEnabled,
-      waitTranscoding: this.waitTranscoding,
-      state: {
-        id: this.state,
-        label: VideoModel.getStateLabel(this.state)
-      },
-      files: []
-    }
-
-    // Format and sort video files
-    detailsJson.files = this.getFormattedVideoFilesJSON()
-
-    return Object.assign(formattedJson, detailsJson)
+    return videoModelToFormattedDetailsJSON(this)
   }
 
   getFormattedVideoFilesJSON (): VideoFile[] {
-    const { baseUrlHttp, baseUrlWs } = this.getBaseUrls()
-
-    return this.VideoFiles
-               .map(videoFile => {
-                 let resolutionLabel = videoFile.resolution + 'p'
-
-                 return {
-                   resolution: {
-                     id: videoFile.resolution,
-                     label: resolutionLabel
-                   },
-                   magnetUri: this.generateMagnetUri(videoFile, baseUrlHttp, baseUrlWs),
-                   size: videoFile.size,
-                   fps: videoFile.fps,
-                   torrentUrl: this.getTorrentUrl(videoFile, baseUrlHttp),
-                   torrentDownloadUrl: this.getTorrentDownloadUrl(videoFile, baseUrlHttp),
-                   fileUrl: this.getVideoFileUrl(videoFile, baseUrlHttp),
-                   fileDownloadUrl: this.getVideoFileDownloadUrl(videoFile, baseUrlHttp)
-                 } as VideoFile
-               })
-               .sort((a, b) => {
-                 if (a.resolution.id < b.resolution.id) return 1
-                 if (a.resolution.id === b.resolution.id) return 0
-                 return -1
-               })
+    return videoFilesModelToFormattedJSON(this, this.VideoFiles)
   }
 
   toActivityPubObject (): VideoTorrentObject {
-    const { baseUrlHttp, baseUrlWs } = this.getBaseUrls()
-    if (!this.Tags) this.Tags = []
-
-    const tag = this.Tags.map(t => ({
-      type: 'Hashtag' as 'Hashtag',
-      name: t.name
-    }))
-
-    let language
-    if (this.language) {
-      language = {
-        identifier: this.language,
-        name: VideoModel.getLanguageLabel(this.language)
-      }
-    }
-
-    let category
-    if (this.category) {
-      category = {
-        identifier: this.category + '',
-        name: VideoModel.getCategoryLabel(this.category)
-      }
-    }
-
-    let licence
-    if (this.licence) {
-      licence = {
-        identifier: this.licence + '',
-        name: VideoModel.getLicenceLabel(this.licence)
-      }
-    }
-
-    const url: ActivityUrlObject[] = []
-    for (const file of this.VideoFiles) {
-      url.push({
-        type: 'Link',
-        mimeType: VIDEO_EXT_MIMETYPE[ file.extname ] as any,
-        href: this.getVideoFileUrl(file, baseUrlHttp),
-        height: file.resolution,
-        size: file.size,
-        fps: file.fps
-      })
-
-      url.push({
-        type: 'Link',
-        mimeType: 'application/x-bittorrent' as 'application/x-bittorrent',
-        href: this.getTorrentUrl(file, baseUrlHttp),
-        height: file.resolution
-      })
-
-      url.push({
-        type: 'Link',
-        mimeType: 'application/x-bittorrent;x-scheme-handler/magnet' as 'application/x-bittorrent;x-scheme-handler/magnet',
-        href: this.generateMagnetUri(file, baseUrlHttp, baseUrlWs),
-        height: file.resolution
-      })
-    }
-
-    // Add video url too
-    url.push({
-      type: 'Link',
-      mimeType: 'text/html',
-      href: CONFIG.WEBSERVER.URL + '/videos/watch/' + this.uuid
-    })
-
-    const subtitleLanguage = []
-    for (const caption of this.VideoCaptions) {
-      subtitleLanguage.push({
-        identifier: caption.language,
-        name: VideoCaptionModel.getLanguageLabel(caption.language)
-      })
-    }
-
-    return {
-      type: 'Video' as 'Video',
-      id: this.url,
-      name: this.name,
-      duration: this.getActivityStreamDuration(),
-      uuid: this.uuid,
-      tag,
-      category,
-      licence,
-      language,
-      views: this.views,
-      sensitive: this.nsfw,
-      waitTranscoding: this.waitTranscoding,
-      state: this.state,
-      commentsEnabled: this.commentsEnabled,
-      published: this.publishedAt.toISOString(),
-      updated: this.updatedAt.toISOString(),
-      mediaType: 'text/markdown',
-      content: this.getTruncatedDescription(),
-      support: this.support,
-      subtitleLanguage,
-      icon: {
-        type: 'Image',
-        url: this.getThumbnailUrl(baseUrlHttp),
-        mediaType: 'image/jpeg',
-        width: THUMBNAILS_SIZE.width,
-        height: THUMBNAILS_SIZE.height
-      },
-      url,
-      likes: getVideoLikesActivityPubUrl(this),
-      dislikes: getVideoDislikesActivityPubUrl(this),
-      shares: getVideoSharesActivityPubUrl(this),
-      comments: getVideoCommentsActivityPubUrl(this),
-      attributedTo: [
-        {
-          type: 'Person',
-          id: this.VideoChannel.Account.Actor.url
-        },
-        {
-          type: 'Group',
-          id: this.VideoChannel.Actor.url
-        }
-      ]
-    }
+    return videoModelToActivityPubObject(this)
   }
 
   getTruncatedDescription () {
@@ -1635,130 +1410,13 @@ export class VideoModel extends Model<VideoModel> {
     return peertubeTruncate(this.description, maxLength)
   }
 
-  async optimizeOriginalVideofile () {
-    const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
-    const newExtname = '.mp4'
-    const inputVideoFile = this.getOriginalFile()
-    const videoInputPath = join(videosDirectory, this.getVideoFilename(inputVideoFile))
-    const videoTranscodedPath = join(videosDirectory, this.id + '-transcoded' + newExtname)
-
-    const transcodeOptions = {
-      inputPath: videoInputPath,
-      outputPath: videoTranscodedPath
-    }
-
-    // Could be very long!
-    await transcode(transcodeOptions)
-
-    try {
-      await remove(videoInputPath)
-
-      // Important to do this before getVideoFilename() to take in account the new file extension
-      inputVideoFile.set('extname', newExtname)
-
-      const videoOutputPath = this.getVideoFilePath(inputVideoFile)
-      await rename(videoTranscodedPath, videoOutputPath)
-      const stats = await stat(videoOutputPath)
-      const fps = await getVideoFileFPS(videoOutputPath)
-
-      inputVideoFile.set('size', stats.size)
-      inputVideoFile.set('fps', fps)
-
-      await this.createTorrentAndSetInfoHash(inputVideoFile)
-      await inputVideoFile.save()
-
-    } catch (err) {
-      // Auto destruction...
-      this.destroy().catch(err => logger.error('Cannot destruct video after transcoding failure.', { err }))
-
-      throw err
-    }
-  }
-
-  async transcodeOriginalVideofile (resolution: VideoResolution, isPortraitMode: boolean) {
-    const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
-    const extname = '.mp4'
-
-    // We are sure it's x264 in mp4 because optimizeOriginalVideofile was already executed
-    const videoInputPath = join(videosDirectory, this.getVideoFilename(this.getOriginalFile()))
-
-    const newVideoFile = new VideoFileModel({
-      resolution,
-      extname,
-      size: 0,
-      videoId: this.id
-    })
-    const videoOutputPath = join(videosDirectory, this.getVideoFilename(newVideoFile))
-
-    const transcodeOptions = {
-      inputPath: videoInputPath,
-      outputPath: videoOutputPath,
-      resolution,
-      isPortraitMode
-    }
-
-    await transcode(transcodeOptions)
-
-    const stats = await stat(videoOutputPath)
-    const fps = await getVideoFileFPS(videoOutputPath)
-
-    newVideoFile.set('size', stats.size)
-    newVideoFile.set('fps', fps)
-
-    await this.createTorrentAndSetInfoHash(newVideoFile)
-
-    await newVideoFile.save()
-
-    this.VideoFiles.push(newVideoFile)
-  }
-
-  async importVideoFile (inputFilePath: string) {
-    const { videoFileResolution } = await getVideoFileResolution(inputFilePath)
-    const { size } = await stat(inputFilePath)
-    const fps = await getVideoFileFPS(inputFilePath)
-
-    let updatedVideoFile = new VideoFileModel({
-      resolution: videoFileResolution,
-      extname: extname(inputFilePath),
-      size,
-      fps,
-      videoId: this.id
-    })
-
-    const currentVideoFile = this.VideoFiles.find(videoFile => videoFile.resolution === updatedVideoFile.resolution)
-
-    if (currentVideoFile) {
-      // Remove old file and old torrent
-      await this.removeFile(currentVideoFile)
-      await this.removeTorrent(currentVideoFile)
-      // Remove the old video file from the array
-      this.VideoFiles = this.VideoFiles.filter(f => f !== currentVideoFile)
-
-      // Update the database
-      currentVideoFile.set('extname', updatedVideoFile.extname)
-      currentVideoFile.set('size', updatedVideoFile.size)
-      currentVideoFile.set('fps', updatedVideoFile.fps)
-
-      updatedVideoFile = currentVideoFile
-    }
-
-    const outputPath = this.getVideoFilePath(updatedVideoFile)
-    await copy(inputFilePath, outputPath)
-
-    await this.createTorrentAndSetInfoHash(updatedVideoFile)
-
-    await updatedVideoFile.save()
-
-    this.VideoFiles.push(updatedVideoFile)
-  }
-
   getOriginalFileResolution () {
     const originalFilePath = this.getVideoFilePath(this.getOriginalFile())
 
     return getVideoFileResolution(originalFilePath)
   }
 
-  getDescriptionPath () {
+  getDescriptionAPIPath () {
     return `/api/${API_VERSION}/videos/${this.uuid}/description`
   }
 
@@ -1786,11 +1444,6 @@ export class VideoModel extends Model<VideoModel> {
       .catch(err => logger.warn('Cannot delete torrent %s.', torrentPath, { err }))
   }
 
-  getActivityStreamDuration () {
-    // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-duration
-    return 'PT' + this.duration + 'S'
-  }
-
   isOutdated () {
     if (this.isOwned()) return false
 
index b2922c5da73d1ba25212f03e5e0c67575b5fe3a1..f5a19c5ea9ec2577ab9dc6fd72695bc6458b96bf 100644 (file)
@@ -45,7 +45,9 @@ describe('Test jobs', function () {
     expect(res.body.total).to.be.above(2)
     expect(res.body.data).to.have.lengthOf(1)
 
-    const job = res.body.data[0]
+    let job = res.body.data[0]
+    // Skip repeat jobs
+    if (job.type === 'videos-views') job = res.body.data[1]
 
     expect(job.state).to.equal('completed')
     expect(job.type).to.equal('activitypub-follow')
index c0ec75a452df71b89f18f54e49d30a65be9bfbf6..6ce4b9dd10d5a7853caf3c3dced9c0cd46570792 100644 (file)
@@ -6,15 +6,16 @@ import { VideoDetails } from '../../../../shared/models/videos'
 import {
   doubleFollow,
   flushAndRunMultipleServers,
-  flushTests,
   getFollowingListPaginationAndSort,
   getVideo,
+  immutableAssign,
   killallServers,
+  root,
   ServerInfo,
   setAccessTokensToServers,
   uploadVideo,
-  wait,
-  root, viewVideo
+  viewVideo,
+  wait
 } from '../../utils'
 import { waitJobs } from '../../utils/server/jobs'
 import * as magnetUtil from 'magnet-uri'
@@ -22,9 +23,16 @@ import { updateRedundancy } from '../../utils/server/redundancy'
 import { ActorFollow } from '../../../../shared/models/actors'
 import { readdir } from 'fs-extra'
 import { join } from 'path'
+import { VideoRedundancyStrategy } from '../../../../shared/models/redundancy'
+import { getStats } from '../../utils/server/stats'
+import { ServerStats } from '../../../../shared/models/server/server-stats.model'
 
 const expect = chai.expect
 
+let servers: ServerInfo[] = []
+let video1Server2UUID: string
+let video2Server2UUID: string
+
 function checkMagnetWebseeds (file: { magnetUri: string, resolution: { id: number } }, baseWebseeds: string[]) {
   const parsed = magnetUtil.decode(file.magnetUri)
 
@@ -34,84 +42,105 @@ function checkMagnetWebseeds (file: { magnetUri: string, resolution: { id: numbe
   }
 }
 
-describe('Test videos redundancy', function () {
-  let servers: ServerInfo[] = []
-  let video1Server2UUID: string
-  let video2Server2UUID: string
-
-  before(async function () {
-    this.timeout(120000)
-
-    servers = await flushAndRunMultipleServers(3)
+async function runServers (strategy: VideoRedundancyStrategy, additionalParams: any = {}) {
+  const config = {
+    redundancy: {
+      videos: {
+        check_interval: '5 seconds',
+        strategies: [
+          immutableAssign({
+            strategy: strategy,
+            size: '100KB'
+          }, additionalParams)
+        ]
+      }
+    }
+  }
+  servers = await flushAndRunMultipleServers(3, config)
 
-    // Get the access tokens
-    await setAccessTokensToServers(servers)
+  // Get the access tokens
+  await setAccessTokensToServers(servers)
 
-    {
-      const res = await uploadVideo(servers[ 1 ].url, servers[ 1 ].accessToken, { name: 'video 1 server 2' })
-      video1Server2UUID = res.body.video.uuid
+  {
+    const res = await uploadVideo(servers[ 1 ].url, servers[ 1 ].accessToken, { name: 'video 1 server 2' })
+    video1Server2UUID = res.body.video.uuid
 
-      await viewVideo(servers[1].url, video1Server2UUID)
-    }
+    await viewVideo(servers[ 1 ].url, video1Server2UUID)
+  }
 
-    {
-      const res = await uploadVideo(servers[ 1 ].url, servers[ 1 ].accessToken, { name: 'video 2 server 2' })
-      video2Server2UUID = res.body.video.uuid
-    }
+  {
+    const res = await uploadVideo(servers[ 1 ].url, servers[ 1 ].accessToken, { name: 'video 2 server 2' })
+    video2Server2UUID = res.body.video.uuid
+  }
 
-    await waitJobs(servers)
+  await waitJobs(servers)
 
-    // Server 1 and server 2 follow each other
-    await doubleFollow(servers[0], servers[1])
-    // Server 1 and server 3 follow each other
-    await doubleFollow(servers[0], servers[2])
-    // Server 2 and server 3 follow each other
-    await doubleFollow(servers[1], servers[2])
+  // Server 1 and server 2 follow each other
+  await doubleFollow(servers[ 0 ], servers[ 1 ])
+  // Server 1 and server 3 follow each other
+  await doubleFollow(servers[ 0 ], servers[ 2 ])
+  // Server 2 and server 3 follow each other
+  await doubleFollow(servers[ 1 ], servers[ 2 ])
 
-    await waitJobs(servers)
-  })
+  await waitJobs(servers)
+}
 
-  it('Should have 1 webseed on the first video', async function () {
-    const webseeds = [
-      'http://localhost:9002/static/webseed/' + video1Server2UUID
-    ]
+async function check1WebSeed (strategy: VideoRedundancyStrategy) {
+  const webseeds = [
+    'http://localhost:9002/static/webseed/' + video1Server2UUID
+  ]
 
-    for (const server of servers) {
+  for (const server of servers) {
+    {
       const res = await getVideo(server.url, video1Server2UUID)
 
       const video: VideoDetails = res.body
       video.files.forEach(f => checkMagnetWebseeds(f, webseeds))
     }
-  })
 
-  it('Should enable redundancy on server 1', async function () {
-    await updateRedundancy(servers[0].url, servers[0].accessToken, servers[1].host, true)
+    {
+      const res = await getStats(server.url)
+      const data: ServerStats = res.body
 
-    const res = await getFollowingListPaginationAndSort(servers[0].url, 0, 5, '-createdAt')
-    const follows: ActorFollow[] = res.body.data
-    const server2 = follows.find(f => f.following.host === 'localhost:9002')
-    const server3 = follows.find(f => f.following.host === 'localhost:9003')
+      expect(data.videosRedundancy).to.have.lengthOf(1)
 
-    expect(server3).to.not.be.undefined
-    expect(server3.following.hostRedundancyAllowed).to.be.false
+      const stat = data.videosRedundancy[0]
+      expect(stat.strategy).to.equal(strategy)
+      expect(stat.totalSize).to.equal(102400)
+      expect(stat.totalUsed).to.equal(0)
+      expect(stat.totalVideoFiles).to.equal(0)
+      expect(stat.totalVideos).to.equal(0)
+    }
+  }
+}
 
-    expect(server2).to.not.be.undefined
-    expect(server2.following.hostRedundancyAllowed).to.be.true
-  })
+async function enableRedundancy () {
+  await updateRedundancy(servers[ 0 ].url, servers[ 0 ].accessToken, servers[ 1 ].host, true)
 
-  it('Should have 2 webseed on the first video', async function () {
-    this.timeout(40000)
+  const res = await getFollowingListPaginationAndSort(servers[ 0 ].url, 0, 5, '-createdAt')
+  const follows: ActorFollow[] = res.body.data
+  const server2 = follows.find(f => f.following.host === 'localhost:9002')
+  const server3 = follows.find(f => f.following.host === 'localhost:9003')
 
-    await waitJobs(servers)
-    await wait(15000)
-    await waitJobs(servers)
+  expect(server3).to.not.be.undefined
+  expect(server3.following.hostRedundancyAllowed).to.be.false
 
-    const webseeds = [
-      'http://localhost:9001/static/webseed/' + video1Server2UUID,
-      'http://localhost:9002/static/webseed/' + video1Server2UUID
-    ]
+  expect(server2).to.not.be.undefined
+  expect(server2.following.hostRedundancyAllowed).to.be.true
+}
+
+async function check2Webseeds (strategy: VideoRedundancyStrategy) {
+  await waitJobs(servers)
+  await wait(15000)
+  await waitJobs(servers)
 
-    for (const server of servers) {
+  const webseeds = [
+    'http://localhost:9001/static/webseed/' + video1Server2UUID,
+    'http://localhost:9002/static/webseed/' + video1Server2UUID
+  ]
+
+  for (const server of servers) {
+    {
       const res = await getVideo(server.url, video1Server2UUID)
 
       const video: VideoDetails = res.body
@@ -120,21 +149,137 @@ describe('Test videos redundancy', function () {
         checkMagnetWebseeds(file, webseeds)
       }
     }
+  }
 
-    const files = await readdir(join(root(), 'test1', 'videos'))
-    expect(files).to.have.lengthOf(4)
+  const files = await readdir(join(root(), 'test1', 'videos'))
+  expect(files).to.have.lengthOf(4)
 
-    for (const resolution of [ 240, 360, 480, 720 ]) {
-      expect(files.find(f => f === `${video1Server2UUID}-${resolution}.mp4`)).to.not.be.undefined
-    }
+  for (const resolution of [ 240, 360, 480, 720 ]) {
+    expect(files.find(f => f === `${video1Server2UUID}-${resolution}.mp4`)).to.not.be.undefined
+  }
+
+  {
+    const res = await getStats(servers[0].url)
+    const data: ServerStats = res.body
+
+    expect(data.videosRedundancy).to.have.lengthOf(1)
+    const stat = data.videosRedundancy[0]
+
+    expect(stat.strategy).to.equal(strategy)
+    expect(stat.totalSize).to.equal(102400)
+    expect(stat.totalUsed).to.be.at.least(1).and.below(102401)
+    expect(stat.totalVideoFiles).to.equal(4)
+    expect(stat.totalVideos).to.equal(1)
+  }
+}
+
+async function cleanServers () {
+  killallServers(servers)
+}
+
+describe('Test videos redundancy', function () {
+
+  describe('With most-views strategy', function () {
+    const strategy = 'most-views'
+
+    before(function () {
+      this.timeout(120000)
+
+      return runServers(strategy)
+    })
+
+    it('Should have 1 webseed on the first video', function () {
+      return check1WebSeed(strategy)
+    })
+
+    it('Should enable redundancy on server 1', function () {
+      return enableRedundancy()
+    })
+
+    it('Should have 2 webseed on the first video', function () {
+      this.timeout(40000)
+
+      return check2Webseeds(strategy)
+    })
+
+    after(function () {
+      return cleanServers()
+    })
   })
 
-  after(async function () {
-    killallServers(servers)
+  describe('With trending strategy', function () {
+    const strategy = 'trending'
 
-    // Keep the logs if the test failed
-    if (this['ok']) {
-      await flushTests()
-    }
+    before(function () {
+      this.timeout(120000)
+
+      return runServers(strategy)
+    })
+
+    it('Should have 1 webseed on the first video', function () {
+      return check1WebSeed(strategy)
+    })
+
+    it('Should enable redundancy on server 1', function () {
+      return enableRedundancy()
+    })
+
+    it('Should have 2 webseed on the first video', function () {
+      this.timeout(40000)
+
+      return check2Webseeds(strategy)
+    })
+
+    after(function () {
+      return cleanServers()
+    })
+  })
+
+  describe('With recently added strategy', function () {
+    const strategy = 'recently-added'
+
+    before(function () {
+      this.timeout(120000)
+
+      return runServers(strategy, { minViews: 3 })
+    })
+
+    it('Should have 1 webseed on the first video', function () {
+      return check1WebSeed(strategy)
+    })
+
+    it('Should enable redundancy on server 1', function () {
+      return enableRedundancy()
+    })
+
+    it('Should still have 1 webseed on the first video', async function () {
+      this.timeout(40000)
+
+      await waitJobs(servers)
+      await wait(15000)
+      await waitJobs(servers)
+
+      return check1WebSeed(strategy)
+    })
+
+    it('Should view 2 times the first video', async function () {
+      this.timeout(40000)
+
+      await viewVideo(servers[ 0 ].url, video1Server2UUID)
+      await viewVideo(servers[ 2 ].url, video1Server2UUID)
+
+      await wait(10000)
+      await waitJobs(servers)
+    })
+
+    it('Should have 2 webseed on the first video', function () {
+      this.timeout(40000)
+
+      return check2Webseeds(strategy)
+    })
+
+    after(function () {
+      return cleanServers()
+    })
   })
 })
index fc9b8880569af18391ed4e9afda6f9f412cc76c2..cb229e876f8fd8d1c5170e4cfc5543ee0d59b34c 100644 (file)
@@ -21,7 +21,7 @@ import { waitJobs } from '../../utils/server/jobs'
 
 const expect = chai.expect
 
-describe('Test stats', function () {
+describe('Test stats (excluding redundancy)', function () {
   let servers: ServerInfo[] = []
 
   before(async function () {
index 1372c03c38d12cdcb20eae7b4206c4174a04089b..26ab4e1bb44e9f6ed063158a3c84db157dc224f5 100644 (file)
@@ -35,7 +35,7 @@ interface ServerInfo {
   }
 }
 
-function flushAndRunMultipleServers (totalServers) {
+function flushAndRunMultipleServers (totalServers: number, configOverride?: Object) {
   let apps = []
   let i = 0
 
@@ -51,10 +51,7 @@ function flushAndRunMultipleServers (totalServers) {
     flushTests()
       .then(() => {
         for (let j = 1; j <= totalServers; j++) {
-          // For the virtual buffer
-          setTimeout(() => {
-            runServer(j).then(app => anotherServerDone(j, app))
-          }, 1000 * (j - 1))
+          runServer(j, configOverride).then(app => anotherServerDone(j, app))
         }
       })
   })
index 9cdec6cff490f8f32e7e73b55f0d99c85e10e929..01989d952b8c460fc811fef0f1abd4c68e159207 100644 (file)
@@ -1,11 +1,16 @@
 import { makeGetRequest } from '../'
 
-function getStats (url: string) {
+function getStats (url: string, useCache = false) {
   const path = '/api/v1/server/stats'
 
+  const query = {
+    t: useCache ? undefined : new Date().getTime()
+  }
+
   return makeGetRequest({
     url,
     path,
+    query,
     statusCodeExpected: 200
   })
 }
index eb84964e09a8265c98b36aeafb7c4ed542a12710..436394c1e082071146331601521643f995432782 100644 (file)
@@ -1,6 +1,19 @@
-export type VideoRedundancyStrategy = 'most-views'
+export type VideoRedundancyStrategy = 'most-views' | 'trending' | 'recently-added'
 
-export interface VideosRedundancy {
-  strategy: VideoRedundancyStrategy
+export type MostViewsRedundancyStrategy = {
+  strategy: 'most-views'
   size: number
 }
+
+export type TrendingRedundancyStrategy = {
+  strategy: 'trending'
+  size: number
+}
+
+export type RecentlyAddedStrategy = {
+  strategy: 'recently-added'
+  size: number
+  minViews: number
+}
+
+export type VideosRedundancy = MostViewsRedundancyStrategy | TrendingRedundancyStrategy | RecentlyAddedStrategy
index 5c1bf34689999d584673770af34b93e092e7b772..a6bd2d4d35ee2756ba05c2e535f83233bbc629ce 100644 (file)
@@ -1,3 +1,5 @@
+import { VideoRedundancyStrategy } from '../redundancy'
+
 export interface ServerStats {
   totalUsers: number
   totalLocalVideos: number
@@ -9,4 +11,12 @@ export interface ServerStats {
 
   totalInstanceFollowers: number
   totalInstanceFollowing: number
+
+  videosRedundancy: {
+    strategy: VideoRedundancyStrategy
+    totalSize: number
+    totalUsed: number
+    totalVideoFiles: number
+    totalVideos: number
+  }[]
 }
index 51c4e0acea07066f2ffeb423d5f1ba77b526da71..8af161b2af96aee840910dcedefb6368b1224206 100644 (file)
@@ -3,6 +3,7 @@ PEERTUBE_DB_PASSWORD=postgres_password
 PEERTUBE_WEBSERVER_HOSTNAME=domain.tld
 PEERTUBE_WEBSERVER_PORT=443
 PEERTUBE_WEBSERVER_HTTPS=true
+PEERTUBE_TRUST_PROXY=127.0.0.1
 PEERTUBE_SMTP_USERNAME=
 PEERTUBE_SMTP_PASSWORD=
 PEERTUBE_SMTP_HOSTNAME=
index 1c732e2e0f096d3f8bdef9b1c68eff27535065dc..daf8858135dc57dc39f7b1dd997f9e4e1fdae85f 100644 (file)
@@ -7,6 +7,8 @@ webserver:
     __name: "PEERTUBE_WEBSERVER_HTTPS"
     __format: "json"
 
+trust_proxy: "PEERTUBE_TRUST_PROXY"
+
 database:
   hostname: "PEERTUBE_DB_HOSTNAME"
   port:
index 0da427037bd720235899aa35d0a43f5a60fe61fa..b0003113371fb5601ee3783a32f5582b0dd33127 100644 (file)
@@ -58,12 +58,14 @@ server {
     root /var/www/certbot;
   }
 
+  # Bypass PeerTube for performance reasons. Could be removed
   location ~ ^/client/(.*\.(js|css|woff2|otf|ttf|woff|eot))$ {
     add_header Cache-Control "public, max-age=31536000, immutable";
 
     alias /var/www/peertube/peertube-latest/client/dist/$1;
   }
 
+  # Bypass PeerTube for performance reasons. Could be removed
   location ~ ^/static/(thumbnails|avatars)/ {
     if ($request_method = 'OPTIONS') {
       add_header 'Access-Control-Allow-Origin' '*';
@@ -102,7 +104,7 @@ server {
     send_timeout                600;
   }
 
-  # Bypass PeerTube webseed route for better performances
+  # Bypass PeerTube for performance reasons. Could be removed
   location /static/webseed {
     # Clients usually have 4 simultaneous webseed connections, so the real limit is 3MB/s per client
     limit_rate 800k;
index c8fb211170bfffe9c39b960c659da791413d1f16..52ff895b1381e2eac5de8c9102664c585faf4592 100644 (file)
--- a/yarn.lock
+++ b/yarn.lock
   dependencies:
     "@types/node" "*"
 
+"@types/memoizee@^0.4.2":
+  version "0.4.2"
+  resolved "https://registry.yarnpkg.com/@types/memoizee/-/memoizee-0.4.2.tgz#a500158999a8144a9b46cf9a9fb49b15f1853573"
+
 "@types/mime@*":
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/@types/mime/-/mime-2.0.0.tgz#5a7306e367c539b9f6543499de8dd519fac37a8b"
@@ -2058,7 +2062,7 @@ error@^7.0.0:
     string-template "~0.2.1"
     xtend "~4.0.0"
 
-es5-ext@^0.10.14, es5-ext@^0.10.35, es5-ext@^0.10.9, es5-ext@~0.10.14:
+es5-ext@^0.10.14, es5-ext@^0.10.35, es5-ext@^0.10.45, es5-ext@^0.10.9, es5-ext@~0.10.14, es5-ext@~0.10.2:
   version "0.10.46"
   resolved "https://registry.yarnpkg.com/es5-ext/-/es5-ext-0.10.46.tgz#efd99f67c5a7ec789baa3daa7f79870388f7f572"
   dependencies:
@@ -2110,7 +2114,7 @@ es6-symbol@3.1.1, es6-symbol@^3.1.1, es6-symbol@~3.1.1:
     d "1"
     es5-ext "~0.10.14"
 
-es6-weak-map@^2.0.1:
+es6-weak-map@^2.0.1, es6-weak-map@^2.0.2:
   version "2.0.2"
   resolved "https://registry.yarnpkg.com/es6-weak-map/-/es6-weak-map-2.0.2.tgz#5e3ab32251ffd1538a1f8e5ffa1357772f92d96f"
   dependencies:
@@ -2223,7 +2227,7 @@ etag@~1.8.1:
   version "1.8.1"
   resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887"
 
-event-emitter@~0.3.5:
+event-emitter@^0.3.5, event-emitter@~0.3.5:
   version "0.3.5"
   resolved "https://registry.yarnpkg.com/event-emitter/-/event-emitter-0.3.5.tgz#df8c69eef1647923c7157b9ce83840610b02cc39"
   dependencies:
@@ -3757,7 +3761,7 @@ is-plain-object@^2.0.1, is-plain-object@^2.0.3, is-plain-object@^2.0.4:
   dependencies:
     isobject "^3.0.1"
 
-is-promise@^2.1.0:
+is-promise@^2.1, is-promise@^2.1.0:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/is-promise/-/is-promise-2.1.0.tgz#79a2a9ece7f096e80f36d2b2f3bc16c1ff4bf3fa"
 
@@ -4490,6 +4494,12 @@ lru-cache@4.1.x, lru-cache@^4.0.1:
     pseudomap "^1.0.2"
     yallist "^2.1.2"
 
+lru-queue@0.1:
+  version "0.1.0"
+  resolved "https://registry.yarnpkg.com/lru-queue/-/lru-queue-0.1.0.tgz#2738bd9f0d3cf4f84490c5736c48699ac632cda3"
+  dependencies:
+    es5-ext "~0.10.2"
+
 lru@^3.0.0, lru@^3.1.0:
   version "3.1.0"
   resolved "https://registry.yarnpkg.com/lru/-/lru-3.1.0.tgz#ea7fb8546d83733396a13091d76cfeb4c06837d5"
@@ -4594,6 +4604,19 @@ mem@^1.1.0:
   dependencies:
     mimic-fn "^1.0.0"
 
+memoizee@^0.4.14:
+  version "0.4.14"
+  resolved "https://registry.yarnpkg.com/memoizee/-/memoizee-0.4.14.tgz#07a00f204699f9a95c2d9e77218271c7cd610d57"
+  dependencies:
+    d "1"
+    es5-ext "^0.10.45"
+    es6-weak-map "^2.0.2"
+    event-emitter "^0.3.5"
+    is-promise "^2.1"
+    lru-queue "0.1"
+    next-tick "1"
+    timers-ext "^0.1.5"
+
 memory-chunk-store@^1.2.0:
   version "1.3.0"
   resolved "https://registry.yarnpkg.com/memory-chunk-store/-/memory-chunk-store-1.3.0.tgz#ae99e7e3b58b52db43d49d94722930d39459d0c4"
@@ -7201,6 +7224,13 @@ timed-out@^4.0.0:
   version "4.0.1"
   resolved "https://registry.yarnpkg.com/timed-out/-/timed-out-4.0.1.tgz#f32eacac5a175bea25d7fab565ab3ed8741ef56f"
 
+timers-ext@^0.1.5:
+  version "0.1.5"
+  resolved "https://registry.yarnpkg.com/timers-ext/-/timers-ext-0.1.5.tgz#77147dd4e76b660c2abb8785db96574cbbd12922"
+  dependencies:
+    es5-ext "~0.10.14"
+    next-tick "1"
+
 tiny-lr@^1.1.1:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/tiny-lr/-/tiny-lr-1.1.1.tgz#9fa547412f238fedb068ee295af8b682c98b2aab"