Compare commits

...

11 Commits

Author SHA1 Message Date
Chocobozzz
25f0ba7bd5 Replace description by name
Because we can't inject HTML in the title attribute
2025-10-31 14:08:05 +01:00
Chocobozzz
3405ebd2e0 Fix activity colors 2025-10-31 11:46:12 +01:00
Chocobozzz
e5351a0eb0 Group channel activities 2025-10-31 11:41:52 +01:00
Chocobozzz
639f73f414 More precise date toggle 2025-10-31 11:11:22 +01:00
Chocobozzz
4cd6dbf253 More precise interval 2025-10-31 11:11:12 +01:00
Chocobozzz
ff758863cf Prefer "shared"
We only have standalone components
2025-10-31 10:48:30 +01:00
Chocobozzz
bd11b153fb Correctly update playlists state
Handle creation/deletion etc
2025-10-31 10:35:39 +01:00
Chocobozzz
4f8edcc095 Add openapi doc for channel activities 2025-10-31 10:08:24 +01:00
Chocobozzz
0edf927e03 Optimize comment SQL query 2025-10-31 09:54:41 +01:00
Chocobozzz
09d90b8152 Add channel activities feature 2025-10-30 16:25:04 +01:00
Chocobozzz
dbc47e0397 Fix openid tests 2025-10-30 08:34:16 +01:00
106 changed files with 3057 additions and 425 deletions

View File

@@ -5,7 +5,6 @@ import { BulkService } from '@app/shared/shared-moderation/bulk.service'
import { VideoBlockService } from '@app/shared/shared-moderation/video-block.service'
import { UserSubscriptionService } from '@app/shared/shared-user-subscription/user-subscription.service'
import { UserAdminService } from '@app/shared/shared-users/user-admin.service'
import { VideoPlaylistService } from '@app/shared/shared-video-playlist/video-playlist.service'
import { AccountVideoChannelsComponent } from './account-video-channels/account-video-channels.component'
import { AccountVideosComponent } from './account-videos/account-videos.component'
import { AccountsComponent } from './accounts.component'
@@ -21,7 +20,6 @@ export default [
providers: [
UserSubscriptionService,
BlocklistService,
VideoPlaylistService,
VideoBlockService,
AbuseService,
UserAdminService,

View File

@@ -1,6 +1,6 @@
import { Component } from '@angular/core'
import { WatchedWordsListAdminOwnerComponent } from '@app/shared/standalone-watched-words/watched-words-list-admin-owner.component'
import { WatchedWordsListAdminOwnerComponent } from '@app/shared/shared-watched-words/watched-words-list-admin-owner.component'
@Component({
templateUrl: './watched-words-list-admin.component.html',

View File

@@ -16,8 +16,7 @@ import { SearchService } from '@app/shared/shared-search/search.service'
import { TwoFactorService } from '@app/shared/shared-users/two-factor.service'
import { UserAdminService } from '@app/shared/shared-users/user-admin.service'
import { VideoCommentService } from '@app/shared/shared-video-comment/video-comment.service'
import { VideoPlaylistService } from '@app/shared/shared-video-playlist/video-playlist.service'
import { WatchedWordsListService } from '@app/shared/standalone-watched-words/watched-words-list.service'
import { WatchedWordsListService } from '@app/shared/shared-watched-words/watched-words-list.service'
import { AdminModerationComponent } from './admin-moderation.component'
import { AdminOverviewComponent } from './admin-overview.component'
import { AdminSettingsComponent } from './admin-settings.component'
@@ -52,7 +51,6 @@ const commonConfig = {
DynamicElementService,
FindInBulkService,
SearchService,
VideoPlaylistService,
WatchedWordsListService
]
}

View File

@@ -7,7 +7,6 @@ import { BlocklistService } from '@app/shared/shared-moderation/blocklist.servic
import { VideoBlockService } from '@app/shared/shared-moderation/video-block.service'
import { FindInBulkService } from '@app/shared/shared-search/find-in-bulk.service'
import { SearchService } from '@app/shared/shared-search/search.service'
import { VideoPlaylistService } from '@app/shared/shared-video-playlist/video-playlist.service'
import { AbuseService } from '@app/shared/shared-moderation/abuse.service'
export default [
@@ -18,7 +17,6 @@ export default [
CustomPageService,
FindInBulkService,
SearchService,
VideoPlaylistService,
CustomMarkupService,
DynamicElementService,
BlocklistService,

View File

@@ -10,7 +10,7 @@ import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap'
import { HttpStatusCode, UserImport, UserImportState } from '@peertube/peertube-models'
import { UploadState, UploaderX, UploadxService } from 'ngx-uploadx'
import { Subscription } from 'rxjs'
import { UploadProgressComponent } from '../../shared/standalone-upload/upload-progress.component'
import { UploadProgressComponent } from '../../shared/shared-upload/upload-progress.component'
import { UserImportExportService } from './user-import-export.service'
@Component({

View File

@@ -1,5 +1,5 @@
import { Component, viewChild } from '@angular/core'
import { UserNotificationsComponent } from '@app/shared/standalone-notifications/user-notifications.component'
import { UserNotificationsComponent } from '@app/shared/shared-notifications/user-notifications.component'
import { NgIf } from '@angular/common'
import { FormsModule } from '@angular/forms'
import { GlobalIconComponent } from '../../shared/shared-icons/global-icon.component'

View File

@@ -0,0 +1,22 @@
<div>
<h2 class="form-title">
<my-global-icon iconName="calendar"></my-global-icon>
<ng-container i18n>Activity</ng-container>
</h2>
<div class="no-results" *ngIf="pagination.totalItems === 0" i18n>
No activity in the channel yet
</div>
<div class="activities" myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [dataObservable]="onDataSubject.asObservable()">
@for (activity of activities; track activity.id; let even = $even) {
<my-date-group-label [date]="activity.createdAt" [store]="groupByDateStore"></my-date-group-label>
<div class="activity-container" [ngClass]="{ even: even }">
<my-video-channel-activity [activity]="activity"></my-video-channel-activity>
</div>
}
</div>
</div>

View File

@@ -0,0 +1,53 @@
@use "_variables" as *;
@use "_mixins" as *;
.activity-container {
margin-bottom: 0.75rem;
border-radius: 24px;
padding: 1rem 1.5rem;
background-color: pvar(--bg-secondary-350);
&.even {
background-color: pvar(--bg-secondary-400);
}
}
my-date-group-label:not(.date-displayed) {
display: none;
}
my-date-group-label.date-displayed {
color: pvar(--fg-300);
font-weight: $font-bold;
display: block;
@include padding-bottom(1.5rem);
@include font-size(18px);
&:not(:first-child) {
border-top: 1px solid pvar(--bg-secondary-500);
@include padding-top(1.5rem);
@include margin-top(1.5rem);
}
}
my-date-group-label:not(.date-displayed) {
display: none;
}
my-date-group-label.date-displayed {
color: pvar(--fg-300);
font-weight: $font-bold;
display: block;
@include padding-bottom(1.5rem);
@include font-size(18px);
&:not(:first-child) {
border-top: 1px solid pvar(--bg-secondary-500);
@include padding-top(1.5rem);
@include margin-top(1.5rem);
}
}

View File

@@ -0,0 +1,96 @@
import { CommonModule } from '@angular/common'
import { Component, inject, OnDestroy, OnInit } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { ComponentPagination, hasMoreItems, Notifier, resetCurrentPage } from '@app/core'
import { GlobalIconComponent } from '@app/shared/shared-icons/global-icon.component'
import { VideoChannelService } from '@app/shared/shared-main/channel/video-channel.service'
import { InfiniteScrollerDirective } from '@app/shared/shared-main/common/infinite-scroller.directive'
import { VideoChannelActivity } from '@peertube/peertube-models'
import { Subject, Subscription } from 'rxjs'
import { VideoChannelEditControllerService } from '../video-channel-edit-controller.service'
import { VideoChannelEdit } from '../video-channel-edit.model'
import { VideoChannelActivityComponent } from './video-channel-activity.component'
import { DateGroupLabelComponent } from '@app/shared/shared-main/date/date-group-label.component'
@Component({
selector: 'my-video-channel-activities',
templateUrl: './video-channel-activities.component.html',
styleUrls: [ './video-channel-activities.component.scss' ],
imports: [
CommonModule,
FormsModule,
ReactiveFormsModule,
GlobalIconComponent,
InfiniteScrollerDirective,
VideoChannelActivityComponent,
DateGroupLabelComponent
]
})
export class VideoChannelActivitiesComponent implements OnInit, OnDestroy {
private notifier = inject(Notifier)
private channelService = inject(VideoChannelService)
private editController = inject(VideoChannelEditControllerService)
pagination: ComponentPagination = {
currentPage: 1,
itemsPerPage: 20,
totalItems: null
}
videoChannelEdit: VideoChannelEdit
activities: VideoChannelActivity[] = []
onDataSubject = new Subject<any[]>()
groupByDateStore = new Set<number>()
private storeSub: Subscription
ngOnInit () {
this.videoChannelEdit = this.editController.getStore()
this.reload()
this.storeSub = this.editController.getStoreChangesObs()
.subscribe(() => {
this.videoChannelEdit = this.editController.getStore()
this.reload()
})
}
ngOnDestroy () {
this.storeSub?.unsubscribe()
}
private reload () {
this.activities = []
resetCurrentPage(this.pagination)
this.loadMoreActivities()
}
onNearOfBottom () {
if (!hasMoreItems(this.pagination)) return
this.pagination.currentPage += 1
this.loadMoreActivities()
}
private loadMoreActivities () {
this.channelService.listActivities({
channelName: this.videoChannelEdit.channel.name,
componentPagination: this.pagination
}).subscribe({
next: res => {
this.activities = this.activities.concat(res.data)
this.pagination.totalItems = res.total
this.onDataSubject.next(res.data)
},
error: err => this.notifier.error(err.message)
})
}
}

View File

@@ -0,0 +1,185 @@
<div class="root">
<div class="message">
@switch (a.action.id) {
@case (1) { <!-- VideoChannelActivityAction.CREATE) -->
@switch (a.targetType.id) {
@case (1) { <!-- VideoChannelActivityTargetType.VIDEO) -->
<ng-container i18n>
<strong>{{ account }}</strong> created video <a [routerLink]="[ '/w', a.video.shortUUID ]">{{ a.video.name }}</a>
</ng-container>
}
@case (2) { <!-- VideoChannelActivityTargetType.PLAYLIST) -->
<ng-container i18n>
<strong>{{ account }}</strong> created playlist <a [routerLink]="[ '/my-library/video-playlists/', a.playlist.shortUUID ]">{{ a.playlist.name }}</a>
</ng-container>
}
<!-- VideoChannelActivityTargetType.CHANNEL) - Case cannot exist -->
@case (4) { <!-- VideoChannelActivityTargetType.CHANNEL_SYNC) -->
<ng-container i18n>
<strong>{{ account }}</strong> created <a routerLink="/my-library/video-channel-syncs">a channel synchronization</a>: {{ a.channelSync.externalChannelUrl }}
</ng-container>
}
@case (5) { <!-- VideoChannelActivityTargetType.VIDEO_IMPORT) -->
<ng-container i18n>
<strong>{{ account }}</strong> imported video <a [routerLink]="[ '/w', a.videoImport.uuid ]">{{ a.videoImport.name }}</a>: {{ a.videoImport.targetUrl }}
</ng-container>
}
}
}
@case (2) { <!-- VideoChannelActivityAction.UPDATE) -->
@switch (a.targetType.id) {
@case (1) { <!-- VideoChannelActivityTargetType.VIDEO) -->
<ng-container i18n>
<strong>{{ account }}</strong> updated video <a [routerLink]="[ '/w', a.video.shortUUID ]">{{ a.video.name }}</a>
</ng-container>
}
@case (2) { <!-- VideoChannelActivityTargetType.PLAYLIST) -->
<ng-container i18n>
<strong>{{ account }}</strong> updated playlist <a [routerLink]="[ '/my-library/video-playlists/', a.playlist.shortUUID ]">{{ a.playlist.name }}</a>
</ng-container>
}
@case (3) { <!-- VideoChannelActivityTargetType.CHANNEL) -->
<ng-container i18n>
<strong>{{ account }}</strong> updated the channel
</ng-container>
}
<!-- VideoChannelActivityTargetType.CHANNEL_SYNC) - Case cannot exist -->
<!-- VideoChannelActivityTargetType.CHANNEL_SYNC) - Case cannot exist -->
}
}
@case (3) { <!-- VideoChannelActivityAction.DELETE) -->
@switch (a.targetType.id) {
@case (1) { <!-- VideoChannelActivityTargetType.VIDEO) -->
<ng-container i18n>
<strong>{{ account }}</strong> deleted video <a [routerLink]="[ '/w', a.video.shortUUID ]">{{ a.video.name }}</a>
</ng-container>
}
@case (2) { <!-- VideoChannelActivityTargetType.PLAYLIST) -->
<ng-container i18n>
<strong>{{ account }}</strong> deleted playlist <a [routerLink]="[ '/my-library/video-playlists/', a.playlist.shortUUID ]">{{ a.playlist.name }}</a>
</ng-container>
}
<!-- VideoChannelActivityTargetType.CHANNEL) - Case cannot exist -->
@case (4) { <!-- VideoChannelActivityTargetType.CHANNEL_SYNC) -->
<ng-container i18n>
<strong>{{ account }}</strong> delete channel synchronization {{ a.channelSync.externalChannelUrl }}
</ng-container>
}
<!-- VideoChannelActivityTargetType.VIDEO_IMPORT) - Case cannot exist -->
}
}
@case (4) { <!-- VideoChannelActivityAction.UPDATE_CAPTIONS) -->
@switch (a.targetType.id) {
@case (1) { <!-- VideoChannelActivityTargetType.VIDEO) -->
<ng-container i18n>
<strong>{{ account }}</strong> updated captions of video <a [routerLink]="[ '/w', a.video.shortUUID ]">{{ a.video.name }}</a>
</ng-container>
}
}
}
@case (5) { <!-- VideoChannelActivityAction.UPDATE_CHAPTERS) -->
@switch (a.targetType.id) {
@case (1) { <!-- VideoChannelActivityTargetType.VIDEO) -->
<ng-container i18n>
<strong>{{ account }}</strong> updated chapters of video <a [routerLink]="[ '/w', a.video.shortUUID ]">{{ a.video.name }}</a>
</ng-container>
}
}
}
@case (6) { <!-- VideoChannelActivityAction.UPDATE_PASSWORDS) -->
@switch (a.targetType.id) {
@case (1) { <!-- VideoChannelActivityTargetType.VIDEO) -->
<ng-container i18n>
<strong>{{ account }}</strong> updated passwords of video <a [routerLink]="[ '/w', a.video.shortUUID ]">{{ a.video.name }}</a>
</ng-container>
}
}
}
@case (7) { <!-- VideoChannelActivityAction.CREATE_STUDIO_TASKS) -->
@switch (a.targetType.id) {
@case (1) { <!-- VideoChannelActivityTargetType.VIDEO) -->
<ng-container i18n>
<strong>{{ account }}</strong> created studio tasks of video <a [routerLink]="[ '/w', a.video.shortUUID ]">{{ a.video.name }}</a>
</ng-container>
}
}
}
@case (8) { <!-- VideoChannelActivityAction.UPDATE_SOURCE_FILE) -->
@switch (a.targetType.id) {
@case (1) { <!-- VideoChannelActivityTargetType.VIDEO) -->
<ng-container i18n>
<strong>{{ account }}</strong> updated source file of video <a [routerLink]="[ '/w', a.video.shortUUID ]">{{ a.video.name }}</a>
</ng-container>
}
}
}
@case (9) { <!-- VideoChannelActivityAction.UPDATE_ELEMENTS) -->
@switch (a.targetType.id) {
@case (2) { <!-- VideoChannelActivityTargetType.PLAYLIST) -->
<ng-container i18n>
<strong>{{ account }}</strong> updated elements of playlist <a [routerLink]="[ '/my-library/video-playlists/', a.playlist.shortUUID ]">{{ a.playlist.name }}</a>
</ng-container>
}
}
}
@case (10) { <!-- VideoChannelActivityAction.REMOVE_CHANNEL_OWNERSHIP) -->
@switch (a.targetType.id) {
@case (1) { <!-- VideoChannelActivityTargetType.VIDEO) -->
<ng-container i18n>
<strong>{{ account }}</strong> removed video <a [routerLink]="[ '/w', a.video.shortUUID ]">{{ a.video.name }}</a> by updating its channel
</ng-container>
}
@case (2) { <!-- VideoChannelActivityTargetType.PLAYLIST) -->
<ng-container i18n>
<strong>{{ account }}</strong> removed playlist <a [routerLink]="[ '/my-library/video-playlists/', a.playlist.shortUUID ]">{{ a.playlist.name }}</a> by updating its channel
</ng-container>
}
}
}
@case (11) { <!-- VideoChannelActivityAction.CREATE_CHANNEL_OWNERSHIP) -->
@switch (a.targetType.id) {
@case (1) { <!-- VideoChannelActivityTargetType.VIDEO) -->
<ng-container i18n>
<strong>{{ account }}</strong> created video <a [routerLink]="[ '/w', a.video.shortUUID ]">{{ a.video.name }}</a> by updating its channel
</ng-container>
}
@case (2) { <!-- VideoChannelActivityTargetType.PLAYLIST) -->
<ng-container i18n>
<strong>{{ account }}</strong> created playlist <a [routerLink]="[ '/my-library/video-playlists/', a.playlist.shortUUID ]">{{ a.playlist.name }}</a> by updating its channel
</ng-container>
}
}
}
}
</div>
<div class="date">
{{ a.createdAt | myFromNow }}
</div>
</div>

View File

@@ -0,0 +1,20 @@
@use "_variables" as *;
@use "_mixins" as *;
.root {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.message {
color: pvar(--fg-300);
word-break: break-word;
}
.date {
color: pvar(--fg-200);
@include font-size(14px);
}

View File

@@ -0,0 +1,30 @@
import { CommonModule } from '@angular/common'
import { Component, input, OnInit } from '@angular/core'
import { RouterModule } from '@angular/router'
import { FromNowPipe } from '@app/shared/shared-main/date/from-now.pipe'
import { VideoChannelActivity } from '@peertube/peertube-models'
@Component({
selector: 'my-video-channel-activity',
templateUrl: './video-channel-activity.component.html',
styleUrls: [ './video-channel-activity.component.scss' ],
imports: [
CommonModule,
RouterModule,
FromNowPipe
]
})
export class VideoChannelActivityComponent implements OnInit {
activity = input<VideoChannelActivity>()
a: VideoChannelActivity
account: string
ngOnInit () {
this.a = this.activity()
this.account = this.a.account
? this.a.account.displayName
: $localize`Deleted account`
}
}

View File

@@ -1,5 +1,5 @@
import { CommonModule } from '@angular/common'
import { Component, inject, OnInit } from '@angular/core'
import { Component, inject, OnDestroy, OnInit } from '@angular/core'
import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'
import {
BuildFormArgumentTyped,
@@ -53,7 +53,7 @@ type Form = {
GlobalIconComponent
]
})
export class VideoChannelEditGeneralComponent implements OnInit {
export class VideoChannelEditGeneralComponent implements OnInit, OnDestroy {
private formReactiveService = inject(FormReactiveService)
private editController = inject(VideoChannelEditControllerService)
@@ -64,6 +64,7 @@ export class VideoChannelEditGeneralComponent implements OnInit {
videoChannelEdit: VideoChannelEdit
private formSub: Subscription
private storeSub: Subscription
get instanceHost () {
return window.location.host
@@ -75,7 +76,7 @@ export class VideoChannelEditGeneralComponent implements OnInit {
this.videoChannelEdit = this.editController.getStore()
this.buildForm()
this.editController.getStoreChangesObs()
this.storeSub = this.editController.getStoreChangesObs()
.subscribe(() => {
this.videoChannelEdit = this.editController.getStore()
@@ -89,6 +90,11 @@ export class VideoChannelEditGeneralComponent implements OnInit {
})
}
ngOnDestroy () {
this.storeSub?.unsubscribe()
this.editController.unregisterSaveHook()
}
private buildForm () {
this.formSub?.unsubscribe()

View File

@@ -1,5 +1,5 @@
import { CommonModule } from '@angular/common'
import { Component, inject, OnInit } from '@angular/core'
import { Component, inject, OnDestroy, OnInit } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { Router } from '@angular/router'
import { AuthService, ConfirmService, Notifier } from '@app/core'
@@ -12,6 +12,7 @@ import { CollaboratorStateComponent } from '@app/shared/shared-main/channel/coll
import { VideoChannelService } from '@app/shared/shared-main/channel/video-channel.service'
import { VideoChannelCollaborator, VideoChannelCollaboratorState } from '@peertube/peertube-models'
import { logger } from '@root-helpers/logger'
import { Subscription } from 'rxjs'
import { VideoChannelEditControllerService } from '../video-channel-edit-controller.service'
import { VideoChannelEdit } from '../video-channel-edit.model'
@@ -31,7 +32,7 @@ import { VideoChannelEdit } from '../video-channel-edit.model'
CollaboratorStateComponent
]
})
export class VideoChannelEditEditorsComponent implements OnInit {
export class VideoChannelEditEditorsComponent implements OnInit, OnDestroy {
private confirmService = inject(ConfirmService)
private notifier = inject(Notifier)
private channelService = inject(VideoChannelService)
@@ -44,12 +45,23 @@ export class VideoChannelEditEditorsComponent implements OnInit {
newEditorUsername: string
collaboratorActions: DropdownAction<VideoChannelCollaborator>[] = []
private storeSub: Subscription
ngOnInit () {
this.videoChannelEdit = this.editController.getStore()
this.storeSub = this.editController.getStoreChangesObs()
.subscribe(() => {
this.videoChannelEdit = this.editController.getStore()
})
this.buildActions()
}
ngOnDestroy () {
this.storeSub?.unsubscribe()
}
private buildActions () {
this.collaboratorActions = [
{

View File

@@ -57,7 +57,7 @@ h2 {
}
@include on-small-main-col {
.watch-button-label {
.go-public-page {
display: none;
}
}

View File

@@ -72,6 +72,18 @@ export class VideoChannelEditComponent implements OnInit, OnDestroy {
label: $localize`Editors`,
routerLink: 'editors',
isDisplayed: () => this.mode === 'update'
},
{
type: 'separator'
},
{
type: 'link',
icon: 'calendar',
label: $localize`Activity`,
routerLink: 'activities',
isDisplayed: () => this.mode === 'update'
}
]
}

View File

@@ -1,4 +1,5 @@
import { Routes } from '@angular/router'
import { VideoChannelActivitiesComponent } from './pages/video-channel-activities.component'
import { VideoChannelEditGeneralComponent } from './pages/video-channel-edit-general.component'
import { VideoChannelEditEditorsComponent } from './pages/video-channel-editors.component'
@@ -25,5 +26,14 @@ export const videoChannelEditRoutes: Routes = [
title: $localize`Channel editors`
}
}
},
{
path: 'activities',
component: VideoChannelActivitiesComponent,
data: {
meta: {
title: $localize`Channel activities`
}
}
}
]

View File

@@ -79,7 +79,7 @@
<my-privacy-badge [playlist]="playlist"></my-privacy-badge>
</td>
<td *ngIf="table.isColumnDisplayed('updated')">
<td *ngIf="table.isColumnDisplayed('updatedAt')">
{{ playlist.updatedAt | ptDate: 'short' }}
</td>
</ng-template>

View File

@@ -23,9 +23,9 @@ import { PTDatePipe } from '../../shared/shared-main/common/date.pipe'
import { NumberFormatterPipe } from '../../shared/shared-main/common/number-formatter.pipe'
import { VideoPlaylistMiniatureComponent } from '../../shared/shared-video-playlist/video-playlist-miniature.component'
import { PrivacyBadgeComponent } from '../../shared/shared-video/privacy-badge.component'
import { ChannelToggleComponent } from '../../shared/standalone-channels/channel-toggle.component'
import { ChannelToggleComponent } from '../../shared/shared-channels/channel-toggle.component'
type ColumnName = 'videoChannelPosition' | 'videos' | 'name' | 'privacy' | 'updated'
type ColumnName = 'videoChannelPosition' | 'videos' | 'name' | 'privacy' | 'updatedAt'
type QueryParams = TableQueryParams & {
channelName?: string
@@ -104,7 +104,7 @@ export class MyVideoPlaylistsComponent implements OnInit, OnDestroy {
{ id: 'videos', label: $localize`Videos`, selected: true, sortable: false },
{ id: 'name', label: $localize`Name`, selected: true, sortable: true },
{ id: 'privacy', label: $localize`Privacy`, selected: true, sortable: false },
{ id: 'updated', label: $localize`Updated`, selected: true, sortable: false }
{ id: 'updatedAt', label: $localize`Updated`, selected: true, sortable: true }
]
}

View File

@@ -9,7 +9,7 @@ import { Video } from '@app/shared/shared-main/video/video.model'
import { VideoService } from '@app/shared/shared-main/video/video.service'
import { TableColumnInfo, TableComponent, TableQueryParams } from '@app/shared/shared-tables/table.component'
import { VideoPlaylistService } from '@app/shared/shared-video-playlist/video-playlist.service'
import { ChannelToggleComponent } from '@app/shared/standalone-channels/channel-toggle.component'
import { ChannelToggleComponent } from '@app/shared/shared-channels/channel-toggle.component'
import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'
import { arrayify, pick } from '@peertube/peertube-core-utils'
import { VideoChannel, VideoExistInPlaylist, VideoPrivacy, VideoPrivacyType, VideosExistInPlaylists } from '@peertube/peertube-models'

View File

@@ -1,7 +1,7 @@
import { Component } from '@angular/core'
import { RouterLink } from '@angular/router'
import { WatchedWordsListAdminOwnerComponent } from '@app/shared/standalone-watched-words/watched-words-list-admin-owner.component'
import { WatchedWordsListAdminOwnerComponent } from '@app/shared/shared-watched-words/watched-words-list-admin-owner.component'
@Component({
templateUrl: './my-watched-words-list.component.html',

View File

@@ -7,8 +7,7 @@ import { VideoBlockService } from '@app/shared/shared-moderation/video-block.ser
import { UserSubscriptionService } from '@app/shared/shared-user-subscription/user-subscription.service'
import { VideoCommentService } from '@app/shared/shared-video-comment/video-comment.service'
import { LiveVideoService } from '@app/shared/shared-video-live/live-video.service'
import { VideoPlaylistService } from '@app/shared/shared-video-playlist/video-playlist.service'
import { WatchedWordsListService } from '@app/shared/standalone-watched-words/watched-words-list.service'
import { WatchedWordsListService } from '@app/shared/shared-watched-words/watched-words-list.service'
import { LoginGuard } from '../core'
import { CommentsOnMyVideosComponent } from './comments-on-my-videos/comments-on-my-videos.component'
import { AutomaticTagService } from './my-auto-tag-policies/automatic-tag.service'
@@ -33,7 +32,6 @@ import { MyWatchedWordsListComponent } from './my-watched-words-list/my-watched-
const commonConfig = {
path: '',
providers: [
VideoPlaylistService,
BlocklistService,
VideoBlockService,
AbuseService,

View File

@@ -3,7 +3,6 @@ import { LoginGuard } from '@app/core'
import { RemoteInteractionComponent } from './remote-interaction.component'
import { FindInBulkService } from '@app/shared/shared-search/find-in-bulk.service'
import { SearchService } from '@app/shared/shared-search/search.service'
import { VideoPlaylistService } from '@app/shared/shared-video-playlist/video-playlist.service'
export default [
{
@@ -11,8 +10,7 @@ export default [
component: RemoteInteractionComponent,
providers: [
FindInBulkService,
SearchService,
VideoPlaylistService
SearchService
],
canActivate: [ LoginGuard ],
data: {

View File

@@ -3,7 +3,6 @@ import { SearchComponent } from './search.component'
import { ChannelLazyLoadResolver, PlaylistLazyLoadResolver, VideoLazyLoadResolver } from './shared'
import { UserSubscriptionService } from '../shared/shared-user-subscription/user-subscription.service'
import { SearchService } from '@app/shared/shared-search/search.service'
import { VideoPlaylistService } from '@app/shared/shared-video-playlist/video-playlist.service'
import { VideoBlockService } from '@app/shared/shared-moderation/video-block.service'
import { BlocklistService } from '@app/shared/shared-moderation/blocklist.service'
import { AbuseService } from '@app/shared/shared-moderation/abuse.service'
@@ -19,7 +18,6 @@ export default [
},
providers: [
SearchService,
VideoPlaylistService,
UserSubscriptionService,
BlocklistService,
VideoBlockService,

View File

@@ -7,14 +7,12 @@ import { BlocklistService } from '@app/shared/shared-moderation/blocklist.servic
import { BulkService } from '@app/shared/shared-moderation/bulk.service'
import { VideoBlockService } from '@app/shared/shared-moderation/video-block.service'
import { UserSubscriptionService } from '@app/shared/shared-user-subscription/user-subscription.service'
import { VideoPlaylistService } from '@app/shared/shared-video-playlist/video-playlist.service'
export default [
{
path: ':videoChannelName',
component: VideoChannelsComponent,
providers: [
VideoPlaylistService,
UserSubscriptionService,
BlocklistService,
BulkService,

View File

@@ -5,10 +5,10 @@ import { AbuseService } from '@app/shared/shared-moderation/abuse.service'
import { BlocklistService } from '@app/shared/shared-moderation/blocklist.service'
import { VideoBlockService } from '@app/shared/shared-moderation/video-block.service'
import { UserSubscriptionService } from '@app/shared/shared-user-subscription/user-subscription.service'
import { VideoPlaylistService } from '@app/shared/shared-video-playlist/video-playlist.service'
import { OverviewService, VideosListAllComponent } from '.'
import { OverviewService } from './overview'
import { VideoOverviewComponent } from './overview/video-overview.component'
import { VideoUserSubscriptionsComponent } from './video-user-subscriptions.component'
import { VideosListAllComponent } from './videos-list-all.component'
export default [
{
@@ -16,7 +16,6 @@ export default [
providers: [
OverviewService,
UserSubscriptionService,
VideoPlaylistService,
BlocklistService,
VideoBlockService,
AbuseService

View File

@@ -8,7 +8,6 @@ import { UserSubscriptionService } from '@app/shared/shared-user-subscription/us
import { UserAdminService } from '@app/shared/shared-users/user-admin.service'
import { VideoCommentService } from '@app/shared/shared-video-comment/video-comment.service'
import { LiveVideoService } from '@app/shared/shared-video-live/live-video.service'
import { VideoPlaylistService } from '@app/shared/shared-video-playlist/video-playlist.service'
import { PlayerSettingsService } from '@app/shared/shared-video/player-settings.service'
import { VideoStateMessageService } from '@app/shared/shared-video/video-state-message.service'
import { OverviewService } from '../+video-list'
@@ -21,7 +20,6 @@ export default [
providers: [
OverviewService,
UserSubscriptionService,
VideoPlaylistService,
BlocklistService,
VideoBlockService,
LiveVideoService,

View File

@@ -9,7 +9,7 @@ import { Video } from '@app/shared/shared-main/video/video.model'
import { VideoStateMessageService } from '@app/shared/shared-video/video-state-message.service'
import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'
import { ButtonComponent } from '../../shared/shared-main/buttons/button.component'
import { UploadProgressComponent } from '../../shared/standalone-upload/upload-progress.component'
import { UploadProgressComponent } from '../../shared/shared-upload/upload-progress.component'
import { ManageErrorsComponent } from './common/manage-errors.component'
import { VideoEdit } from './common/video-edit.model'
import { VideoManageController } from './video-manage-controller.service'

View File

@@ -6,7 +6,7 @@ import { GlobalIconComponent } from '@app/shared/shared-icons/global-icon.compon
import { ButtonComponent } from '@app/shared/shared-main/buttons/button.component'
import { LoaderComponent } from '@app/shared/shared-main/common/loader.component'
import { UserNotificationService } from '@app/shared/shared-main/users/user-notification.service'
import { UserNotificationsComponent } from '@app/shared/standalone-notifications/user-notifications.component'
import { UserNotificationsComponent } from '@app/shared/shared-notifications/user-notifications.component'
import { NgbDropdown, NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
import { Subject, Subscription } from 'rxjs'
import { filter } from 'rxjs/operators'

View File

@@ -99,6 +99,7 @@ const icons = {
'user-x': require('../../../assets/images/feather/user-x.svg'),
'user': require('../../../assets/images/feather/user.svg'),
'grip-horizontal': require('../../../assets/images/feather/grip-horizontal.svg'),
'calendar': require('../../../assets/images/feather/calendar.svg'),
'users': require('../../../assets/images/feather/users.svg')
}

View File

@@ -4,6 +4,7 @@ import { ComponentPaginationLight, RestExtractor, RestService, ServerService } f
import {
ActorImage,
ResultList,
VideoChannelActivity,
VideoChannelCollaborator,
VideoChannelCreate,
VideoChannel as VideoChannelServer,
@@ -162,4 +163,22 @@ export class VideoChannelService {
return this.authHttp.delete(url)
.pipe(catchError(err => this.restExtractor.handleError(err)))
}
// ---------------------------------------------------------------------------
listActivities (options: {
channelName: string
componentPagination: ComponentPaginationLight
}) {
const { channelName, componentPagination } = options
const pagination = this.restService.componentToRestPagination(componentPagination)
let params = new HttpParams()
params = this.restService.addRestGetParams(params, pagination)
const url = VideoChannelService.BASE_VIDEO_CHANNEL_URL + channelName + '/activities'
return this.authHttp.get<ResultList<VideoChannelActivity>>(url, { params })
.pipe(catchError(err => this.restExtractor.handleError(err)))
}
}

View File

@@ -0,0 +1,3 @@
@if (text) {
{{ text }}
}

View File

@@ -0,0 +1,81 @@
import { ChangeDetectionStrategy, Component, HostBinding, input, OnInit } from '@angular/core'
import { isLastMonth, isLastWeek, isThisMonth, isToday, isYesterday } from '@peertube/peertube-core-utils'
export enum GroupDate {
TODAY = 1,
YESTERDAY = 2,
THIS_WEEK = 3,
THIS_MONTH = 4,
LAST_MONTH = 5,
OLDER = 6
}
export type GroupDateLabels = { [id in GroupDate]: string }
@Component({
selector: 'my-date-group-label',
templateUrl: 'date-group-label.component.html',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class DateGroupLabelComponent implements OnInit {
store = input.required<Set<number>>()
date = input.required<Date | string>()
groupedDateLabels = input<GroupDateLabels>({
[GroupDate.TODAY]: $localize`Today`,
[GroupDate.YESTERDAY]: $localize`Yesterday`,
[GroupDate.THIS_WEEK]: $localize`This week`,
[GroupDate.THIS_MONTH]: $localize`This month`,
[GroupDate.LAST_MONTH]: $localize`Last month`,
[GroupDate.OLDER]: $localize`Older`
})
text: string
@HostBinding('class.date-displayed')
dateDisplayed = false
ngOnInit (): void {
const periods = [
{
value: GroupDate.TODAY,
validator: (d: Date) => isToday(d)
},
{
value: GroupDate.YESTERDAY,
validator: (d: Date) => isYesterday(d)
},
{
value: GroupDate.THIS_WEEK,
validator: (d: Date) => isLastWeek(d)
},
{
value: GroupDate.THIS_MONTH,
validator: (d: Date) => isThisMonth(d)
},
{
value: GroupDate.LAST_MONTH,
validator: (d: Date) => isLastMonth(d)
},
{
value: GroupDate.OLDER,
validator: () => true
}
]
for (const period of periods) {
if (period.validator(new Date(this.date()))) {
if (this.store().has(period.value) === true) break
// Only "Older" period, no need to display anything
if (period.value === GroupDate.OLDER && this.store().size === 0) break
this.store().add(period.value)
this.text = this.groupedDateLabels()[period.value]
this.dateDisplayed = true
break
}
}
}
}

View File

@@ -1,10 +1,11 @@
import { Component, OnChanges, inject, input, model } from '@angular/core'
import { ChangeDetectionStrategy, Component, OnChanges, inject, input, model } from '@angular/core'
import { FromNowPipe } from './from-now.pipe'
@Component({
selector: 'my-date-toggle',
templateUrl: './date-toggle.component.html',
styleUrls: [ './date-toggle.component.scss' ],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true
})
export class DateToggleComponent implements OnChanges {
@@ -40,6 +41,6 @@ export class DateToggleComponent implements OnChanges {
private updateDates () {
this.dateRelative = this.fromNowPipe.transform(this.date())
this.dateAbsolute = this.date().toLocaleDateString()
this.dateAbsolute = this.date().toLocaleString()
}
}

View File

@@ -7,12 +7,11 @@ import { formatICU } from '@app/helpers'
standalone: true
})
export class FromNowPipe implements PipeTransform {
transform (arg: number | Date | string) {
const argDate = new Date(arg)
const seconds = Math.trunc((Date.now() - argDate.getTime()) / 1000)
const seconds = Math.round((Date.now() - argDate.getTime()) / 1000)
let interval = Math.trunc(seconds / 31536000)
let interval = Math.round(seconds / 31536000)
if (interval >= 1) {
return formatICU($localize`{interval, plural, =1 {1 year ago} other {{interval} years ago}}`, { interval })
}
@@ -20,7 +19,7 @@ export class FromNowPipe implements PipeTransform {
return formatICU($localize`{interval, plural, =1 {in 1 year} other {in {interval} years}}`, { interval: -interval })
}
interval = Math.trunc(seconds / 2419200)
interval = Math.round(seconds / 2419200)
// 12 months = 360 days, but a year ~ 365 days
// Display "1 year ago" rather than "12 months ago"
if (interval >= 12) return $localize`1 year ago`
@@ -33,7 +32,7 @@ export class FromNowPipe implements PipeTransform {
return formatICU($localize`{interval, plural, =1 {in 1 month} other {in {interval} months}}`, { interval: -interval })
}
interval = Math.trunc(seconds / 604800)
interval = Math.round(seconds / 604800)
// 4 weeks ~ 28 days, but our month is 30 days
// Display "1 month ago" rather than "4 weeks ago"
if (interval >= 4) return $localize`1 month ago`
@@ -46,7 +45,7 @@ export class FromNowPipe implements PipeTransform {
return formatICU($localize`{interval, plural, =1 {in 1 week} other {in {interval} weeks}}`, { interval: -interval })
}
interval = Math.trunc(seconds / 86400)
interval = Math.round(seconds / 86400)
if (interval >= 1) {
return formatICU($localize`{interval, plural, =1 {1 day ago} other {{interval} days ago}}`, { interval })
}
@@ -54,7 +53,7 @@ export class FromNowPipe implements PipeTransform {
return formatICU($localize`{interval, plural, =1 {in 1 day} other {in {interval} days}}`, { interval: -interval })
}
interval = Math.trunc(seconds / 3600)
interval = Math.round(seconds / 3600)
if (interval >= 1) {
return formatICU($localize`{interval, plural, =1 {1 hour ago} other {{interval} hours ago}}`, { interval })
}
@@ -62,9 +61,9 @@ export class FromNowPipe implements PipeTransform {
return formatICU($localize`{interval, plural, =1 {in 1 hour} other {in {interval} hours}}`, { interval: -interval })
}
interval = Math.trunc(seconds / 60)
interval = Math.round(seconds / 60)
if (interval >= 1) return $localize`${interval} min ago`
if (interval <= -1) return $localize`in ${ -interval } min`
if (interval <= -1) return $localize`in ${-interval} min`
return $localize`just now`
}

View File

@@ -24,7 +24,7 @@ import { VideoService } from '../shared-main/video/video.service'
import { VideoThumbnailComponent } from '../shared-thumbnail/video-thumbnail.component'
import { VideoPlaylistService } from '../shared-video-playlist/video-playlist.service'
import { VideoViewsCounterComponent } from '../shared-video/video-views-counter.component'
import { ActorHostComponent } from '../standalone-actor/actor-host.component'
import { ActorHostComponent } from '../shared-actor/actor-host.component'
import { VideoActionsDisplayType, VideoActionsDropdownComponent } from './video-actions-dropdown.component'
export type MiniatureDisplayOptions = {

View File

@@ -21,7 +21,7 @@
class="videos" [ngClass]="{ 'display-as-row': displayAsRow() }"
>
<ng-container *ngIf="highlightedLives.length !== 0">
<h2 class="date-title">
<h2 class="videos-section-title">
<my-global-icon class="pt-icon me-1 top--1px" iconName="live"></my-global-icon>
<ng-container i18n>Lives</ng-container>
</h2>
@@ -37,15 +37,17 @@
</div>
</ng-container>
<h2 *ngIf="!groupByDate()" class="date-title">
<my-global-icon class="pt-icon me-1" iconName="videos"></my-global-icon> Videos
<h2 class="date-title" [ngClass]="{ 'visually-hidden': groupByDate() }">
<my-global-icon class="pt-icon me-1" iconName="videos"></my-global-icon>
<ng-container i18n>Videos</ng-container>
</h2>
</ng-container>
<ng-container *ngFor="let video of videos; trackBy: videoById;">
<h2 class="date-title" *ngIf="getCurrentGroupedDateLabel(video)">
{{ getCurrentGroupedDateLabel(video) }}
</h2>
@if (groupByDate()) {
<my-date-group-label [date]="video.publishedAt" [store]="groupByDateStore" [groupedDateLabels]="groupedDateLabels"></my-date-group-label>
}
<div class="video-wrapper">
<my-video-miniature

View File

@@ -1,7 +1,7 @@
@use '_bootstrap-variables';
@use '_variables' as *;
@use '_mixins' as *;
@use '_miniature' as *;
@use "_bootstrap-variables";
@use "_variables" as *;
@use "_mixins" as *;
@use "_miniature" as *;
// Cannot set margin top to videos-header because of the main header fixed position
$margin-top: 2rem;
@@ -21,7 +21,12 @@ $margin-top: 2rem;
}
}
.date-title {
my-date-group-label:not(.date-displayed) {
display: none;
}
my-date-group-label.date-displayed,
.videos-section-title {
font-weight: $font-semibold;
margin-bottom: 20px;
font-size: 1rem;
@@ -30,7 +35,7 @@ $margin-top: 2rem;
grid-column: 1 / -1;
&:not(:first-child) {
margin-top: .5rem;
margin-top: 0.5rem;
padding-top: 20px;
border-top: 1px solid $separator-border-color;
}

View File

@@ -13,7 +13,6 @@ import {
updatePaginationOnDelete
} from '@app/core'
import { GlobalIconComponent, GlobalIconName } from '@app/shared/shared-icons/global-icon.component'
import { isLastMonth, isLastWeek, isThisMonth, isToday, isYesterday } from '@peertube/peertube-core-utils'
import { ResultList, VideoSortField } from '@peertube/peertube-models'
import { logger } from '@root-helpers/logger'
import debug from 'debug'
@@ -21,6 +20,7 @@ import { Observable, Subject, Subscription, forkJoin, fromEvent, of } from 'rxjs
import { concatMap, debounceTime, map, switchMap } from 'rxjs/operators'
import { ButtonComponent } from '../shared-main/buttons/button.component'
import { InfiniteScrollerDirective } from '../shared-main/common/infinite-scroller.directive'
import { DateGroupLabelComponent, GroupDate, GroupDateLabels } from '../shared-main/date/date-group-label.component'
import { Syndication } from '../shared-main/feeds/syndication.model'
import { Video } from '../shared-main/video/video.model'
import { VideoFiltersHeaderComponent } from './video-filters-header.component'
@@ -35,16 +35,6 @@ export type HeaderAction = {
routerLink?: string
}
enum GroupDate {
UNKNOWN = 0,
TODAY = 1,
YESTERDAY = 2,
THIS_WEEK = 3,
THIS_MONTH = 4,
LAST_MONTH = 5,
OLDER = 6
}
@Component({
selector: 'my-videos-list',
templateUrl: './videos-list.component.html',
@@ -56,7 +46,8 @@ enum GroupDate {
VideoFiltersHeaderComponent,
InfiniteScrollerDirective,
VideoMiniatureComponent,
GlobalIconComponent
GlobalIconComponent,
DateGroupLabelComponent
]
})
export class VideosListComponent implements OnInit, OnDestroy {
@@ -111,6 +102,16 @@ export class VideosListComponent implements OnInit, OnDestroy {
}
displayModerationBlock = true
groupByDateStore = new Set<number>()
groupedDateLabels: GroupDateLabels = {
[GroupDate.TODAY]: $localize`Today's videos`,
[GroupDate.YESTERDAY]: $localize`Yesterday's videos`,
[GroupDate.THIS_WEEK]: $localize`This week's videos`,
[GroupDate.THIS_MONTH]: $localize`This month's videos`,
[GroupDate.LAST_MONTH]: $localize`Last month's videos`,
[GroupDate.OLDER]: $localize`Older videos`
}
private routeSub: Subscription
private userSub: Subscription
private resizeSub: Subscription
@@ -121,17 +122,6 @@ export class VideosListComponent implements OnInit, OnDestroy {
totalItems: null
}
private groupedDateLabels: { [id in GroupDate]: string } = {
[GroupDate.UNKNOWN]: null,
[GroupDate.TODAY]: $localize`Today's videos`,
[GroupDate.YESTERDAY]: $localize`Yesterday's videos`,
[GroupDate.THIS_WEEK]: $localize`This week's videos`,
[GroupDate.THIS_MONTH]: $localize`This month's videos`,
[GroupDate.LAST_MONTH]: $localize`Last month's videos`,
[GroupDate.OLDER]: $localize`Older videos`
}
private groupedDates: { [id: number]: GroupDate } = {}
private lastQueryLength: number
private videoRequests = new Subject<{
@@ -226,6 +216,7 @@ export class VideosListComponent implements OnInit, OnDestroy {
this.hasDoneFirstQuery = false
this.videos = []
this.highlightedLives = []
this.groupByDateStore.clear()
if (this.highlightLives() && (!this.filters.live || this.filters.live === 'both')) {
liveFilters = this.filters.clone()
@@ -350,8 +341,6 @@ export class VideosListComponent implements OnInit, OnDestroy {
this.videos = this.videos.concat(videos)
if (this.groupByDate()) this.buildGroupedDateLabels()
this.onVideosDataSubject.next(videos)
this.videosLoaded.emit(this.videos)
},
@@ -364,65 +353,4 @@ export class VideosListComponent implements OnInit, OnDestroy {
}
})
}
// ---------------------------------------------------------------------------
private buildGroupedDateLabels () {
let currentGroupedDate: GroupDate = GroupDate.UNKNOWN
const periods = [
{
value: GroupDate.TODAY,
validator: (d: Date) => isToday(d)
},
{
value: GroupDate.YESTERDAY,
validator: (d: Date) => isYesterday(d)
},
{
value: GroupDate.THIS_WEEK,
validator: (d: Date) => isLastWeek(d)
},
{
value: GroupDate.THIS_MONTH,
validator: (d: Date) => isThisMonth(d)
},
{
value: GroupDate.LAST_MONTH,
validator: (d: Date) => isLastMonth(d)
},
{
value: GroupDate.OLDER,
validator: () => true
}
]
let onlyOlderPeriod = true
for (const video of this.videos) {
const publishedDate = video.publishedAt
for (const period of periods) {
if (currentGroupedDate <= period.value && period.validator(publishedDate)) {
if (currentGroupedDate !== period.value) {
if (period.value !== GroupDate.OLDER) onlyOlderPeriod = false
currentGroupedDate = period.value
this.groupedDates[video.id] = currentGroupedDate
}
break
}
}
}
// No need to group by date, there is only "Older" period available
if (onlyOlderPeriod) this.groupedDates = {}
}
getCurrentGroupedDateLabel (video: Video) {
if (this.groupByDate() === false) return undefined
return this.groupedDateLabels[this.groupedDates[video.id]]
}
}

View File

@@ -87,7 +87,8 @@ export class VideoAddToPlaylistComponent extends FormReactive implements OnInit,
private disabled = false
private listenToPlaylistChangeSub: Subscription
private listenToVideoPlaylistChangeSub: Subscription
private listenToAccountPlaylistsChangeSub: Subscription
private playlistsData: CachedPlaylist[] = []
private pendingAddId: number
@@ -101,7 +102,7 @@ export class VideoAddToPlaylistComponent extends FormReactive implements OnInit,
displayName: VIDEO_PLAYLIST_DISPLAY_NAME_VALIDATOR
})
this.videoPlaylistService.listenToMyAccountPlaylistsChange()
this.listenToAccountPlaylistsChangeSub = this.videoPlaylistService.listenToMyAccountPlaylistsChange()
.subscribe(result => {
this.playlistsData = result.data
@@ -121,6 +122,8 @@ export class VideoAddToPlaylistComponent extends FormReactive implements OnInit,
ngOnDestroy () {
this.unsubscribePlaylistChanges()
this.listenToAccountPlaylistsChangeSub?.unsubscribe()
}
disableForReuse () {
@@ -338,15 +341,15 @@ export class VideoAddToPlaylistComponent extends FormReactive implements OnInit,
private listenToVideoPlaylistChange () {
this.unsubscribePlaylistChanges()
this.listenToPlaylistChangeSub = this.videoPlaylistService.listenToVideoPlaylistChange(this.video().id)
this.listenToVideoPlaylistChangeSub = this.videoPlaylistService.listenToVideoPlaylistChange(this.video().id)
.pipe(filter(() => this.disabled === false))
.subscribe(existResult => this.rebuildPlaylists(existResult))
}
private unsubscribePlaylistChanges () {
if (this.listenToPlaylistChangeSub) {
this.listenToPlaylistChangeSub.unsubscribe()
this.listenToPlaylistChangeSub = undefined
if (this.listenToVideoPlaylistChangeSub) {
this.listenToVideoPlaylistChangeSub.unsubscribe()
this.listenToVideoPlaylistChangeSub = undefined
}
}

View File

@@ -1,7 +1,7 @@
<div class="miniature" [ngClass]="{ 'no-videos': playlist().videosLength === 0, 'to-manage': toManage(), 'display-as-row': displayAsRow() }">
<my-link
[internalLink]="playlistRouterLink" [href]="playlistHref" [target]="playlistTarget" inheritParentStyle="true" inheritParentDimension="true"
[title]="playlist().description" class="miniature-thumbnail" tabindex="-1"
[title]="playlist().displayName" class="miniature-thumbnail" tabindex="-1"
>
<img alt="" [attr.aria-label]="playlist().displayName" [attr.src]="playlist().thumbnailUrl" />
@@ -18,7 +18,7 @@
<div class="miniature-info">
<my-link
[internalLink]="playlistRouterLink" [href]="playlistHref" [target]="playlistTarget" inheritParentStyle="true" inheritParentDimension="true"
[title]="playlist().description" class="miniature-name" className="ellipsis-multiline-2"
[title]="playlist().displayName" class="miniature-name" className="ellipsis-multiline-2"
>
{{ playlist().displayName }}
</my-link>

View File

@@ -33,7 +33,7 @@ const debugLogger = debug('peertube:playlists:VideoPlaylistService')
export type CachedPlaylist = VideoPlaylist | { id: number, displayName: string }
@Injectable()
@Injectable({ providedIn: 'root' })
export class VideoPlaylistService {
private authHttp = inject(HttpClient)
private auth = inject(AuthService)

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-calendar-search-icon lucide-calendar-search"><path d="M16 2v4"/><path d="M21 11.75V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h7.25"/><path d="m22 22-1.875-1.875"/><path d="M3 10h18"/><path d="M8 2v4"/><circle cx="18" cy="18" r="3"/></svg>

After

Width:  |  Height:  |  Size: 447 B

View File

@@ -7,6 +7,7 @@
line-height: 1;
@include font-size(2rem);
@include rfs(2rem, margin-top);
@include rfs(2rem, margin-bottom);
my-global-icon {

View File

@@ -1,6 +1,7 @@
import { Jsonify } from 'type-fest'
export function pick<O extends object, K extends keyof O> (object: O, keys: K[]): Pick<O, K> {
// Forbid _attributes key to prevent pick on a sequelize model, that doesn't work for attributes
export function pick<O extends (object & { _attributes?: never }), K extends keyof O> (object: O, keys: K[]): Pick<O, K> {
const result: any = {}
for (const key of keys) {

View File

@@ -1,3 +1,4 @@
export * from './video-channel-activity.model.js'
export * from './video-channel-collaborator.model.js'
export * from './video-channel-create-result.model.js'
export * from './video-channel-create.model.js'

View File

@@ -0,0 +1,94 @@
import { AccountSummary } from '../../actors/account.model.js'
export const VideoChannelActivityAction = {
CREATE: 1,
UPDATE: 2,
DELETE: 3,
UPDATE_CAPTIONS: 4,
UPDATE_CHAPTERS: 5,
UPDATE_PASSWORDS: 6,
CREATE_STUDIO_TASKS: 7,
UPDATE_SOURCE_FILE: 8,
UPDATE_ELEMENTS: 9,
REMOVE_CHANNEL_OWNERSHIP: 10,
CREATE_CHANNEL_OWNERSHIP: 11
} as const
export type VideoChannelActivityActionType = typeof VideoChannelActivityAction[keyof typeof VideoChannelActivityAction]
// ---------------------------------------------------------------------------
export const VideoChannelActivityTarget = {
VIDEO: 1,
PLAYLIST: 2,
CHANNEL: 3,
CHANNEL_SYNC: 4,
VIDEO_IMPORT: 5
} as const
export type VideoChannelActivityTargetType = typeof VideoChannelActivityTarget[keyof typeof VideoChannelActivityTarget]
// ---------------------------------------------------------------------------
// To add update diff later
export interface VideoChannelActivityDetails {
}
export interface VideoChannelActivity {
id: number
// The account may have been deleted
account?: AccountSummary
action: {
id: VideoChannelActivityActionType
label: string
}
targetType: {
id: VideoChannelActivityTargetType
label: string
}
details: VideoChannelActivityDetails
createdAt: Date
channel: {
id: number
name: string
displayName: string
url: string
}
video?: {
id: number
name: string
uuid: string
shortUUID: string
url: string
isLive: boolean
}
videoImport?: {
id: number
name: string
uuid: string
shortUUID: string
url: string
targetUrl: string
}
playlist?: {
id: number
name: string
uuid: string
shortUUID: string
url: string
}
channelSync?: {
id: number
externalChannelUrl: string
}
}

View File

@@ -4,14 +4,15 @@ import { buildAbsoluteFixturePath } from '@peertube/peertube-node-utils'
import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js'
export class CaptionsCommand extends AbstractCommand {
add (options: OverrideCommandOptions & {
videoId: string | number
language: string
fixture: string
mimeType?: string
}) {
const { videoId, language, fixture, mimeType } = options
add (
options: OverrideCommandOptions & {
videoId: string | number
language: string
fixture?: string
mimeType?: string
}
) {
const { videoId, language, fixture = 'subtitle-good2.vtt', mimeType } = options
const path = '/api/v1/videos/' + videoId + '/captions/' + language
@@ -33,10 +34,12 @@ export class CaptionsCommand extends AbstractCommand {
})
}
runGenerate (options: OverrideCommandOptions & {
videoId: string | number
forceTranscription?: boolean
}) {
runGenerate (
options: OverrideCommandOptions & {
videoId: string | number
forceTranscription?: boolean
}
) {
const { videoId } = options
const path = '/api/v1/videos/' + videoId + '/captions/generate'
@@ -50,10 +53,12 @@ export class CaptionsCommand extends AbstractCommand {
})
}
list (options: OverrideCommandOptions & {
videoId: string | number
videoPassword?: string
}) {
list (
options: OverrideCommandOptions & {
videoId: string | number
videoPassword?: string
}
) {
const { videoId, videoPassword } = options
const path = '/api/v1/videos/' + videoId + '/captions'
@@ -67,10 +72,12 @@ export class CaptionsCommand extends AbstractCommand {
})
}
delete (options: OverrideCommandOptions & {
videoId: string | number
language: string
}) {
delete (
options: OverrideCommandOptions & {
videoId: string | number
language: string
}
) {
const { videoId, language } = options
const path = '/api/v1/videos/' + videoId + '/captions/' + language

View File

@@ -4,6 +4,7 @@ import {
HttpStatusCode,
ResultList,
VideoChannel,
VideoChannelActivity,
VideoChannelCreate,
VideoChannelCreateResult,
VideoChannelUpdate,
@@ -155,12 +156,19 @@ export class ChannelsCommand extends AbstractCommand {
updateImage (
options: OverrideCommandOptions & {
fixture: string
fixture?: string
channelName: string | number
type: 'avatar' | 'banner'
}
) {
const { channelName, fixture, type } = options
const { channelName, type } = options
let fixture = options.fixture
if (!fixture) {
if (type === 'avatar') fixture = 'avatar.png'
else fixture = 'banner.jpg'
}
const path = `/api/v1/video-channels/${channelName}/${type}/pick`
@@ -195,6 +203,8 @@ export class ChannelsCommand extends AbstractCommand {
})
}
// ---------------------------------------------------------------------------
listFollowers (
options: OverrideCommandOptions & {
channelName: string
@@ -219,6 +229,29 @@ export class ChannelsCommand extends AbstractCommand {
})
}
listActivities (
options: OverrideCommandOptions & {
channelName: string
start?: number
count?: number
sort?: string
}
) {
const { channelName, start, count, sort } = options
const path = '/api/v1/video-channels/' + channelName + '/activities'
const query = { start, count, sort }
return this.getRequestBody<ResultList<VideoChannelActivity>>({
...options,
path,
query,
implicitToken: true,
defaultExpectedStatus: HttpStatusCode.OK_200
})
}
importVideos (
options: OverrideCommandOptions & VideosImportInChannelCreate & {
channelName: string

View File

@@ -0,0 +1,82 @@
import { HttpStatusCode } from '@peertube/peertube-models'
import {
createSingleServer,
killallServers,
makeGetRequest,
PeerTubeServer,
setAccessTokensToServers
} from '@peertube/peertube-server-commands'
import { checkBadCountPagination, checkBadSortPagination, checkBadStartPagination } from '@tests/shared/checks.js'
describe('Test video channel activities API validator', () => {
const path = '/api/v1/video-channels/root_channel/activities'
let server: PeerTubeServer
let userToken: string
let editorToken: string
// ---------------------------------------------------------------
before(async function () {
this.timeout(30000)
server = await createSingleServer(1)
await setAccessTokensToServers([ server ])
userToken = await server.users.generateUserAndToken('user')
editorToken = await server.channelCollaborators.createEditor('editor', 'root_channel')
})
describe('When listing channel activities', function () {
it('Should fail with a bad start pagination', async function () {
await checkBadStartPagination(server.url, path, server.accessToken)
})
it('Should fail with a bad count pagination', async function () {
await checkBadCountPagination(server.url, path, server.accessToken)
})
it('Should fail with an incorrect sort', async function () {
await checkBadSortPagination(server.url, path, server.accessToken)
})
it('Should fail without authentication', async function () {
await server.channels.listActivities({ token: null, channelName: 'invalid', expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
})
it('Should fail with a bad channel', async function () {
await server.channels.listActivities({ channelName: 'invalid', expectedStatus: HttpStatusCode.NOT_FOUND_404 })
})
it('Should fail with a non owned channel', async function () {
await server.channels.listActivities({ token: userToken, channelName: 'root_channel', expectedStatus: HttpStatusCode.FORBIDDEN_403 })
})
it('Should succeed with correct parameters', async function () {
await makeGetRequest({
url: server.url,
path,
token: server.accessToken,
expectedStatus: HttpStatusCode.OK_200
})
await server.channels.listActivities({
channelName: 'root_channel',
start: 0,
count: 10,
sort: '-createdAt'
})
await server.channels.listActivities({
token: editorToken,
channelName: 'root_channel',
start: 0,
count: 10,
sort: '-createdAt'
})
})
})
after(async function () {
await killallServers([ server ])
})
})

View File

@@ -3,6 +3,7 @@ import './accounts.js'
import './auto-tags.js'
import './blocklist.js'
import './bulk.js'
import './channel-activities.js'
import './channel-import-videos.js'
import './config.js'
import './contact-form.js'

View File

@@ -0,0 +1,779 @@
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
import {
LiveVideoLatencyMode,
VideoChannelActivityAction,
VideoChannelActivityTarget,
VideoCreateResult,
VideoImport,
VideoPlaylistCreateResult,
VideoPlaylistElementCreateResult,
VideoPlaylistPrivacy,
VideoPrivacy
} from '@peertube/peertube-models'
import {
cleanupTests,
createSingleServer,
PeerTubeServer,
setAccessTokensToServers,
setDefaultAccountAvatar,
waitJobs
} from '@peertube/peertube-server-commands'
import { FIXTURE_URLS } from '@tests/shared/fixture-urls.js'
import { expect } from 'chai'
describe('Test channel activities', function () {
let server: PeerTubeServer
let editorToken: string
let channelId: number
let channelId2: number
async function getActivityAfterAction (action: () => Promise<any>) {
const before = new Date()
await action()
const { data } = await server.channels.listActivities({ channelName: 'eminem_channel', sort: '-createdAt' })
const activity = data[0]
expect(new Date(activity.createdAt).getTime()).to.be.greaterThan(before.getTime())
if (data.length >= 2) {
expect(new Date(data[1].createdAt).getTime()).to.be.below(before.getTime())
}
return data[0]
}
before(async function () {
server = await createSingleServer(1)
await setAccessTokensToServers([ server ])
await setDefaultAccountAvatar([ server ])
{
const channel = await server.channels.create({ attributes: { name: 'eminem_channel', displayName: 'Eminem' } })
channelId = channel.id
}
{
const channel = await server.channels.create({ attributes: { name: 'mero', displayName: 'Mero' } })
channelId2 = channel.id
}
editorToken = await server.channelCollaborators.createEditor('editor', 'eminem_channel')
})
describe('Channel', function () {
it('Should have an empty activities list', async function () {
const { data, total } = await server.channels.listActivities({ channelName: 'eminem_channel' })
expect(data).to.have.lengthOf(0)
expect(total).to.equal(0)
})
it('Should update a channel', async function () {
const a = await getActivityAfterAction(() => {
return server.channels.update({
channelName: 'eminem_channel',
attributes: {
displayName: 'Updated Eminem',
description: 'This is the new description'
}
})
})
expect(a.id).to.exist
expect(a.account.id).to.exist
expect(a.account.avatars).to.have.length.greaterThan(3)
expect(a.account.displayName).to.equal('root')
expect(a.account.host).to.exist
expect(a.account.url).to.exist
expect(a.action.id).to.equal(VideoChannelActivityAction.UPDATE)
expect(a.action.label).to.equal('Update')
expect(a.targetType.id).to.equal(VideoChannelActivityTarget.CHANNEL)
expect(a.targetType.label).to.equal('Channel')
expect(a.channel.id).to.exist
expect(a.channel.displayName).to.equal('Updated Eminem')
expect(a.channel.name).to.equal('eminem_channel')
expect(a.channel.url).to.exist
expect(a.details).to.be.null
})
it('Should update channel avatar', async function () {
const a = await getActivityAfterAction(() => {
return server.channels.updateImage({
channelName: 'eminem_channel',
type: 'avatar'
})
})
expect(a.action.id).to.equal(VideoChannelActivityAction.UPDATE)
expect(a.targetType.id).to.equal(VideoChannelActivityTarget.CHANNEL)
expect(a.channel.name).to.equal('eminem_channel')
})
it('Should update channel banner', async function () {
const a = await getActivityAfterAction(() => {
return server.channels.updateImage({
channelName: 'eminem_channel',
type: 'banner'
})
})
expect(a.action.id).to.equal(VideoChannelActivityAction.UPDATE)
expect(a.targetType.id).to.equal(VideoChannelActivityTarget.CHANNEL)
expect(a.channel.name).to.equal('eminem_channel')
})
it('Should delete channel avatar by editor', async function () {
const a = await getActivityAfterAction(() => {
return server.channels.deleteImage({
token: editorToken,
channelName: 'eminem_channel',
type: 'avatar'
})
})
expect(a.account.displayName).to.equal('editor')
expect(a.account.avatars).to.have.lengthOf(0)
expect(a.action.id).to.equal(VideoChannelActivityAction.UPDATE)
expect(a.targetType.id).to.equal(VideoChannelActivityTarget.CHANNEL)
expect(a.channel.name).to.equal('eminem_channel')
})
it('Should delete channel banner', async function () {
const a = await getActivityAfterAction(() => {
return server.channels.deleteImage({
channelName: 'eminem_channel',
type: 'avatar'
})
})
expect(a.action.id).to.equal(VideoChannelActivityAction.UPDATE)
expect(a.targetType.id).to.equal(VideoChannelActivityTarget.CHANNEL)
expect(a.channel.name).to.equal('eminem_channel')
})
it('Should update player settings', async function () {
const a = await getActivityAfterAction(() => {
return server.playerSettings.updateForChannel({
channelHandle: 'eminem_channel',
theme: 'lucide'
})
})
expect(a.action.id).to.equal(VideoChannelActivityAction.UPDATE)
expect(a.targetType.id).to.equal(VideoChannelActivityTarget.CHANNEL)
})
})
describe('Videos', function () {
let video: VideoCreateResult
before(async function () {
await server.config.updateExistingConfig({
newConfig: {
videoFile: {
update: {
enabled: true
}
}
}
})
await server.config.enableMinimumTranscoding()
})
it('Should upload a video', async function () {
const a = await getActivityAfterAction(async () => {
video = await server.videos.quickUpload({
name: 'uploaded video',
channelId
})
})
expect(a.action.id).to.equal(VideoChannelActivityAction.CREATE)
expect(a.targetType.id).to.equal(VideoChannelActivityTarget.VIDEO)
expect(a.video.id).to.exist
expect(a.video.name).to.equal('uploaded video')
expect(a.video.uuid).to.exist
expect(a.video.shortUUID).to.exist
expect(a.video.url).to.exist
expect(a.video.isLive).to.be.false
})
it('Should update the video', async function () {
const a = await getActivityAfterAction(() => {
return server.videos.update({
id: video.id,
attributes: {
name: 'Updated video name'
}
})
})
expect(a.action.id).to.equal(VideoChannelActivityAction.UPDATE)
expect(a.targetType.id).to.equal(VideoChannelActivityTarget.VIDEO)
expect(a.video.name).to.equal('Updated video name')
})
it('Should add captions', async function () {
const a = await getActivityAfterAction(async () => {
await server.captions.add({
videoId: video.id,
language: 'fr'
})
})
expect(a.action.id).to.equal(VideoChannelActivityAction.UPDATE_CAPTIONS)
expect(a.targetType.id).to.equal(VideoChannelActivityTarget.VIDEO)
})
it('Should remove captions by editor', async function () {
const a = await getActivityAfterAction(async () => {
await server.captions.delete({ videoId: video.id, language: 'fr', token: editorToken })
})
expect(a.account.name).to.equal('editor')
expect(a.action.id).to.equal(VideoChannelActivityAction.UPDATE_CAPTIONS)
expect(a.targetType.id).to.equal(VideoChannelActivityTarget.VIDEO)
})
it('Should add a password', async function () {
await server.videos.update({
id: video.id,
attributes: { privacy: VideoPrivacy.PASSWORD_PROTECTED, videoPasswords: [ 'password 1' ] }
})
const a = await getActivityAfterAction(async () => {
await server.videoPasswords.updateAll({
videoId: video.id,
passwords: [ 'mypassword1', 'mypassword2' ]
})
})
expect(a.action.id).to.equal(VideoChannelActivityAction.UPDATE_PASSWORDS)
expect(a.targetType.id).to.equal(VideoChannelActivityTarget.VIDEO)
})
it('Should delete a password', async function () {
const { data } = await server.videoPasswords.list({ videoId: video.id })
const a = await getActivityAfterAction(async () => {
await server.videoPasswords.remove({ id: data[0].id, videoId: video.id, token: editorToken })
})
expect(a.action.id).to.equal(VideoChannelActivityAction.UPDATE_PASSWORDS)
expect(a.targetType.id).to.equal(VideoChannelActivityTarget.VIDEO)
await server.videos.update({ id: video.id, attributes: { privacy: VideoPrivacy.PUBLIC } })
})
it('Should add chapters', async function () {
const a = await getActivityAfterAction(async () => {
await server.chapters.update({
videoId: video.id,
chapters: [
{ timecode: 0, title: 'Intro' }
]
})
})
expect(a.action.id).to.equal(VideoChannelActivityAction.UPDATE_CHAPTERS)
expect(a.targetType.id).to.equal(VideoChannelActivityTarget.VIDEO)
})
it('Should add a source file', async function () {
this.timeout(60000)
const video = await server.videos.quickUpload({ name: 'video with source file', channelId })
await waitJobs([ server ])
const a = await getActivityAfterAction(async () => {
await server.videos.replaceSourceFile({
token: editorToken,
videoId: video.id,
fixture: 'video_short.mp4'
})
})
await waitJobs([ server ])
expect(a.account.name).to.equal('editor')
expect(a.action.id).to.equal(VideoChannelActivityAction.UPDATE_SOURCE_FILE)
expect(a.targetType.id).to.equal(VideoChannelActivityTarget.VIDEO)
})
it('Should delete a source file', async function () {
const a = await getActivityAfterAction(async () => {
await server.videos.deleteSource({ id: video.id })
})
expect(a.action.id).to.equal(VideoChannelActivityAction.UPDATE_SOURCE_FILE)
expect(a.targetType.id).to.equal(VideoChannelActivityTarget.VIDEO)
})
it('Should create a studio task', async function () {
this.timeout(60000)
await server.config.enableStudio()
const video = await server.videos.quickUpload({ name: 'video for studio', channelId })
await waitJobs([ server ])
const a = await getActivityAfterAction(async () => {
await server.videoStudio.createEditionTasks({
videoId: video.id,
token: editorToken,
tasks: [
{
name: 'cut',
options: {
start: 1,
end: 3
}
}
]
})
})
expect(a.account.name).to.equal('editor')
expect(a.action.id).to.equal(VideoChannelActivityAction.CREATE_STUDIO_TASKS)
expect(a.targetType.id).to.equal(VideoChannelActivityTarget.VIDEO)
})
it('Should delete the video', async function () {
const a = await getActivityAfterAction(() => {
return server.videos.remove({ id: video.id })
})
expect(a.action.id).to.equal(VideoChannelActivityAction.DELETE)
expect(a.targetType.id).to.equal(VideoChannelActivityTarget.VIDEO)
expect(a.video.id).to.exist
expect(a.video.name).to.equal('Updated video name')
expect(a.video.uuid).to.exist
expect(a.video.shortUUID).to.exist
expect(a.video.url).to.exist
expect(a.video.isLive).to.be.false
})
it('Should update the channel of a video', async function () {
const video = await server.videos.quickUpload({ name: 'video to change channel', channelId })
{
const a = await getActivityAfterAction(() => {
return server.videos.update({ id: video.id, attributes: { channelId: channelId2 } })
})
expect(a.action.id).to.equal(VideoChannelActivityAction.REMOVE_CHANNEL_OWNERSHIP)
expect(a.targetType.id).to.equal(VideoChannelActivityTarget.VIDEO)
expect(a.video.name).to.equal('video to change channel')
}
{
const { data } = await server.channels.listActivities({ channelName: 'mero', sort: '-createdAt' })
const a2 = data[0]
expect(a2.action.id).to.equal(VideoChannelActivityAction.CREATE_CHANNEL_OWNERSHIP)
expect(a2.targetType.id).to.equal(VideoChannelActivityTarget.VIDEO)
expect(a2.video.name).to.equal('video to change channel')
}
})
})
describe('Lives', async function () {
let liveVideo: VideoCreateResult
before(async function () {
await server.config.enableLive()
})
it('Should create a live', async function () {
const a = await getActivityAfterAction(async () => {
liveVideo = await server.live.create({
fields: {
name: 'live',
privacy: VideoPrivacy.PUBLIC,
channelId
}
})
})
expect(a.action.id).to.equal(VideoChannelActivityAction.CREATE)
expect(a.targetType.id).to.equal(VideoChannelActivityTarget.VIDEO)
expect(a.video.id).to.exist
expect(a.video.name).to.equal('live')
expect(a.video.uuid).to.exist
expect(a.video.shortUUID).to.exist
expect(a.video.url).to.exist
expect(a.video.isLive).to.be.true
})
it('Should update the live', async function () {
const a = await getActivityAfterAction(() => {
return server.live.update({
videoId: liveVideo.id,
fields: {
latencyMode: LiveVideoLatencyMode.HIGH_LATENCY
}
})
})
expect(a.action.id).to.equal(VideoChannelActivityAction.UPDATE)
expect(a.targetType.id).to.equal(VideoChannelActivityTarget.VIDEO)
})
it('Should delete the live', async function () {
const a = await getActivityAfterAction(() => {
return server.videos.remove({ id: liveVideo.id })
})
expect(a.action.id).to.equal(VideoChannelActivityAction.DELETE)
expect(a.targetType.id).to.equal(VideoChannelActivityTarget.VIDEO)
expect(a.video.name).to.equal('live')
expect(a.video.isLive).to.be.true
})
})
describe('Video imports', function () {
let videoImport: VideoImport
it('Should import a video', async function () {
const a = await getActivityAfterAction(async () => {
videoImport = await server.videoImports.importVideo({ attributes: { targetUrl: FIXTURE_URLS.goodVideo, channelId } })
})
expect(a.action.id).to.equal(VideoChannelActivityAction.CREATE)
expect(a.targetType.id).to.equal(VideoChannelActivityTarget.VIDEO_IMPORT)
expect(a.videoImport.id).to.exist
expect(a.videoImport.name).to.equal('good_video')
expect(a.videoImport.targetUrl).to.equal(FIXTURE_URLS.goodVideo)
expect(a.videoImport.uuid).to.exist
expect(a.videoImport.shortUUID).to.exist
expect(a.videoImport.url).to.exist
})
it('Should delete the import video', async function () {
const a = await getActivityAfterAction(() => {
return server.videos.remove({ id: videoImport.video.id })
})
expect(a.action.id).to.equal(VideoChannelActivityAction.DELETE)
expect(a.targetType.id).to.equal(VideoChannelActivityTarget.VIDEO)
const { data } = await server.channels.listActivities({ channelName: 'eminem_channel', sort: '-createdAt' })
const importRow = data[1]
expect(importRow.videoImport.id).to.exist
expect(importRow.videoImport.name).to.equal('good_video')
expect(importRow.videoImport.targetUrl).to.equal(FIXTURE_URLS.goodVideo)
expect(importRow.videoImport.uuid).to.exist
expect(importRow.videoImport.shortUUID).to.exist
expect(importRow.videoImport.url).to.exist
})
})
describe('Playlists', function () {
let playlist: VideoPlaylistCreateResult
let playlistElement: VideoPlaylistElementCreateResult
it('Should create a playlist', async function () {
const a = await getActivityAfterAction(async () => {
playlist = await server.playlists.create({
attributes: {
displayName: 'My playlist',
videoChannelId: channelId,
privacy: VideoPlaylistPrivacy.PRIVATE
}
})
})
expect(a.action.id).to.equal(VideoChannelActivityAction.CREATE)
expect(a.targetType.id).to.equal(VideoChannelActivityTarget.PLAYLIST)
expect(a.playlist.id).to.exist
expect(a.playlist.name).to.equal('My playlist')
expect(a.playlist.uuid).exist
expect(a.playlist.url).to.exist
})
it('Should update the playlist', async function () {
const a = await getActivityAfterAction(() => {
return server.playlists.update({
playlistId: playlist.id,
attributes: {
displayName: 'My playlist updated',
videoChannelId: channelId
}
})
})
expect(a.action.id).to.equal(VideoChannelActivityAction.UPDATE)
expect(a.targetType.id).to.equal(VideoChannelActivityTarget.PLAYLIST)
expect(a.playlist.name).to.equal('My playlist updated')
})
it('Should add elements in a playlist', async function () {
const video = await server.videos.quickUpload({ name: 'playlist video 1', channelId })
const a = await getActivityAfterAction(async () => {
playlistElement = await server.playlists.addElement({
playlistId: playlist.id,
attributes: {
videoId: video.id
}
})
})
expect(a.action.id).to.equal(VideoChannelActivityAction.UPDATE_ELEMENTS)
expect(a.targetType.id).to.equal(VideoChannelActivityTarget.PLAYLIST)
})
it('Should update elements in a playlist', async function () {
const a = await getActivityAfterAction(() => {
return server.playlists.updateElement({
playlistId: playlist.id,
elementId: playlistElement.id,
attributes: {
startTimestamp: 10
}
})
})
expect(a.action.id).to.equal(VideoChannelActivityAction.UPDATE_ELEMENTS)
expect(a.targetType.id).to.equal(VideoChannelActivityTarget.PLAYLIST)
})
it('Should delete elements in a playlist', async function () {
const a = await getActivityAfterAction(async () => {
await server.playlists.removeElement({
playlistId: playlist.id,
elementId: playlistElement.id
})
})
expect(a.action.id).to.equal(VideoChannelActivityAction.UPDATE_ELEMENTS)
expect(a.targetType.id).to.equal(VideoChannelActivityTarget.PLAYLIST)
})
it('Should delete the playlist', async function () {
const a = await getActivityAfterAction(() => {
return server.playlists.delete({ playlistId: playlist.id })
})
expect(a.action.id).to.equal(VideoChannelActivityAction.DELETE)
expect(a.targetType.id).to.equal(VideoChannelActivityTarget.PLAYLIST)
expect(a.playlist.name).to.equal('My playlist updated')
})
it('Should assign a new channel to a playlist without channel', async function () {
const playlist = await server.playlists.create({
attributes: {
displayName: 'With a new channel',
privacy: VideoPlaylistPrivacy.PRIVATE
}
})
const a = await getActivityAfterAction(() => {
return server.playlists.update({ playlistId: playlist.id, attributes: { videoChannelId: channelId } })
})
expect(a.action.id).to.equal(VideoChannelActivityAction.CREATE_CHANNEL_OWNERSHIP)
expect(a.targetType.id).to.equal(VideoChannelActivityTarget.PLAYLIST)
expect(a.playlist.name).to.equal('With a new channel')
})
it('Should delete the channel of a playlist', async function () {
const playlist = await server.playlists.create({
attributes: {
displayName: 'Playlist channel deleted',
videoChannelId: channelId,
privacy: VideoPlaylistPrivacy.UNLISTED
}
})
const a = await getActivityAfterAction(() => {
return server.playlists.update({ playlistId: playlist.id, attributes: { videoChannelId: 'null' as any } })
})
expect(a.action.id).to.equal(VideoChannelActivityAction.REMOVE_CHANNEL_OWNERSHIP)
expect(a.targetType.id).to.equal(VideoChannelActivityTarget.PLAYLIST)
expect(a.playlist.name).to.equal('Playlist channel deleted')
})
it('Should update the channel of a playlist', async function () {
{
const playlist = await server.playlists.create({
attributes: {
displayName: 'Playlist channel updated',
videoChannelId: channelId,
privacy: VideoPlaylistPrivacy.PUBLIC
}
})
const a = await getActivityAfterAction(() => {
return server.playlists.update({ playlistId: playlist.id, attributes: { videoChannelId: channelId2 } })
})
expect(a.action.id).to.equal(VideoChannelActivityAction.REMOVE_CHANNEL_OWNERSHIP)
expect(a.targetType.id).to.equal(VideoChannelActivityTarget.PLAYLIST)
expect(a.playlist.name).to.equal('Playlist channel updated')
}
{
const { data } = await server.channels.listActivities({ channelName: 'mero', sort: '-createdAt' })
const a2 = data[0]
expect(a2.action.id).to.equal(VideoChannelActivityAction.CREATE_CHANNEL_OWNERSHIP)
expect(a2.targetType.id).to.equal(VideoChannelActivityTarget.PLAYLIST)
expect(a2.playlist.name).to.equal('Playlist channel updated')
}
})
})
describe('Channel sync', function () {
let syncId: number
before(async function () {
await server.config.enableChannelSync()
})
it('Should create a channel sync', async function () {
const a = await getActivityAfterAction(async () => {
const { videoChannelSync } = await server.channelSyncs.create({
attributes: {
externalChannelUrl: FIXTURE_URLS.youtubePlaylist,
videoChannelId: channelId
}
})
syncId = videoChannelSync.id
})
expect(a.action.id).to.equal(VideoChannelActivityAction.CREATE)
expect(a.targetType.id).to.equal(VideoChannelActivityTarget.CHANNEL_SYNC)
expect(a.channelSync.id).to.exist
expect(a.channelSync.externalChannelUrl).to.equal(FIXTURE_URLS.youtubePlaylist)
})
it('Should delete a channel sync', async function () {
const a = await getActivityAfterAction(() => {
return server.channelSyncs.delete({ channelSyncId: syncId })
})
expect(a.action.id).to.equal(VideoChannelActivityAction.DELETE)
expect(a.targetType.id).to.equal(VideoChannelActivityTarget.CHANNEL_SYNC)
expect(a.channelSync.externalChannelUrl).to.equal(FIXTURE_URLS.youtubePlaylist)
})
})
describe('Common', function () {
let channelId: number
before(async function () {
await server.channels.create({ attributes: { name: 'soprano_channel', displayName: 'Soprano' } })
await server.channels.update({
channelName: 'soprano_channel',
attributes: {
description: 'This is the new description'
}
})
channelId = await server.channels.getIdOf({ channelName: 'soprano_channel' })
const { uuid } = await server.videos.quickUpload({
name: 'Inaya',
channelId
})
await server.videos.remove({ id: uuid })
})
it('Should correctly paginate activities', async function () {
const { data, total } = await server.channels.listActivities({ channelName: 'soprano_channel', start: 1, count: 1 })
expect(total).to.equal(3)
expect(data).to.have.lengthOf(1)
expect(data[0].targetType.id).to.equal(VideoChannelActivityTarget.VIDEO)
expect(data[0].action.id).to.equal(VideoChannelActivityAction.CREATE)
{
const { data, total } = await server.channels.listActivities({ channelName: 'soprano_channel', start: 1, count: 2 })
expect(total).to.equal(3)
expect(data).to.have.lengthOf(2)
}
})
it('Should correctly sort activities', async function () {
{
const { data, total } = await server.channels.listActivities({
channelName: 'soprano_channel',
sort: 'createdAt',
start: 0,
count: 1
})
expect(total).to.equal(3)
expect(data).to.have.lengthOf(1)
expect(data[0].targetType.id).to.equal(VideoChannelActivityTarget.CHANNEL)
expect(data[0].action.id).to.equal(VideoChannelActivityAction.UPDATE)
}
{
const { data, total } = await server.channels.listActivities({
channelName: 'soprano_channel',
sort: '-createdAt',
start: 0,
count: 1
})
expect(total).to.equal(3)
expect(data).to.have.lengthOf(1)
expect(data[0].targetType.id).to.equal(VideoChannelActivityTarget.VIDEO)
expect(data[0].action.id).to.equal(VideoChannelActivityAction.DELETE)
}
})
it('Should still display activities if the account is deleted', async function () {
const editor2Token = await server.channelCollaborators.createEditor('editor2', 'soprano_channel')
await server.videos.quickUpload({ name: 'deleted account', channelId, token: editor2Token })
await server.users.deleteMe({ token: editor2Token })
const { data } = await server.channels.listActivities({
channelName: 'soprano_channel',
sort: '-createdAt',
start: 0,
count: 1
})
expect(data).to.have.lengthOf(1)
const a = data[0]
expect(a.targetType.id).to.equal(VideoChannelActivityTarget.VIDEO)
expect(a.action.id).to.equal(VideoChannelActivityAction.CREATE)
expect(a.account).to.not.exist
expect(a.video.name).to.equal('deleted account')
})
})
after(async function () {
await cleanupTests([ server ])
})
})

View File

@@ -1,3 +1,4 @@
import './channel-activities.js'
import './channel-import-videos.js'
import './generate-download.js'
import './multiple-servers.js'

View File

@@ -135,7 +135,7 @@ describe('Test video playlists', function () {
await commands[0].create({
attributes: {
displayName: 'my super playlist',
displayName: 'my normal playlist',
privacy: VideoPlaylistPrivacy.PUBLIC,
description: 'my super description',
thumbnailfile: 'custom-thumbnail.jpg',
@@ -176,7 +176,7 @@ describe('Test video playlists', function () {
expect(body.data).to.have.lengthOf(1)
playlist = body.data[0]
expect(playlist.displayName).to.equal('my super playlist')
expect(playlist.displayName).to.equal('my normal playlist')
expect(playlist.privacy.id).to.equal(VideoPlaylistPrivacy.PUBLIC)
expect(playlist.type.id).to.equal(VideoPlaylistType.REGULAR)
}

View File

@@ -39,7 +39,7 @@ describe('Official plugin auth-openid-connect', function () {
const peertubeRes = await getOpenIdUrl(openIdLoginUrl)
const kcRes = await loginOnKeycloak({ loginPageUrl: extractLocation(peertubeRes) })
const ptBypassPath = await sendBackKeycloakCode({ peertubeRes, kcRes })
const ptBypassPath = await sendBackKeycloakCode({ peertubeRes, kcRes, success: true })
const externalAuthToken = new URL(ptBypassPath, server.url).searchParams.get('externalAuthToken')
const { body } = await server.login.loginUsingExternalToken({ username: 'myuser_example.com', externalAuthToken })
@@ -75,7 +75,7 @@ describe('Official plugin auth-openid-connect', function () {
const peertubeRes = await getOpenIdUrl(openIdLoginUrl, 'http://example.com')
const kcRes = await loginOnKeycloak({ loginPageUrl: extractLocation(peertubeRes) })
const ptBypassPath = await sendBackKeycloakCode({ peertubeRes, kcRes, redirectUrl: 'http://example.com' })
const ptBypassPath = await sendBackKeycloakCode({ peertubeRes, kcRes, redirectUrl: 'http://example.com', success: true })
const externalAuthToken = new URL(ptBypassPath, server.url).searchParams.get('externalAuthToken')
const { body } = await server.login.loginUsingExternalToken({ username: 'myuser_example.com', externalAuthToken })
@@ -102,7 +102,8 @@ describe('Official plugin auth-openid-connect', function () {
const peertubeRes = await getOpenIdUrl(openIdLoginUrl)
const kcRes = await loginOnKeycloak({ loginPageUrl: extractLocation(peertubeRes), username: 'user_group1' })
await sendBackKeycloakCode({ peertubeRes, kcRes, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
const redirectUrl = await sendBackKeycloakCode({ peertubeRes, kcRes, success: false })
expect(redirectUrl).to.equal('/login?externalAuthError=true')
}
{
@@ -113,7 +114,7 @@ describe('Official plugin auth-openid-connect', function () {
const peertubeRes = await getOpenIdUrl(openIdLoginUrl)
const kcRes = await loginOnKeycloak({ loginPageUrl: extractLocation(peertubeRes), username: 'user_group1' })
await sendBackKeycloakCode({ peertubeRes, kcRes, expectedStatus: HttpStatusCode.FOUND_302, username: 'user_group1_example.com' })
await sendBackKeycloakCode({ peertubeRes, kcRes, username: 'user_group1_example.com', success: true })
}
{
@@ -124,7 +125,8 @@ describe('Official plugin auth-openid-connect', function () {
const peertubeRes = await getOpenIdUrl(openIdLoginUrl)
const kcRes = await loginOnKeycloak({ loginPageUrl: extractLocation(peertubeRes), username: 'user_group1' })
await sendBackKeycloakCode({ peertubeRes, kcRes, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
const redirectUrl = await sendBackKeycloakCode({ peertubeRes, kcRes, success: false })
expect(redirectUrl).to.equal('/login?externalAuthError=true')
}
})
@@ -134,7 +136,7 @@ describe('Official plugin auth-openid-connect', function () {
{
const peertubeRes = await getOpenIdUrl(openIdLoginUrl)
const kcRes = await loginOnKeycloak({ loginPageUrl: extractLocation(peertubeRes), username: 'moderator' })
const ptBypassPath = await sendBackKeycloakCode({ peertubeRes, kcRes, username: 'moderator_example.com' })
const ptBypassPath = await sendBackKeycloakCode({ peertubeRes, kcRes, username: 'moderator_example.com', success: true })
const externalAuthToken = new URL(ptBypassPath, server.url).searchParams.get('externalAuthToken')
@@ -146,7 +148,7 @@ describe('Official plugin auth-openid-connect', function () {
{
const peertubeRes = await getOpenIdUrl(openIdLoginUrl)
const kcRes = await loginOnKeycloak({ loginPageUrl: extractLocation(peertubeRes), username: 'user_group2' })
const ptBypassPath = await sendBackKeycloakCode({ peertubeRes, kcRes, username: 'user_group2_example.com' })
const ptBypassPath = await sendBackKeycloakCode({ peertubeRes, kcRes, username: 'user_group2_example.com', success: true })
const externalAuthToken = new URL(ptBypassPath, server.url).searchParams.get('externalAuthToken')
@@ -240,11 +242,11 @@ async function loginOnKeycloak (options: {
async function sendBackKeycloakCode (options: {
peertubeRes: Response
kcRes: Response
success: boolean
redirectUrl?: string
expectedStatus?: HttpStatusCodeType
username?: string
}) {
const { peertubeRes, kcRes, redirectUrl = '/login', expectedStatus = HttpStatusCode.FOUND_302, username = 'myuser_example.com' } = options
const { peertubeRes, kcRes, redirectUrl = '/login', success, username = 'myuser_example.com' } = options
const kcText = kcRes.text
@@ -261,15 +263,16 @@ async function sendBackKeycloakCode (options: {
state: extractInputValue(kcText, 'state'),
session_state: extractInputValue(kcText, 'session_state')
},
expectedStatus
expectedStatus: HttpStatusCode.FOUND_302
})
if (expectedStatus !== HttpStatusCode.FOUND_302) return undefined
const ptBypassPath = res.headers['location']
expect(ptBypassPath).to.include(redirectUrl)
expect(ptBypassPath).to.include('?externalAuthToken=')
expect(ptBypassPath).to.include('username=' + username)
if (success) {
expect(ptBypassPath).to.include(redirectUrl)
expect(ptBypassPath).to.include('?externalAuthToken=')
expect(ptBypassPath).to.include('username=' + username)
}
return ptBypassPath
}

View File

@@ -93,7 +93,7 @@ async function updateVideoPlayerSettings (req: express.Request, res: express.Res
const body: PlayerVideoSettingsUpdate = req.body
const video = res.locals.videoAll
const setting = await upsertPlayerSettings({ settings: body, channel: undefined, video })
const setting = await upsertPlayerSettings({ user: res.locals.oauth.token.User, settings: body, channel: undefined, video })
await sendUpdateVideoPlayerSettings(video, setting, undefined)
@@ -104,7 +104,7 @@ async function updateChannelPlayerSettings (req: express.Request, res: express.R
const body: PlayerChannelSettingsUpdate = req.body
const channel = res.locals.videoChannel
const settings = await upsertPlayerSettings({ settings: body, channel, video: undefined })
const settings = await upsertPlayerSettings({ user: res.locals.oauth.token.User, settings: body, channel, video: undefined })
await sendUpdateChannelPlayerSettings(channel, settings, undefined)

View File

@@ -1,6 +1,7 @@
import { HttpStatusCode, VideoChannelSyncState } from '@peertube/peertube-models'
import { HttpStatusCode, VideoChannelActivityAction, VideoChannelSyncState } from '@peertube/peertube-models'
import { auditLoggerFactory, getAuditIdFromRes, VideoChannelSyncAuditView } from '@server/helpers/audit-logger.js'
import { logger } from '@server/helpers/logger.js'
import { sequelizeTypescript } from '@server/initializers/database.js'
import {
apiRateLimiter,
asyncMiddleware,
@@ -10,6 +11,7 @@ import {
ensureSyncIsEnabled,
videoChannelSyncValidator
} from '@server/middlewares/index.js'
import { VideoChannelActivityModel } from '@server/models/video/video-channel-activity.js'
import { VideoChannelSyncModel } from '@server/models/video/video-channel-sync.js'
import { MChannelSyncFormattable } from '@server/types/models/index.js'
import express from 'express'
@@ -45,8 +47,18 @@ async function createVideoChannelSync (req: express.Request, res: express.Respon
state: VideoChannelSyncState.WAITING_FIRST_RUN
})
await syncCreated.save()
syncCreated.VideoChannel = res.locals.videoChannel
await sequelizeTypescript.transaction(async transaction => {
await syncCreated.save({ transaction })
syncCreated.VideoChannel = res.locals.videoChannel
await VideoChannelActivityModel.addChannelSyncActivity({
action: VideoChannelActivityAction.CREATE,
user: res.locals.oauth.token.User,
channel: res.locals.videoChannel,
sync: syncCreated,
transaction
})
})
auditLogger.create(getAuditIdFromRes(res), new VideoChannelSyncAuditView(syncCreated.toFormattedJSON()))
@@ -64,7 +76,17 @@ async function createVideoChannelSync (req: express.Request, res: express.Respon
async function removeVideoChannelSync (req: express.Request, res: express.Response) {
const syncInstance = res.locals.videoChannelSync
await syncInstance.destroy()
await sequelizeTypescript.transaction(async transaction => {
await syncInstance.destroy({ transaction })
await VideoChannelActivityModel.addChannelSyncActivity({
action: VideoChannelActivityAction.DELETE,
user: res.locals.oauth.token.User,
channel: res.locals.videoChannel,
sync: syncInstance,
transaction
})
})
auditLogger.delete(getAuditIdFromRes(res), new VideoChannelSyncAuditView(syncInstance.toFormattedJSON()))

View File

@@ -1,6 +1,6 @@
import {
ActorImageType,
HttpStatusCode,
VideoChannelActivityAction,
VideoChannelCreate,
VideoChannelUpdate,
VideoPlaylistForAccountListQuery,
@@ -12,18 +12,17 @@ import { Hooks } from '@server/lib/plugins/hooks.js'
import { reorderPlaylistOrElementsPosition, sendPlaylistPositionUpdateOfChannel } from '@server/lib/video-playlist.js'
import { ActorFollowModel } from '@server/models/actor/actor-follow.js'
import { getServerActor } from '@server/models/application/application.js'
import { VideoChannelActivityModel } from '@server/models/video/video-channel-activity.js'
import { MChannelBannerAccountDefault } from '@server/types/models/index.js'
import express from 'express'
import { auditLoggerFactory, getAuditIdFromRes, VideoChannelAuditView } from '../../../helpers/audit-logger.js'
import { resetSequelizeInstance } from '../../../helpers/database-utils.js'
import { buildNSFWFilters, createReqFiles, getCountVideos, isUserAbleToSearchRemoteURI } from '../../../helpers/express-utils.js'
import { buildNSFWFilters, getCountVideos, isUserAbleToSearchRemoteURI } from '../../../helpers/express-utils.js'
import { logger } from '../../../helpers/logger.js'
import { getFormattedObjects } from '../../../helpers/utils.js'
import { MIMETYPES } from '../../../initializers/constants.js'
import { sequelizeTypescript } from '../../../initializers/database.js'
import { sendUpdateActor } from '../../../lib/activitypub/send/index.js'
import { JobQueue } from '../../../lib/job-queue/index.js'
import { deleteLocalActorImageFile, updateLocalActorImageFiles } from '../../../lib/local-actor.js'
import { createLocalVideoChannelWithoutKeys, federateAllVideosOfChannel } from '../../../lib/video-channel.js'
import {
apiRateLimiter,
@@ -42,11 +41,10 @@ import {
videoChannelsUpdateValidator,
videoPlaylistsSortValidator
} from '../../../middlewares/index.js'
import { updateAvatarValidator, updateBannerValidator } from '../../../middlewares/validators/actor-image.js'
import {
ensureChannelOwnerCanUpload,
videoChannelActivitiesSortValidator,
videoChannelFollowersSortValidator,
videoChannelImportVideosValidator,
videoChannelsFollowersSortValidator,
videoChannelsHandleValidatorFactory,
videoChannelsListValidator,
videosSortValidator
@@ -61,15 +59,15 @@ import { VideoChannelModel } from '../../../models/video/video-channel.js'
import { VideoPlaylistModel } from '../../../models/video/video-playlist.js'
import { VideoModel } from '../../../models/video/video.js'
import { channelCollaborators } from './video-channel-collaborators.js'
import { videoChannelLogosRouter } from './video-channel-logos.js'
const auditLogger = auditLoggerFactory('channels')
const reqAvatarFile = createReqFiles([ 'avatarfile' ], MIMETYPES.IMAGE.MIMETYPE_EXT)
const reqBannerFile = createReqFiles([ 'bannerfile' ], MIMETYPES.IMAGE.MIMETYPE_EXT)
const videoChannelRouter = express.Router()
videoChannelRouter.use(apiRateLimiter)
videoChannelRouter.use(channelCollaborators)
videoChannelRouter.use(videoChannelLogosRouter)
videoChannelRouter.get(
'/',
@@ -83,38 +81,6 @@ videoChannelRouter.get(
videoChannelRouter.post('/', authenticate, asyncMiddleware(videoChannelsAddValidator), asyncRetryTransactionMiddleware(createVideoChannel))
videoChannelRouter.post(
'/:handle/avatar/pick',
authenticate,
reqAvatarFile,
asyncMiddleware(videoChannelsHandleValidatorFactory({ checkIsLocal: true, checkCanManage: true, checkIsOwner: false })),
updateAvatarValidator,
asyncMiddleware(updateVideoChannelAvatar)
)
videoChannelRouter.post(
'/:handle/banner/pick',
authenticate,
reqBannerFile,
asyncMiddleware(videoChannelsHandleValidatorFactory({ checkIsLocal: true, checkCanManage: true, checkIsOwner: false })),
updateBannerValidator,
asyncMiddleware(updateVideoChannelBanner)
)
videoChannelRouter.delete(
'/:handle/avatar',
authenticate,
asyncMiddleware(videoChannelsHandleValidatorFactory({ checkIsLocal: true, checkCanManage: true, checkIsOwner: false })),
asyncMiddleware(deleteVideoChannelAvatar)
)
videoChannelRouter.delete(
'/:handle/banner',
authenticate,
asyncMiddleware(videoChannelsHandleValidatorFactory({ checkIsLocal: true, checkCanManage: true, checkIsOwner: false })),
asyncMiddleware(deleteVideoChannelBanner)
)
videoChannelRouter.put(
'/:handle',
authenticate,
@@ -178,18 +144,28 @@ videoChannelRouter.get(
authenticate,
asyncMiddleware(videoChannelsHandleValidatorFactory({ checkIsLocal: false, checkCanManage: true, checkIsOwner: false })),
paginationValidator,
videoChannelsFollowersSortValidator,
videoChannelFollowersSortValidator,
setDefaultSort,
setDefaultPagination,
asyncMiddleware(listVideoChannelFollowers)
)
videoChannelRouter.get(
'/:handle/activities',
authenticate,
asyncMiddleware(videoChannelsHandleValidatorFactory({ checkIsLocal: true, checkCanManage: true, checkIsOwner: false })),
paginationValidator,
videoChannelActivitiesSortValidator,
setDefaultSort,
setDefaultPagination,
asyncMiddleware(listVideoChannelActivities)
)
videoChannelRouter.post(
'/:handle/import-videos',
authenticate,
asyncMiddleware(videoChannelsHandleValidatorFactory({ checkIsLocal: true, checkCanManage: true, checkIsOwner: false })),
asyncMiddleware(videoChannelImportVideosValidator),
asyncMiddleware(ensureChannelOwnerCanUpload),
asyncMiddleware(importVideosInChannel)
)
@@ -220,60 +196,6 @@ async function listVideoChannels (req: express.Request, res: express.Response) {
return res.json(getFormattedObjects(resultList.data, resultList.total))
}
async function updateVideoChannelBanner (req: express.Request, res: express.Response) {
const bannerPhysicalFile = req.files['bannerfile'][0]
const videoChannel = res.locals.videoChannel
const oldVideoChannelAuditKeys = new VideoChannelAuditView(videoChannel.toFormattedJSON())
const banners = await updateLocalActorImageFiles({
accountOrChannel: videoChannel,
imagePhysicalFile: bannerPhysicalFile,
type: ActorImageType.BANNER,
sendActorUpdate: true
})
auditLogger.update(getAuditIdFromRes(res), new VideoChannelAuditView(videoChannel.toFormattedJSON()), oldVideoChannelAuditKeys)
return res.json({
banners: banners.map(b => b.toFormattedJSON())
})
}
async function updateVideoChannelAvatar (req: express.Request, res: express.Response) {
const avatarPhysicalFile = req.files['avatarfile'][0]
const videoChannel = res.locals.videoChannel
const oldVideoChannelAuditKeys = new VideoChannelAuditView(videoChannel.toFormattedJSON())
const avatars = await updateLocalActorImageFiles({
accountOrChannel: videoChannel,
imagePhysicalFile: avatarPhysicalFile,
type: ActorImageType.AVATAR,
sendActorUpdate: true
})
auditLogger.update(getAuditIdFromRes(res), new VideoChannelAuditView(videoChannel.toFormattedJSON()), oldVideoChannelAuditKeys)
return res.json({
avatars: avatars.map(a => a.toFormattedJSON())
})
}
async function deleteVideoChannelAvatar (req: express.Request, res: express.Response) {
const videoChannel = res.locals.videoChannel
await deleteLocalActorImageFile(videoChannel, ActorImageType.AVATAR)
return res.status(HttpStatusCode.NO_CONTENT_204).end()
}
async function deleteVideoChannelBanner (req: express.Request, res: express.Response) {
const videoChannel = res.locals.videoChannel
await deleteLocalActorImageFile(videoChannel, ActorImageType.BANNER)
return res.status(HttpStatusCode.NO_CONTENT_204).end()
}
async function createVideoChannel (req: express.Request, res: express.Response) {
const videoChannelInfo: VideoChannelCreate = req.body
@@ -330,6 +252,13 @@ async function updateVideoChannel (req: express.Request, res: express.Response)
oldVideoChannelAuditKeys
)
await VideoChannelActivityModel.addChannelActivity({
action: VideoChannelActivityAction.UPDATE,
user: res.locals.oauth.token.User,
channel: videoChannelInstanceUpdated,
transaction: t
})
Hooks.runAction('action:api.video-channel.updated', { videoChannel: videoChannelInstanceUpdated, req, res })
logger.info('Video channel %s updated.', videoChannelInstance.Actor.url)
@@ -499,9 +428,24 @@ async function listVideoChannelFollowers (req: express.Request, res: express.Res
return res.json(getFormattedObjects(resultList.data, resultList.total))
}
async function listVideoChannelActivities (req: express.Request, res: express.Response) {
const channel = res.locals.videoChannel
const resultList = await VideoChannelActivityModel.listForAPI({
channelId: channel.id,
start: req.query.start,
count: req.query.count,
sort: req.query.sort
})
return res.json(getFormattedObjects(resultList.data, resultList.total))
}
async function importVideosInChannel (req: express.Request, res: express.Response) {
const { externalChannelUrl } = req.body as VideosImportInChannelCreate
// TODO: add channel activity for this endpoint
await JobQueue.Instance.createJob({
type: 'video-channel-import',
payload: {

View File

@@ -0,0 +1,138 @@
import { ActorImageType, HttpStatusCode, VideoChannelActivityAction } from '@peertube/peertube-models'
import { VideoChannelActivityModel } from '@server/models/video/video-channel-activity.js'
import express from 'express'
import { auditLoggerFactory, getAuditIdFromRes, VideoChannelAuditView } from '../../../helpers/audit-logger.js'
import { createReqFiles } from '../../../helpers/express-utils.js'
import { MIMETYPES } from '../../../initializers/constants.js'
import { deleteLocalActorImageFile, updateLocalActorImageFiles } from '../../../lib/local-actor.js'
import { asyncMiddleware, authenticate } from '../../../middlewares/index.js'
import { updateAvatarValidator, updateBannerValidator } from '../../../middlewares/validators/actor-image.js'
import { videoChannelsHandleValidatorFactory } from '../../../middlewares/validators/index.js'
const auditLogger = auditLoggerFactory('channels')
const reqAvatarFile = createReqFiles([ 'avatarfile' ], MIMETYPES.IMAGE.MIMETYPE_EXT)
const reqBannerFile = createReqFiles([ 'bannerfile' ], MIMETYPES.IMAGE.MIMETYPE_EXT)
const videoChannelLogosRouter = express.Router()
videoChannelLogosRouter.post(
'/:handle/avatar/pick',
authenticate,
reqAvatarFile,
asyncMiddleware(videoChannelsHandleValidatorFactory({ checkIsLocal: true, checkCanManage: true, checkIsOwner: false })),
updateAvatarValidator,
asyncMiddleware(updateVideoChannelAvatar)
)
videoChannelLogosRouter.post(
'/:handle/banner/pick',
authenticate,
reqBannerFile,
asyncMiddleware(videoChannelsHandleValidatorFactory({ checkIsLocal: true, checkCanManage: true, checkIsOwner: false })),
updateBannerValidator,
asyncMiddleware(updateVideoChannelBanner)
)
videoChannelLogosRouter.delete(
'/:handle/avatar',
authenticate,
asyncMiddleware(videoChannelsHandleValidatorFactory({ checkIsLocal: true, checkCanManage: true, checkIsOwner: false })),
asyncMiddleware(deleteVideoChannelAvatar)
)
videoChannelLogosRouter.delete(
'/:handle/banner',
authenticate,
asyncMiddleware(videoChannelsHandleValidatorFactory({ checkIsLocal: true, checkCanManage: true, checkIsOwner: false })),
asyncMiddleware(deleteVideoChannelBanner)
)
// ---------------------------------------------------------------------------
export {
videoChannelLogosRouter
}
// ---------------------------------------------------------------------------
async function updateVideoChannelBanner (req: express.Request, res: express.Response) {
const bannerPhysicalFile = req.files['bannerfile'][0]
const videoChannel = res.locals.videoChannel
const oldVideoChannelAuditKeys = new VideoChannelAuditView(videoChannel.toFormattedJSON())
const banners = await updateLocalActorImageFiles({
accountOrChannel: videoChannel,
imagePhysicalFile: bannerPhysicalFile,
type: ActorImageType.BANNER,
sendActorUpdate: true
})
await VideoChannelActivityModel.addChannelActivity({
action: VideoChannelActivityAction.UPDATE,
user: res.locals.oauth.token.User,
channel: videoChannel,
transaction: undefined
})
auditLogger.update(getAuditIdFromRes(res), new VideoChannelAuditView(videoChannel.toFormattedJSON()), oldVideoChannelAuditKeys)
return res.json({
banners: banners.map(b => b.toFormattedJSON())
})
}
async function updateVideoChannelAvatar (req: express.Request, res: express.Response) {
const avatarPhysicalFile = req.files['avatarfile'][0]
const videoChannel = res.locals.videoChannel
const oldVideoChannelAuditKeys = new VideoChannelAuditView(videoChannel.toFormattedJSON())
const avatars = await updateLocalActorImageFiles({
accountOrChannel: videoChannel,
imagePhysicalFile: avatarPhysicalFile,
type: ActorImageType.AVATAR,
sendActorUpdate: true
})
await VideoChannelActivityModel.addChannelActivity({
action: VideoChannelActivityAction.UPDATE,
user: res.locals.oauth.token.User,
channel: videoChannel,
transaction: undefined
})
auditLogger.update(getAuditIdFromRes(res), new VideoChannelAuditView(videoChannel.toFormattedJSON()), oldVideoChannelAuditKeys)
return res.json({
avatars: avatars.map(a => a.toFormattedJSON())
})
}
async function deleteVideoChannelAvatar (req: express.Request, res: express.Response) {
const videoChannel = res.locals.videoChannel
await deleteLocalActorImageFile(videoChannel, ActorImageType.AVATAR)
await VideoChannelActivityModel.addChannelActivity({
action: VideoChannelActivityAction.UPDATE,
user: res.locals.oauth.token.User,
channel: videoChannel,
transaction: undefined
})
return res.status(HttpStatusCode.NO_CONTENT_204).end()
}
async function deleteVideoChannelBanner (req: express.Request, res: express.Response) {
const videoChannel = res.locals.videoChannel
await deleteLocalActorImageFile(videoChannel, ActorImageType.BANNER)
await VideoChannelActivityModel.addChannelActivity({
action: VideoChannelActivityAction.UPDATE,
user: res.locals.oauth.token.User,
channel: videoChannel,
transaction: undefined
})
return res.status(HttpStatusCode.NO_CONTENT_204).end()
}

View File

@@ -1,6 +1,7 @@
import { forceNumber } from '@peertube/peertube-core-utils'
import {
HttpStatusCode,
VideoChannelActivityAction,
VideoPlaylistCreate,
VideoPlaylistCreateResult,
VideoPlaylistElementCreate,
@@ -20,6 +21,7 @@ import {
sendPlaylistPositionUpdateOfChannel
} from '@server/lib/video-playlist.js'
import { getServerActor } from '@server/models/application/application.js'
import { VideoChannelActivityModel } from '@server/models/video/video-channel-activity.js'
import { MVideoPlaylistFull, MVideoPlaylistThumbnail } from '@server/types/models/index.js'
import express from 'express'
import { resetSequelizeInstance, retryTransactionWrapper } from '../../helpers/database-utils.js'
@@ -186,9 +188,9 @@ async function createVideoPlaylist (req: express.Request, res: express.Response)
videoPlaylist.url = getLocalVideoPlaylistActivityPubUrl(videoPlaylist) // We use the UUID, so set the URL after building the object
if (videoPlaylistInfo.videoChannelId) {
const videoChannel = res.locals.videoChannel
const videoChannel = res.locals.videoChannel
if (videoChannel && videoPlaylistInfo.videoChannelId) {
videoPlaylist.videoChannelId = videoChannel.id
videoPlaylist.VideoChannel = videoChannel
}
@@ -221,6 +223,16 @@ async function createVideoPlaylist (req: express.Request, res: express.Response)
videoPlaylistCreated.OwnerAccount = await AccountModel.load(user.Account.id, t)
await sendCreateVideoPlaylist(videoPlaylistCreated, t)
if (videoChannel) {
await VideoChannelActivityModel.addPlaylistActivity({
action: VideoChannelActivityAction.CREATE,
user,
channel: videoChannel,
playlist: videoPlaylistCreated,
transaction: t
})
}
return videoPlaylistCreated
})
})
@@ -238,7 +250,7 @@ async function createVideoPlaylist (req: express.Request, res: express.Response)
async function updateVideoPlaylist (req: express.Request, res: express.Response) {
const playlist = res.locals.videoPlaylistFull
const videoPlaylistInfoToUpdate = req.body as VideoPlaylistUpdate
const body = req.body as VideoPlaylistUpdate
const wasPrivatePlaylist = playlist.privacy === VideoPlaylistPrivacy.PRIVATE
const wasNotPrivatePlaylist = playlist.privacy !== VideoPlaylistPrivacy.PRIVATE
@@ -256,39 +268,59 @@ async function updateVideoPlaylist (req: express.Request, res: express.Response)
try {
await sequelizeTypescript.transaction(async t => {
if (videoPlaylistInfoToUpdate.videoChannelId !== undefined) {
if (videoPlaylistInfoToUpdate.videoChannelId === null) {
removedFromChannel = {
id: playlist.videoChannelId,
position: playlist.videoChannelPosition
}
const newChannel = res.locals.videoChannel
const user = res.locals.oauth.token.User
playlist.videoChannelId = null
} else {
const videoChannel = res.locals.videoChannel
// Had a channel, but the user changed it (to null or another channel)
if (playlist.videoChannelId && body.videoChannelId !== undefined && body.videoChannelId !== playlist.videoChannelId) {
await VideoChannelActivityModel.addPlaylistActivity({
action: VideoChannelActivityAction.REMOVE_CHANNEL_OWNERSHIP,
user,
channel: playlist.VideoChannel,
playlist,
transaction: t
})
if (playlist.videoChannelId !== videoPlaylistInfoToUpdate.videoChannelId) {
removedFromChannel = {
id: playlist.videoChannelId,
position: playlist.videoChannelPosition
}
playlist.videoChannelPosition = await VideoPlaylistModel.getNextPositionOf({
videoChannelId: videoChannel.id,
transaction: t
})
}
playlist.videoChannelId = videoChannel.id
playlist.VideoChannel = videoChannel
removedFromChannel = {
id: playlist.videoChannelId,
position: playlist.videoChannelPosition
}
playlist.videoChannelId = null
playlist.VideoChannel = null
}
if (videoPlaylistInfoToUpdate.displayName !== undefined) playlist.name = videoPlaylistInfoToUpdate.displayName
if (videoPlaylistInfoToUpdate.description !== undefined) playlist.description = videoPlaylistInfoToUpdate.description
if (newChannel && newChannel.id !== playlist.videoChannelId) {
await VideoChannelActivityModel.addPlaylistActivity({
action: VideoChannelActivityAction.CREATE_CHANNEL_OWNERSHIP,
user,
channel: newChannel,
playlist,
transaction: t
})
if (videoPlaylistInfoToUpdate.privacy !== undefined) {
playlist.privacy = forceNumber(videoPlaylistInfoToUpdate.privacy) as VideoPlaylistPrivacyType
playlist.videoChannelPosition = await VideoPlaylistModel.getNextPositionOf({
videoChannelId: newChannel.id,
transaction: t
})
playlist.videoChannelId = newChannel.id
playlist.VideoChannel = newChannel
} else if (newChannel) {
await VideoChannelActivityModel.addPlaylistActivity({
action: VideoChannelActivityAction.UPDATE,
user: res.locals.oauth.token.User,
channel: newChannel,
playlist,
transaction: t
})
}
if (body.displayName !== undefined) playlist.name = body.displayName
if (body.description !== undefined) playlist.description = body.description
if (body.privacy !== undefined) {
playlist.privacy = forceNumber(body.privacy) as VideoPlaylistPrivacyType
if (wasNotPrivatePlaylist === true && playlist.privacy === VideoPlaylistPrivacy.PRIVATE) {
await sendDeleteVideoPlaylist(playlist, t)
@@ -360,6 +392,14 @@ async function removeVideoPlaylist (req: express.Request, res: express.Response)
if (videoPlaylistInstance.videoChannelId) {
await sendPlaylistPositionUpdateOfChannel(videoPlaylistInstance.videoChannelId, t)
await VideoChannelActivityModel.addPlaylistActivity({
action: VideoChannelActivityAction.DELETE,
user: res.locals.oauth.token.User,
channel: videoPlaylistInstance.VideoChannel,
playlist: videoPlaylistInstance,
transaction: t
})
}
logger.info('Video playlist %s deleted.', videoPlaylistInstance.uuid)
@@ -394,6 +434,16 @@ async function addVideoInPlaylist (req: express.Request, res: express.Response)
videoPlaylist.changed('updatedAt', true)
await videoPlaylist.save({ transaction: t })
if (videoPlaylist.VideoChannel) {
await VideoChannelActivityModel.addPlaylistActivity({
action: VideoChannelActivityAction.UPDATE_ELEMENTS,
user: res.locals.oauth.token.User,
channel: videoPlaylist.VideoChannel,
playlist: videoPlaylist,
transaction: t
})
}
return playlistElement
})
@@ -432,6 +482,16 @@ async function updateVideoPlaylistElement (req: express.Request, res: express.Re
await sendUpdateVideoPlaylist(videoPlaylist, t)
if (videoPlaylist.VideoChannel) {
await VideoChannelActivityModel.addPlaylistActivity({
action: VideoChannelActivityAction.UPDATE_ELEMENTS,
user: res.locals.oauth.token.User,
channel: videoPlaylist.VideoChannel,
playlist: videoPlaylist,
transaction: t
})
}
return element
})
@@ -459,6 +519,16 @@ async function removeVideoFromPlaylist (req: express.Request, res: express.Respo
videoPlaylist.changed('updatedAt', true)
await videoPlaylist.save({ transaction: t })
if (videoPlaylist.VideoChannel) {
await VideoChannelActivityModel.addPlaylistActivity({
action: VideoChannelActivityAction.UPDATE_ELEMENTS,
user: res.locals.oauth.token.User,
channel: videoPlaylist.VideoChannel,
playlist: videoPlaylist,
transaction: t
})
}
logger.info('Video playlist element %d of playlist %s deleted.', videoPlaylistElement.position, videoPlaylist.uuid)
})

View File

@@ -1,4 +1,4 @@
import { HttpStatusCode, VideoCaptionGenerate } from '@peertube/peertube-models'
import { HttpStatusCode, VideoCaptionGenerate, VideoChannelActivityAction } from '@peertube/peertube-models'
import { retryTransactionWrapper } from '@server/helpers/database-utils.js'
import { Hooks } from '@server/lib/plugins/hooks.js'
import { createLocalCaption, createTranscriptionTaskIfNeeded, updateHLSMasterOnCaptionChangeIfNeeded } from '@server/lib/video-captions.js'
@@ -18,6 +18,7 @@ import {
listVideoCaptionsValidator
} from '../../../middlewares/validators/index.js'
import { VideoCaptionModel } from '../../../models/video/video-caption.js'
import { VideoChannelActivityModel } from '@server/models/video/video-channel-activity.js'
const lTags = loggerTagsFactory('api', 'video-caption')
@@ -94,7 +95,17 @@ async function createVideoCaption (req: express.Request, res: express.Response)
}
await retryTransactionWrapper(() => {
return sequelizeTypescript.transaction(t => federateVideoIfNeeded(video, false, t))
return sequelizeTypescript.transaction(async t => {
await VideoChannelActivityModel.addVideoActivity({
action: VideoChannelActivityAction.UPDATE_CAPTIONS,
user: res.locals.oauth.token.User,
channel: video.VideoChannel,
video,
transaction: t
})
return federateVideoIfNeeded(video, false, t)
})
})
Hooks.runAction('action:api.video-caption.created', { caption: videoCaption, req, res })
@@ -116,7 +127,17 @@ async function deleteVideoCaption (req: express.Request, res: express.Response)
}
await retryTransactionWrapper(() => {
return sequelizeTypescript.transaction(t => federateVideoIfNeeded(video, false, t))
return sequelizeTypescript.transaction(async t => {
await VideoChannelActivityModel.addVideoActivity({
action: VideoChannelActivityAction.UPDATE_CAPTIONS,
user: res.locals.oauth.token.User,
channel: video.VideoChannel,
video,
transaction: t
})
return federateVideoIfNeeded(video, false, t)
})
})
logger.info('Video caption %s of video %s deleted.', videoCaption.language, video.uuid, lTags(video.uuid))

View File

@@ -1,21 +1,24 @@
import { HttpStatusCode, VideoChannelActivityAction, VideoChapterUpdate } from '@peertube/peertube-models'
import { retryTransactionWrapper } from '@server/helpers/database-utils.js'
import { sequelizeTypescript } from '@server/initializers/database.js'
import { federateVideoIfNeeded } from '@server/lib/activitypub/videos/federate.js'
import { replaceChapters } from '@server/lib/video-chapters.js'
import { VideoChannelActivityModel } from '@server/models/video/video-channel-activity.js'
import { VideoChapterModel } from '@server/models/video/video-chapter.js'
import express from 'express'
import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate } from '../../../middlewares/index.js'
import { updateVideoChaptersValidator, videosCustomGetValidator } from '../../../middlewares/validators/index.js'
import { VideoChapterModel } from '@server/models/video/video-chapter.js'
import { HttpStatusCode, VideoChapterUpdate } from '@peertube/peertube-models'
import { sequelizeTypescript } from '@server/initializers/database.js'
import { retryTransactionWrapper } from '@server/helpers/database-utils.js'
import { federateVideoIfNeeded } from '@server/lib/activitypub/videos/federate.js'
import { replaceChapters } from '@server/lib/video-chapters.js'
const videoChaptersRouter = express.Router()
videoChaptersRouter.get('/:id/chapters',
videoChaptersRouter.get(
'/:id/chapters',
asyncMiddleware(videosCustomGetValidator('only-video-and-blacklist')),
asyncMiddleware(listVideoChapters)
)
videoChaptersRouter.put('/:videoId/chapters',
videoChaptersRouter.put(
'/:videoId/chapters',
authenticate,
asyncMiddleware(updateVideoChaptersValidator),
asyncRetryTransactionMiddleware(replaceVideoChapters)
@@ -43,6 +46,14 @@ async function replaceVideoChapters (req: express.Request, res: express.Response
return sequelizeTypescript.transaction(async t => {
await replaceChapters({ video, chapters: body.chapters, transaction: t })
await VideoChannelActivityModel.addVideoActivity({
action: VideoChannelActivityAction.UPDATE_CHAPTERS,
user: res.locals.oauth.token.User,
channel: video.VideoChannel,
video,
transaction: t
})
await federateVideoIfNeeded(video, false, t)
})
})

View File

@@ -1,7 +1,8 @@
import { HttpStatusCode } from '@peertube/peertube-models'
import { HttpStatusCode, VideoChannelActivityAction } from '@peertube/peertube-models'
import { pickCommonVideoQuery } from '@server/helpers/query.js'
import { openapiOperationDoc } from '@server/middlewares/doc.js'
import { getServerActor } from '@server/models/application/application.js'
import { VideoChannelActivityModel } from '@server/models/video/video-channel-activity.js'
import express from 'express'
import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger.js'
import { buildNSFWFilters, getCountVideos } from '../../../helpers/express-utils.js'
@@ -178,6 +179,14 @@ async function removeVideo (req: express.Request, res: express.Response) {
await sequelizeTypescript.transaction(async t => {
await videoInstance.destroy({ transaction: t })
await VideoChannelActivityModel.addVideoActivity({
action: VideoChannelActivityAction.DELETE,
user: res.locals.oauth.token.User,
channel: videoInstance.VideoChannel,
video: videoInstance,
transaction: t
})
})
auditLogger.delete(getAuditIdFromRes(res), new VideoAuditView(videoInstance.toFormattedDetailsJSON()))

View File

@@ -1,5 +1,13 @@
import { pick } from '@peertube/peertube-core-utils'
import { HttpStatusCode, LiveVideoCreate, LiveVideoUpdate, ThumbnailType, UserRight, VideoState } from '@peertube/peertube-models'
import {
HttpStatusCode,
LiveVideoCreate,
LiveVideoUpdate,
ThumbnailType,
UserRight,
VideoChannelActivityAction,
VideoState
} from '@peertube/peertube-models'
import { uuidToShort } from '@peertube/peertube-node-utils'
import { exists, isArray } from '@server/helpers/custom-validators/misc.js'
import { retryTransactionWrapper } from '@server/helpers/database-utils.js'
@@ -26,6 +34,7 @@ import express from 'express'
import { Transaction } from 'sequelize'
import { logger, loggerTagsFactory } from '../../../helpers/logger.js'
import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, optionalAuthenticate } from '../../../middlewares/index.js'
import { VideoChannelActivityModel } from '@server/models/video/video-channel-activity.js'
const lTags = loggerTagsFactory('api', 'live')
@@ -134,11 +143,19 @@ async function updateLiveVideo (req: express.Request, res: express.Response) {
videoLive.LiveSchedules = await VideoLiveScheduleModel.addToLiveId(videoLive.id, body.schedules.map(s => s.startAt), t)
}
}
video.VideoLive = await videoLive.save({ transaction: t })
await VideoChannelActivityModel.addVideoActivity({
action: VideoChannelActivityAction.UPDATE,
user: res.locals.oauth.token.User,
channel: video.VideoChannel,
video,
transaction: t
})
})
})
video.VideoLive = await videoLive.save()
await federateVideoIfNeeded(video, false)
return res.status(HttpStatusCode.NO_CONTENT_204).end()

View File

@@ -1,9 +1,9 @@
import { HttpStatusCode, VideoChannelActivityAction } from '@peertube/peertube-models'
import { logger, loggerTagsFactory } from '@server/helpers/logger.js'
import { VideoChannelActivityModel } from '@server/models/video/video-channel-activity.js'
import { VideoPasswordModel } from '@server/models/video/video-password.js'
import express from 'express'
import { Transaction } from 'sequelize'
import { HttpStatusCode } from '@peertube/peertube-models'
import { logger, loggerTagsFactory } from '@server/helpers/logger.js'
import { getVideoWithAttributes } from '@server/helpers/video.js'
import { VideoPasswordModel } from '@server/models/video/video-password.js'
import { getFormattedObjects } from '../../../helpers/utils.js'
import {
asyncMiddleware,
@@ -23,7 +23,8 @@ import {
const lTags = loggerTagsFactory('api', 'video')
const videoPasswordRouter = express.Router()
videoPasswordRouter.get('/:videoId/passwords',
videoPasswordRouter.get(
'/:videoId/passwords',
authenticate,
paginationValidator,
videoPasswordsSortValidator,
@@ -33,13 +34,15 @@ videoPasswordRouter.get('/:videoId/passwords',
asyncMiddleware(listVideoPasswords)
)
videoPasswordRouter.put('/:videoId/passwords',
videoPasswordRouter.put(
'/:videoId/passwords',
authenticate,
asyncMiddleware(updateVideoPasswordListValidator),
asyncMiddleware(updateVideoPasswordList)
)
videoPasswordRouter.delete('/:videoId/passwords/:passwordId',
videoPasswordRouter.delete(
'/:videoId/passwords/:passwordId',
authenticate,
asyncMiddleware(removeVideoPasswordValidator),
asyncRetryTransactionMiddleware(removeVideoPassword)
@@ -67,7 +70,7 @@ async function listVideoPasswords (req: express.Request, res: express.Response)
}
async function updateVideoPasswordList (req: express.Request, res: express.Response) {
const videoInstance = getVideoWithAttributes(res)
const videoInstance = res.locals.videoAll
const videoId = videoInstance.id
const passwordArray = req.body.passwords as string[]
@@ -75,6 +78,14 @@ async function updateVideoPasswordList (req: express.Request, res: express.Respo
await VideoPasswordModel.sequelize.transaction(async (t: Transaction) => {
await VideoPasswordModel.deleteAllPasswords(videoId, t)
await VideoPasswordModel.addPasswords(passwordArray, videoId, t)
await VideoChannelActivityModel.addVideoActivity({
action: VideoChannelActivityAction.UPDATE_PASSWORDS,
user: res.locals.oauth.token.User,
channel: videoInstance.VideoChannel,
video: videoInstance,
transaction: t
})
})
logger.info(
@@ -88,10 +99,19 @@ async function updateVideoPasswordList (req: express.Request, res: express.Respo
}
async function removeVideoPassword (req: express.Request, res: express.Response) {
const videoInstance = getVideoWithAttributes(res)
const videoInstance = res.locals.videoAll
const password = res.locals.videoPassword
await VideoPasswordModel.deletePassword(password.id)
await VideoChannelActivityModel.addVideoActivity({
action: VideoChannelActivityAction.UPDATE_PASSWORDS,
user: res.locals.oauth.token.User,
channel: videoInstance.VideoChannel,
video: videoInstance,
transaction: null
})
logger.info(
'Password with id %d of video named %s and uuid %s has been deleted.',
password.id,

View File

@@ -1,5 +1,5 @@
import { buildAspectRatio } from '@peertube/peertube-core-utils'
import { HttpStatusCode, VideoState } from '@peertube/peertube-models'
import { HttpStatusCode, VideoChannelActivityAction, VideoState } from '@peertube/peertube-models'
import { sequelizeTypescript } from '@server/initializers/database.js'
import { CreateJobArgument, CreateJobOptions, JobQueue } from '@server/lib/job-queue/index.js'
import { Hooks } from '@server/lib/plugins/hooks.js'
@@ -12,6 +12,7 @@ import { addRemoteStoryboardJobIfNeeded, buildLocalStoryboardJobIfNeeded, buildM
import { VideoPathManager } from '@server/lib/video-path-manager.js'
import { buildNextVideoState } from '@server/lib/video-state.js'
import { openapiOperationDoc } from '@server/middlewares/doc.js'
import { VideoChannelActivityModel } from '@server/models/video/video-channel-activity.js'
import { VideoModel } from '@server/models/video/video.js'
import { MStreamingPlaylistFiles, MVideo, MVideoFile, MVideoFullLight } from '@server/types/models/index.js'
import express from 'express'
@@ -73,6 +74,14 @@ async function deleteVideoLatestSourceFile (req: express.Request, res: express.R
await videoSource.save()
await VideoChannelActivityModel.addVideoActivity({
action: VideoChannelActivityAction.UPDATE_SOURCE_FILE,
user: res.locals.oauth.token.User,
channel: video.VideoChannel,
video,
transaction: null
})
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}
@@ -132,6 +141,14 @@ async function replaceVideoSourceResumable (req: express.Request, res: express.R
transaction
})
await VideoChannelActivityModel.addVideoActivity({
action: VideoChannelActivityAction.UPDATE_SOURCE_FILE,
user,
channel: video.VideoChannel,
video,
transaction
})
return video
})
@@ -147,6 +164,7 @@ async function replaceVideoSourceResumable (req: express.Request, res: express.R
await regenerateMiniaturesIfNeeded(video, res.locals.ffprobe)
await video.VideoChannel.setAsUpdated()
await addVideoJobsAfterUpload(video, videoFile.withVideoOrPlaylist(video))
logger.info('Replaced video file of video %s with uuid %s.', video.name, video.uuid, lTags(video.uuid))

View File

@@ -7,6 +7,7 @@ import { MIMETYPES, VIDEO_FILTERS } from '@server/initializers/constants.js'
import { buildTaskFileFieldname, createVideoStudioJob, getStudioTaskFilePath, getTaskFileFromReq } from '@server/lib/video-studio.js'
import {
HttpStatusCode,
VideoChannelActivityAction,
VideoState,
VideoStudioCreateEdition,
VideoStudioTask,
@@ -17,6 +18,7 @@ import {
VideoStudioTaskWatermark
} from '@peertube/peertube-models'
import { asyncMiddleware, authenticate, videoStudioAddEditionValidator } from '../../../middlewares/index.js'
import { VideoChannelActivityModel } from '@server/models/video/video-channel-activity.js'
const studioRouter = express.Router()
@@ -45,7 +47,8 @@ const tasksFiles = createAnyReqFiles(
}
)
studioRouter.post('/:videoId/studio/edit',
studioRouter.post(
'/:videoId/studio/edit',
authenticate,
tasksFiles,
asyncMiddleware(videoStudioAddEditionValidator),
@@ -73,12 +76,22 @@ async function createEditionTasks (req: express.Request, res: express.Response)
tasks: await Bluebird.mapSeries(body.tasks, (t, i) => buildTaskPayload(t, i, files))
}
const user = res.locals.oauth.token.User
await createVideoStudioJob({
user: res.locals.oauth.token.User,
user,
payload,
video
})
await VideoChannelActivityModel.addVideoActivity({
action: VideoChannelActivityAction.CREATE_STUDIO_TASKS,
user,
channel: video.VideoChannel,
video,
transaction: null
})
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}

View File

@@ -3,6 +3,7 @@ import {
HttpStatusCode,
NSFWFlag,
ThumbnailType,
VideoChannelActivityAction,
VideoCommentPolicy,
VideoPrivacy,
VideoPrivacyType,
@@ -36,6 +37,7 @@ import { autoBlacklistVideoIfNeeded } from '../../../lib/video-blacklist.js'
import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, videosUpdateValidator } from '../../../middlewares/index.js'
import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update.js'
import { VideoModel } from '../../../models/video/video.js'
import { VideoChannelActivityModel } from '@server/models/video/video-channel-activity.js'
const lTags = loggerTagsFactory('api', 'video')
const auditLogger = auditLoggerFactory('videos')
@@ -64,6 +66,7 @@ async function updateVideo (req: express.Request, res: express.Response) {
const videoFromReq = res.locals.videoAll
const oldVideoAuditView = new VideoAuditView(videoFromReq.toFormattedDetailsJSON())
const body: VideoUpdate = req.body
const user = res.locals.oauth.token.User
const hadPrivacyForFederation = isPrivacyForFederation(videoFromReq.privacy)
const oldPrivacy = videoFromReq.privacy
@@ -148,13 +151,40 @@ async function updateVideo (req: express.Request, res: express.Response) {
}
// Video channel update?
if (res.locals.videoChannel && videoInstanceUpdated.channelId !== res.locals.videoChannel.id) {
await videoInstanceUpdated.$set('VideoChannel', res.locals.videoChannel, { transaction: t })
videoInstanceUpdated.VideoChannel = res.locals.videoChannel
const newChannel = res.locals.videoChannel
if (newChannel && videoInstanceUpdated.channelId !== newChannel.id) {
const oldChannel = videoInstanceUpdated.VideoChannel
await VideoChannelActivityModel.addVideoActivity({
action: VideoChannelActivityAction.REMOVE_CHANNEL_OWNERSHIP,
user,
channel: oldChannel,
video: videoInstanceUpdated,
transaction: t
})
await VideoChannelActivityModel.addVideoActivity({
action: VideoChannelActivityAction.CREATE_CHANNEL_OWNERSHIP,
user,
channel: newChannel,
video: videoInstanceUpdated,
transaction: t
})
await videoInstanceUpdated.$set('VideoChannel', newChannel, { transaction: t })
videoInstanceUpdated.VideoChannel = newChannel
if (hadPrivacyForFederation === true) {
await changeVideoChannelShare(videoInstanceUpdated, oldVideoChannel, t)
}
} else {
await VideoChannelActivityModel.addVideoActivity({
action: VideoChannelActivityAction.UPDATE,
user: res.locals.oauth.token.User,
channel: videoInstanceUpdated.VideoChannel,
video: videoInstanceUpdated,
transaction: t
})
}
// Schedule an update in the future?
@@ -176,7 +206,7 @@ async function updateVideo (req: express.Request, res: express.Response) {
await autoBlacklistVideoIfNeeded({
video: videoInstanceUpdated,
user: res.locals.oauth.token.User,
user,
isRemote: false,
isNew: false,
isNewFile: false,

View File

@@ -20,6 +20,10 @@ import {
UserImportStateType,
UserRegistrationState,
UserRegistrationStateType,
VideoChannelActivityAction,
VideoChannelActivityActionType,
VideoChannelActivityTarget,
VideoChannelActivityTargetType,
VideoChannelCollaboratorState,
VideoChannelCollaboratorStateType,
VideoChannelSyncState,
@@ -54,7 +58,7 @@ import { CONFIG, registerConfigChangedHandler } from './config.js'
// ---------------------------------------------------------------------------
export const LAST_MIGRATION_VERSION = 940
export const LAST_MIGRATION_VERSION = 945
// ---------------------------------------------------------------------------
@@ -157,7 +161,9 @@ export const SORTABLE_COLUMNS = {
AVAILABLE_PLUGINS: [ 'npmName', 'popularity', 'trending' ],
VIDEO_REDUNDANCIES: [ 'name' ]
VIDEO_REDUNDANCIES: [ 'name' ],
VIDEO_CHANNEL_ACTIVITIES: [ 'createdAt' ]
}
export const ROUTE_CACHE_LIFETIME = {
@@ -698,6 +704,28 @@ export const CHANNEL_COLLABORATOR_STATE: { [id in VideoChannelCollaboratorStateT
[VideoChannelCollaboratorState.REJECTED]: 'Rejected'
}
export const VIDEO_CHANNEL_ACTIVITY_ACTIONS: { [id in VideoChannelActivityActionType]: string } = {
[VideoChannelActivityAction.CREATE]: 'Create',
[VideoChannelActivityAction.UPDATE]: 'Update',
[VideoChannelActivityAction.DELETE]: 'Delete',
[VideoChannelActivityAction.UPDATE_CAPTIONS]: 'Update captions',
[VideoChannelActivityAction.UPDATE_CHAPTERS]: 'Update chapters',
[VideoChannelActivityAction.UPDATE_PASSWORDS]: 'Update passwords',
[VideoChannelActivityAction.CREATE_STUDIO_TASKS]: 'Create studio tasks',
[VideoChannelActivityAction.UPDATE_SOURCE_FILE]: 'Update source file',
[VideoChannelActivityAction.UPDATE_ELEMENTS]: 'Update elements',
[VideoChannelActivityAction.REMOVE_CHANNEL_OWNERSHIP]: 'Remove channel ownership',
[VideoChannelActivityAction.CREATE_CHANNEL_OWNERSHIP]: 'Create channel ownership'
}
export const VIDEO_CHANNEL_ACTIVITY_TARGETS: { [id in VideoChannelActivityTargetType]: string } = {
[VideoChannelActivityTarget.CHANNEL]: 'Channel',
[VideoChannelActivityTarget.CHANNEL_SYNC]: 'Channel synchronization',
[VideoChannelActivityTarget.PLAYLIST]: 'Playlist',
[VideoChannelActivityTarget.VIDEO]: 'Video',
[VideoChannelActivityTarget.VIDEO_IMPORT]: 'Video import'
}
export const MIMETYPES = {
AUDIO: {
MIMETYPE_EXT: {

View File

@@ -18,6 +18,7 @@ import { UserVideoHistoryModel } from '@server/models/user/user-video-history.js
import { UserModel } from '@server/models/user/user.js'
import { PlayerSettingModel } from '@server/models/video/player-setting.js'
import { StoryboardModel } from '@server/models/video/storyboard.js'
import { VideoChannelActivityModel } from '@server/models/video/video-channel-activity.js'
import { VideoChannelCollaboratorModel } from '@server/models/video/video-channel-collaborator.js'
import { VideoChannelSyncModel } from '@server/models/video/video-channel-sync.js'
import { VideoChapterModel } from '@server/models/video/video-chapter.js'
@@ -148,6 +149,7 @@ export async function initDatabaseModels (silent: boolean) {
VideoShareModel,
VideoFileModel,
VideoSourceModel,
VideoChannelActivityModel,
VideoChapterModel,
VideoCaptionModel,
VideoBlacklistModel,

View File

@@ -0,0 +1,48 @@
import * as Sequelize from 'sequelize'
async function up (utils: {
transaction: Sequelize.Transaction
queryInterface: Sequelize.QueryInterface
sequelize: Sequelize.Sequelize
}): Promise<void> {
{
await utils.sequelize.query(
`CREATE TABLE IF NOT EXISTS "videoChannelActivity" (
"id" SERIAL,
"action" INTEGER NOT NULL,
"targetType" INTEGER NOT NULL,
"data" JSONB NOT NULL,
"details" JSONB,
"videoChannelId" INTEGER NOT NULL REFERENCES "videoChannel" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
"accountId" INTEGER REFERENCES "account" ("id") ON DELETE
SET
NULL ON UPDATE CASCADE,
"videoId" INTEGER REFERENCES "video" ("id") ON DELETE
SET
NULL ON UPDATE CASCADE,
"videoPlaylistId" INTEGER REFERENCES "videoPlaylist" ("id") ON DELETE
SET
NULL ON UPDATE CASCADE,
"videoChannelSyncId" INTEGER REFERENCES "videoChannelSync" ("id") ON DELETE
SET
NULL ON UPDATE CASCADE,
"videoImportId" INTEGER REFERENCES "videoImport" ("id") ON DELETE
SET
NULL ON UPDATE CASCADE,
"createdAt" TIMESTAMP WITH TIME ZONE NOT NULL,
"updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL,
PRIMARY KEY ("id")
);`,
{ transaction: utils.transaction }
)
}
}
function down (options) {
throw new Error('Not implemented.')
}
export {
down,
up
}

View File

@@ -1,13 +1,13 @@
import { PlayerSettingsObject } from '@peertube/peertube-models'
import { sanitizeAndCheckPlayerSettingsObject } from '@server/helpers/custom-validators/activitypub/player-settings.js'
import { MChannelId, MChannelUrl, MVideoIdUrl } from '../../types/models/index.js'
import { MChannelDefault, MVideoIdUrl } from '../../types/models/index.js'
import { upsertPlayerSettings } from '../player-settings.js'
import { fetchAPObjectIfNeeded } from './activity.js'
import { checkUrlsSameHost } from './url.js'
export async function upsertAPPlayerSettings (options: {
video: MVideoIdUrl
channel: MChannelUrl & MChannelId
channel: MChannelDefault
settingsObject: PlayerSettingsObject | string
contextUrl: string
}) {
@@ -30,7 +30,7 @@ export async function upsertAPPlayerSettings (options: {
throw new Error(`Player settings ${settingsObject.id} object is not on the same host as context URL ${contextUrl}`)
}
await upsertPlayerSettings({ settings: getPlayerSettingsAttributesFromObject(settingsObject), channel, video })
await upsertPlayerSettings({ user: null, settings: getPlayerSettingsAttributesFromObject(settingsObject), channel, video })
}
// ---------------------------------------------------------------------------

View File

@@ -18,7 +18,7 @@ import { logger } from '../../../helpers/logger.js'
import { sequelizeTypescript } from '../../../initializers/database.js'
import { ActorModel } from '../../../models/actor/actor.js'
import { APProcessorOptions } from '../../../types/activitypub-processor.model.js'
import { MActorAccountChannelId, MActorFull, MActorSignature } from '../../../types/models/index.js'
import { MActorFull, MActorSignature } from '../../../types/models/index.js'
import { fetchAPObjectIfNeeded } from '../activity.js'
import { getOrCreateAPActor } from '../actors/get.js'
import { APActorUpdater } from '../actors/updater.js'
@@ -142,13 +142,13 @@ async function processUpdatePlayerSettings (
byActor: MActorSignature,
settingsObject: PlayerSettingsObject
) {
let actor: MActorAccountChannelId
let actor: MActorFull
const { video } = await maybeGetOrCreateAPVideo({ videoObject: settingsObject.object })
if (!video) {
try {
actor = await getOrCreateAPActor(settingsObject.object)
actor = await getOrCreateAPActor(settingsObject.object, 'all')
} catch {
actor = undefined
}

View File

@@ -232,11 +232,9 @@ async function handleRefreshGrant (options: {
lastActivityIP: options.ip,
lastActivityDate: new Date(),
...pick(refreshToken.token, [
'loginDevice',
'loginIP',
'loginDate'
])
loginIP: refreshToken.token.loginIP,
loginDate: refreshToken.token.loginDate,
loginDevice: refreshToken.token.loginDevice
})
return saveToken(token, client, refreshToken.user, { refreshTokenAuthName })

View File

@@ -6,6 +6,7 @@ import {
PeerTubeError,
ThumbnailType,
ThumbnailType_Type,
VideoChannelActivityAction,
VideoCreate,
VideoPrivacy,
VideoStateType
@@ -16,12 +17,13 @@ import { LoggerTagsFn, logger } from '@server/helpers/logger.js'
import { CONFIG } from '@server/initializers/config.js'
import { sequelizeTypescript } from '@server/initializers/database.js'
import { ScheduleVideoUpdateModel } from '@server/models/video/schedule-video-update.js'
import { VideoChannelActivityModel } from '@server/models/video/video-channel-activity.js'
import { VideoLiveReplaySettingModel } from '@server/models/video/video-live-replay-setting.js'
import { VideoLiveScheduleModel } from '@server/models/video/video-live-schedule.js'
import { VideoLiveModel } from '@server/models/video/video-live.js'
import { VideoPasswordModel } from '@server/models/video/video-password.js'
import { VideoModel } from '@server/models/video/video.js'
import { MChannel, MChannelAccountLight, MThumbnail, MUser, MVideoFile, MVideoFullLight } from '@server/types/models/index.js'
import { MChannel, MChannelAccountLight, MThumbnail, MUserAccountId, MVideoFile, MVideoFullLight } from '@server/types/models/index.js'
import { FilteredModelAttributes } from '@server/types/sequelize.js'
import { FfprobeData } from 'fluent-ffmpeg'
import { move } from 'fs-extra/esm'
@@ -92,7 +94,7 @@ export class LocalVideoCreator {
liveAttributes: LiveAttributes
channel: MChannelAccountLight
user: MUser
user: MUserAccountId
videoAttributeResultHook: VideoAttributeHookFilter
thumbnails: ThumbnailOptions
@@ -139,6 +141,14 @@ export class LocalVideoCreator {
return sequelizeTypescript.transaction(async transaction => {
await this.video.save({ transaction })
await VideoChannelActivityModel.addVideoActivity({
action: VideoChannelActivityAction.CREATE,
user: this.options.user,
channel: this.channel,
video: this.video,
transaction
})
for (const thumbnail of thumbnails) {
await this.video.addAndSaveThumbnail(thumbnail, transaction)
}

View File

@@ -1,15 +1,17 @@
import { PlayerChannelSettings, PlayerVideoSettings } from '@peertube/peertube-models'
import { PlayerChannelSettings, PlayerVideoSettings, VideoChannelActivityAction } from '@peertube/peertube-models'
import { retryTransactionWrapper } from '@server/helpers/database-utils.js'
import { sequelizeTypescript } from '@server/initializers/database.js'
import { PlayerSettingModel } from '@server/models/video/player-setting.js'
import { MChannelId, MVideoId } from '@server/types/models/index.js'
import { VideoChannelActivityModel } from '@server/models/video/video-channel-activity.js'
import { MChannelDefault, MUserAccountId, MVideoId } from '@server/types/models/index.js'
export async function upsertPlayerSettings (options: {
user: MUserAccountId
settings: PlayerVideoSettings | PlayerChannelSettings
channel: MChannelId
channel: MChannelDefault
video: MVideoId
}) {
const { settings, channel, video } = options
const { user, settings, channel, video } = options
if (!channel && !video) throw new Error('channel or video must be specified')
@@ -21,11 +23,22 @@ export async function upsertPlayerSettings (options: {
if (setting) await setting.destroy({ transaction })
return PlayerSettingModel.create({
const playerSettings = await PlayerSettingModel.create({
theme: settings.theme,
channelId: channel?.id,
videoId: video?.id
}, { transaction })
if (user && channel) {
await VideoChannelActivityModel.addChannelActivity({
action: VideoChannelActivityAction.UPDATE,
user,
channel,
transaction
})
}
return playerSettings
})
})
}

View File

@@ -2,6 +2,7 @@ import {
NSFWFlag,
ThumbnailType,
ThumbnailType_Type,
VideoChannelActivityAction,
VideoImportCreate,
VideoImportPayload,
VideoImportState,
@@ -19,6 +20,7 @@ import { Hooks } from '@server/lib/plugins/hooks.js'
import { ServerConfigManager } from '@server/lib/server-config-manager.js'
import { autoBlacklistVideoIfNeeded } from '@server/lib/video-blacklist.js'
import { buildCommentsPolicy, setVideoTags } from '@server/lib/video.js'
import { VideoChannelActivityModel } from '@server/models/video/video-channel-activity.js'
import { VideoImportModel } from '@server/models/video/video-import.js'
import { VideoPasswordModel } from '@server/models/video/video-password.js'
import { VideoModel } from '@server/models/video/video.js'
@@ -27,7 +29,7 @@ import {
MChannelAccountDefault,
MChannelSync,
MThumbnail,
MUser,
MUserAccountId,
MVideo,
MVideoAccountDefault,
MVideoImportFormattable,
@@ -73,7 +75,7 @@ async function insertFromImportIntoDB (parameters: {
videoChannel: MChannelAccountDefault
tags: string[]
videoImportAttributes: FilteredModelAttributes<VideoImportModel>
user: MUser
user: MUserAccountId
videoPasswords?: string[]
}): Promise<MVideoImportFormattable> {
const { video, thumbnailModel, previewModel, videoChannel, tags, videoImportAttributes, user, videoPasswords } = parameters
@@ -113,6 +115,15 @@ async function insertFromImportIntoDB (parameters: {
) as MVideoImportFormattable
videoImport.Video = videoCreated
await VideoChannelActivityModel.addVideoImportActivity({
action: VideoChannelActivityAction.CREATE,
channel: videoChannel,
videoImport,
video: videoCreated,
user,
transaction: t
})
return videoImport
})
@@ -164,7 +175,7 @@ async function buildVideoFromImport ({ channelId, importData, importDataOverride
async function buildYoutubeDLImport (options: {
targetUrl: string
channel: MChannelAccountDefault
user: MUser
user: MUserAccountId
channelSync?: MChannelSync
importDataOverride?: Partial<VideoImportCreate>
thumbnailFilePath?: string

View File

@@ -33,7 +33,8 @@ export const videoPasswordsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VID
export const watchedWordsListsSortValidator = checkSortFactory(SORTABLE_COLUMNS.WATCHED_WORDS_LISTS)
export const accountsFollowersSortValidator = checkSortFactory(SORTABLE_COLUMNS.ACCOUNT_FOLLOWERS)
export const videoChannelsFollowersSortValidator = checkSortFactory(SORTABLE_COLUMNS.CHANNEL_FOLLOWERS)
export const videoChannelFollowersSortValidator = checkSortFactory(SORTABLE_COLUMNS.CHANNEL_FOLLOWERS)
export const videoChannelActivitiesSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_CHANNEL_ACTIVITIES)
export const userRegistrationsSortValidator = checkSortFactory(SORTABLE_COLUMNS.USER_REGISTRATIONS)

View File

@@ -35,14 +35,17 @@ export const videoChannelsAddValidator = [
if (actor) {
res.fail({
status: HttpStatusCode.CONFLICT_409,
message: 'Another actor (account/channel) with this name on this instance already exists or has already existed.'
message: req.t(
'Another actor (account/channel) with name {name} on this instance already exists or has already existed.',
{ name: req.body.name }
)
})
return false
}
const count = await VideoChannelModel.countByAccount(res.locals.oauth.token.User.Account.id)
if (count >= CONFIG.VIDEO_CHANNELS.MAX_PER_USER) {
res.fail({ message: `You cannot create more than ${CONFIG.VIDEO_CHANNELS.MAX_PER_USER} channels` })
res.fail({ message: req.t('You cannot create more than {count} channels', { count: CONFIG.VIDEO_CHANNELS.MAX_PER_USER }) })
return false
}
@@ -73,7 +76,7 @@ export const videoChannelsUpdateValidator = [
export const videoChannelsRemoveValidator = [
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (!await checkVideoChannelIsNotTheLastOne(res.locals.videoChannel, res)) return
if (!await checkVideoChannelIsNotTheLastOne(res.locals.videoChannel, req, res)) return
return next()
}
@@ -100,17 +103,6 @@ export const videoChannelsHandleValidatorFactory = (options: {
]
}
export const ensureChannelOwnerCanUpload = [
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
const channel = res.locals.videoChannel
const user = { id: channel.Account.userId }
if (!await checkUserQuota({ user, videoFileSize: 1, req, res })) return
next()
}
]
export const listAccountChannelsValidator = [
query('withStats')
.optional()
@@ -155,7 +147,7 @@ export const videoChannelImportVideosValidator = [
if (!CONFIG.IMPORT.VIDEOS.HTTP.ENABLED) {
return res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: 'Channel import is impossible as video upload via HTTP is not enabled on the server'
message: req.t('Channel import is impossible as video upload via HTTP is not enabled on the server')
})
}
@@ -164,23 +156,26 @@ export const videoChannelImportVideosValidator = [
if (res.locals.videoChannelSync && res.locals.videoChannelSync.videoChannelId !== res.locals.videoChannel.id) {
return res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: 'This channel sync is not owned by this channel'
message: req.t('This channel sync is not owned by this channel')
})
}
const user = { id: res.locals.videoChannel.Account.userId }
if (!await checkUserQuota({ user, videoFileSize: 1, req, res })) return
return next()
}
]
// ---------------------------------------------------------------------------
async function checkVideoChannelIsNotTheLastOne (videoChannel: MChannelAccountDefault, res: express.Response) {
async function checkVideoChannelIsNotTheLastOne (videoChannel: MChannelAccountDefault, req: express.Request, res: express.Response) {
const count = await VideoChannelModel.countByAccount(videoChannel.Account.id)
if (count <= 1) {
res.fail({
status: HttpStatusCode.CONFLICT_409,
message: 'Cannot remove the last channel of this user'
message: req.t('Cannot remove the last channel of this user')
})
return false
}

Some files were not shown because too many files have changed in this diff Show More