mirror of
https://github.com/Chocobozzz/PeerTube.git
synced 2025-10-03 09:49:20 +02:00
Add ability to order playlists
This commit is contained in:
parent
546bd42240
commit
0adafa0fc0
48 changed files with 1776 additions and 607 deletions
|
@ -35,7 +35,7 @@
|
||||||
<span class="pt-badge badge-purple" i18n>Remote</span>
|
<span class="pt-badge badge-purple" i18n>Remote</span>
|
||||||
}
|
}
|
||||||
|
|
||||||
<my-video-privacy-badge [video]="video"></my-video-privacy-badge>
|
<my-privacy-badge [video]="video"></my-privacy-badge>
|
||||||
|
|
||||||
<my-video-nsfw-badge [video]="video" theme="red"></my-video-nsfw-badge>
|
<my-video-nsfw-badge [video]="video" theme="red"></my-video-nsfw-badge>
|
||||||
|
|
||||||
|
|
|
@ -8,7 +8,7 @@ my-embed {
|
||||||
}
|
}
|
||||||
|
|
||||||
.pt-badge,
|
.pt-badge,
|
||||||
my-video-privacy-badge,
|
my-privacy-badge,
|
||||||
my-video-nsfw-badge {
|
my-video-nsfw-badge {
|
||||||
@include margin-right(5px);
|
@include margin-right(5px);
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,6 +11,7 @@ import { Video } from '@app/shared/shared-main/video/video.model'
|
||||||
import { VideoService } from '@app/shared/shared-main/video/video.service'
|
import { VideoService } from '@app/shared/shared-main/video/video.service'
|
||||||
import { VideoBlockComponent } from '@app/shared/shared-moderation/video-block.component'
|
import { VideoBlockComponent } from '@app/shared/shared-moderation/video-block.component'
|
||||||
import { VideoBlockService } from '@app/shared/shared-moderation/video-block.service'
|
import { VideoBlockService } from '@app/shared/shared-moderation/video-block.service'
|
||||||
|
import { PrivacyBadgeComponent } from '@app/shared/shared-video/privacy-badge.component'
|
||||||
import { getAllFiles } from '@peertube/peertube-core-utils'
|
import { getAllFiles } from '@peertube/peertube-core-utils'
|
||||||
import { FileStorage, NSFWFlag, UserRight, VideoFile, VideoState, VideoStreamingPlaylistType } from '@peertube/peertube-models'
|
import { FileStorage, NSFWFlag, UserRight, VideoFile, VideoState, VideoStreamingPlaylistType } from '@peertube/peertube-models'
|
||||||
import { videoRequiresFileToken } from '@root-helpers/video'
|
import { videoRequiresFileToken } from '@root-helpers/video'
|
||||||
|
@ -29,7 +30,6 @@ import {
|
||||||
VideoActionsDropdownComponent
|
VideoActionsDropdownComponent
|
||||||
} from '../../../shared/shared-video-miniature/video-actions-dropdown.component'
|
} from '../../../shared/shared-video-miniature/video-actions-dropdown.component'
|
||||||
import { VideoNSFWBadgeComponent } from '../../../shared/shared-video/video-nsfw-badge.component'
|
import { VideoNSFWBadgeComponent } from '../../../shared/shared-video/video-nsfw-badge.component'
|
||||||
import { VideoPrivacyBadgeComponent } from '../../../shared/shared-video/video-privacy-badge.component'
|
|
||||||
import { VideoAdminService } from './video-admin.service'
|
import { VideoAdminService } from './video-admin.service'
|
||||||
|
|
||||||
type ColumnName =
|
type ColumnName =
|
||||||
|
@ -54,7 +54,7 @@ type ColumnName =
|
||||||
PTDatePipe,
|
PTDatePipe,
|
||||||
RouterLink,
|
RouterLink,
|
||||||
BytesPipe,
|
BytesPipe,
|
||||||
VideoPrivacyBadgeComponent,
|
PrivacyBadgeComponent,
|
||||||
VideoNSFWBadgeComponent,
|
VideoNSFWBadgeComponent,
|
||||||
TableComponent,
|
TableComponent,
|
||||||
NumberFormatterPipe
|
NumberFormatterPipe
|
||||||
|
|
|
@ -103,7 +103,7 @@ export class MyVideoPlaylistElementsComponent implements OnInit, OnDestroy {
|
||||||
this.playlistElements.splice(previousIndex, 1)
|
this.playlistElements.splice(previousIndex, 1)
|
||||||
this.playlistElements.splice(newIndex, 0, element)
|
this.playlistElements.splice(newIndex, 0, element)
|
||||||
|
|
||||||
this.videoPlaylistService.reorderPlaylist(this.playlist.id, oldPosition, insertAfter)
|
this.videoPlaylistService.reorderVideosOfPlaylist(this.playlist.id, oldPosition, insertAfter)
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
this.reorderClientPositions()
|
this.reorderClientPositions()
|
||||||
|
|
|
@ -1,25 +1,92 @@
|
||||||
<div class="video-playlists-header d-flex align-items-end gap-2 flex-wrap">
|
@if (user.videoChannels.length > 1) {
|
||||||
<span class="total-items" *ngIf="pagination.totalItems"> {{ getTotalTitle() }}</span>
|
<div class="form-group">
|
||||||
|
<div class="label" i18n>Filter by a channel</div>
|
||||||
|
<div class="form-group-description" i18n>This allows you to reorder playlists assigned to it</div>
|
||||||
|
|
||||||
<my-advanced-input-filter class="d-block ms-auto" (search)="onSearch($event)" emitOnInit="true"></my-advanced-input-filter>
|
<div class="channel-filters">
|
||||||
|
@for (channel of channels; track channel.id) {
|
||||||
|
<my-channel-toggle [channel]="channel" [(ngModel)]="channel.selected" (ngModelChange)="onChannelFilter(channel)"></my-channel-toggle>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<a class="peertube-create-button" routerLink="create">
|
<my-table
|
||||||
<my-global-icon iconName="add" aria-hidden="true"></my-global-icon>
|
#table
|
||||||
<ng-container i18n>Create playlist</ng-container>
|
key="MyVideoPlaylistsComponent"
|
||||||
</a>
|
[defaultColumns]="columns"
|
||||||
</div>
|
defaultSort="updatedAt"
|
||||||
|
i18n-paginatorText
|
||||||
|
paginatorText="playlists per page"
|
||||||
|
defaultRowsPerPage="10"
|
||||||
|
[dataLoader]="dataLoader"
|
||||||
|
[customParseQueryParams]="customParseQueryParams"
|
||||||
|
[customUpdateUrl]="customUpdateUrl"
|
||||||
|
[hasExpandedRow]="hasExpandedRow"
|
||||||
|
columnConfig="false"
|
||||||
|
[reorderableRows]="hasReorderableRows()"
|
||||||
|
(rowReorder)="onRowReorder($event)"
|
||||||
|
dragHandleTitle="Drag and drop this row to reorder playlists in your channel"
|
||||||
|
i18n-dragHandleTitle
|
||||||
|
>
|
||||||
|
<ng-template #totalTitle let-totalRecords>
|
||||||
|
@if (getFilteredChannel()) {
|
||||||
|
<ng-container i18n>{ totalRecords, plural, =0 {No playlist} =1 {1 playlist} other {{{ totalRecords | myNumberFormatter }} playlists}} in {{ getFilteredChannel().displayName }} channel</ng-container>
|
||||||
|
} @else {
|
||||||
|
<ng-container i18n>{ totalRecords, plural, =0 {No playlist} =1 {1 playlist} other {{{ totalRecords | myNumberFormatter }} playlists}}</ng-container>
|
||||||
|
}
|
||||||
|
</ng-template>
|
||||||
|
|
||||||
<div class="video-playlists" myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [dataObservable]="onDataSubject.asObservable()">
|
<ng-template #captionRight>
|
||||||
<div *ngFor="let playlist of videoPlaylists" class="video-playlist">
|
<my-advanced-input-filter
|
||||||
<my-video-playlist-miniature
|
inputId="table-search" icon="true" emitOnInit="false" i18n-placeholder placeholder="Search your playlists"
|
||||||
[playlist]="playlist" [toManage]="true" [displayChannel]="true"
|
(search)="table.onSearch($event)"
|
||||||
[displayDescription]="true" [displayPrivacy]="true" [displayAsRow]="true"
|
></my-advanced-input-filter>
|
||||||
></my-video-playlist-miniature>
|
|
||||||
|
|
||||||
<div *ngIf="isRegularPlaylist(playlist)" class="video-playlist-buttons">
|
<a class="peertube-create-button" routerLink="create">
|
||||||
|
<my-global-icon iconName="add" aria-hidden="true"></my-global-icon>
|
||||||
|
<ng-container i18n>Create playlist</ng-container>
|
||||||
|
</a>
|
||||||
|
</ng-template>
|
||||||
|
|
||||||
|
<ng-template #tableCells let-playlist>
|
||||||
|
|
||||||
|
<td *ngIf="table.isColumnDisplayed('videoChannelPosition')" style="width: 10px;">
|
||||||
|
{{ playlist.videoChannelPosition }}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td *ngIf="table.isColumnDisplayed('videos')">
|
||||||
|
<my-video-playlist-miniature [playlist]="playlist" thumbnailOnly="true"></my-video-playlist-miniature>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td *ngIf="table.isColumnDisplayed('name')">
|
||||||
|
<a [routerLink]="[ '/my-library/video-playlists', playlist.shortUUID ]" class="name">
|
||||||
|
{{ playlist.displayName }}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td *ngIf="table.isColumnDisplayed('privacy')">
|
||||||
|
<my-privacy-badge [playlist]="playlist"></my-privacy-badge>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td *ngIf="table.isColumnDisplayed('updated')">
|
||||||
|
{{ playlist.updatedAt | ptDate: 'short' }}
|
||||||
|
</td>
|
||||||
|
</ng-template>
|
||||||
|
|
||||||
|
<ng-template #actionCell let-playlist>
|
||||||
|
@if(isRegularPlaylist(playlist)) {
|
||||||
<my-delete-button label (click)="deleteVideoPlaylist(playlist)"></my-delete-button>
|
<my-delete-button label (click)="deleteVideoPlaylist(playlist)"></my-delete-button>
|
||||||
|
|
||||||
<my-edit-button label [ptRouterLink]="[ 'update', playlist.shortUUID ]"></my-edit-button>
|
<my-edit-button label [ptRouterLink]="[ 'update', playlist.shortUUID ]"></my-edit-button>
|
||||||
</div>
|
}
|
||||||
</div>
|
</ng-template>
|
||||||
</div>
|
|
||||||
|
<ng-template #expandedRow let-playlist>
|
||||||
|
{{ playlist.description }}
|
||||||
|
</ng-template>
|
||||||
|
|
||||||
|
<ng-template #noResults let-search>
|
||||||
|
{{ getNoResults(search) }}
|
||||||
|
</ng-template>
|
||||||
|
</my-table>
|
||||||
|
|
|
@ -1,53 +1,24 @@
|
||||||
@use '_variables' as *;
|
@use "_variables" as *;
|
||||||
@use '_mixins' as *;
|
@use "_mixins" as *;
|
||||||
@use '_form-mixins' as *;
|
@use "_form-mixins" as *;
|
||||||
|
@use "_actor" as *;
|
||||||
|
|
||||||
input[type=text] {
|
.channel-label {
|
||||||
@include peertube-input-text(300px);
|
@include channel-label;
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-playlist {
|
.channel-filters {
|
||||||
@include row-blocks($column-responsive: false);
|
@include channel-filters;
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-playlist-buttons {
|
.name {
|
||||||
display: flex;
|
color: var(--fg);
|
||||||
align-self: flex-end;
|
line-height: 1rem;
|
||||||
|
font-weight: $font-bold;
|
||||||
@include margin-left(10px);
|
text-decoration: none;
|
||||||
}
|
|
||||||
|
|
||||||
.video-playlists-header {
|
|
||||||
margin-bottom: 30px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
my-video-playlist-miniature {
|
my-video-playlist-miniature {
|
||||||
display: block;
|
display: block;
|
||||||
flex-grow: 1;
|
width: 130px;
|
||||||
}
|
|
||||||
|
|
||||||
my-delete-button {
|
|
||||||
@include margin-right(10px);
|
|
||||||
}
|
|
||||||
|
|
||||||
@include on-small-main-col {
|
|
||||||
.video-playlists-header {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-playlist {
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-playlist-buttons {
|
|
||||||
margin-top: 10px;
|
|
||||||
|
|
||||||
@include margin-left(auto);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@include on-mobile-main-col {
|
|
||||||
.action-button {
|
|
||||||
@include margin-left(0);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,52 +1,145 @@
|
||||||
import { NgFor, NgIf } from '@angular/common'
|
import { DragDropModule } from '@angular/cdk/drag-drop'
|
||||||
import { Component, inject } from '@angular/core'
|
import { CommonModule } from '@angular/common'
|
||||||
|
import { ChangeDetectorRef, Component, inject, OnDestroy, OnInit, viewChild } from '@angular/core'
|
||||||
|
import { FormsModule } from '@angular/forms'
|
||||||
import { RouterLink } from '@angular/router'
|
import { RouterLink } from '@angular/router'
|
||||||
import { AuthService, ComponentPagination, ConfirmService, Notifier, resetCurrentPage, updatePaginationOnDelete } from '@app/core'
|
import { AuthService, AuthUser, ConfirmService, Notifier, RestPagination, ScreenService } from '@app/core'
|
||||||
import { formatICU } from '@app/helpers'
|
import { HeaderService } from '@app/header/header.service'
|
||||||
|
import { Actor } from '@app/shared/shared-main/account/actor.model'
|
||||||
|
import { TableColumnInfo, TableComponent, TableQueryParams } from '@app/shared/shared-tables/table.component'
|
||||||
import { VideoPlaylist } from '@app/shared/shared-video-playlist/video-playlist.model'
|
import { VideoPlaylist } from '@app/shared/shared-video-playlist/video-playlist.model'
|
||||||
import { VideoPlaylistService } from '@app/shared/shared-video-playlist/video-playlist.service'
|
import { VideoPlaylistService } from '@app/shared/shared-video-playlist/video-playlist.service'
|
||||||
import { VideoPlaylistType } from '@peertube/peertube-models'
|
import { VideoChannel, VideoPlaylistType } from '@peertube/peertube-models'
|
||||||
import { Subject } from 'rxjs'
|
import debug from 'debug'
|
||||||
import { mergeMap } from 'rxjs/operators'
|
import { SortMeta } from 'primeng/api'
|
||||||
|
import { TableRowReorderEvent } from 'primeng/table'
|
||||||
|
import { Subject, tap } from 'rxjs'
|
||||||
import { AdvancedInputFilterComponent } from '../../shared/shared-forms/advanced-input-filter.component'
|
import { AdvancedInputFilterComponent } from '../../shared/shared-forms/advanced-input-filter.component'
|
||||||
import { GlobalIconComponent } from '../../shared/shared-icons/global-icon.component'
|
import { GlobalIconComponent } from '../../shared/shared-icons/global-icon.component'
|
||||||
import { DeleteButtonComponent } from '../../shared/shared-main/buttons/delete-button.component'
|
import { DeleteButtonComponent } from '../../shared/shared-main/buttons/delete-button.component'
|
||||||
import { EditButtonComponent } from '../../shared/shared-main/buttons/edit-button.component'
|
import { EditButtonComponent } from '../../shared/shared-main/buttons/edit-button.component'
|
||||||
import { InfiniteScrollerDirective } from '../../shared/shared-main/common/infinite-scroller.directive'
|
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 { 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'
|
||||||
|
|
||||||
|
type ColumnName = 'videoChannelPosition' | 'videos' | 'name' | 'privacy' | 'updated'
|
||||||
|
|
||||||
|
type QueryParams = TableQueryParams & {
|
||||||
|
channelName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const debugLogger = debug('peertube:my-video-playlists')
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
templateUrl: './my-video-playlists.component.html',
|
templateUrl: './my-video-playlists.component.html',
|
||||||
styleUrls: [ './my-video-playlists.component.scss' ],
|
styleUrls: [ './my-video-playlists.component.scss' ],
|
||||||
imports: [
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
GlobalIconComponent,
|
GlobalIconComponent,
|
||||||
NgIf,
|
|
||||||
AdvancedInputFilterComponent,
|
AdvancedInputFilterComponent,
|
||||||
RouterLink,
|
RouterLink,
|
||||||
InfiniteScrollerDirective,
|
|
||||||
NgFor,
|
|
||||||
VideoPlaylistMiniatureComponent,
|
VideoPlaylistMiniatureComponent,
|
||||||
DeleteButtonComponent,
|
DeleteButtonComponent,
|
||||||
EditButtonComponent
|
EditButtonComponent,
|
||||||
|
ChannelToggleComponent,
|
||||||
|
TableComponent,
|
||||||
|
NumberFormatterPipe,
|
||||||
|
PrivacyBadgeComponent,
|
||||||
|
PTDatePipe,
|
||||||
|
DragDropModule
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class MyVideoPlaylistsComponent {
|
export class MyVideoPlaylistsComponent implements OnInit, OnDestroy {
|
||||||
private authService = inject(AuthService)
|
|
||||||
private notifier = inject(Notifier)
|
private notifier = inject(Notifier)
|
||||||
private confirmService = inject(ConfirmService)
|
private confirmService = inject(ConfirmService)
|
||||||
private videoPlaylistService = inject(VideoPlaylistService)
|
private videoPlaylistService = inject(VideoPlaylistService)
|
||||||
|
private auth = inject(AuthService)
|
||||||
|
private headerService = inject(HeaderService)
|
||||||
|
private cdr = inject(ChangeDetectorRef)
|
||||||
|
private screenService = inject(ScreenService)
|
||||||
|
|
||||||
videoPlaylists: VideoPlaylist[] = []
|
readonly table = viewChild<TableComponent<VideoPlaylist, ColumnName, QueryParams>>('table')
|
||||||
|
|
||||||
pagination: ComponentPagination = {
|
user: AuthUser
|
||||||
currentPage: 1,
|
channels: (VideoChannel & { selected: boolean })[] = []
|
||||||
itemsPerPage: 5,
|
|
||||||
totalItems: null
|
|
||||||
}
|
|
||||||
|
|
||||||
onDataSubject = new Subject<any[]>()
|
onDataSubject = new Subject<any[]>()
|
||||||
|
|
||||||
search: string
|
columns: TableColumnInfo<ColumnName>[] = []
|
||||||
|
|
||||||
|
customUpdateUrl: typeof this._customUpdateUrl
|
||||||
|
customParseQueryParams: typeof this._customParseQueryParams
|
||||||
|
dataLoader: typeof this._dataLoader
|
||||||
|
hasExpandedRow: typeof this._hasExpandedRow
|
||||||
|
|
||||||
|
private playlistsAfterDrop: VideoPlaylist[] = []
|
||||||
|
private playlistsBeforeDrop: VideoPlaylist[] = []
|
||||||
|
private paginationStart = 0
|
||||||
|
|
||||||
|
constructor () {
|
||||||
|
this.customUpdateUrl = this._customUpdateUrl.bind(this)
|
||||||
|
this.customParseQueryParams = this._customParseQueryParams.bind(this)
|
||||||
|
this.dataLoader = this._dataLoader.bind(this)
|
||||||
|
this.hasExpandedRow = this._hasExpandedRow.bind(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit () {
|
||||||
|
this.headerService.setSearchHidden(true)
|
||||||
|
|
||||||
|
this.user = this.auth.getUser()
|
||||||
|
|
||||||
|
this.columns = [
|
||||||
|
{
|
||||||
|
id: 'videoChannelPosition',
|
||||||
|
label: $localize`Position`,
|
||||||
|
selected: true,
|
||||||
|
sortable: true,
|
||||||
|
isDisplayed: () => this.hasReorderableRows()
|
||||||
|
},
|
||||||
|
{ 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 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy () {
|
||||||
|
this.headerService.setSearchHidden(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private _customParseQueryParams (queryParams: QueryParams) {
|
||||||
|
this.user = this.auth.getUser()
|
||||||
|
this.channels = this.user.videoChannels.map(c => ({
|
||||||
|
...c,
|
||||||
|
|
||||||
|
selected: queryParams.channelName === c.name
|
||||||
|
}))
|
||||||
|
|
||||||
|
this.cdr.detectChanges()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
getNoResults (search?: string) {
|
||||||
|
if (search) {
|
||||||
|
return $localize`No playlists found matching your search.`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.getFilteredChannel()) {
|
||||||
|
return $localize`No playlists found in selected channels.`
|
||||||
|
}
|
||||||
|
|
||||||
|
return $localize`You don't have any playlists published yet.`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private _customUpdateUrl (): Partial<Record<keyof QueryParams, any>> {
|
||||||
|
return { channelName: this.getFilteredChannel()?.name }
|
||||||
|
}
|
||||||
|
|
||||||
async deleteVideoPlaylist (videoPlaylist: VideoPlaylist) {
|
async deleteVideoPlaylist (videoPlaylist: VideoPlaylist) {
|
||||||
const res = await this.confirmService.confirm(
|
const res = await this.confirmService.confirm(
|
||||||
|
@ -58,8 +151,7 @@ export class MyVideoPlaylistsComponent {
|
||||||
this.videoPlaylistService.removeVideoPlaylist(videoPlaylist)
|
this.videoPlaylistService.removeVideoPlaylist(videoPlaylist)
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
this.videoPlaylists = this.videoPlaylists.filter(p => p.id !== videoPlaylist.id)
|
this.table().loadData()
|
||||||
updatePaginationOnDelete(this.pagination)
|
|
||||||
|
|
||||||
this.notifier.success($localize`Playlist ${videoPlaylist.displayName} deleted.`)
|
this.notifier.success($localize`Playlist ${videoPlaylist.displayName} deleted.`)
|
||||||
},
|
},
|
||||||
|
@ -72,41 +164,95 @@ export class MyVideoPlaylistsComponent {
|
||||||
return playlist.type.id === VideoPlaylistType.REGULAR
|
return playlist.type.id === VideoPlaylistType.REGULAR
|
||||||
}
|
}
|
||||||
|
|
||||||
onNearOfBottom () {
|
onRowReorder (event: TableRowReorderEvent) {
|
||||||
// Last page
|
const { dragIndex, dropIndex } = event
|
||||||
if (this.pagination.totalItems <= (this.pagination.currentPage * this.pagination.itemsPerPage)) return
|
|
||||||
|
|
||||||
this.pagination.currentPage += 1
|
// PrimeNG index takes into account the pagination
|
||||||
this.loadVideoPlaylists()
|
const previousIndex = dragIndex - this.paginationStart
|
||||||
|
const newIndex = dropIndex - this.paginationStart
|
||||||
|
|
||||||
|
const dragPlaylist = this.playlistsBeforeDrop[previousIndex]
|
||||||
|
const dropAfterPlaylist = this.playlistsBeforeDrop[newIndex]
|
||||||
|
|
||||||
|
debugLogger('onRowReorder', { previousIndex, newIndex, dragPlaylist, dropAfterPlaylist })
|
||||||
|
|
||||||
|
if (previousIndex === newIndex) return
|
||||||
|
|
||||||
|
const oldPosition = dragPlaylist.videoChannelPosition
|
||||||
|
let insertAfter = dropAfterPlaylist.videoChannelPosition
|
||||||
|
|
||||||
|
if (oldPosition > insertAfter) insertAfter--
|
||||||
|
|
||||||
|
debugLogger('Will reorder', { oldPosition, insertAfter })
|
||||||
|
|
||||||
|
for (let i = 1; i <= this.playlistsAfterDrop.length; i++) {
|
||||||
|
this.playlistsAfterDrop[i - 1].videoChannelPosition = i + this.paginationStart
|
||||||
|
}
|
||||||
|
|
||||||
|
this.videoPlaylistService.reorderPlaylistsOfChannel(this.getFilteredChannel().name, oldPosition, insertAfter)
|
||||||
|
.subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.table().loadData({ skipLoader: !this.screenService.isInTouchScreen() })
|
||||||
|
|
||||||
|
this.notifier.success($localize`Playlists reordered`)
|
||||||
|
},
|
||||||
|
|
||||||
|
error: err => this.notifier.error(err.message)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
onSearch (search: string) {
|
onChannelFilter (channel: VideoChannel & { selected: boolean }) {
|
||||||
this.search = search
|
for (const c of this.channels) {
|
||||||
resetCurrentPage(this.pagination)
|
if (c.id !== channel.id) {
|
||||||
|
c.selected = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.loadVideoPlaylists(true)
|
this.table().onFilter()
|
||||||
}
|
}
|
||||||
|
|
||||||
getTotalTitle () {
|
hasReorderableRows () {
|
||||||
return formatICU(
|
return !!this.getFilteredChannel() || this.user.videoChannels.length === 1
|
||||||
$localize`${this.pagination.totalItems} {total, plural, =1 {playlist} other {playlists}}`,
|
}
|
||||||
{ total: this.pagination.totalItems }
|
|
||||||
|
private _dataLoader (options: {
|
||||||
|
pagination: RestPagination
|
||||||
|
sort: SortMeta
|
||||||
|
search: string
|
||||||
|
}) {
|
||||||
|
const { pagination, sort, search } = options
|
||||||
|
|
||||||
|
this.paginationStart = pagination.start
|
||||||
|
|
||||||
|
const channel = this.getFilteredChannel()
|
||||||
|
const obs = channel
|
||||||
|
? this.videoPlaylistService.listChannelPlaylists({
|
||||||
|
videoChannel: { nameWithHost: Actor.CREATE_BY_STRING(channel.name, channel.host) },
|
||||||
|
restPagination: pagination,
|
||||||
|
sort,
|
||||||
|
search
|
||||||
|
})
|
||||||
|
: this.videoPlaylistService.listAccountPlaylists({
|
||||||
|
account: this.user.account,
|
||||||
|
restPagination: pagination,
|
||||||
|
sort,
|
||||||
|
search
|
||||||
|
})
|
||||||
|
|
||||||
|
// Keep a duplicate array of playlists to calculate the position before the drag and drop
|
||||||
|
return obs.pipe(
|
||||||
|
tap(({ data }) => {
|
||||||
|
this.playlistsAfterDrop = data
|
||||||
|
this.playlistsBeforeDrop = [ ...data ]
|
||||||
|
})
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private loadVideoPlaylists (reset = false) {
|
private _hasExpandedRow (playlist: VideoPlaylist) {
|
||||||
this.authService.userInformationLoaded
|
return !!playlist.description
|
||||||
.pipe(mergeMap(() => {
|
}
|
||||||
const user = this.authService.getUser()
|
|
||||||
|
|
||||||
return this.videoPlaylistService.listAccountPlaylists(user.account, this.pagination, '-updatedAt', this.search)
|
getFilteredChannel () {
|
||||||
})).subscribe(res => {
|
return this.channels.find(c => c.selected)
|
||||||
if (reset) this.videoPlaylists = []
|
|
||||||
|
|
||||||
this.videoPlaylists = this.videoPlaylists.concat(res.data)
|
|
||||||
this.pagination.totalItems = res.total
|
|
||||||
|
|
||||||
this.onDataSubject.next(res.data)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
<div class="channel-filters">
|
<div class="channel-filters">
|
||||||
<div class="channels-label" i18n>Per channel:</div>
|
<div class="channel-label" i18n>Per channel:</div>
|
||||||
|
|
||||||
@for (channel of channels; track channel.id) {
|
@for (channel of channels; track channel.id) {
|
||||||
<my-channel-toggle [channel]="channel" [(ngModel)]="channel.selected" (ngModelChange)="table.onFilter()"></my-channel-toggle>
|
<my-channel-toggle [channel]="channel" [(ngModel)]="channel.selected" (ngModelChange)="table.onFilter()"></my-channel-toggle>
|
||||||
|
@ -64,7 +64,7 @@
|
||||||
|
|
||||||
<td *ngIf="table.isColumnDisplayed('privacy')">
|
<td *ngIf="table.isColumnDisplayed('privacy')">
|
||||||
<div class="d-flex flex-wrap gap-1">
|
<div class="d-flex flex-wrap gap-1">
|
||||||
<my-video-privacy-badge [video]="video"></my-video-privacy-badge>
|
<my-privacy-badge [video]="video"></my-privacy-badge>
|
||||||
|
|
||||||
<span *ngIf="video.blacklisted" class="pt-badge badge-red" i18n [ngbTooltip]="video.blacklistedReason">Blocked</span>
|
<span *ngIf="video.blacklisted" class="pt-badge badge-red" i18n [ngbTooltip]="video.blacklistedReason">Blocked</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
@use "_variables" as *;
|
@use "_variables" as *;
|
||||||
@use "_mixins" as *;
|
@use "_mixins" as *;
|
||||||
|
@use "_actor" as *;
|
||||||
|
|
||||||
my-select-checkbox {
|
my-select-checkbox {
|
||||||
min-width: 250px;
|
min-width: 250px;
|
||||||
|
@ -34,24 +35,12 @@ td {
|
||||||
padding-bottom: 0.75rem;
|
padding-bottom: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.channel-label {
|
||||||
|
@include channel-label;
|
||||||
|
}
|
||||||
|
|
||||||
.channel-filters {
|
.channel-filters {
|
||||||
display: flex;
|
@include channel-filters;
|
||||||
align-items: center;
|
|
||||||
padding-bottom: 1rem;
|
|
||||||
border-bottom: 1px solid $separator-border-color;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
gap: 1rem;
|
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.channels-label {
|
|
||||||
color: pvar(--fg-200);
|
|
||||||
font-weight: $font-bold;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (min-width: $small-view) {
|
@media screen and (min-width: $small-view) {
|
||||||
|
|
|
@ -28,8 +28,8 @@ import {
|
||||||
VideoActionsDisplayType,
|
VideoActionsDisplayType,
|
||||||
VideoActionsDropdownComponent
|
VideoActionsDropdownComponent
|
||||||
} from '../../shared/shared-video-miniature/video-actions-dropdown.component'
|
} from '../../shared/shared-video-miniature/video-actions-dropdown.component'
|
||||||
|
import { PrivacyBadgeComponent } from '../../shared/shared-video/privacy-badge.component'
|
||||||
import { VideoNSFWBadgeComponent } from '../../shared/shared-video/video-nsfw-badge.component'
|
import { VideoNSFWBadgeComponent } from '../../shared/shared-video/video-nsfw-badge.component'
|
||||||
import { VideoPrivacyBadgeComponent } from '../../shared/shared-video/video-privacy-badge.component'
|
|
||||||
import { VideoStateBadgeComponent } from '../../shared/shared-video/video-state-badge.component'
|
import { VideoStateBadgeComponent } from '../../shared/shared-video/video-state-badge.component'
|
||||||
import { VideoChangeOwnershipComponent } from './modals/video-change-ownership.component'
|
import { VideoChangeOwnershipComponent } from './modals/video-change-ownership.component'
|
||||||
|
|
||||||
|
@ -40,7 +40,6 @@ type VideoType = 'live' | 'vod'
|
||||||
type QueryParams = TableQueryParams & {
|
type QueryParams = TableQueryParams & {
|
||||||
channelNameOneOf?: string[]
|
channelNameOneOf?: string[]
|
||||||
privacyOneOf?: string[]
|
privacyOneOf?: string[]
|
||||||
search?: string
|
|
||||||
videoType?: VideoType
|
videoType?: VideoType
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -59,13 +58,13 @@ type QueryParams = TableQueryParams & {
|
||||||
RouterLink,
|
RouterLink,
|
||||||
NumberFormatterPipe,
|
NumberFormatterPipe,
|
||||||
VideoChangeOwnershipComponent,
|
VideoChangeOwnershipComponent,
|
||||||
VideoPrivacyBadgeComponent,
|
|
||||||
VideoStateBadgeComponent,
|
VideoStateBadgeComponent,
|
||||||
ChannelToggleComponent,
|
ChannelToggleComponent,
|
||||||
SelectCheckboxComponent,
|
SelectCheckboxComponent,
|
||||||
PTDatePipe,
|
PTDatePipe,
|
||||||
VideoNSFWBadgeComponent,
|
VideoNSFWBadgeComponent,
|
||||||
TableComponent
|
TableComponent,
|
||||||
|
PrivacyBadgeComponent
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class MyVideosComponent implements OnInit, OnDestroy {
|
export class MyVideosComponent implements OnInit, OnDestroy {
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { NgFor, NgIf } from '@angular/common'
|
import { CommonModule } from '@angular/common'
|
||||||
import { AfterViewInit, Component, OnDestroy, OnInit, inject } from '@angular/core'
|
import { AfterViewInit, Component, inject, OnDestroy, OnInit } from '@angular/core'
|
||||||
import { ComponentPagination, hasMoreItems, HooksService, resetCurrentPage, ScreenService } from '@app/core'
|
import { ComponentPagination, hasMoreItems, HooksService, resetCurrentPage, ScreenService } from '@app/core'
|
||||||
import { VideoChannel } from '@app/shared/shared-main/channel/video-channel.model'
|
import { VideoChannel } from '@app/shared/shared-main/channel/video-channel.model'
|
||||||
import { VideoChannelService } from '@app/shared/shared-main/channel/video-channel.service'
|
import { VideoChannelService } from '@app/shared/shared-main/channel/video-channel.service'
|
||||||
|
@ -13,7 +13,7 @@ import { VideoPlaylistMiniatureComponent } from '../../shared/shared-video-playl
|
||||||
selector: 'my-video-channel-playlists',
|
selector: 'my-video-channel-playlists',
|
||||||
templateUrl: './video-channel-playlists.component.html',
|
templateUrl: './video-channel-playlists.component.html',
|
||||||
styleUrls: [ './video-channel-playlists.component.scss' ],
|
styleUrls: [ './video-channel-playlists.component.scss' ],
|
||||||
imports: [ NgIf, InfiniteScrollerDirective, NgFor, VideoPlaylistMiniatureComponent ]
|
imports: [ CommonModule, InfiniteScrollerDirective, VideoPlaylistMiniatureComponent ]
|
||||||
})
|
})
|
||||||
export class VideoChannelPlaylistsComponent implements OnInit, AfterViewInit, OnDestroy {
|
export class VideoChannelPlaylistsComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||||
private videoPlaylistService = inject(VideoPlaylistService)
|
private videoPlaylistService = inject(VideoPlaylistService)
|
||||||
|
@ -68,14 +68,17 @@ export class VideoChannelPlaylistsComponent implements OnInit, AfterViewInit, On
|
||||||
}
|
}
|
||||||
|
|
||||||
private loadVideoPlaylists () {
|
private loadVideoPlaylists () {
|
||||||
this.videoPlaylistService.listChannelPlaylists(this.videoChannel, this.pagination)
|
this.videoPlaylistService.listChannelPlaylists({
|
||||||
.subscribe(res => {
|
videoChannel: this.videoChannel,
|
||||||
this.videoPlaylists = this.videoPlaylists.concat(res.data)
|
componentPagination: this.pagination,
|
||||||
this.pagination.totalItems = res.total
|
sort: 'videoChannelPosition'
|
||||||
|
}).subscribe(res => {
|
||||||
|
this.videoPlaylists = this.videoPlaylists.concat(res.data)
|
||||||
|
this.pagination.totalItems = res.total
|
||||||
|
|
||||||
this.hooks.runAction('action:video-channel-playlists.playlists.loaded', 'video-channel', { playlists: this.videoPlaylists })
|
this.hooks.runAction('action:video-channel-playlists.playlists.loaded', 'video-channel', { playlists: this.videoPlaylists })
|
||||||
|
|
||||||
this.onDataSubject.next(res.data)
|
this.onDataSubject.next(res.data)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -89,7 +89,7 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
dropPoint: {
|
dropPoint: {
|
||||||
color: '{primary.color}'
|
color: 'var(--border-primary)'
|
||||||
},
|
},
|
||||||
columnResizer: {
|
columnResizer: {
|
||||||
width: '0.5rem'
|
width: '0.5rem'
|
||||||
|
|
|
@ -97,6 +97,7 @@ const icons = {
|
||||||
'user-add': require('../../../assets/images/feather/user-plus.svg'),
|
'user-add': require('../../../assets/images/feather/user-plus.svg'),
|
||||||
'user-x': require('../../../assets/images/feather/user-x.svg'),
|
'user-x': require('../../../assets/images/feather/user-x.svg'),
|
||||||
'user': require('../../../assets/images/feather/user.svg'),
|
'user': require('../../../assets/images/feather/user.svg'),
|
||||||
|
'grip-horizontal': require('../../../assets/images/feather/grip-horizontal.svg'),
|
||||||
'users': require('../../../assets/images/feather/users.svg')
|
'users': require('../../../assets/images/feather/users.svg')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -19,6 +19,7 @@
|
||||||
[expandedRowKeys]="expandedRows"
|
[expandedRowKeys]="expandedRows"
|
||||||
(onRowExpand)="rowExpand.emit($event)"
|
(onRowExpand)="rowExpand.emit($event)"
|
||||||
[ngClass]="{ loading: loading, 'sticky-table': actionCell }"
|
[ngClass]="{ loading: loading, 'sticky-table': actionCell }"
|
||||||
|
(onRowReorder)="rowReorder.emit($event)"
|
||||||
>
|
>
|
||||||
<ng-template #caption>
|
<ng-template #caption>
|
||||||
<div class="caption">
|
<div class="caption">
|
||||||
|
@ -62,6 +63,10 @@
|
||||||
<p-tableHeaderCheckbox ariaLabel="Select all rows" i18n-ariaLabel></p-tableHeaderCheckbox>
|
<p-tableHeaderCheckbox ariaLabel="Select all rows" i18n-ariaLabel></p-tableHeaderCheckbox>
|
||||||
</th>
|
</th>
|
||||||
|
|
||||||
|
@if (reorderableRows()) {
|
||||||
|
<th scope="col" width="25px"></th>
|
||||||
|
}
|
||||||
|
|
||||||
<th *ngIf="expandedRow">
|
<th *ngIf="expandedRow">
|
||||||
<div class="visually-hidden">{{ expandedIconTooltip() }}</div>
|
<div class="visually-hidden">{{ expandedIconTooltip() }}</div>
|
||||||
</th>
|
</th>
|
||||||
|
@ -69,9 +74,7 @@
|
||||||
@for (column of columns; track column.id) {
|
@for (column of columns; track column.id) {
|
||||||
@if (isColumnDisplayed(column.id)) {
|
@if (isColumnDisplayed(column.id)) {
|
||||||
@if (column.sortable) {
|
@if (column.sortable) {
|
||||||
<th
|
<th scope="col" [ngbTooltip]="sortTooltip" container="body" [pSortableColumn]="getUntypedColumnId(column)" [ngClass]="column.class">
|
||||||
scope="col" [ngbTooltip]="sortTooltip" container="body" [pSortableColumn]="getUntypedColumnId(column)" [ngClass]="column.class"
|
|
||||||
>
|
|
||||||
{{ column.label }}
|
{{ column.label }}
|
||||||
<small *ngIf="column.labelSmall">{{ column.labelSmall }}</small>
|
<small *ngIf="column.labelSmall">{{ column.labelSmall }}</small>
|
||||||
|
|
||||||
|
@ -126,14 +129,39 @@
|
||||||
</tr>
|
</tr>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
|
||||||
<ng-template #body let-item let-expanded="expanded">
|
<ng-template #body let-item let-expanded="expanded" let-index="rowIndex">
|
||||||
<tr [pSelectableRow]="item">
|
<tr [pSelectableRow]="item" [pReorderableRow]="index" [pSelectableRowDisabled]="!reorderableRows()">
|
||||||
|
@if (reorderableRows()) {
|
||||||
|
<td>
|
||||||
|
@if (inInTouchScreen()) {
|
||||||
|
@let position = index + pagination.start;
|
||||||
|
|
||||||
|
@if (index !== 0) {
|
||||||
|
<my-button theme="tertiary" icon="arrow-down" class="rotate-180" i18n-title title="Move up" (click)="rowReorder.emit({ dragIndex: position, dropIndex: position - 1 })"></my-button>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (index !== totalRecords - 1) {
|
||||||
|
<my-button theme="tertiary" icon="arrow-down" i18n-title title="Move down" (click)="rowReorder.emit({ dragIndex: position, dropIndex: position + 1 })"></my-button>
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
|
<button class="button-unstyle" [title]="dragHandleTitle()" pReorderableRowHandle>
|
||||||
|
<my-global-icon iconName="grip-horizontal"></my-global-icon>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
}
|
||||||
|
|
||||||
<td *ngIf="hasBulkActions()" class="checkbox-cell">
|
<td *ngIf="hasBulkActions()" class="checkbox-cell">
|
||||||
<p-tableCheckbox [value]="item" ariaLabel="Select this row" i18n-ariaLabel></p-tableCheckbox>
|
<p-tableCheckbox [value]="item" ariaLabel="Select this row" i18n-ariaLabel></p-tableCheckbox>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td *ngIf="expandedRow" class="expand-cell">
|
<td *ngIf="expandedRow" class="expand-cell">
|
||||||
<my-table-expander-icon *ngIf="hasExpandedRow()(item)" [pRowToggler]="item" [tooltip]="expandedIconTooltip()" [expanded]="expanded"></my-table-expander-icon>
|
<my-table-expander-icon
|
||||||
|
*ngIf="hasExpandedRow()(item)"
|
||||||
|
[pRowToggler]="item"
|
||||||
|
[tooltip]="expandedIconTooltip()"
|
||||||
|
[expanded]="expanded"
|
||||||
|
></my-table-expander-icon>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<ng-template *ngTemplateOutlet="tableCells; context: { $implicit: item }"></ng-template>
|
<ng-template *ngTemplateOutlet="tableCells; context: { $implicit: item }"></ng-template>
|
||||||
|
|
|
@ -14,16 +14,17 @@ import {
|
||||||
} from '@angular/core'
|
} from '@angular/core'
|
||||||
import { FormsModule } from '@angular/forms'
|
import { FormsModule } from '@angular/forms'
|
||||||
import { ActivatedRoute } from '@angular/router'
|
import { ActivatedRoute } from '@angular/router'
|
||||||
import { LocalStorageService, Notifier, PeerTubeRouterService, RestPagination } from '@app/core'
|
import { LocalStorageService, Notifier, PeerTubeRouterService, RestPagination, ScreenService } from '@app/core'
|
||||||
import { NgbDropdownModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbDropdownModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { ResultList } from '@peertube/peertube-models'
|
import { ResultList } from '@peertube/peertube-models'
|
||||||
import { logger } from '@root-helpers/logger'
|
import { logger } from '@root-helpers/logger'
|
||||||
import { peertubeLocalStorage } from '@root-helpers/peertube-web-storage'
|
import { peertubeLocalStorage } from '@root-helpers/peertube-web-storage'
|
||||||
import debug from 'debug'
|
import debug from 'debug'
|
||||||
import { SharedModule, SortMeta } from 'primeng/api'
|
import { SharedModule, SortMeta } from 'primeng/api'
|
||||||
import { TableLazyLoadEvent, TableModule, TableRowExpandEvent } from 'primeng/table'
|
import { TableLazyLoadEvent, TableModule, TableRowExpandEvent, TableRowReorderEvent } from 'primeng/table'
|
||||||
import { finalize, Observable, Subscription } from 'rxjs'
|
import { finalize, Observable, Subscription } from 'rxjs'
|
||||||
import { PeertubeCheckboxComponent } from '../shared-forms/peertube-checkbox.component'
|
import { PeertubeCheckboxComponent } from '../shared-forms/peertube-checkbox.component'
|
||||||
|
import { GlobalIconComponent } from '../shared-icons/global-icon.component'
|
||||||
import { ActionDropdownComponent, DropdownAction } from '../shared-main/buttons/action-dropdown.component'
|
import { ActionDropdownComponent, DropdownAction } from '../shared-main/buttons/action-dropdown.component'
|
||||||
import { ButtonComponent } from '../shared-main/buttons/button.component'
|
import { ButtonComponent } from '../shared-main/buttons/button.component'
|
||||||
import { AutoColspanDirective } from '../shared-main/common/auto-colspan.directive'
|
import { AutoColspanDirective } from '../shared-main/common/auto-colspan.directive'
|
||||||
|
@ -77,7 +78,8 @@ type BulkActions<Data> = DropdownAction<Data[]>[][] | DropdownAction<Data[]>[]
|
||||||
NgbDropdownModule,
|
NgbDropdownModule,
|
||||||
PeertubeCheckboxComponent,
|
PeertubeCheckboxComponent,
|
||||||
AutoColspanDirective,
|
AutoColspanDirective,
|
||||||
TableExpanderIconComponent
|
TableExpanderIconComponent,
|
||||||
|
GlobalIconComponent
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class TableComponent<Data, ColumnName = string, QueryParams extends TableQueryParams = TableQueryParams>
|
export class TableComponent<Data, ColumnName = string, QueryParams extends TableQueryParams = TableQueryParams>
|
||||||
|
@ -87,12 +89,16 @@ export class TableComponent<Data, ColumnName = string, QueryParams extends Table
|
||||||
private route = inject(ActivatedRoute)
|
private route = inject(ActivatedRoute)
|
||||||
private peertubeRouter = inject(PeerTubeRouterService)
|
private peertubeRouter = inject(PeerTubeRouterService)
|
||||||
private notifier = inject(Notifier)
|
private notifier = inject(Notifier)
|
||||||
|
private screenService = inject(ScreenService)
|
||||||
|
|
||||||
readonly key = input.required<string>()
|
readonly key = input.required<string>()
|
||||||
readonly dataKey = input<string>('id')
|
readonly dataKey = input<string>('id')
|
||||||
readonly defaultColumns = input.required<TableColumnInfo<ColumnName>[]>()
|
readonly defaultColumns = input.required<TableColumnInfo<ColumnName>[]>()
|
||||||
readonly dataLoader = input.required<DataLoader<Data>>()
|
readonly dataLoader = input.required<DataLoader<Data>>()
|
||||||
|
|
||||||
|
readonly reorderableRows = input(false, { transform: booleanAttribute })
|
||||||
|
readonly dragHandleTitle = input<string>(undefined)
|
||||||
|
|
||||||
readonly defaultSort = input<string>('createdAt')
|
readonly defaultSort = input<string>('createdAt')
|
||||||
readonly defaultSortOrder = input<'asc' | 'desc'>('desc')
|
readonly defaultSortOrder = input<'asc' | 'desc'>('desc')
|
||||||
|
|
||||||
|
@ -132,6 +138,7 @@ export class TableComponent<Data, ColumnName = string, QueryParams extends Table
|
||||||
expandedRow: TemplateRef<any>
|
expandedRow: TemplateRef<any>
|
||||||
|
|
||||||
readonly rowExpand = output<TableRowExpandEvent>()
|
readonly rowExpand = output<TableRowExpandEvent>()
|
||||||
|
readonly rowReorder = output<TableRowReorderEvent>()
|
||||||
|
|
||||||
selectedRows: Data[] = []
|
selectedRows: Data[] = []
|
||||||
expandedRows = {}
|
expandedRows = {}
|
||||||
|
@ -256,28 +263,30 @@ export class TableComponent<Data, ColumnName = string, QueryParams extends Table
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
saveSelectedColumns () {
|
saveSelectedColumns () {
|
||||||
const enabled = this.columns.filter(c => c.selected !== false).map(c => c.id)
|
const enabled = this.columns.filter(c => c.selected === false).map(c => c.id)
|
||||||
|
|
||||||
this.peertubeLocalStorage.setItem(this.getColumnLocalStorageKey(), JSON.stringify(enabled))
|
this.peertubeLocalStorage.setItem(this.getColumnDisabledLocalStorageKey(), JSON.stringify(enabled))
|
||||||
}
|
}
|
||||||
|
|
||||||
private loadSelectedColumns () {
|
private loadSelectedColumns () {
|
||||||
const enabledString = this.peertubeLocalStorage.getItem(this.getColumnLocalStorageKey())
|
const disabledString = this.peertubeLocalStorage.getItem(this.getColumnDisabledLocalStorageKey())
|
||||||
|
if (!disabledString) return
|
||||||
|
|
||||||
if (!enabledString) return
|
|
||||||
try {
|
try {
|
||||||
const enabled = JSON.parse(enabledString)
|
const disabled = JSON.parse(disabledString)
|
||||||
|
|
||||||
for (const column of this.columns) {
|
for (const column of this.columns) {
|
||||||
column.selected = enabled.includes(column.id)
|
if (!disabled.includes(column.id)) continue
|
||||||
|
|
||||||
|
column.selected = false
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('Cannot load selected columns.', err)
|
logger.error('Cannot load selected columns.', err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private getColumnLocalStorageKey () {
|
private getColumnDisabledLocalStorageKey () {
|
||||||
return 'rest-table-columns-' + this.key()
|
return 'rest-table-columns-disabled-' + this.key()
|
||||||
}
|
}
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@ -393,6 +402,10 @@ export class TableComponent<Data, ColumnName = string, QueryParams extends Table
|
||||||
return this.selectedRows.length !== 0
|
return this.selectedRows.length !== 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
inInTouchScreen () {
|
||||||
|
return this.screenService.isInTouchScreen()
|
||||||
|
}
|
||||||
|
|
||||||
getPaginationTemplate () {
|
getPaginationTemplate () {
|
||||||
return $localize`Showing {first} to {last} of {totalRecords} elements`
|
return $localize`Showing {first} to {last} of {totalRecords} elements`
|
||||||
}
|
}
|
||||||
|
@ -433,8 +446,12 @@ export class TableComponent<Data, ColumnName = string, QueryParams extends Table
|
||||||
return this.getRandomBadge(type, value)
|
return this.getRandomBadge(type, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
loadData () {
|
loadData (options: {
|
||||||
this.loading = true
|
skipLoader?: boolean // default false
|
||||||
|
} = {}) {
|
||||||
|
const { skipLoader = false } = options
|
||||||
|
|
||||||
|
if (!skipLoader) this.loading = true
|
||||||
|
|
||||||
return new Promise<void>((res, rej) => {
|
return new Promise<void>((res, rej) => {
|
||||||
this.dataLoader()({
|
this.dataLoader()({
|
||||||
|
|
|
@ -14,28 +14,30 @@
|
||||||
</div>
|
</div>
|
||||||
</my-link>
|
</my-link>
|
||||||
|
|
||||||
<div class="miniature-info">
|
@if (!thumbnailOnly()) {
|
||||||
<my-link
|
<div class="miniature-info">
|
||||||
[internalLink]="playlistRouterLink" [href]="playlistHref" [target]="playlistTarget" inheritParentStyle="true" inheritParentDimension="true"
|
<my-link
|
||||||
[title]="playlist().description" class="miniature-name" className="ellipsis-multiline-2"
|
[internalLink]="playlistRouterLink" [href]="playlistHref" [target]="playlistTarget" inheritParentStyle="true" inheritParentDimension="true"
|
||||||
>
|
[title]="playlist().description" class="miniature-name" className="ellipsis-multiline-2"
|
||||||
{{ playlist().displayName }}
|
>
|
||||||
</my-link>
|
{{ playlist().displayName }}
|
||||||
|
</my-link>
|
||||||
|
|
||||||
<my-link
|
<my-link
|
||||||
*ngIf="displayChannel() && playlist().videoChannelBy"
|
*ngIf="displayChannel() && playlist().videoChannelBy"
|
||||||
class="by"
|
class="by"
|
||||||
[internalLink]="ownerRouterLink" [href]="ownerHref" [target]="ownerTarget" inheritParentStyle="true"
|
[internalLink]="ownerRouterLink" [href]="ownerHref" [target]="ownerTarget" inheritParentStyle="true"
|
||||||
>
|
>
|
||||||
{{ playlist().videoChannelBy }}
|
{{ playlist().videoChannelBy }}
|
||||||
</my-link>
|
</my-link>
|
||||||
|
|
||||||
<div class="privacy-date">
|
<div class="privacy-date">
|
||||||
<span class="privacy" *ngIf="displayPrivacy()">{{ playlist().privacy.label }}</span>
|
<span class="privacy" *ngIf="displayPrivacy()">{{ playlist().privacy.label }}</span>
|
||||||
|
|
||||||
<span i18n class="updated-at">Updated {{ playlist().updatedAt | myFromNow }}</span>
|
<span i18n class="updated-at">Updated {{ playlist().updatedAt | myFromNow }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div *ngIf="displayDescription()" class="description" [innerHTML]="playlistDescription"></div>
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
<div *ngIf="displayDescription()" class="description" [innerHTML]="playlistDescription"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { NgClass, NgIf } from '@angular/common'
|
import { NgClass, NgIf } from '@angular/common'
|
||||||
import { Component, OnInit, inject, input } from '@angular/core'
|
import { Component, OnInit, booleanAttribute, inject, input } from '@angular/core'
|
||||||
import { MarkdownService } from '@app/core'
|
import { MarkdownService } from '@app/core'
|
||||||
import { LinkType } from 'src/types/link.type'
|
import { LinkType } from 'src/types/link.type'
|
||||||
import { LinkComponent } from '../shared-main/common/link.component'
|
import { LinkComponent } from '../shared-main/common/link.component'
|
||||||
|
@ -19,10 +19,12 @@ export class VideoPlaylistMiniatureComponent implements OnInit {
|
||||||
|
|
||||||
readonly toManage = input(false)
|
readonly toManage = input(false)
|
||||||
|
|
||||||
readonly displayChannel = input(false)
|
readonly thumbnailOnly = input(false, { transform: booleanAttribute })
|
||||||
readonly displayDescription = input(false)
|
|
||||||
readonly displayPrivacy = input(false)
|
readonly displayChannel = input(false, { transform: booleanAttribute })
|
||||||
readonly displayAsRow = input(false)
|
readonly displayDescription = input(false, { transform: booleanAttribute })
|
||||||
|
readonly displayPrivacy = input(false, { transform: booleanAttribute })
|
||||||
|
readonly displayAsRow = input(false, { transform: booleanAttribute })
|
||||||
|
|
||||||
readonly linkType = input<LinkType>('internal')
|
readonly linkType = input<LinkType>('internal')
|
||||||
|
|
||||||
|
|
|
@ -32,6 +32,8 @@ export class VideoPlaylist implements ServerVideoPlaylist {
|
||||||
updatedAt: Date | string
|
updatedAt: Date | string
|
||||||
|
|
||||||
ownerAccount: AccountSummary
|
ownerAccount: AccountSummary
|
||||||
|
|
||||||
|
videoChannelPosition: number
|
||||||
videoChannel?: VideoChannelSummary
|
videoChannel?: VideoChannelSummary
|
||||||
|
|
||||||
thumbnailPath: string
|
thumbnailPath: string
|
||||||
|
@ -80,6 +82,8 @@ export class VideoPlaylist implements ServerVideoPlaylist {
|
||||||
this.ownerAccount = hash.ownerAccount
|
this.ownerAccount = hash.ownerAccount
|
||||||
this.ownerBy = Actor.CREATE_BY_STRING(hash.ownerAccount.name, hash.ownerAccount.host)
|
this.ownerBy = Actor.CREATE_BY_STRING(hash.ownerAccount.name, hash.ownerAccount.host)
|
||||||
|
|
||||||
|
this.videoChannelPosition = hash.videoChannelPosition
|
||||||
|
|
||||||
if (hash.videoChannel) {
|
if (hash.videoChannel) {
|
||||||
this.videoChannel = hash.videoChannel
|
this.videoChannel = hash.videoChannel
|
||||||
this.videoChannelBy = Actor.CREATE_BY_STRING(hash.videoChannel.name, hash.videoChannel.host)
|
this.videoChannelBy = Actor.CREATE_BY_STRING(hash.videoChannel.name, hash.videoChannel.host)
|
||||||
|
|
|
@ -1,32 +1,33 @@
|
||||||
import debug from 'debug'
|
|
||||||
import { merge, Observable, of, ReplaySubject, Subject } from 'rxjs'
|
|
||||||
import { catchError, filter, map, share, switchMap, tap } from 'rxjs/operators'
|
|
||||||
import { HttpClient, HttpContext, HttpParams } from '@angular/common/http'
|
import { HttpClient, HttpContext, HttpParams } from '@angular/common/http'
|
||||||
import { Injectable, inject } from '@angular/core'
|
import { inject, Injectable } from '@angular/core'
|
||||||
import { AuthService, AuthUser, ComponentPaginationLight, RestExtractor, RestService, ServerService } from '@app/core'
|
import { AuthService, AuthUser, ComponentPaginationLight, RestExtractor, RestPagination, RestService, ServerService } from '@app/core'
|
||||||
import { buildBulkObservable, objectToFormData } from '@app/helpers'
|
import { buildBulkObservable, objectToFormData } from '@app/helpers'
|
||||||
import { NGX_LOADING_BAR_IGNORED } from '@ngx-loading-bar/http-client'
|
import { NGX_LOADING_BAR_IGNORED } from '@ngx-loading-bar/http-client'
|
||||||
import {
|
import {
|
||||||
CachedVideoExistInPlaylist,
|
CachedVideoExistInPlaylist,
|
||||||
CachedVideosExistInPlaylists,
|
CachedVideosExistInPlaylists,
|
||||||
ResultList,
|
ResultList,
|
||||||
VideoExistInPlaylist,
|
|
||||||
VideoPlaylist as VideoPlaylistServerModel,
|
|
||||||
VideoPlaylistCreate,
|
|
||||||
VideoPlaylistElement as ServerVideoPlaylistElement,
|
VideoPlaylistElement as ServerVideoPlaylistElement,
|
||||||
|
VideoExistInPlaylist,
|
||||||
|
VideoPlaylistCreate,
|
||||||
VideoPlaylistElementCreate,
|
VideoPlaylistElementCreate,
|
||||||
VideoPlaylistElementUpdate,
|
VideoPlaylistElementUpdate,
|
||||||
VideoPlaylistReorder,
|
VideoPlaylistReorder,
|
||||||
|
VideoPlaylist as VideoPlaylistServerModel,
|
||||||
VideoPlaylistUpdate,
|
VideoPlaylistUpdate,
|
||||||
VideosExistInPlaylists
|
VideosExistInPlaylists
|
||||||
} from '@peertube/peertube-models'
|
} from '@peertube/peertube-models'
|
||||||
|
import debug from 'debug'
|
||||||
|
import { SortMeta } from 'primeng/api'
|
||||||
|
import { merge, Observable, of, ReplaySubject, Subject } from 'rxjs'
|
||||||
|
import { catchError, filter, map, share, switchMap, tap } from 'rxjs/operators'
|
||||||
import { environment } from '../../../environments/environment'
|
import { environment } from '../../../environments/environment'
|
||||||
import { VideoPlaylistElement } from './video-playlist-element.model'
|
import { Account } from '../shared-main/account/account.model'
|
||||||
import { VideoPlaylist } from './video-playlist.model'
|
import { AccountService } from '../shared-main/account/account.service'
|
||||||
import { VideoChannel } from '../shared-main/channel/video-channel.model'
|
import { VideoChannel } from '../shared-main/channel/video-channel.model'
|
||||||
import { VideoChannelService } from '../shared-main/channel/video-channel.service'
|
import { VideoChannelService } from '../shared-main/channel/video-channel.service'
|
||||||
import { AccountService } from '../shared-main/account/account.service'
|
import { VideoPlaylistElement } from './video-playlist-element.model'
|
||||||
import { Account } from '../shared-main/account/account.model'
|
import { VideoPlaylist } from './video-playlist.model'
|
||||||
|
|
||||||
const debugLogger = debug('peertube:playlists:VideoPlaylistService')
|
const debugLogger = debug('peertube:playlists:VideoPlaylistService')
|
||||||
|
|
||||||
|
@ -73,12 +74,48 @@ export class VideoPlaylistService {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
listChannelPlaylists (videoChannel: VideoChannel, componentPagination: ComponentPaginationLight): Observable<ResultList<VideoPlaylist>> {
|
listChannelPlaylists (options: {
|
||||||
|
videoChannel: Pick<VideoChannel, 'nameWithHost'>
|
||||||
|
sort?: SortMeta | string
|
||||||
|
componentPagination?: ComponentPaginationLight
|
||||||
|
restPagination?: RestPagination
|
||||||
|
search?: string
|
||||||
|
}): Observable<ResultList<VideoPlaylist>> {
|
||||||
|
const { videoChannel } = options
|
||||||
|
|
||||||
const url = VideoChannelService.BASE_VIDEO_CHANNEL_URL + videoChannel.nameWithHost + '/video-playlists'
|
const url = VideoChannelService.BASE_VIDEO_CHANNEL_URL + videoChannel.nameWithHost + '/video-playlists'
|
||||||
const pagination = this.restService.componentToRestPagination(componentPagination)
|
|
||||||
|
return this.listPlaylists({ url, ...options })
|
||||||
|
}
|
||||||
|
|
||||||
|
listAccountPlaylists (options: {
|
||||||
|
account: Account
|
||||||
|
sort: SortMeta | string
|
||||||
|
restPagination?: RestPagination
|
||||||
|
search?: string
|
||||||
|
}): Observable<ResultList<VideoPlaylist>> {
|
||||||
|
const { account } = options
|
||||||
|
|
||||||
|
const url = AccountService.BASE_ACCOUNT_URL + account.nameWithHost + '/video-playlists'
|
||||||
|
|
||||||
|
return this.listPlaylists({ url, ...options })
|
||||||
|
}
|
||||||
|
|
||||||
|
private listPlaylists (options: {
|
||||||
|
url: string
|
||||||
|
sort?: SortMeta | string
|
||||||
|
componentPagination?: ComponentPaginationLight
|
||||||
|
restPagination?: RestPagination
|
||||||
|
search?: string
|
||||||
|
}) {
|
||||||
|
const { url, sort, search } = options
|
||||||
|
|
||||||
let params = new HttpParams()
|
let params = new HttpParams()
|
||||||
params = this.restService.addRestGetParams(params, pagination)
|
|
||||||
|
const restPagination = options.restPagination ?? this.restService.componentToRestPagination(options.componentPagination)
|
||||||
|
params = this.restService.addRestGetParams(params, restPagination, sort)
|
||||||
|
|
||||||
|
if (search) params = this.restService.addObjectParams(params, { search })
|
||||||
|
|
||||||
return this.authHttp.get<ResultList<VideoPlaylist>>(url, { params })
|
return this.authHttp.get<ResultList<VideoPlaylist>>(url, { params })
|
||||||
.pipe(
|
.pipe(
|
||||||
|
@ -93,42 +130,25 @@ export class VideoPlaylistService {
|
||||||
if (this.myAccountPlaylistCache) return of(this.myAccountPlaylistCache)
|
if (this.myAccountPlaylistCache) return of(this.myAccountPlaylistCache)
|
||||||
}
|
}
|
||||||
|
|
||||||
const obs = this.listAccountPlaylists(user.account, undefined, '-updatedAt', search)
|
const obs = this.listAccountPlaylists({
|
||||||
.pipe(
|
account: user.account,
|
||||||
tap(result => {
|
sort: '-updatedAt',
|
||||||
if (!search) {
|
search
|
||||||
this.myAccountPlaylistCacheRunning = undefined
|
}).pipe(
|
||||||
this.myAccountPlaylistCache = result
|
tap(result => {
|
||||||
}
|
if (!search) {
|
||||||
}),
|
this.myAccountPlaylistCacheRunning = undefined
|
||||||
share()
|
this.myAccountPlaylistCache = result
|
||||||
)
|
}
|
||||||
|
}),
|
||||||
|
share()
|
||||||
|
)
|
||||||
|
|
||||||
if (!search) this.myAccountPlaylistCacheRunning = obs
|
if (!search) this.myAccountPlaylistCacheRunning = obs
|
||||||
return obs
|
return obs
|
||||||
}
|
}
|
||||||
|
|
||||||
listAccountPlaylists (
|
// ---------------------------------------------------------------------------
|
||||||
account: Account,
|
|
||||||
componentPagination: ComponentPaginationLight,
|
|
||||||
sort: string,
|
|
||||||
search?: string
|
|
||||||
): Observable<ResultList<VideoPlaylist>> {
|
|
||||||
const url = AccountService.BASE_ACCOUNT_URL + account.nameWithHost + '/video-playlists'
|
|
||||||
const pagination = componentPagination
|
|
||||||
? this.restService.componentToRestPagination(componentPagination)
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
let params = new HttpParams()
|
|
||||||
params = this.restService.addRestGetParams(params, pagination, sort)
|
|
||||||
if (search) params = this.restService.addObjectParams(params, { search })
|
|
||||||
|
|
||||||
return this.authHttp.get<ResultList<VideoPlaylist>>(url, { params })
|
|
||||||
.pipe(
|
|
||||||
switchMap(res => this.extractPlaylists(res)),
|
|
||||||
catchError(err => this.restExtractor.handleError(err))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
getVideoPlaylist (id: string | number) {
|
getVideoPlaylist (id: string | number) {
|
||||||
const url = VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + id
|
const url = VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + id
|
||||||
|
@ -258,7 +278,19 @@ export class VideoPlaylistService {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
reorderPlaylist (playlistId: number, oldPosition: number, newPosition: number) {
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
reorderPlaylistsOfChannel (channelName: string, oldPosition: number, newPosition: number) {
|
||||||
|
const body: VideoPlaylistReorder = {
|
||||||
|
startPosition: oldPosition,
|
||||||
|
insertAfterPosition: newPosition
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.authHttp.post(VideoChannelService.BASE_VIDEO_CHANNEL_URL + channelName + '/video-playlists/reorder', body)
|
||||||
|
.pipe(catchError(err => this.restExtractor.handleError(err)))
|
||||||
|
}
|
||||||
|
|
||||||
|
reorderVideosOfPlaylist (playlistId: number, oldPosition: number, newPosition: number) {
|
||||||
const body: VideoPlaylistReorder = {
|
const body: VideoPlaylistReorder = {
|
||||||
startPosition: oldPosition,
|
startPosition: oldPosition,
|
||||||
insertAfterPosition: newPosition
|
insertAfterPosition: newPosition
|
||||||
|
@ -268,6 +300,8 @@ export class VideoPlaylistService {
|
||||||
.pipe(catchError(err => this.restExtractor.handleError(err)))
|
.pipe(catchError(err => this.restExtractor.handleError(err)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
getPlaylistVideos (options: {
|
getPlaylistVideos (options: {
|
||||||
videoPlaylistId: number | string
|
videoPlaylistId: number | string
|
||||||
componentPagination: ComponentPaginationLight
|
componentPagination: ComponentPaginationLight
|
||||||
|
|
|
@ -0,0 +1,72 @@
|
||||||
|
import { CommonModule } from '@angular/common'
|
||||||
|
import { Component, inject, input, LOCALE_ID, OnChanges } from '@angular/core'
|
||||||
|
import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
|
import { Video, VideoPlaylistPrivacy, VideoPlaylistPrivacyType, VideoPrivacy, VideoPrivacyType } from '@peertube/peertube-models'
|
||||||
|
import { VideoPlaylist } from '../shared-video-playlist/video-playlist.model'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'my-privacy-badge',
|
||||||
|
templateUrl: './privacy-badge.component.html',
|
||||||
|
imports: [ CommonModule, NgbTooltipModule ]
|
||||||
|
})
|
||||||
|
export class PrivacyBadgeComponent implements OnChanges {
|
||||||
|
private readonly localeId = inject(LOCALE_ID)
|
||||||
|
|
||||||
|
readonly video = input<Pick<Video, 'privacy' | 'scheduledUpdate'>>(undefined)
|
||||||
|
readonly playlist = input<Pick<VideoPlaylist, 'privacy'>>(undefined)
|
||||||
|
|
||||||
|
private videoBadges: { [id in VideoPrivacyType]: string } = {
|
||||||
|
[VideoPrivacy.PUBLIC]: 'badge-green',
|
||||||
|
[VideoPrivacy.INTERNAL]: 'badge-yellow',
|
||||||
|
[VideoPrivacy.PRIVATE]: 'badge-grey',
|
||||||
|
[VideoPrivacy.PASSWORD_PROTECTED]: 'badge-purple',
|
||||||
|
[VideoPrivacy.UNLISTED]: 'badge-blue'
|
||||||
|
}
|
||||||
|
|
||||||
|
private playlistBadges: { [id in VideoPlaylistPrivacyType]: string } = {
|
||||||
|
[VideoPlaylistPrivacy.PUBLIC]: 'badge-green',
|
||||||
|
[VideoPlaylistPrivacy.PRIVATE]: 'badge-grey',
|
||||||
|
[VideoPlaylistPrivacy.UNLISTED]: 'badge-blue'
|
||||||
|
}
|
||||||
|
|
||||||
|
label: string
|
||||||
|
badgeClass: string
|
||||||
|
tooltip: string
|
||||||
|
|
||||||
|
ngOnChanges (): void {
|
||||||
|
this.label = this.buildLabel()
|
||||||
|
this.badgeClass = this.buildBadgeClass()
|
||||||
|
this.tooltip = this.buildTooltip()
|
||||||
|
}
|
||||||
|
|
||||||
|
buildBadgeClass () {
|
||||||
|
if (this.video()) return this.videoBadges[this.video().privacy.id]
|
||||||
|
|
||||||
|
if (this.playlist()) return this.playlistBadges[this.playlist().privacy.id]
|
||||||
|
|
||||||
|
throw new Error('Missing video or playlist input in PrivacyBadgeComponent')
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildLabel () {
|
||||||
|
if (this.video()) {
|
||||||
|
if (this.video().scheduledUpdate) return $localize`Scheduled`
|
||||||
|
|
||||||
|
return this.video().privacy.label
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.playlist()) {
|
||||||
|
return this.playlist().privacy.label
|
||||||
|
}
|
||||||
|
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildTooltip () {
|
||||||
|
if (this.video()?.scheduledUpdate) {
|
||||||
|
const updateAt = new Date(this.video().scheduledUpdate.updateAt.toString()).toLocaleString(this.localeId)
|
||||||
|
return $localize`Scheduled on ${updateAt}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.buildLabel()
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,55 +0,0 @@
|
||||||
import { CommonModule } from '@angular/common'
|
|
||||||
import { Component, inject, input, LOCALE_ID, OnChanges } from '@angular/core'
|
|
||||||
import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'
|
|
||||||
import { Video, VideoPrivacy, VideoPrivacyType } from '@peertube/peertube-models'
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'my-video-privacy-badge',
|
|
||||||
templateUrl: './video-privacy-badge.component.html',
|
|
||||||
imports: [ CommonModule, NgbTooltipModule ]
|
|
||||||
})
|
|
||||||
export class VideoPrivacyBadgeComponent implements OnChanges {
|
|
||||||
private readonly localeId = inject(LOCALE_ID)
|
|
||||||
readonly video = input.required<Pick<Video, 'privacy' | 'scheduledUpdate'>>()
|
|
||||||
|
|
||||||
private badges: { [id in VideoPrivacyType]: string } = {
|
|
||||||
[VideoPrivacy.PUBLIC]: 'badge-green',
|
|
||||||
[VideoPrivacy.INTERNAL]: 'badge-yellow',
|
|
||||||
[VideoPrivacy.PRIVATE]: 'badge-grey',
|
|
||||||
[VideoPrivacy.PASSWORD_PROTECTED]: 'badge-purple',
|
|
||||||
[VideoPrivacy.UNLISTED]: 'badge-blue'
|
|
||||||
}
|
|
||||||
|
|
||||||
label: string
|
|
||||||
badgeClass: string
|
|
||||||
tooltip: string
|
|
||||||
|
|
||||||
ngOnChanges (): void {
|
|
||||||
this.label = this.buildLabel()
|
|
||||||
this.badgeClass = this.buildBadgeClass()
|
|
||||||
this.tooltip = this.buildTooltip()
|
|
||||||
}
|
|
||||||
|
|
||||||
buildBadgeClass () {
|
|
||||||
return this.badges[this.video().privacy.id]
|
|
||||||
}
|
|
||||||
|
|
||||||
private buildLabel () {
|
|
||||||
const video = this.video()
|
|
||||||
|
|
||||||
if (video.scheduledUpdate) return $localize`Scheduled`
|
|
||||||
|
|
||||||
return video.privacy.label
|
|
||||||
}
|
|
||||||
|
|
||||||
private buildTooltip () {
|
|
||||||
const video = this.video()
|
|
||||||
|
|
||||||
if (video.scheduledUpdate) {
|
|
||||||
const updateAt = new Date(video.scheduledUpdate.updateAt.toString()).toLocaleString(this.localeId)
|
|
||||||
return $localize`Scheduled on ${updateAt}`
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.buildLabel()
|
|
||||||
}
|
|
||||||
}
|
|
1
client/src/assets/images/feather/grip-horizontal.svg
Normal file
1
client/src/assets/images/feather/grip-horizontal.svg
Normal 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-grip-horizontal-icon lucide-grip-horizontal"><circle cx="12" cy="9" r="1"/><circle cx="19" cy="9" r="1"/><circle cx="5" cy="9" r="1"/><circle cx="12" cy="15" r="1"/><circle cx="19" cy="15" r="1"/><circle cx="5" cy="15" r="1"/></svg>
|
After Width: | Height: | Size: 435 B |
|
@ -1,8 +1,8 @@
|
||||||
@use 'sass:color';
|
@use "sass:color";
|
||||||
@use '_badges' as *;
|
@use "_badges" as *;
|
||||||
@use '_icons' as *;
|
@use "_icons" as *;
|
||||||
@use '_variables' as *;
|
@use "_variables" as *;
|
||||||
@use '_mixins' as *;
|
@use "_mixins" as *;
|
||||||
|
|
||||||
.scale-x--1 {
|
.scale-x--1 {
|
||||||
transform: scaleX(-1);
|
transform: scaleX(-1);
|
||||||
|
@ -11,3 +11,7 @@
|
||||||
.scale-y--1 {
|
.scale-y--1 {
|
||||||
transform: scaleY(-1);
|
transform: scaleY(-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rotate-180 {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
@use '_variables' as *;
|
@use "_variables" as *;
|
||||||
@use '_mixins' as *;
|
@use "_mixins" as *;
|
||||||
|
|
||||||
@mixin actor-row ($avatar-margin-right: 10px, $min-height: 130px, $separator: true) {
|
@mixin actor-row($avatar-margin-right: 10px, $min-height: 130px, $separator: true) {
|
||||||
@include row-blocks($min-height: $min-height, $separator: $separator);
|
@include row-blocks($min-height: $min-height, $separator: $separator);
|
||||||
|
|
||||||
> my-actor-avatar {
|
> my-actor-avatar {
|
||||||
|
@ -62,3 +62,23 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@mixin channel-filters {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
border-bottom: 1px solid $separator-border-color;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
gap: 1rem;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin channel-label {
|
||||||
|
color: pvar(--fg-200);
|
||||||
|
font-weight: $font-bold;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
|
@ -35,7 +35,7 @@ export interface ActivityPubActor {
|
||||||
|
|
||||||
published?: string
|
published?: string
|
||||||
|
|
||||||
// For export
|
// Used by the user export feature
|
||||||
likes?: string
|
likes?: string
|
||||||
dislikes?: string
|
dislikes?: string
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,6 +19,8 @@ export interface PlaylistObject {
|
||||||
published: string
|
published: string
|
||||||
updated: string
|
updated: string
|
||||||
|
|
||||||
|
videoChannelPosition: number
|
||||||
|
|
||||||
orderedItems?: string[]
|
orderedItems?: string[]
|
||||||
|
|
||||||
partOf?: string
|
partOf?: string
|
||||||
|
|
|
@ -10,3 +10,4 @@ export * from './video-playlist-reorder.model.js'
|
||||||
export * from './video-playlist-type.model.js'
|
export * from './video-playlist-type.model.js'
|
||||||
export * from './video-playlist-update.model.js'
|
export * from './video-playlist-update.model.js'
|
||||||
export * from './video-playlist.model.js'
|
export * from './video-playlist.model.js'
|
||||||
|
export * from './video-playlists-list-query.model.js'
|
||||||
|
|
|
@ -31,5 +31,7 @@ export interface VideoPlaylist {
|
||||||
updatedAt: Date | string
|
updatedAt: Date | string
|
||||||
|
|
||||||
ownerAccount: AccountSummary
|
ownerAccount: AccountSummary
|
||||||
|
|
||||||
|
videoChannelPosition: number
|
||||||
videoChannel?: VideoChannelSummary
|
videoChannel?: VideoChannelSummary
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { VideoPlaylistType_Type } from './video-playlist-type.model.js'
|
||||||
|
|
||||||
|
export interface VideoPlaylistsListQuery {
|
||||||
|
start?: number
|
||||||
|
count?: number
|
||||||
|
sort?: string
|
||||||
|
search?: string
|
||||||
|
playlistType?: VideoPlaylistType_Type
|
||||||
|
}
|
|
@ -22,7 +22,6 @@ import { unwrapBody } from '../requests/index.js'
|
||||||
import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js'
|
import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js'
|
||||||
|
|
||||||
export class PlaylistsCommand extends AbstractCommand {
|
export class PlaylistsCommand extends AbstractCommand {
|
||||||
|
|
||||||
list (options: OverrideCommandOptions & {
|
list (options: OverrideCommandOptions & {
|
||||||
start?: number
|
start?: number
|
||||||
count?: number
|
count?: number
|
||||||
|
@ -42,13 +41,15 @@ export class PlaylistsCommand extends AbstractCommand {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
listByChannel (options: OverrideCommandOptions & {
|
listByChannel (
|
||||||
handle: string
|
options: OverrideCommandOptions & {
|
||||||
start?: number
|
handle: string
|
||||||
count?: number
|
start?: number
|
||||||
sort?: string
|
count?: number
|
||||||
playlistType?: VideoPlaylistType_Type
|
sort?: string
|
||||||
}) {
|
playlistType?: VideoPlaylistType_Type
|
||||||
|
}
|
||||||
|
) {
|
||||||
const path = '/api/v1/video-channels/' + options.handle + '/video-playlists'
|
const path = '/api/v1/video-channels/' + options.handle + '/video-playlists'
|
||||||
const query = pick(options, [ 'start', 'count', 'sort', 'playlistType' ])
|
const query = pick(options, [ 'start', 'count', 'sort', 'playlistType' ])
|
||||||
|
|
||||||
|
@ -62,14 +63,16 @@ export class PlaylistsCommand extends AbstractCommand {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
listByAccount (options: OverrideCommandOptions & {
|
listByAccount (
|
||||||
handle: string
|
options: OverrideCommandOptions & {
|
||||||
start?: number
|
handle: string
|
||||||
count?: number
|
start?: number
|
||||||
sort?: string
|
count?: number
|
||||||
search?: string
|
sort?: string
|
||||||
playlistType?: VideoPlaylistType_Type
|
search?: string
|
||||||
}) {
|
playlistType?: VideoPlaylistType_Type
|
||||||
|
}
|
||||||
|
) {
|
||||||
const path = '/api/v1/accounts/' + options.handle + '/video-playlists'
|
const path = '/api/v1/accounts/' + options.handle + '/video-playlists'
|
||||||
const query = pick(options, [ 'start', 'count', 'sort', 'search', 'playlistType' ])
|
const query = pick(options, [ 'start', 'count', 'sort', 'search', 'playlistType' ])
|
||||||
|
|
||||||
|
@ -85,9 +88,11 @@ export class PlaylistsCommand extends AbstractCommand {
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
get (options: OverrideCommandOptions & {
|
get (
|
||||||
playlistId: number | string
|
options: OverrideCommandOptions & {
|
||||||
}) {
|
playlistId: number | string
|
||||||
|
}
|
||||||
|
) {
|
||||||
const { playlistId } = options
|
const { playlistId } = options
|
||||||
const path = '/api/v1/video-playlists/' + playlistId
|
const path = '/api/v1/video-playlists/' + playlistId
|
||||||
|
|
||||||
|
@ -100,9 +105,11 @@ export class PlaylistsCommand extends AbstractCommand {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async getWatchLater (options: OverrideCommandOptions & {
|
async getWatchLater (
|
||||||
handle: string
|
options: OverrideCommandOptions & {
|
||||||
}) {
|
handle: string
|
||||||
|
}
|
||||||
|
) {
|
||||||
const { data: playlists } = await this.listByAccount({
|
const { data: playlists } = await this.listByAccount({
|
||||||
...options,
|
...options,
|
||||||
|
|
||||||
|
@ -114,12 +121,14 @@ export class PlaylistsCommand extends AbstractCommand {
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
listVideos (options: OverrideCommandOptions & {
|
listVideos (
|
||||||
playlistId: number | string
|
options: OverrideCommandOptions & {
|
||||||
start?: number
|
playlistId: number | string
|
||||||
count?: number
|
start?: number
|
||||||
query?: { nsfw?: BooleanBothQuery }
|
count?: number
|
||||||
}) {
|
query?: { nsfw?: BooleanBothQuery }
|
||||||
|
}
|
||||||
|
) {
|
||||||
const path = '/api/v1/video-playlists/' + options.playlistId + '/videos'
|
const path = '/api/v1/video-playlists/' + options.playlistId + '/videos'
|
||||||
const query = options.query ?? {}
|
const query = options.query ?? {}
|
||||||
|
|
||||||
|
@ -137,9 +146,11 @@ export class PlaylistsCommand extends AbstractCommand {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
delete (options: OverrideCommandOptions & {
|
delete (
|
||||||
playlistId: number | string
|
options: OverrideCommandOptions & {
|
||||||
}) {
|
playlistId: number | string
|
||||||
|
}
|
||||||
|
) {
|
||||||
const path = '/api/v1/video-playlists/' + options.playlistId
|
const path = '/api/v1/video-playlists/' + options.playlistId
|
||||||
|
|
||||||
return this.deleteRequest({
|
return this.deleteRequest({
|
||||||
|
@ -151,9 +162,11 @@ export class PlaylistsCommand extends AbstractCommand {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async create (options: OverrideCommandOptions & {
|
async create (
|
||||||
attributes: VideoPlaylistCreate
|
options: OverrideCommandOptions & {
|
||||||
}) {
|
attributes: VideoPlaylistCreate
|
||||||
|
}
|
||||||
|
) {
|
||||||
const path = '/api/v1/video-playlists'
|
const path = '/api/v1/video-playlists'
|
||||||
|
|
||||||
const fields = omit(options.attributes, [ 'thumbnailfile' ])
|
const fields = omit(options.attributes, [ 'thumbnailfile' ])
|
||||||
|
@ -175,10 +188,12 @@ export class PlaylistsCommand extends AbstractCommand {
|
||||||
return body.videoPlaylist
|
return body.videoPlaylist
|
||||||
}
|
}
|
||||||
|
|
||||||
async quickCreate (options: OverrideCommandOptions & {
|
async quickCreate (
|
||||||
displayName: string
|
options: OverrideCommandOptions & {
|
||||||
privacy?: VideoPlaylistPrivacyType
|
displayName: string
|
||||||
}) {
|
privacy?: VideoPlaylistPrivacyType
|
||||||
|
}
|
||||||
|
) {
|
||||||
const { displayName, privacy = VideoPlaylistPrivacy.PUBLIC } = options
|
const { displayName, privacy = VideoPlaylistPrivacy.PUBLIC } = options
|
||||||
|
|
||||||
const { videoChannels } = await this.server.users.getMyInfo({ token: options.token })
|
const { videoChannels } = await this.server.users.getMyInfo({ token: options.token })
|
||||||
|
@ -196,10 +211,12 @@ export class PlaylistsCommand extends AbstractCommand {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
update (options: OverrideCommandOptions & {
|
update (
|
||||||
attributes: VideoPlaylistUpdate
|
options: OverrideCommandOptions & {
|
||||||
playlistId: number | string
|
attributes: VideoPlaylistUpdate
|
||||||
}) {
|
playlistId: number | string
|
||||||
|
}
|
||||||
|
) {
|
||||||
const path = '/api/v1/video-playlists/' + options.playlistId
|
const path = '/api/v1/video-playlists/' + options.playlistId
|
||||||
|
|
||||||
const fields = omit(options.attributes, [ 'thumbnailfile' ])
|
const fields = omit(options.attributes, [ 'thumbnailfile' ])
|
||||||
|
@ -219,10 +236,12 @@ export class PlaylistsCommand extends AbstractCommand {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async addElement (options: OverrideCommandOptions & {
|
async addElement (
|
||||||
playlistId: number | string
|
options: OverrideCommandOptions & {
|
||||||
attributes: VideoPlaylistElementCreate | { videoId: string }
|
playlistId: number | string
|
||||||
}) {
|
attributes: VideoPlaylistElementCreate | { videoId: string }
|
||||||
|
}
|
||||||
|
) {
|
||||||
const attributes = {
|
const attributes = {
|
||||||
...options.attributes,
|
...options.attributes,
|
||||||
|
|
||||||
|
@ -243,11 +262,13 @@ export class PlaylistsCommand extends AbstractCommand {
|
||||||
return body.videoPlaylistElement
|
return body.videoPlaylistElement
|
||||||
}
|
}
|
||||||
|
|
||||||
updateElement (options: OverrideCommandOptions & {
|
updateElement (
|
||||||
playlistId: number | string
|
options: OverrideCommandOptions & {
|
||||||
elementId: number | string
|
playlistId: number | string
|
||||||
attributes: VideoPlaylistElementUpdate
|
elementId: number | string
|
||||||
}) {
|
attributes: VideoPlaylistElementUpdate
|
||||||
|
}
|
||||||
|
) {
|
||||||
const path = '/api/v1/video-playlists/' + options.playlistId + '/videos/' + options.elementId
|
const path = '/api/v1/video-playlists/' + options.playlistId + '/videos/' + options.elementId
|
||||||
|
|
||||||
return this.putBodyRequest({
|
return this.putBodyRequest({
|
||||||
|
@ -260,10 +281,12 @@ export class PlaylistsCommand extends AbstractCommand {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
removeElement (options: OverrideCommandOptions & {
|
removeElement (
|
||||||
playlistId: number | string
|
options: OverrideCommandOptions & {
|
||||||
elementId: number
|
playlistId: number | string
|
||||||
}) {
|
elementId: number
|
||||||
|
}
|
||||||
|
) {
|
||||||
const path = '/api/v1/video-playlists/' + options.playlistId + '/videos/' + options.elementId
|
const path = '/api/v1/video-playlists/' + options.playlistId + '/videos/' + options.elementId
|
||||||
|
|
||||||
return this.deleteRequest({
|
return this.deleteRequest({
|
||||||
|
@ -275,10 +298,30 @@ export class PlaylistsCommand extends AbstractCommand {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
reorderElements (options: OverrideCommandOptions & {
|
reorderPlaylistsOfChannel (
|
||||||
playlistId: number | string
|
options: OverrideCommandOptions & {
|
||||||
attributes: VideoPlaylistReorder
|
channelName: string
|
||||||
}) {
|
attributes: VideoPlaylistReorder
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const path = '/api/v1/video-channels/' + options.channelName + '/video-playlists/reorder'
|
||||||
|
|
||||||
|
return this.postBodyRequest({
|
||||||
|
...options,
|
||||||
|
|
||||||
|
path,
|
||||||
|
fields: options.attributes,
|
||||||
|
implicitToken: true,
|
||||||
|
defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
reorderElements (
|
||||||
|
options: OverrideCommandOptions & {
|
||||||
|
playlistId: number | string
|
||||||
|
attributes: VideoPlaylistReorder
|
||||||
|
}
|
||||||
|
) {
|
||||||
const path = '/api/v1/video-playlists/' + options.playlistId + '/videos/reorder'
|
const path = '/api/v1/video-playlists/' + options.playlistId + '/videos/reorder'
|
||||||
|
|
||||||
return this.postBodyRequest({
|
return this.postBodyRequest({
|
||||||
|
@ -294,7 +337,7 @@ export class PlaylistsCommand extends AbstractCommand {
|
||||||
getPrivacies (options: OverrideCommandOptions = {}) {
|
getPrivacies (options: OverrideCommandOptions = {}) {
|
||||||
const path = '/api/v1/video-playlists/privacies'
|
const path = '/api/v1/video-playlists/privacies'
|
||||||
|
|
||||||
return this.getRequestBody<{ [ id: number ]: string }>({
|
return this.getRequestBody<{ [id: number]: string }>({
|
||||||
...options,
|
...options,
|
||||||
|
|
||||||
path,
|
path,
|
||||||
|
@ -303,9 +346,11 @@ export class PlaylistsCommand extends AbstractCommand {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
videosExist (options: OverrideCommandOptions & {
|
videosExist (
|
||||||
videoIds: number[]
|
options: OverrideCommandOptions & {
|
||||||
}) {
|
videoIds: number[]
|
||||||
|
}
|
||||||
|
) {
|
||||||
const { videoIds } = options
|
const { videoIds } = options
|
||||||
const path = '/api/v1/users/me/video-playlists/videos-exist'
|
const path = '/api/v1/users/me/video-playlists/videos-exist'
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
|
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
|
||||||
|
|
||||||
import { checkBadCountPagination, checkBadSortPagination, checkBadStartPagination } from '@tests/shared/checks.js'
|
|
||||||
import {
|
import {
|
||||||
HttpStatusCode,
|
HttpStatusCode,
|
||||||
VideoPlaylistCreate,
|
VideoPlaylistCreate,
|
||||||
|
@ -20,6 +19,7 @@ import {
|
||||||
setAccessTokensToServers,
|
setAccessTokensToServers,
|
||||||
setDefaultVideoChannel
|
setDefaultVideoChannel
|
||||||
} from '@peertube/peertube-server-commands'
|
} from '@peertube/peertube-server-commands'
|
||||||
|
import { checkBadCountPagination, checkBadSortPagination, checkBadStartPagination } from '@tests/shared/checks.js'
|
||||||
|
|
||||||
describe('Test video playlists API validator', function () {
|
describe('Test video playlists API validator', function () {
|
||||||
let server: PeerTubeServer
|
let server: PeerTubeServer
|
||||||
|
@ -478,13 +478,13 @@ describe('Test video playlists API validator', function () {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('When reordering elements of a playlist', function () {
|
describe('When reordering elements of a playlist or playlists in a channel', function () {
|
||||||
let videoId3: number
|
let videoId3: number
|
||||||
let videoId4: number
|
let videoId4: number
|
||||||
|
|
||||||
const getBase = (
|
const getBase = (
|
||||||
attributes?: Partial<VideoPlaylistReorder>,
|
attributes?: Partial<VideoPlaylistReorder>,
|
||||||
wrapper?: Partial<Parameters<PlaylistsCommand['reorderElements']>[0]>
|
wrapper?: Partial<Parameters<PlaylistsCommand['reorderElements'] | PlaylistsCommand['reorderPlaylistsOfChannel']>[0]>
|
||||||
) => {
|
) => {
|
||||||
return {
|
return {
|
||||||
attributes: {
|
attributes: {
|
||||||
|
@ -495,6 +495,7 @@ describe('Test video playlists API validator', function () {
|
||||||
...attributes
|
...attributes
|
||||||
},
|
},
|
||||||
|
|
||||||
|
channelName: server.store.channel.name,
|
||||||
playlistId: playlist.shortUUID,
|
playlistId: playlist.shortUUID,
|
||||||
expectedStatus: HttpStatusCode.BAD_REQUEST_400,
|
expectedStatus: HttpStatusCode.BAD_REQUEST_400,
|
||||||
|
|
||||||
|
@ -506,87 +507,129 @@ describe('Test video playlists API validator', function () {
|
||||||
videoId3 = (await server.videos.quickUpload({ name: 'video 3' })).id
|
videoId3 = (await server.videos.quickUpload({ name: 'video 3' })).id
|
||||||
videoId4 = (await server.videos.quickUpload({ name: 'video 4' })).id
|
videoId4 = (await server.videos.quickUpload({ name: 'video 4' })).id
|
||||||
|
|
||||||
|
await command.create({
|
||||||
|
attributes: {
|
||||||
|
displayName: 'super playlist 2',
|
||||||
|
privacy: VideoPlaylistPrivacy.PUBLIC,
|
||||||
|
videoChannelId: server.store.channel.id
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
for (const id of [ videoId3, videoId4 ]) {
|
for (const id of [ videoId3, videoId4 ]) {
|
||||||
await command.addElement({ playlistId: playlist.shortUUID, attributes: { videoId: id } })
|
await command.addElement({ playlistId: playlist.shortUUID, attributes: { videoId: id } })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Should fail with an unauthenticated user', async function () {
|
describe('Common checks', function () {
|
||||||
const params = getBase({}, { token: null, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
|
it('Should fail with an unauthenticated user', async function () {
|
||||||
await command.reorderElements(params)
|
const params = getBase({}, { token: null, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
|
||||||
|
await command.reorderElements(params)
|
||||||
|
await command.reorderPlaylistsOfChannel(params)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should fail with an invalid start position', async function () {
|
||||||
|
{
|
||||||
|
const params = getBase({ startPosition: -1 })
|
||||||
|
await command.reorderElements(params)
|
||||||
|
await command.reorderPlaylistsOfChannel(params)
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const params = getBase({ startPosition: 'toto' as any })
|
||||||
|
await command.reorderElements(params)
|
||||||
|
await command.reorderPlaylistsOfChannel(params)
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const params = getBase({ startPosition: 42 })
|
||||||
|
await command.reorderElements(params)
|
||||||
|
await command.reorderPlaylistsOfChannel(params)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should fail with an invalid insert after position', async function () {
|
||||||
|
{
|
||||||
|
const params = getBase({ insertAfterPosition: 'toto' as any })
|
||||||
|
await command.reorderElements(params)
|
||||||
|
await command.reorderPlaylistsOfChannel(params)
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const params = getBase({ insertAfterPosition: -2 })
|
||||||
|
await command.reorderElements(params)
|
||||||
|
await command.reorderPlaylistsOfChannel(params)
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const params = getBase({ insertAfterPosition: 42 })
|
||||||
|
await command.reorderElements(params)
|
||||||
|
await command.reorderPlaylistsOfChannel(params)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should fail with an invalid reorder length', async function () {
|
||||||
|
{
|
||||||
|
const params = getBase({ reorderLength: 'toto' as any })
|
||||||
|
await command.reorderElements(params)
|
||||||
|
await command.reorderPlaylistsOfChannel(params)
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const params = getBase({ reorderLength: -2 })
|
||||||
|
await command.reorderElements(params)
|
||||||
|
await command.reorderPlaylistsOfChannel(params)
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const params = getBase({ reorderLength: 42 })
|
||||||
|
await command.reorderElements(params)
|
||||||
|
await command.reorderPlaylistsOfChannel(params)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Succeed with the correct params', async function () {
|
||||||
|
const params = getBase({}, { expectedStatus: HttpStatusCode.NO_CONTENT_204 })
|
||||||
|
await command.reorderElements(params)
|
||||||
|
await command.reorderPlaylistsOfChannel(params)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Should fail with the playlist of another user', async function () {
|
describe('Reordering elements of a playlist checks', function () {
|
||||||
const params = getBase({}, { token: userAccessToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
|
it('Should fail with the playlist of another user', async function () {
|
||||||
await command.reorderElements(params)
|
const params = getBase({}, { token: userAccessToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
|
||||||
|
await command.reorderElements(params)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should fail with an invalid playlist', async function () {
|
||||||
|
{
|
||||||
|
const params = getBase({}, { playlistId: 'toto' })
|
||||||
|
await command.reorderElements(params)
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const params = getBase({}, { playlistId: 42, expectedStatus: HttpStatusCode.NOT_FOUND_404 })
|
||||||
|
await command.reorderElements(params)
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Should fail with an invalid playlist', async function () {
|
describe('Reordering playlists of a channel checks', function () {
|
||||||
{
|
it('Should fail with the channel of another user', async function () {
|
||||||
const params = getBase({}, { playlistId: 'toto' })
|
const params = getBase({}, { token: userAccessToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
|
||||||
await command.reorderElements(params)
|
await command.reorderPlaylistsOfChannel(params)
|
||||||
}
|
})
|
||||||
|
|
||||||
{
|
it('Should fail with an unknown channel', async function () {
|
||||||
const params = getBase({}, { playlistId: 42, expectedStatus: HttpStatusCode.NOT_FOUND_404 })
|
const params = getBase({}, { channelName: 'toto', expectedStatus: HttpStatusCode.NOT_FOUND_404 })
|
||||||
await command.reorderElements(params)
|
await command.reorderPlaylistsOfChannel(params)
|
||||||
}
|
})
|
||||||
})
|
|
||||||
|
|
||||||
it('Should fail with an invalid start position', async function () {
|
it('Should fail with an invalid channel', async function () {
|
||||||
{
|
{
|
||||||
const params = getBase({ startPosition: -1 })
|
const params = getBase({}, { channelName: 42 as any, expectedStatus: HttpStatusCode.NOT_FOUND_404 })
|
||||||
await command.reorderElements(params)
|
await command.reorderPlaylistsOfChannel(params)
|
||||||
}
|
}
|
||||||
|
})
|
||||||
{
|
|
||||||
const params = getBase({ startPosition: 'toto' as any })
|
|
||||||
await command.reorderElements(params)
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
const params = getBase({ startPosition: 42 })
|
|
||||||
await command.reorderElements(params)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Should fail with an invalid insert after position', async function () {
|
|
||||||
{
|
|
||||||
const params = getBase({ insertAfterPosition: 'toto' as any })
|
|
||||||
await command.reorderElements(params)
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
const params = getBase({ insertAfterPosition: -2 })
|
|
||||||
await command.reorderElements(params)
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
const params = getBase({ insertAfterPosition: 42 })
|
|
||||||
await command.reorderElements(params)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Should fail with an invalid reorder length', async function () {
|
|
||||||
{
|
|
||||||
const params = getBase({ reorderLength: 'toto' as any })
|
|
||||||
await command.reorderElements(params)
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
const params = getBase({ reorderLength: -2 })
|
|
||||||
await command.reorderElements(params)
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
const params = getBase({ reorderLength: 42 })
|
|
||||||
await command.reorderElements(params)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Succeed with the correct params', async function () {
|
|
||||||
const params = getBase({}, { expectedStatus: HttpStatusCode.NO_CONTENT_204 })
|
|
||||||
await command.reorderElements(params)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
|
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
|
||||||
|
|
||||||
import { expect } from 'chai'
|
|
||||||
import { wait } from '@peertube/peertube-core-utils'
|
import { wait } from '@peertube/peertube-core-utils'
|
||||||
import {
|
import {
|
||||||
HttpStatusCode,
|
HttpStatusCode,
|
||||||
|
@ -26,6 +25,7 @@ import {
|
||||||
} from '@peertube/peertube-server-commands'
|
} from '@peertube/peertube-server-commands'
|
||||||
import { testImageGeneratedByFFmpeg } from '@tests/shared/checks.js'
|
import { testImageGeneratedByFFmpeg } from '@tests/shared/checks.js'
|
||||||
import { checkPlaylistFilesWereRemoved } from '@tests/shared/video-playlists.js'
|
import { checkPlaylistFilesWereRemoved } from '@tests/shared/video-playlists.js'
|
||||||
|
import { expect } from 'chai'
|
||||||
|
|
||||||
async function checkPlaylistElementType (
|
async function checkPlaylistElementType (
|
||||||
servers: PeerTubeServer[],
|
servers: PeerTubeServer[],
|
||||||
|
@ -1198,6 +1198,322 @@ describe('Test video playlists', function () {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('Playlist position', function () {
|
||||||
|
const playlists: VideoPlaylistCreateResult[] = []
|
||||||
|
let channelId1: number
|
||||||
|
let channelId2: number
|
||||||
|
let lastCheck: () => Promise<any>
|
||||||
|
|
||||||
|
async function createPlaylists (channelId: number) {
|
||||||
|
playlists[0] = await commands[0].create({
|
||||||
|
attributes: {
|
||||||
|
displayName: 'playlist 0',
|
||||||
|
privacy: VideoPlaylistPrivacy.PUBLIC,
|
||||||
|
videoChannelId: channelId
|
||||||
|
}
|
||||||
|
})
|
||||||
|
playlists[1] = await commands[0].create({
|
||||||
|
attributes: {
|
||||||
|
displayName: 'playlist 1',
|
||||||
|
privacy: VideoPlaylistPrivacy.PUBLIC,
|
||||||
|
videoChannelId: channelId
|
||||||
|
}
|
||||||
|
})
|
||||||
|
playlists[2] = await commands[0].create({
|
||||||
|
attributes: {
|
||||||
|
displayName: 'playlist 2',
|
||||||
|
privacy: VideoPlaylistPrivacy.UNLISTED,
|
||||||
|
videoChannelId: channelId
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
playlists[3] = await commands[0].create({
|
||||||
|
attributes: {
|
||||||
|
displayName: 'playlist 3',
|
||||||
|
privacy: VideoPlaylistPrivacy.PRIVATE
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
playlists[4] = await commands[0].create({
|
||||||
|
attributes: {
|
||||||
|
displayName: 'playlist 4',
|
||||||
|
privacy: VideoPlaylistPrivacy.PRIVATE,
|
||||||
|
videoChannelId: channelId
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
playlists[5] = await commands[0].create({
|
||||||
|
attributes: {
|
||||||
|
displayName: 'playlist 5',
|
||||||
|
privacy: VideoPlaylistPrivacy.PUBLIC,
|
||||||
|
videoChannelId: channelId
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getPosition (server: PeerTubeServer, options: { uuid: string }) {
|
||||||
|
const playlist = await server.playlists.get({ playlistId: options.uuid, token: server.accessToken })
|
||||||
|
|
||||||
|
return playlist.videoChannelPosition
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getPlaylistNames (server: PeerTubeServer) {
|
||||||
|
const { data } = await server.playlists.listByChannel({
|
||||||
|
start: 0,
|
||||||
|
count: 10,
|
||||||
|
token: server.accessToken,
|
||||||
|
handle: 'channel_2@' + servers[0].host,
|
||||||
|
sort: 'videoChannelPosition'
|
||||||
|
})
|
||||||
|
|
||||||
|
return data.map(p => p.displayName)
|
||||||
|
}
|
||||||
|
|
||||||
|
before(async function () {
|
||||||
|
{
|
||||||
|
const channel = await servers[0].channels.create({ attributes: { name: 'channel_1', displayName: 'Channel 1' } })
|
||||||
|
channelId1 = channel.id
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const channel = await servers[0].channels.create({ attributes: { name: 'channel_2', displayName: 'Channel 2' } })
|
||||||
|
channelId2 = channel.id
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should create playlist with default positions', async function () {
|
||||||
|
await createPlaylists(channelId1)
|
||||||
|
await waitJobs(servers)
|
||||||
|
|
||||||
|
expect(await getPosition(servers[0], playlists[0])).to.equal(1)
|
||||||
|
expect(await getPosition(servers[0], playlists[1])).to.equal(2)
|
||||||
|
expect(await getPosition(servers[0], playlists[2])).to.equal(3)
|
||||||
|
expect(await getPosition(servers[0], playlists[3])).to.not.exist
|
||||||
|
expect(await getPosition(servers[0], playlists[4])).to.equal(4)
|
||||||
|
expect(await getPosition(servers[0], playlists[5])).to.equal(5)
|
||||||
|
|
||||||
|
expect(await getPosition(servers[1], playlists[0])).to.equal(1)
|
||||||
|
expect(await getPosition(servers[1], playlists[1])).to.equal(2)
|
||||||
|
expect(await getPosition(servers[1], playlists[2])).to.equal(3)
|
||||||
|
await servers[1].playlists.get({ playlistId: playlists[3].uuid, expectedStatus: HttpStatusCode.NOT_FOUND_404 })
|
||||||
|
await servers[1].playlists.get({ playlistId: playlists[4].uuid, expectedStatus: HttpStatusCode.NOT_FOUND_404 })
|
||||||
|
expect(await getPosition(servers[1], playlists[5])).to.equal(5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should sort playlists by position', async function () {
|
||||||
|
// Public view
|
||||||
|
{
|
||||||
|
const { data } = await servers[0].playlists.listByChannel({
|
||||||
|
handle: 'channel_1',
|
||||||
|
start: 0,
|
||||||
|
count: 10,
|
||||||
|
sort: 'videoChannelPosition'
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(data).to.have.lengthOf(3)
|
||||||
|
expect(data[0].id).to.equal(playlists[0].id)
|
||||||
|
expect(data[0].videoChannelPosition).to.equal(1)
|
||||||
|
expect(data[1].id).to.equal(playlists[1].id)
|
||||||
|
expect(data[1].videoChannelPosition).to.equal(2)
|
||||||
|
expect(data[2].id).to.equal(playlists[5].id)
|
||||||
|
expect(data[2].videoChannelPosition).to.equal(5)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Channel with token
|
||||||
|
{
|
||||||
|
const { data } = await servers[0].playlists.listByChannel({
|
||||||
|
handle: 'channel_1',
|
||||||
|
start: 0,
|
||||||
|
count: 10,
|
||||||
|
sort: 'videoChannelPosition',
|
||||||
|
token: servers[0].accessToken
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(data).to.have.lengthOf(5)
|
||||||
|
expect(data[0].id).to.equal(playlists[0].id)
|
||||||
|
expect(data[0].videoChannelPosition).to.equal(1)
|
||||||
|
expect(data[1].id).to.equal(playlists[1].id)
|
||||||
|
expect(data[1].videoChannelPosition).to.equal(2)
|
||||||
|
expect(data[2].id).to.equal(playlists[2].id)
|
||||||
|
expect(data[2].videoChannelPosition).to.equal(3)
|
||||||
|
expect(data[3].id).to.equal(playlists[4].id)
|
||||||
|
expect(data[3].videoChannelPosition).to.equal(4)
|
||||||
|
expect(data[4].id).to.equal(playlists[5].id)
|
||||||
|
expect(data[4].videoChannelPosition).to.equal(5)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should delete a playlist and update positions', async function () {
|
||||||
|
this.timeout(30000)
|
||||||
|
|
||||||
|
await commands[0].delete({ playlistId: playlists[1].id })
|
||||||
|
await waitJobs(servers)
|
||||||
|
|
||||||
|
lastCheck = async () => {
|
||||||
|
expect(await getPosition(servers[0], playlists[0])).to.equal(1)
|
||||||
|
await servers[0].playlists.get({ playlistId: playlists[1].uuid, expectedStatus: HttpStatusCode.NOT_FOUND_404 })
|
||||||
|
expect(await getPosition(servers[0], playlists[2])).to.equal(2)
|
||||||
|
expect(await getPosition(servers[0], playlists[3])).to.not.exist
|
||||||
|
expect(await getPosition(servers[0], playlists[4])).to.equal(3)
|
||||||
|
expect(await getPosition(servers[0], playlists[5])).to.equal(4)
|
||||||
|
|
||||||
|
expect(await getPosition(servers[1], playlists[0])).to.equal(1)
|
||||||
|
await servers[1].playlists.get({ playlistId: playlists[1].uuid, expectedStatus: HttpStatusCode.NOT_FOUND_404 })
|
||||||
|
expect(await getPosition(servers[1], playlists[2])).to.equal(2)
|
||||||
|
await servers[1].playlists.get({ playlistId: playlists[3].uuid, expectedStatus: HttpStatusCode.NOT_FOUND_404 })
|
||||||
|
await servers[1].playlists.get({ playlistId: playlists[4].uuid, expectedStatus: HttpStatusCode.NOT_FOUND_404 })
|
||||||
|
expect(await getPosition(servers[1], playlists[5])).to.equal(4)
|
||||||
|
}
|
||||||
|
|
||||||
|
await lastCheck()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should update a playlist and not change position', async function () {
|
||||||
|
this.timeout(30000)
|
||||||
|
|
||||||
|
await commands[0].update({
|
||||||
|
playlistId: playlists[0].id,
|
||||||
|
attributes: {
|
||||||
|
displayName: 'playlist 0 updated',
|
||||||
|
description: 'description updated',
|
||||||
|
privacy: VideoPlaylistPrivacy.UNLISTED,
|
||||||
|
thumbnailfile: 'custom-thumbnail.jpg',
|
||||||
|
videoChannelId: channelId1
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await waitJobs(servers)
|
||||||
|
|
||||||
|
await lastCheck()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should change the playlist channel and update position', async function () {
|
||||||
|
this.timeout(30000)
|
||||||
|
|
||||||
|
await servers[0].playlists.update({
|
||||||
|
playlistId: playlists[2].id,
|
||||||
|
attributes: {
|
||||||
|
videoChannelId: servers[0].store.channel.id
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await waitJobs(servers)
|
||||||
|
|
||||||
|
expect(await getPosition(servers[0], playlists[0])).to.equal(1)
|
||||||
|
expect(await getPosition(servers[0], playlists[4])).to.equal(2)
|
||||||
|
expect(await getPosition(servers[0], playlists[5])).to.equal(3)
|
||||||
|
|
||||||
|
expect(await getPosition(servers[1], playlists[0])).to.equal(1)
|
||||||
|
expect(await getPosition(servers[1], playlists[5])).to.equal(3)
|
||||||
|
|
||||||
|
// New position after channel change
|
||||||
|
expect(await getPosition(servers[0], playlists[2])).to.equal(4)
|
||||||
|
expect(await getPosition(servers[1], playlists[2])).to.equal(4)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should reorder the playlists', async function () {
|
||||||
|
this.timeout(30000)
|
||||||
|
|
||||||
|
await createPlaylists(channelId2)
|
||||||
|
await waitJobs(servers)
|
||||||
|
|
||||||
|
// Initial state
|
||||||
|
expect(await getPlaylistNames(servers[0])).to.deep.equal([
|
||||||
|
'playlist 0',
|
||||||
|
'playlist 1',
|
||||||
|
'playlist 2',
|
||||||
|
'playlist 4',
|
||||||
|
'playlist 5'
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(await getPlaylistNames(servers[1])).to.deep.equal([
|
||||||
|
'playlist 0',
|
||||||
|
'playlist 1',
|
||||||
|
'playlist 5'
|
||||||
|
])
|
||||||
|
|
||||||
|
await commands[0].reorderPlaylistsOfChannel({
|
||||||
|
channelName: 'channel_2',
|
||||||
|
attributes: {
|
||||||
|
startPosition: 3,
|
||||||
|
insertAfterPosition: 4
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await waitJobs(servers)
|
||||||
|
|
||||||
|
expect(await getPlaylistNames(servers[0])).to.deep.equal([
|
||||||
|
'playlist 0',
|
||||||
|
'playlist 1',
|
||||||
|
'playlist 4',
|
||||||
|
'playlist 2',
|
||||||
|
'playlist 5'
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(await getPlaylistNames(servers[1])).to.deep.equal([
|
||||||
|
'playlist 0',
|
||||||
|
'playlist 1',
|
||||||
|
'playlist 5'
|
||||||
|
])
|
||||||
|
|
||||||
|
await commands[0].reorderPlaylistsOfChannel({
|
||||||
|
channelName: 'channel_2',
|
||||||
|
attributes: {
|
||||||
|
startPosition: 1,
|
||||||
|
insertAfterPosition: 3,
|
||||||
|
reorderLength: 2
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await waitJobs(servers)
|
||||||
|
|
||||||
|
expect(await getPlaylistNames(servers[0])).to.deep.equal([
|
||||||
|
'playlist 4',
|
||||||
|
'playlist 0',
|
||||||
|
'playlist 1',
|
||||||
|
'playlist 2',
|
||||||
|
'playlist 5'
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(await getPlaylistNames(servers[1])).to.deep.equal([
|
||||||
|
'playlist 0',
|
||||||
|
'playlist 1',
|
||||||
|
'playlist 5'
|
||||||
|
])
|
||||||
|
|
||||||
|
await commands[0].reorderPlaylistsOfChannel({
|
||||||
|
channelName: 'channel_2',
|
||||||
|
attributes: {
|
||||||
|
startPosition: 3,
|
||||||
|
insertAfterPosition: 0,
|
||||||
|
reorderLength: 1
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await waitJobs(servers)
|
||||||
|
|
||||||
|
expect(await getPlaylistNames(servers[0])).to.deep.equal([
|
||||||
|
'playlist 1',
|
||||||
|
'playlist 4',
|
||||||
|
'playlist 0',
|
||||||
|
'playlist 2',
|
||||||
|
'playlist 5'
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(await getPlaylistNames(servers[1])).to.deep.equal([
|
||||||
|
'playlist 1',
|
||||||
|
'playlist 0',
|
||||||
|
'playlist 5'
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should delete a channel and delete playlists', async function () {
|
||||||
|
await servers[0].channels.delete({ channelName: 'channel_2' })
|
||||||
|
await waitJobs(servers)
|
||||||
|
|
||||||
|
const playlist = await servers[0].playlists.get({ playlistId: playlists[0].uuid, token: servers[0].accessToken })
|
||||||
|
expect(playlist.videoChannel).to.not.exist
|
||||||
|
expect(playlist.videoChannelPosition).to.not.exist
|
||||||
|
|
||||||
|
await servers[1].playlists.get({ playlistId: playlists[0].uuid, expectedStatus: HttpStatusCode.NOT_FOUND_404 })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
after(async function () {
|
after(async function () {
|
||||||
await cleanupTests(servers)
|
await cleanupTests(servers)
|
||||||
})
|
})
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { VideoPlaylistsListQuery } from '@peertube/peertube-models'
|
||||||
import { pickCommonVideoQuery } from '@server/helpers/query.js'
|
import { pickCommonVideoQuery } from '@server/helpers/query.js'
|
||||||
import { ActorFollowModel } from '@server/models/actor/actor-follow.js'
|
import { ActorFollowModel } from '@server/models/actor/actor-follow.js'
|
||||||
import { getServerActor } from '@server/models/application/application.js'
|
import { getServerActor } from '@server/models/application/application.js'
|
||||||
|
@ -182,6 +183,7 @@ async function listAccountChannelsSync (req: express.Request, res: express.Respo
|
||||||
|
|
||||||
async function listAccountPlaylists (req: express.Request, res: express.Response) {
|
async function listAccountPlaylists (req: express.Request, res: express.Response) {
|
||||||
const serverActor = await getServerActor()
|
const serverActor = await getServerActor()
|
||||||
|
const query = req.query as VideoPlaylistsListQuery
|
||||||
|
|
||||||
// Allow users to see their private/unlisted video playlists
|
// Allow users to see their private/unlisted video playlists
|
||||||
let listMyPlaylists = false
|
let listMyPlaylists = false
|
||||||
|
@ -190,18 +192,19 @@ async function listAccountPlaylists (req: express.Request, res: express.Response
|
||||||
}
|
}
|
||||||
|
|
||||||
const resultList = await VideoPlaylistModel.listForApi({
|
const resultList = await VideoPlaylistModel.listForApi({
|
||||||
search: req.query.search,
|
|
||||||
|
|
||||||
followerActorId: isUserAbleToSearchRemoteURI(res)
|
followerActorId: isUserAbleToSearchRemoteURI(res)
|
||||||
? null
|
? null
|
||||||
: serverActor.id,
|
: serverActor.id,
|
||||||
|
|
||||||
start: req.query.start,
|
|
||||||
count: req.query.count,
|
|
||||||
sort: req.query.sort,
|
|
||||||
accountId: res.locals.account.id,
|
accountId: res.locals.account.id,
|
||||||
listMyPlaylists,
|
listMyPlaylists,
|
||||||
type: req.query.playlistType
|
|
||||||
|
start: query.start,
|
||||||
|
count: query.count,
|
||||||
|
sort: query.sort,
|
||||||
|
search: query.search,
|
||||||
|
|
||||||
|
type: query.playlistType
|
||||||
})
|
})
|
||||||
|
|
||||||
return res.json(getFormattedObjects(resultList.data, resultList.total))
|
return res.json(getFormattedObjects(resultList.data, resultList.total))
|
||||||
|
|
|
@ -3,10 +3,13 @@ import {
|
||||||
HttpStatusCode,
|
HttpStatusCode,
|
||||||
VideoChannelCreate,
|
VideoChannelCreate,
|
||||||
VideoChannelUpdate,
|
VideoChannelUpdate,
|
||||||
|
VideoPlaylistReorder,
|
||||||
|
VideoPlaylistsListQuery,
|
||||||
VideosImportInChannelCreate
|
VideosImportInChannelCreate
|
||||||
} from '@peertube/peertube-models'
|
} from '@peertube/peertube-models'
|
||||||
import { pickCommonVideoQuery } from '@server/helpers/query.js'
|
import { pickCommonVideoQuery } from '@server/helpers/query.js'
|
||||||
import { Hooks } from '@server/lib/plugins/hooks.js'
|
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 { ActorFollowModel } from '@server/models/actor/actor-follow.js'
|
||||||
import { getServerActor } from '@server/models/application/application.js'
|
import { getServerActor } from '@server/models/application/application.js'
|
||||||
import { MChannelBannerAccountDefault } from '@server/types/models/index.js'
|
import { MChannelBannerAccountDefault } from '@server/types/models/index.js'
|
||||||
|
@ -48,7 +51,10 @@ import {
|
||||||
videoChannelsListValidator,
|
videoChannelsListValidator,
|
||||||
videosSortValidator
|
videosSortValidator
|
||||||
} from '../../middlewares/validators/index.js'
|
} from '../../middlewares/validators/index.js'
|
||||||
import { commonVideoPlaylistFiltersValidator } from '../../middlewares/validators/videos/video-playlists.js'
|
import {
|
||||||
|
commonVideoPlaylistFiltersValidator,
|
||||||
|
videoPlaylistsReorderInChannelValidator
|
||||||
|
} from '../../middlewares/validators/videos/video-playlists.js'
|
||||||
import { AccountModel } from '../../models/account/account.js'
|
import { AccountModel } from '../../models/account/account.js'
|
||||||
import { guessAdditionalAttributesFromQuery } from '../../models/video/formatter/index.js'
|
import { guessAdditionalAttributesFromQuery } from '../../models/video/formatter/index.js'
|
||||||
import { VideoChannelModel } from '../../models/video/video-channel.js'
|
import { VideoChannelModel } from '../../models/video/video-channel.js'
|
||||||
|
@ -129,6 +135,8 @@ videoChannelRouter.get(
|
||||||
asyncMiddleware(getVideoChannel)
|
asyncMiddleware(getVideoChannel)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
videoChannelRouter.get(
|
videoChannelRouter.get(
|
||||||
'/:handle/video-playlists',
|
'/:handle/video-playlists',
|
||||||
optionalAuthenticate,
|
optionalAuthenticate,
|
||||||
|
@ -141,6 +149,16 @@ videoChannelRouter.get(
|
||||||
asyncMiddleware(listVideoChannelPlaylists)
|
asyncMiddleware(listVideoChannelPlaylists)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
videoChannelRouter.post(
|
||||||
|
'/:handle/video-playlists/reorder',
|
||||||
|
authenticate,
|
||||||
|
asyncMiddleware(videoChannelsHandleValidatorFactory({ checkIsLocal: true, checkManage: true })),
|
||||||
|
asyncMiddleware(videoPlaylistsReorderInChannelValidator),
|
||||||
|
asyncRetryTransactionMiddleware(reorderPlaylistsInChannel)
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
videoChannelRouter.get(
|
videoChannelRouter.get(
|
||||||
'/:handle/videos',
|
'/:handle/videos',
|
||||||
asyncMiddleware(videoChannelsHandleValidatorFactory({ checkIsLocal: false, checkManage: false })),
|
asyncMiddleware(videoChannelsHandleValidatorFactory({ checkIsLocal: false, checkManage: false })),
|
||||||
|
@ -360,24 +378,76 @@ async function getVideoChannel (req: express.Request, res: express.Response) {
|
||||||
return res.json(videoChannel.toFormattedJSON())
|
return res.json(videoChannel.toFormattedJSON())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Playlists
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async function listVideoChannelPlaylists (req: express.Request, res: express.Response) {
|
async function listVideoChannelPlaylists (req: express.Request, res: express.Response) {
|
||||||
const serverActor = await getServerActor()
|
const serverActor = await getServerActor()
|
||||||
|
const { count, playlistType, sort, start, search } = req.query as VideoPlaylistsListQuery
|
||||||
|
const videoChannel = res.locals.videoChannel
|
||||||
|
|
||||||
|
// Allow users to see their private/unlisted video playlists
|
||||||
|
const listMyPlaylists = !!res.locals.oauth && res.locals.oauth.token.User.Account.id === videoChannel.accountId
|
||||||
|
|
||||||
const resultList = await VideoPlaylistModel.listForApi({
|
const resultList = await VideoPlaylistModel.listForApi({
|
||||||
followerActorId: isUserAbleToSearchRemoteURI(res)
|
followerActorId: isUserAbleToSearchRemoteURI(res)
|
||||||
? null
|
? null
|
||||||
: serverActor.id,
|
: serverActor.id,
|
||||||
|
|
||||||
start: req.query.start,
|
videoChannelId: videoChannel.id,
|
||||||
count: req.query.count,
|
listMyPlaylists,
|
||||||
sort: req.query.sort,
|
|
||||||
videoChannelId: res.locals.videoChannel.id,
|
start,
|
||||||
type: req.query.playlistType
|
count,
|
||||||
|
sort,
|
||||||
|
search,
|
||||||
|
type: playlistType
|
||||||
})
|
})
|
||||||
|
|
||||||
return res.json(getFormattedObjects(resultList.data, resultList.total))
|
return res.json(getFormattedObjects(resultList.data, resultList.total))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function reorderPlaylistsInChannel (req: express.Request, res: express.Response) {
|
||||||
|
const body: VideoPlaylistReorder = req.body
|
||||||
|
const videoChannel = res.locals.videoChannel
|
||||||
|
|
||||||
|
const start: number = body.startPosition
|
||||||
|
const insertAfter: number = body.insertAfterPosition
|
||||||
|
const reorderLength: number = body.reorderLength || 1
|
||||||
|
|
||||||
|
if (start === insertAfter) {
|
||||||
|
return res.status(HttpStatusCode.NO_CONTENT_204).end()
|
||||||
|
}
|
||||||
|
|
||||||
|
await sequelizeTypescript.transaction(async t => {
|
||||||
|
await reorderPlaylistOrElementsPosition({
|
||||||
|
model: VideoPlaylistModel,
|
||||||
|
instance: videoChannel,
|
||||||
|
start,
|
||||||
|
insertAfter,
|
||||||
|
reorderLength,
|
||||||
|
transaction: t
|
||||||
|
})
|
||||||
|
|
||||||
|
videoChannel.changed('updatedAt', true)
|
||||||
|
await videoChannel.save({ transaction: t })
|
||||||
|
|
||||||
|
await sendUpdateActor(videoChannel, t)
|
||||||
|
await sendPlaylistPositionUpdateOfChannel(videoChannel.id, t)
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`Reordered playlist of channel ${videoChannel.name} (inserted after position ${insertAfter} elements ${start} - ${
|
||||||
|
start + reorderLength - 1
|
||||||
|
}).`
|
||||||
|
)
|
||||||
|
|
||||||
|
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async function listVideoChannelVideos (req: express.Request, res: express.Response) {
|
async function listVideoChannelVideos (req: express.Request, res: express.Response) {
|
||||||
const serverActor = await getServerActor()
|
const serverActor = await getServerActor()
|
||||||
|
|
||||||
|
|
|
@ -14,7 +14,11 @@ import {
|
||||||
import { uuidToShort } from '@peertube/peertube-node-utils'
|
import { uuidToShort } from '@peertube/peertube-node-utils'
|
||||||
import { scheduleRefreshIfNeeded } from '@server/lib/activitypub/playlists/index.js'
|
import { scheduleRefreshIfNeeded } from '@server/lib/activitypub/playlists/index.js'
|
||||||
import { Hooks } from '@server/lib/plugins/hooks.js'
|
import { Hooks } from '@server/lib/plugins/hooks.js'
|
||||||
import { generateThumbnailForPlaylist } from '@server/lib/video-playlist.js'
|
import {
|
||||||
|
generateThumbnailForPlaylist,
|
||||||
|
reorderPlaylistOrElementsPosition,
|
||||||
|
sendPlaylistPositionUpdateOfChannel
|
||||||
|
} from '@server/lib/video-playlist.js'
|
||||||
import { getServerActor } from '@server/models/application/application.js'
|
import { getServerActor } from '@server/models/application/application.js'
|
||||||
import { MVideoPlaylistFull, MVideoPlaylistThumbnail } from '@server/types/models/index.js'
|
import { MVideoPlaylistFull, MVideoPlaylistThumbnail } from '@server/types/models/index.js'
|
||||||
import express from 'express'
|
import express from 'express'
|
||||||
|
@ -95,6 +99,10 @@ videoPlaylistRouter.delete(
|
||||||
asyncRetryTransactionMiddleware(removeVideoPlaylist)
|
asyncRetryTransactionMiddleware(removeVideoPlaylist)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Playlist elements
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
videoPlaylistRouter.get(
|
videoPlaylistRouter.get(
|
||||||
'/:playlistId/videos',
|
'/:playlistId/videos',
|
||||||
asyncMiddleware(videoPlaylistsGetValidator('summary')),
|
asyncMiddleware(videoPlaylistsGetValidator('summary')),
|
||||||
|
@ -115,7 +123,7 @@ videoPlaylistRouter.post(
|
||||||
'/:playlistId/videos/reorder',
|
'/:playlistId/videos/reorder',
|
||||||
authenticate,
|
authenticate,
|
||||||
asyncMiddleware(videoPlaylistsReorderVideosValidator),
|
asyncMiddleware(videoPlaylistsReorderVideosValidator),
|
||||||
asyncRetryTransactionMiddleware(reorderVideosPlaylist)
|
asyncRetryTransactionMiddleware(reorderVideosOfPlaylist)
|
||||||
)
|
)
|
||||||
|
|
||||||
videoPlaylistRouter.put(
|
videoPlaylistRouter.put(
|
||||||
|
@ -196,6 +204,13 @@ async function createVideoPlaylist (req: express.Request, res: express.Response)
|
||||||
|
|
||||||
const videoPlaylistCreated = await retryTransactionWrapper(() => {
|
const videoPlaylistCreated = await retryTransactionWrapper(() => {
|
||||||
return sequelizeTypescript.transaction(async t => {
|
return sequelizeTypescript.transaction(async t => {
|
||||||
|
if (videoPlaylist.videoChannelId) {
|
||||||
|
videoPlaylist.videoChannelPosition = await VideoPlaylistModel.getNextPositionOf({
|
||||||
|
videoChannelId: videoPlaylist.videoChannelId,
|
||||||
|
transaction: t
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const videoPlaylistCreated = await videoPlaylist.save({ transaction: t }) as MVideoPlaylistFull
|
const videoPlaylistCreated = await videoPlaylist.save({ transaction: t }) as MVideoPlaylistFull
|
||||||
|
|
||||||
if (thumbnailModel) {
|
if (thumbnailModel) {
|
||||||
|
@ -222,50 +237,65 @@ async function createVideoPlaylist (req: express.Request, res: express.Response)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateVideoPlaylist (req: express.Request, res: express.Response) {
|
async function updateVideoPlaylist (req: express.Request, res: express.Response) {
|
||||||
const videoPlaylistInstance = res.locals.videoPlaylistFull
|
const playlist = res.locals.videoPlaylistFull
|
||||||
const videoPlaylistInfoToUpdate = req.body as VideoPlaylistUpdate
|
const videoPlaylistInfoToUpdate = req.body as VideoPlaylistUpdate
|
||||||
|
|
||||||
const wasPrivatePlaylist = videoPlaylistInstance.privacy === VideoPlaylistPrivacy.PRIVATE
|
const wasPrivatePlaylist = playlist.privacy === VideoPlaylistPrivacy.PRIVATE
|
||||||
const wasNotPrivatePlaylist = videoPlaylistInstance.privacy !== VideoPlaylistPrivacy.PRIVATE
|
const wasNotPrivatePlaylist = playlist.privacy !== VideoPlaylistPrivacy.PRIVATE
|
||||||
|
|
||||||
|
let removedFromChannel: { id: number, position: number }
|
||||||
|
|
||||||
const thumbnailField = req.files?.['thumbnailfile']
|
const thumbnailField = req.files?.['thumbnailfile']
|
||||||
const thumbnailModel = thumbnailField
|
const thumbnailModel = thumbnailField
|
||||||
? await updateLocalPlaylistMiniatureFromExisting({
|
? await updateLocalPlaylistMiniatureFromExisting({
|
||||||
inputPath: thumbnailField[0].path,
|
inputPath: thumbnailField[0].path,
|
||||||
playlist: videoPlaylistInstance,
|
playlist,
|
||||||
automaticallyGenerated: false
|
automaticallyGenerated: false
|
||||||
})
|
})
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await sequelizeTypescript.transaction(async t => {
|
await sequelizeTypescript.transaction(async t => {
|
||||||
const sequelizeOptions = {
|
|
||||||
transaction: t
|
|
||||||
}
|
|
||||||
|
|
||||||
if (videoPlaylistInfoToUpdate.videoChannelId !== undefined) {
|
if (videoPlaylistInfoToUpdate.videoChannelId !== undefined) {
|
||||||
if (videoPlaylistInfoToUpdate.videoChannelId === null) {
|
if (videoPlaylistInfoToUpdate.videoChannelId === null) {
|
||||||
videoPlaylistInstance.videoChannelId = null
|
removedFromChannel = {
|
||||||
|
id: playlist.videoChannelId,
|
||||||
|
position: playlist.videoChannelPosition
|
||||||
|
}
|
||||||
|
|
||||||
|
playlist.videoChannelId = null
|
||||||
} else {
|
} else {
|
||||||
const videoChannel = res.locals.videoChannel
|
const videoChannel = res.locals.videoChannel
|
||||||
|
|
||||||
videoPlaylistInstance.videoChannelId = videoChannel.id
|
if (playlist.videoChannelId !== videoPlaylistInfoToUpdate.videoChannelId) {
|
||||||
videoPlaylistInstance.VideoChannel = videoChannel
|
removedFromChannel = {
|
||||||
|
id: playlist.videoChannelId,
|
||||||
|
position: playlist.videoChannelPosition
|
||||||
|
}
|
||||||
|
|
||||||
|
playlist.videoChannelPosition = await VideoPlaylistModel.getNextPositionOf({
|
||||||
|
videoChannelId: videoChannel.id,
|
||||||
|
transaction: t
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
playlist.videoChannelId = videoChannel.id
|
||||||
|
playlist.VideoChannel = videoChannel
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (videoPlaylistInfoToUpdate.displayName !== undefined) videoPlaylistInstance.name = videoPlaylistInfoToUpdate.displayName
|
if (videoPlaylistInfoToUpdate.displayName !== undefined) playlist.name = videoPlaylistInfoToUpdate.displayName
|
||||||
if (videoPlaylistInfoToUpdate.description !== undefined) videoPlaylistInstance.description = videoPlaylistInfoToUpdate.description
|
if (videoPlaylistInfoToUpdate.description !== undefined) playlist.description = videoPlaylistInfoToUpdate.description
|
||||||
|
|
||||||
if (videoPlaylistInfoToUpdate.privacy !== undefined) {
|
if (videoPlaylistInfoToUpdate.privacy !== undefined) {
|
||||||
videoPlaylistInstance.privacy = forceNumber(videoPlaylistInfoToUpdate.privacy) as VideoPlaylistPrivacyType
|
playlist.privacy = forceNumber(videoPlaylistInfoToUpdate.privacy) as VideoPlaylistPrivacyType
|
||||||
|
|
||||||
if (wasNotPrivatePlaylist === true && videoPlaylistInstance.privacy === VideoPlaylistPrivacy.PRIVATE) {
|
if (wasNotPrivatePlaylist === true && playlist.privacy === VideoPlaylistPrivacy.PRIVATE) {
|
||||||
await sendDeleteVideoPlaylist(videoPlaylistInstance, t)
|
await sendDeleteVideoPlaylist(playlist, t)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const playlistUpdated = await videoPlaylistInstance.save(sequelizeOptions)
|
const playlistUpdated = await playlist.save({ transaction: t })
|
||||||
|
|
||||||
if (thumbnailModel) {
|
if (thumbnailModel) {
|
||||||
thumbnailModel.automaticallyGenerated = false
|
thumbnailModel.automaticallyGenerated = false
|
||||||
|
@ -280,7 +310,18 @@ async function updateVideoPlaylist (req: express.Request, res: express.Response)
|
||||||
await sendUpdateVideoPlaylist(playlistUpdated, t)
|
await sendUpdateVideoPlaylist(playlistUpdated, t)
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info('Video playlist %s updated.', videoPlaylistInstance.uuid)
|
if (removedFromChannel) {
|
||||||
|
await VideoPlaylistModel.increasePositionOf({
|
||||||
|
videoChannelId: removedFromChannel.id,
|
||||||
|
fromPosition: removedFromChannel.position,
|
||||||
|
by: -1,
|
||||||
|
transaction: t
|
||||||
|
})
|
||||||
|
|
||||||
|
await sendPlaylistPositionUpdateOfChannel(removedFromChannel.id, t)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Video playlist %s updated.', playlist.uuid)
|
||||||
|
|
||||||
return playlistUpdated
|
return playlistUpdated
|
||||||
})
|
})
|
||||||
|
@ -289,7 +330,7 @@ async function updateVideoPlaylist (req: express.Request, res: express.Response)
|
||||||
|
|
||||||
// If the transaction is retried, sequelize will think the object has not changed
|
// If the transaction is retried, sequelize will think the object has not changed
|
||||||
// So we need to restore the previous fields
|
// So we need to restore the previous fields
|
||||||
await resetSequelizeInstance(videoPlaylistInstance)
|
await resetSequelizeInstance(playlist)
|
||||||
|
|
||||||
throw err
|
throw err
|
||||||
}
|
}
|
||||||
|
@ -299,11 +340,27 @@ async function updateVideoPlaylist (req: express.Request, res: express.Response)
|
||||||
|
|
||||||
async function removeVideoPlaylist (req: express.Request, res: express.Response) {
|
async function removeVideoPlaylist (req: express.Request, res: express.Response) {
|
||||||
const videoPlaylistInstance = res.locals.videoPlaylistSummary
|
const videoPlaylistInstance = res.locals.videoPlaylistSummary
|
||||||
|
const positionToDelete = videoPlaylistInstance.videoChannelPosition
|
||||||
|
|
||||||
await sequelizeTypescript.transaction(async t => {
|
await sequelizeTypescript.transaction(async t => {
|
||||||
await videoPlaylistInstance.destroy({ transaction: t })
|
await videoPlaylistInstance.destroy({ transaction: t })
|
||||||
|
|
||||||
await sendDeleteVideoPlaylist(videoPlaylistInstance, t)
|
if (videoPlaylistInstance.privacy !== VideoPlaylistPrivacy.PRIVATE) {
|
||||||
|
await sendDeleteVideoPlaylist(videoPlaylistInstance, t)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (videoPlaylistInstance.videoChannelId) {
|
||||||
|
await VideoPlaylistModel.increasePositionOf({
|
||||||
|
videoChannelId: videoPlaylistInstance.videoChannelId,
|
||||||
|
fromPosition: positionToDelete,
|
||||||
|
by: -1,
|
||||||
|
transaction: t
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (videoPlaylistInstance.videoChannelId) {
|
||||||
|
await sendPlaylistPositionUpdateOfChannel(videoPlaylistInstance.videoChannelId, t)
|
||||||
|
}
|
||||||
|
|
||||||
logger.info('Video playlist %s deleted.', videoPlaylistInstance.uuid)
|
logger.info('Video playlist %s deleted.', videoPlaylistInstance.uuid)
|
||||||
})
|
})
|
||||||
|
@ -311,6 +368,10 @@ async function removeVideoPlaylist (req: express.Request, res: express.Response)
|
||||||
return res.type('json').status(HttpStatusCode.NO_CONTENT_204).end()
|
return res.type('json').status(HttpStatusCode.NO_CONTENT_204).end()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Videos in playlist
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async function addVideoInPlaylist (req: express.Request, res: express.Response) {
|
async function addVideoInPlaylist (req: express.Request, res: express.Response) {
|
||||||
const body: VideoPlaylistElementCreate = req.body
|
const body: VideoPlaylistElementCreate = req.body
|
||||||
const videoPlaylist = res.locals.videoPlaylistFull
|
const videoPlaylist = res.locals.videoPlaylistFull
|
||||||
|
@ -388,7 +449,12 @@ async function removeVideoFromPlaylist (req: express.Request, res: express.Respo
|
||||||
await videoPlaylistElement.destroy({ transaction: t })
|
await videoPlaylistElement.destroy({ transaction: t })
|
||||||
|
|
||||||
// Decrease position of the next elements
|
// Decrease position of the next elements
|
||||||
await VideoPlaylistElementModel.increasePositionOf(videoPlaylist.id, positionToDelete, -1, t)
|
await VideoPlaylistElementModel.increasePositionOf({
|
||||||
|
videoPlaylistId: videoPlaylist.id,
|
||||||
|
fromPosition: positionToDelete,
|
||||||
|
by: -1,
|
||||||
|
transaction: t
|
||||||
|
})
|
||||||
|
|
||||||
videoPlaylist.changed('updatedAt', true)
|
videoPlaylist.changed('updatedAt', true)
|
||||||
await videoPlaylist.save({ transaction: t })
|
await videoPlaylist.save({ transaction: t })
|
||||||
|
@ -407,7 +473,7 @@ async function removeVideoFromPlaylist (req: express.Request, res: express.Respo
|
||||||
return res.type('json').status(HttpStatusCode.NO_CONTENT_204).end()
|
return res.type('json').status(HttpStatusCode.NO_CONTENT_204).end()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function reorderVideosPlaylist (req: express.Request, res: express.Response) {
|
async function reorderVideosOfPlaylist (req: express.Request, res: express.Response) {
|
||||||
const videoPlaylist = res.locals.videoPlaylistFull
|
const videoPlaylist = res.locals.videoPlaylistFull
|
||||||
const body: VideoPlaylistReorder = req.body
|
const body: VideoPlaylistReorder = req.body
|
||||||
|
|
||||||
|
@ -419,34 +485,16 @@ async function reorderVideosPlaylist (req: express.Request, res: express.Respons
|
||||||
return res.status(HttpStatusCode.NO_CONTENT_204).end()
|
return res.status(HttpStatusCode.NO_CONTENT_204).end()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Example: if we reorder position 2 and insert after position 5 (so at position 6): # 1 2 3 4 5 6 7 8 9
|
|
||||||
// * increase position when position > 5 # 1 2 3 4 5 7 8 9 10
|
|
||||||
// * update position 2 -> position 6 # 1 3 4 5 6 7 8 9 10
|
|
||||||
// * decrease position when position position > 2 # 1 2 3 4 5 6 7 8 9
|
|
||||||
await sequelizeTypescript.transaction(async t => {
|
await sequelizeTypescript.transaction(async t => {
|
||||||
const newPosition = insertAfter + 1
|
await reorderPlaylistOrElementsPosition({
|
||||||
|
model: VideoPlaylistElementModel,
|
||||||
// Add space after the position when we want to insert our reordered elements (increase)
|
instance: videoPlaylist,
|
||||||
await VideoPlaylistElementModel.increasePositionOf(videoPlaylist.id, newPosition, reorderLength, t)
|
start,
|
||||||
|
insertAfter,
|
||||||
let oldPosition = start
|
reorderLength,
|
||||||
|
|
||||||
// We incremented the position of the elements we want to reorder
|
|
||||||
if (start >= newPosition) oldPosition += reorderLength
|
|
||||||
|
|
||||||
const endOldPosition = oldPosition + reorderLength - 1
|
|
||||||
// Insert our reordered elements in their place (update)
|
|
||||||
await VideoPlaylistElementModel.reassignPositionOf({
|
|
||||||
videoPlaylistId: videoPlaylist.id,
|
|
||||||
firstPosition: oldPosition,
|
|
||||||
endPosition: endOldPosition,
|
|
||||||
newPosition,
|
|
||||||
transaction: t
|
transaction: t
|
||||||
})
|
})
|
||||||
|
|
||||||
// Decrease positions of elements after the old position of our ordered elements (decrease)
|
|
||||||
await VideoPlaylistElementModel.increasePositionOf(videoPlaylist.id, oldPosition, -reorderLength, t)
|
|
||||||
|
|
||||||
videoPlaylist.changed('updatedAt', true)
|
videoPlaylist.changed('updatedAt', true)
|
||||||
await videoPlaylist.save({ transaction: t })
|
await videoPlaylist.save({ transaction: t })
|
||||||
|
|
||||||
|
|
|
@ -191,6 +191,10 @@ const contextStore: { [id in ContextType]: (string | { [id: string]: string })[]
|
||||||
'@type': 'sc:Number',
|
'@type': 'sc:Number',
|
||||||
'@id': 'pt:position'
|
'@id': 'pt:position'
|
||||||
},
|
},
|
||||||
|
videoChannelPosition: {
|
||||||
|
'@type': 'sc:Number',
|
||||||
|
'@id': 'pt:position'
|
||||||
|
},
|
||||||
startTimestamp: {
|
startTimestamp: {
|
||||||
'@type': 'sc:Number',
|
'@type': 'sc:Number',
|
||||||
'@id': 'pt:startTimestamp'
|
'@id': 'pt:startTimestamp'
|
||||||
|
|
|
@ -48,7 +48,7 @@ import { CONFIG, registerConfigChangedHandler } from './config.js'
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export const LAST_MIGRATION_VERSION = 900
|
export const LAST_MIGRATION_VERSION = 905
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@ -143,7 +143,7 @@ export const SORTABLE_COLUMNS = {
|
||||||
|
|
||||||
USER_NOTIFICATIONS: [ 'createdAt', 'read' ],
|
USER_NOTIFICATIONS: [ 'createdAt', 'read' ],
|
||||||
|
|
||||||
VIDEO_PLAYLISTS: [ 'name', 'displayName', 'createdAt', 'updatedAt' ],
|
VIDEO_PLAYLISTS: [ 'name', 'displayName', 'createdAt', 'updatedAt', 'videoChannelPosition' ],
|
||||||
|
|
||||||
PLUGINS: [ 'name', 'createdAt', 'updatedAt' ],
|
PLUGINS: [ 'name', 'createdAt', 'updatedAt' ],
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,38 @@
|
||||||
|
import * as Sequelize from 'sequelize'
|
||||||
|
|
||||||
|
async function up (utils: {
|
||||||
|
transaction: Sequelize.Transaction
|
||||||
|
queryInterface: Sequelize.QueryInterface
|
||||||
|
sequelize: Sequelize.Sequelize
|
||||||
|
}): Promise<void> {
|
||||||
|
const { transaction } = utils
|
||||||
|
|
||||||
|
{
|
||||||
|
await utils.queryInterface.addColumn('videoPlaylist', 'videoChannelPosition', {
|
||||||
|
type: Sequelize.INTEGER,
|
||||||
|
defaultValue: null,
|
||||||
|
allowNull: true
|
||||||
|
}, { transaction })
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
await utils.sequelize.query(
|
||||||
|
`UPDATE "videoPlaylist" SET "videoChannelPosition" = tmp.position FROM (` +
|
||||||
|
`SELECT tmp.*, ROW_NUMBER () ` +
|
||||||
|
`OVER (PARTITION BY "videoChannelId" ORDER BY "updatedAt" DESC) AS "position" from "videoPlaylist" "tmp" ` +
|
||||||
|
`WHERE "tmp"."videoChannelPosition" IS NULL AND "tmp"."videoChannelId" IS NOT NULL` +
|
||||||
|
`) tmp ` +
|
||||||
|
`WHERE "videoPlaylist"."id" = tmp.id`,
|
||||||
|
{ transaction }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function down (options) {
|
||||||
|
throw new Error('Not implemented.')
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
up,
|
||||||
|
down
|
||||||
|
}
|
|
@ -16,6 +16,7 @@ export function playlistObjectToDBAttributes (playlistObject: PlaylistObject, to
|
||||||
privacy,
|
privacy,
|
||||||
url: playlistObject.id,
|
url: playlistObject.id,
|
||||||
uuid: playlistObject.uuid,
|
uuid: playlistObject.uuid,
|
||||||
|
videoChannelPosition: playlistObject.videoChannelPosition,
|
||||||
videoChannelId: null,
|
videoChannelId: null,
|
||||||
ownerAccountId: null,
|
ownerAccountId: null,
|
||||||
createdAt: new Date(playlistObject.published),
|
createdAt: new Date(playlistObject.published),
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { Transaction } from 'sequelize'
|
|
||||||
import { getServerActor } from '@server/models/application/application.js'
|
|
||||||
import { ActivityAudience, ActivityDelete } from '@peertube/peertube-models'
|
import { ActivityAudience, ActivityDelete } from '@peertube/peertube-models'
|
||||||
|
import { AccountModel } from '@server/models/account/account.js'
|
||||||
|
import { getServerActor } from '@server/models/application/application.js'
|
||||||
|
import { Transaction } from 'sequelize'
|
||||||
import { logger } from '../../../helpers/logger.js'
|
import { logger } from '../../../helpers/logger.js'
|
||||||
import { ActorModel } from '../../../models/actor/actor.js'
|
import { ActorModel } from '../../../models/actor/actor.js'
|
||||||
import { VideoCommentModel } from '../../../models/video/video-comment.js'
|
import { VideoCommentModel } from '../../../models/video/video-comment.js'
|
||||||
|
@ -11,7 +12,6 @@ import { audiencify } from '../audience.js'
|
||||||
import { getDeleteActivityPubUrl } from '../url.js'
|
import { getDeleteActivityPubUrl } from '../url.js'
|
||||||
import { getActorsInvolvedInVideo, getVideoCommentAudience } from './shared/index.js'
|
import { getActorsInvolvedInVideo, getVideoCommentAudience } from './shared/index.js'
|
||||||
import { broadcastToActors, broadcastToFollowers, sendVideoRelatedActivity, unicastTo } from './shared/send-utils.js'
|
import { broadcastToActors, broadcastToFollowers, sendVideoRelatedActivity, unicastTo } from './shared/send-utils.js'
|
||||||
import { AccountModel } from '@server/models/account/account.js'
|
|
||||||
|
|
||||||
async function sendDeleteVideo (video: MVideoAccountLight, transaction: Transaction) {
|
async function sendDeleteVideo (video: MVideoAccountLight, transaction: Transaction) {
|
||||||
logger.info('Creating job to broadcast delete of video %s.', video.url)
|
logger.info('Creating job to broadcast delete of video %s.', video.url)
|
||||||
|
@ -140,8 +140,8 @@ async function sendDeleteVideoPlaylist (videoPlaylist: MVideoPlaylistFullSummary
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export {
|
export {
|
||||||
sendDeleteVideo,
|
|
||||||
sendDeleteActor,
|
sendDeleteActor,
|
||||||
|
sendDeleteVideo,
|
||||||
sendDeleteVideoComment,
|
sendDeleteVideoComment,
|
||||||
sendDeleteVideoPlaylist
|
sendDeleteVideoPlaylist
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,14 +1,17 @@
|
||||||
import * as Sequelize from 'sequelize'
|
|
||||||
import { VideoPlaylistPrivacy, VideoPlaylistType } from '@peertube/peertube-models'
|
import { VideoPlaylistPrivacy, VideoPlaylistType } from '@peertube/peertube-models'
|
||||||
|
import { logger } from '@server/helpers/logger.js'
|
||||||
|
import { SequelizeModel } from '@server/models/shared/sequelize-type.js'
|
||||||
|
import { VideoPlaylistElementModel } from '@server/models/video/video-playlist-element.js'
|
||||||
|
import { Transaction } from 'sequelize'
|
||||||
import { VideoPlaylistModel } from '../models/video/video-playlist.js'
|
import { VideoPlaylistModel } from '../models/video/video-playlist.js'
|
||||||
import { MAccount, MVideoThumbnail } from '../types/models/index.js'
|
import { MAccount, MVideoThumbnail } from '../types/models/index.js'
|
||||||
import { MVideoPlaylistOwner, MVideoPlaylistThumbnail } from '../types/models/video/video-playlist.js'
|
import { MVideoPlaylistOwner, MVideoPlaylistThumbnail } from '../types/models/video/video-playlist.js'
|
||||||
|
import { sendUpdateVideoPlaylist } from './activitypub/send/send-update.js'
|
||||||
import { getLocalVideoPlaylistActivityPubUrl } from './activitypub/url.js'
|
import { getLocalVideoPlaylistActivityPubUrl } from './activitypub/url.js'
|
||||||
import { VideoMiniaturePermanentFileCache } from './files-cache/video-miniature-permanent-file-cache.js'
|
import { VideoMiniaturePermanentFileCache } from './files-cache/video-miniature-permanent-file-cache.js'
|
||||||
import { updateLocalPlaylistMiniatureFromExisting } from './thumbnail.js'
|
import { updateLocalPlaylistMiniatureFromExisting } from './thumbnail.js'
|
||||||
import { logger } from '@server/helpers/logger.js'
|
|
||||||
|
|
||||||
export async function createWatchLaterPlaylist (account: MAccount, t: Sequelize.Transaction) {
|
export async function createWatchLaterPlaylist (account: MAccount, t: Transaction) {
|
||||||
const videoPlaylist: MVideoPlaylistOwner = new VideoPlaylistModel({
|
const videoPlaylist: MVideoPlaylistOwner = new VideoPlaylistModel({
|
||||||
name: 'Watch later',
|
name: 'Watch later',
|
||||||
privacy: VideoPlaylistPrivacy.PRIVATE,
|
privacy: VideoPlaylistPrivacy.PRIVATE,
|
||||||
|
@ -51,3 +54,63 @@ export async function generateThumbnailForPlaylist (videoPlaylist: MVideoPlaylis
|
||||||
|
|
||||||
videoPlaylist.Thumbnail = await thumbnailModel.save()
|
videoPlaylist.Thumbnail = await thumbnailModel.save()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function reorderPlaylistOrElementsPosition<T extends typeof VideoPlaylistElementModel | typeof VideoPlaylistModel> (options: {
|
||||||
|
model: T
|
||||||
|
instance: SequelizeModel<T>
|
||||||
|
start: number
|
||||||
|
insertAfter: number
|
||||||
|
reorderLength: number
|
||||||
|
transaction: Transaction
|
||||||
|
}) {
|
||||||
|
const { model, start, insertAfter, reorderLength, instance, transaction } = options
|
||||||
|
|
||||||
|
// Example: if we reorder position 2 and insert after position 5 (so at position 6): # 1 2 3 4 5 6 7 8 9
|
||||||
|
// * increase position when position > 5 # 1 2 3 4 5 7 8 9 10
|
||||||
|
// * update position 2 -> position 6 # 1 3 4 5 6 7 8 9 10
|
||||||
|
// * decrease position when position position > 2 # 1 2 3 4 5 6 7 8 9
|
||||||
|
|
||||||
|
const newPosition = insertAfter + 1
|
||||||
|
|
||||||
|
// Add space after the position when we want to insert our reordered elements (increase)
|
||||||
|
await model.increasePositionOf({
|
||||||
|
videoChannelId: instance.id,
|
||||||
|
videoPlaylistId: instance.id,
|
||||||
|
fromPosition: newPosition,
|
||||||
|
by: reorderLength,
|
||||||
|
transaction
|
||||||
|
})
|
||||||
|
|
||||||
|
let oldPosition = start
|
||||||
|
|
||||||
|
// We incremented the position of the elements we want to reorder
|
||||||
|
if (start >= newPosition) oldPosition += reorderLength
|
||||||
|
|
||||||
|
const endOldPosition = oldPosition + reorderLength - 1
|
||||||
|
// Insert our reordered elements in their place (update)
|
||||||
|
await model.reassignPositionOf({
|
||||||
|
videoPlaylistId: instance.id,
|
||||||
|
videoChannelId: instance.id,
|
||||||
|
firstPosition: oldPosition,
|
||||||
|
endPosition: endOldPosition,
|
||||||
|
newPosition,
|
||||||
|
transaction
|
||||||
|
})
|
||||||
|
|
||||||
|
// Decrease positions of elements after the old position of our ordered elements (decrease)
|
||||||
|
await model.increasePositionOf({
|
||||||
|
videoPlaylistId: instance.id,
|
||||||
|
videoChannelId: instance.id,
|
||||||
|
fromPosition: oldPosition,
|
||||||
|
by: -reorderLength,
|
||||||
|
transaction
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendPlaylistPositionUpdateOfChannel (channelId: number, transaction: Transaction) {
|
||||||
|
const playlists = await VideoPlaylistModel.listPlaylistOfChannel(channelId, transaction)
|
||||||
|
|
||||||
|
for (const playlist of playlists) {
|
||||||
|
await sendUpdateVideoPlaylist(playlist, transaction)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -8,6 +8,7 @@ import {
|
||||||
VideoPlaylistType,
|
VideoPlaylistType,
|
||||||
VideoPlaylistUpdate
|
VideoPlaylistUpdate
|
||||||
} from '@peertube/peertube-models'
|
} from '@peertube/peertube-models'
|
||||||
|
import { VideoPlaylistModel } from '@server/models/video/video-playlist.js'
|
||||||
import { ExpressPromiseHandler } from '@server/types/express-handler.js'
|
import { ExpressPromiseHandler } from '@server/types/express-handler.js'
|
||||||
import { MUserAccountId } from '@server/types/models/index.js'
|
import { MUserAccountId } from '@server/types/models/index.js'
|
||||||
import express from 'express'
|
import express from 'express'
|
||||||
|
@ -44,7 +45,7 @@ import {
|
||||||
VideoPlaylistFetchType
|
VideoPlaylistFetchType
|
||||||
} from '../shared/index.js'
|
} from '../shared/index.js'
|
||||||
|
|
||||||
const videoPlaylistsAddValidator = getCommonPlaylistEditAttributes().concat([
|
export const videoPlaylistsAddValidator = getCommonPlaylistEditAttributes().concat([
|
||||||
body('displayName')
|
body('displayName')
|
||||||
.custom(isVideoPlaylistNameValid),
|
.custom(isVideoPlaylistNameValid),
|
||||||
|
|
||||||
|
@ -69,7 +70,7 @@ const videoPlaylistsAddValidator = getCommonPlaylistEditAttributes().concat([
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
|
|
||||||
const videoPlaylistsUpdateValidator = getCommonPlaylistEditAttributes().concat([
|
export const videoPlaylistsUpdateValidator = getCommonPlaylistEditAttributes().concat([
|
||||||
isValidPlaylistIdParam('playlistId'),
|
isValidPlaylistIdParam('playlistId'),
|
||||||
|
|
||||||
body('displayName')
|
body('displayName')
|
||||||
|
@ -116,7 +117,7 @@ const videoPlaylistsUpdateValidator = getCommonPlaylistEditAttributes().concat([
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
|
|
||||||
const videoPlaylistsDeleteValidator = [
|
export const videoPlaylistsDeleteValidator = [
|
||||||
isValidPlaylistIdParam('playlistId'),
|
isValidPlaylistIdParam('playlistId'),
|
||||||
|
|
||||||
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||||
|
@ -137,7 +138,7 @@ const videoPlaylistsDeleteValidator = [
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
const videoPlaylistsGetValidator = (fetchType: VideoPlaylistFetchType) => {
|
export const videoPlaylistsGetValidator = (fetchType: VideoPlaylistFetchType) => {
|
||||||
return [
|
return [
|
||||||
isValidPlaylistIdParam('playlistId'),
|
isValidPlaylistIdParam('playlistId'),
|
||||||
|
|
||||||
|
@ -181,7 +182,7 @@ const videoPlaylistsGetValidator = (fetchType: VideoPlaylistFetchType) => {
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
const videoPlaylistsSearchValidator = [
|
export const videoPlaylistsSearchValidator = [
|
||||||
query('search')
|
query('search')
|
||||||
.optional()
|
.optional()
|
||||||
.not().isEmpty(),
|
.not().isEmpty(),
|
||||||
|
@ -193,7 +194,7 @@ const videoPlaylistsSearchValidator = [
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
const videoPlaylistsAddVideoValidator = [
|
export const videoPlaylistsAddVideoValidator = [
|
||||||
isValidPlaylistIdParam('playlistId'),
|
isValidPlaylistIdParam('playlistId'),
|
||||||
|
|
||||||
body('videoId')
|
body('videoId')
|
||||||
|
@ -222,7 +223,7 @@ const videoPlaylistsAddVideoValidator = [
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
const videoPlaylistsUpdateOrRemoveVideoValidator = [
|
export const videoPlaylistsUpdateOrRemoveVideoValidator = [
|
||||||
isValidPlaylistIdParam('playlistId'),
|
isValidPlaylistIdParam('playlistId'),
|
||||||
param('playlistElementId')
|
param('playlistElementId')
|
||||||
.customSanitizer(toCompleteUUID)
|
.customSanitizer(toCompleteUUID)
|
||||||
|
@ -257,7 +258,7 @@ const videoPlaylistsUpdateOrRemoveVideoValidator = [
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
const videoPlaylistElementAPGetValidator = [
|
export const videoPlaylistElementAPGetValidator = [
|
||||||
isValidPlaylistIdParam('playlistId'),
|
isValidPlaylistIdParam('playlistId'),
|
||||||
param('playlistElementId')
|
param('playlistElementId')
|
||||||
.custom(isIdValid),
|
.custom(isIdValid),
|
||||||
|
@ -290,7 +291,38 @@ const videoPlaylistElementAPGetValidator = [
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
const videoPlaylistsReorderVideosValidator = [
|
export const videoPlaylistsReorderInChannelValidator = [
|
||||||
|
body('startPosition')
|
||||||
|
.isInt({ min: 1 }),
|
||||||
|
body('insertAfterPosition')
|
||||||
|
.isInt({ min: 0 }),
|
||||||
|
body('reorderLength')
|
||||||
|
.optional()
|
||||||
|
.isInt({ min: 1 }),
|
||||||
|
|
||||||
|
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||||
|
if (areValidationErrors(req, res)) return
|
||||||
|
|
||||||
|
const nextPosition = await VideoPlaylistModel.getNextPositionOf({ videoChannelId: res.locals.videoChannel.id })
|
||||||
|
const startPosition: number = req.body.startPosition
|
||||||
|
const insertAfterPosition: number = req.body.insertAfterPosition
|
||||||
|
const reorderLength: number = req.body.reorderLength
|
||||||
|
|
||||||
|
if (startPosition >= nextPosition || insertAfterPosition >= nextPosition) {
|
||||||
|
res.fail({ message: `Start position or insert after position exceed the channel limits (max: ${nextPosition - 1})` })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reorderLength && reorderLength + startPosition > nextPosition) {
|
||||||
|
res.fail({ message: `Reorder length with this start position exceeds the channel limits (max: ${nextPosition - startPosition})` })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
return next()
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
export const videoPlaylistsReorderVideosValidator = [
|
||||||
isValidPlaylistIdParam('playlistId'),
|
isValidPlaylistIdParam('playlistId'),
|
||||||
|
|
||||||
body('startPosition')
|
body('startPosition')
|
||||||
|
@ -328,7 +360,7 @@ const videoPlaylistsReorderVideosValidator = [
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
const commonVideoPlaylistFiltersValidator = [
|
export const commonVideoPlaylistFiltersValidator = [
|
||||||
query('playlistType')
|
query('playlistType')
|
||||||
.optional()
|
.optional()
|
||||||
.custom(isVideoPlaylistTypeValid),
|
.custom(isVideoPlaylistTypeValid),
|
||||||
|
@ -340,7 +372,7 @@ const commonVideoPlaylistFiltersValidator = [
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
const doVideosInPlaylistExistValidator = [
|
export const doVideosInPlaylistExistValidator = [
|
||||||
query('videoIds')
|
query('videoIds')
|
||||||
.customSanitizer(toIntArray)
|
.customSanitizer(toIntArray)
|
||||||
.custom(v => isArrayOf(v, isIdValid)).withMessage('Should have a valid video ids array'),
|
.custom(v => isArrayOf(v, isIdValid)).withMessage('Should have a valid video ids array'),
|
||||||
|
@ -354,22 +386,6 @@ const doVideosInPlaylistExistValidator = [
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export {
|
|
||||||
commonVideoPlaylistFiltersValidator,
|
|
||||||
doVideosInPlaylistExistValidator,
|
|
||||||
videoPlaylistElementAPGetValidator,
|
|
||||||
videoPlaylistsAddValidator,
|
|
||||||
videoPlaylistsAddVideoValidator,
|
|
||||||
videoPlaylistsDeleteValidator,
|
|
||||||
videoPlaylistsGetValidator,
|
|
||||||
videoPlaylistsReorderVideosValidator,
|
|
||||||
videoPlaylistsSearchValidator,
|
|
||||||
videoPlaylistsUpdateOrRemoveVideoValidator,
|
|
||||||
videoPlaylistsUpdateValidator
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function getCommonPlaylistEditAttributes () {
|
function getCommonPlaylistEditAttributes () {
|
||||||
return [
|
return [
|
||||||
body('thumbnailfile')
|
body('thumbnailfile')
|
||||||
|
|
78
server/core/models/shared/position.ts
Normal file
78
server/core/models/shared/position.ts
Normal file
|
@ -0,0 +1,78 @@
|
||||||
|
import { forceNumber } from '@peertube/peertube-core-utils'
|
||||||
|
import { AggregateOptions, Attributes, Model, ModelStatic, Op, Sequelize, Transaction, WhereOptions } from 'sequelize'
|
||||||
|
|
||||||
|
export async function getNextPositionOf<T extends Model> (options: {
|
||||||
|
model: ModelStatic<T>
|
||||||
|
columnName: keyof Attributes<T>
|
||||||
|
where: WhereOptions<Attributes<T>>
|
||||||
|
transaction: Transaction
|
||||||
|
}) {
|
||||||
|
const { columnName, model, where, transaction } = options
|
||||||
|
|
||||||
|
const query: AggregateOptions<number> = {
|
||||||
|
where,
|
||||||
|
transaction
|
||||||
|
}
|
||||||
|
|
||||||
|
const position = await model.max(columnName, query)
|
||||||
|
|
||||||
|
return position
|
||||||
|
? position + 1
|
||||||
|
: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reassignPositionOf<T extends Model> (options: {
|
||||||
|
model: ModelStatic<T>
|
||||||
|
columnName: keyof Attributes<T>
|
||||||
|
where: WhereOptions<Attributes<T>>
|
||||||
|
transaction: Transaction
|
||||||
|
|
||||||
|
firstPosition: number
|
||||||
|
endPosition: number
|
||||||
|
newPosition: number
|
||||||
|
}) {
|
||||||
|
const { firstPosition, endPosition, newPosition, model, where, columnName, transaction } = options
|
||||||
|
|
||||||
|
const query = {
|
||||||
|
where: {
|
||||||
|
...where,
|
||||||
|
|
||||||
|
[columnName]: {
|
||||||
|
[Op.gte]: firstPosition,
|
||||||
|
[Op.lte]: endPosition
|
||||||
|
}
|
||||||
|
},
|
||||||
|
transaction,
|
||||||
|
validate: false // We use a literal to update the position
|
||||||
|
}
|
||||||
|
|
||||||
|
const escapedColumnName = model.sequelize.escape(columnName as string).replace(/'/g, '')
|
||||||
|
const positionQuery = Sequelize.literal(`${forceNumber(newPosition)} + "${escapedColumnName}" - ${forceNumber(firstPosition)}`)
|
||||||
|
|
||||||
|
return model.update({ [columnName]: positionQuery } as Record<typeof columnName, typeof positionQuery>, query)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function increasePositionOf<T extends Model> (options: {
|
||||||
|
model: ModelStatic<T>
|
||||||
|
columnName: keyof Attributes<T>
|
||||||
|
where: WhereOptions<Attributes<T>>
|
||||||
|
transaction: Transaction
|
||||||
|
|
||||||
|
fromPosition: number
|
||||||
|
by: number
|
||||||
|
}) {
|
||||||
|
const { model, where, transaction, fromPosition, by, columnName } = options
|
||||||
|
|
||||||
|
const query = {
|
||||||
|
where: {
|
||||||
|
...where,
|
||||||
|
|
||||||
|
[columnName]: {
|
||||||
|
[Op.gte]: fromPosition
|
||||||
|
}
|
||||||
|
},
|
||||||
|
transaction
|
||||||
|
}
|
||||||
|
|
||||||
|
return model.increment({ [columnName]: by } as Record<typeof columnName, number>, query)
|
||||||
|
}
|
|
@ -38,7 +38,7 @@ export function getPlaylistSort (value: string, lastSort: OrderItem = [ 'id', 'A
|
||||||
const { direction, field } = buildSortDirectionAndField(value)
|
const { direction, field } = buildSortDirectionAndField(value)
|
||||||
|
|
||||||
if (field.toLowerCase() === 'name') {
|
if (field.toLowerCase() === 'name') {
|
||||||
return [ [ 'displayName', direction ], lastSort ]
|
return [ [ 'name', direction ], lastSort ]
|
||||||
}
|
}
|
||||||
|
|
||||||
return getSort(value, lastSort)
|
return getSort(value, lastSort)
|
||||||
|
|
|
@ -1,19 +1,3 @@
|
||||||
import { AggregateOptions, Op, ScopeOptions, Sequelize, Transaction } from 'sequelize'
|
|
||||||
import {
|
|
||||||
AllowNull,
|
|
||||||
BelongsTo,
|
|
||||||
Column,
|
|
||||||
CreatedAt,
|
|
||||||
DataType,
|
|
||||||
Default,
|
|
||||||
ForeignKey,
|
|
||||||
Is,
|
|
||||||
IsInt,
|
|
||||||
Min, Table,
|
|
||||||
UpdatedAt
|
|
||||||
} from 'sequelize-typescript'
|
|
||||||
import validator from 'validator'
|
|
||||||
import { forceNumber } from '@peertube/peertube-core-utils'
|
|
||||||
import {
|
import {
|
||||||
PlaylistElementObject,
|
PlaylistElementObject,
|
||||||
VideoPlaylistElement,
|
VideoPlaylistElement,
|
||||||
|
@ -26,16 +10,33 @@ import {
|
||||||
MVideoPlaylistElement,
|
MVideoPlaylistElement,
|
||||||
MVideoPlaylistElementAP,
|
MVideoPlaylistElementAP,
|
||||||
MVideoPlaylistElementFormattable,
|
MVideoPlaylistElementFormattable,
|
||||||
MVideoPlaylistElementVideoUrlPlaylistPrivacy,
|
|
||||||
MVideoPlaylistElementVideoThumbnail,
|
MVideoPlaylistElementVideoThumbnail,
|
||||||
MVideoPlaylistElementVideoUrl
|
MVideoPlaylistElementVideoUrl,
|
||||||
|
MVideoPlaylistElementVideoUrlPlaylistPrivacy
|
||||||
} from '@server/types/models/video/video-playlist-element.js'
|
} from '@server/types/models/video/video-playlist-element.js'
|
||||||
|
import { ScopeOptions, Transaction } from 'sequelize'
|
||||||
|
import {
|
||||||
|
AllowNull,
|
||||||
|
BelongsTo,
|
||||||
|
Column,
|
||||||
|
CreatedAt,
|
||||||
|
DataType,
|
||||||
|
Default,
|
||||||
|
ForeignKey,
|
||||||
|
Is,
|
||||||
|
IsInt,
|
||||||
|
Min,
|
||||||
|
Table,
|
||||||
|
UpdatedAt
|
||||||
|
} from 'sequelize-typescript'
|
||||||
|
import validator from 'validator'
|
||||||
import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc.js'
|
import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc.js'
|
||||||
import { CONSTRAINTS_FIELDS, USER_EXPORT_MAX_ITEMS } from '../../initializers/constants.js'
|
import { CONSTRAINTS_FIELDS, USER_EXPORT_MAX_ITEMS } from '../../initializers/constants.js'
|
||||||
import { AccountModel } from '../account/account.js'
|
import { AccountModel } from '../account/account.js'
|
||||||
import { SequelizeModel, getSort, throwIfNotValid } from '../shared/index.js'
|
import { SequelizeModel, getSort, throwIfNotValid } from '../shared/index.js'
|
||||||
|
import { getNextPositionOf, increasePositionOf, reassignPositionOf } from '../shared/position.js'
|
||||||
import { VideoPlaylistModel } from './video-playlist.js'
|
import { VideoPlaylistModel } from './video-playlist.js'
|
||||||
import { ForAPIOptions, ScopeNames as VideoScopeNames, VideoModel } from './video.js'
|
import { ForAPIOptions, VideoModel, ScopeNames as VideoScopeNames } from './video.js'
|
||||||
|
|
||||||
@Table({
|
@Table({
|
||||||
tableName: 'videoPlaylistElement',
|
tableName: 'videoPlaylistElement',
|
||||||
|
@ -138,7 +139,8 @@ export class VideoPlaylistElementModel extends SequelizeModel<VideoPlaylistEleme
|
||||||
const forApiOptions: ForAPIOptions = { withAccountBlockerIds: accountIds }
|
const forApiOptions: ForAPIOptions = { withAccountBlockerIds: accountIds }
|
||||||
videoScope.push({
|
videoScope.push({
|
||||||
method: [
|
method: [
|
||||||
VideoScopeNames.FOR_API, forApiOptions
|
VideoScopeNames.FOR_API,
|
||||||
|
forApiOptions
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -277,15 +279,12 @@ export class VideoPlaylistElementModel extends SequelizeModel<VideoPlaylistEleme
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
static getNextPositionOf (videoPlaylistId: number, transaction?: Transaction) {
|
static getNextPositionOf (videoPlaylistId: number, transaction?: Transaction) {
|
||||||
const query: AggregateOptions<number> = {
|
return getNextPositionOf({
|
||||||
where: {
|
model: VideoPlaylistElementModel,
|
||||||
videoPlaylistId
|
columnName: 'position',
|
||||||
},
|
where: { videoPlaylistId },
|
||||||
transaction
|
transaction
|
||||||
}
|
})
|
||||||
|
|
||||||
return VideoPlaylistElementModel.max('position', query)
|
|
||||||
.then(position => position ? position + 1 : 1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static reassignPositionOf (options: {
|
static reassignPositionOf (options: {
|
||||||
|
@ -297,41 +296,39 @@ export class VideoPlaylistElementModel extends SequelizeModel<VideoPlaylistEleme
|
||||||
}) {
|
}) {
|
||||||
const { videoPlaylistId, firstPosition, endPosition, newPosition, transaction } = options
|
const { videoPlaylistId, firstPosition, endPosition, newPosition, transaction } = options
|
||||||
|
|
||||||
const query = {
|
return reassignPositionOf({
|
||||||
where: {
|
model: VideoPlaylistElementModel,
|
||||||
videoPlaylistId,
|
columnName: 'position',
|
||||||
position: {
|
where: { videoPlaylistId },
|
||||||
[Op.gte]: firstPosition,
|
|
||||||
[Op.lte]: endPosition
|
|
||||||
}
|
|
||||||
},
|
|
||||||
transaction,
|
transaction,
|
||||||
validate: false // We use a literal to update the position
|
|
||||||
}
|
|
||||||
|
|
||||||
const positionQuery = Sequelize.literal(`${forceNumber(newPosition)} + "position" - ${forceNumber(firstPosition)}`)
|
firstPosition,
|
||||||
return VideoPlaylistElementModel.update({ position: positionQuery }, query)
|
endPosition,
|
||||||
|
newPosition
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
static increasePositionOf (
|
static increasePositionOf (options: {
|
||||||
videoPlaylistId: number,
|
videoPlaylistId: number
|
||||||
fromPosition: number,
|
fromPosition: number
|
||||||
by = 1,
|
by: number
|
||||||
transaction?: Transaction
|
transaction?: Transaction
|
||||||
) {
|
}) {
|
||||||
const query = {
|
const { videoPlaylistId, fromPosition, by, transaction } = options
|
||||||
where: {
|
|
||||||
videoPlaylistId,
|
|
||||||
position: {
|
|
||||||
[Op.gte]: fromPosition
|
|
||||||
}
|
|
||||||
},
|
|
||||||
transaction
|
|
||||||
}
|
|
||||||
|
|
||||||
return VideoPlaylistElementModel.increment({ position: by }, query)
|
return increasePositionOf({
|
||||||
|
model: VideoPlaylistElementModel,
|
||||||
|
columnName: 'position',
|
||||||
|
where: { videoPlaylistId },
|
||||||
|
transaction,
|
||||||
|
|
||||||
|
fromPosition,
|
||||||
|
by
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
toFormattedJSON (
|
toFormattedJSON (
|
||||||
this: MVideoPlaylistElementFormattable,
|
this: MVideoPlaylistElementFormattable,
|
||||||
options: { accountId?: number } = {}
|
options: { accountId?: number } = {}
|
||||||
|
|
|
@ -24,7 +24,8 @@ import {
|
||||||
HasMany,
|
HasMany,
|
||||||
HasOne,
|
HasOne,
|
||||||
Is,
|
Is,
|
||||||
IsUUID, Scopes,
|
IsUUID,
|
||||||
|
Scopes,
|
||||||
Table,
|
Table,
|
||||||
UpdatedAt
|
UpdatedAt
|
||||||
} from 'sequelize-typescript'
|
} from 'sequelize-typescript'
|
||||||
|
@ -67,6 +68,7 @@ import {
|
||||||
setAsUpdated,
|
setAsUpdated,
|
||||||
throwIfNotValid
|
throwIfNotValid
|
||||||
} from '../shared/index.js'
|
} from '../shared/index.js'
|
||||||
|
import { getNextPositionOf, increasePositionOf, reassignPositionOf } from '../shared/position.js'
|
||||||
import { ThumbnailModel } from './thumbnail.js'
|
import { ThumbnailModel } from './thumbnail.js'
|
||||||
import { VideoChannelModel, ScopeNames as VideoChannelScopeNames } from './video-channel.js'
|
import { VideoChannelModel, ScopeNames as VideoChannelScopeNames } from './video-channel.js'
|
||||||
import { VideoPlaylistElementModel } from './video-playlist-element.js'
|
import { VideoPlaylistElementModel } from './video-playlist-element.js'
|
||||||
|
@ -89,6 +91,7 @@ type AvailableForListOptions = {
|
||||||
search?: string
|
search?: string
|
||||||
host?: string
|
host?: string
|
||||||
uuids?: string[]
|
uuids?: string[]
|
||||||
|
channelNameOneOf?: string[]
|
||||||
withVideos?: boolean
|
withVideos?: boolean
|
||||||
forCount?: boolean
|
forCount?: boolean
|
||||||
}
|
}
|
||||||
|
@ -269,7 +272,6 @@ function getVideoLengthSelect () {
|
||||||
} as FindOptions
|
} as FindOptions
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@Table({
|
@Table({
|
||||||
tableName: 'videoPlaylist',
|
tableName: 'videoPlaylist',
|
||||||
indexes: [
|
indexes: [
|
||||||
|
@ -325,6 +327,10 @@ export class VideoPlaylistModel extends SequelizeModel<VideoPlaylistModel> {
|
||||||
@Column
|
@Column
|
||||||
type: VideoPlaylistType_Type
|
type: VideoPlaylistType_Type
|
||||||
|
|
||||||
|
@AllowNull(true)
|
||||||
|
@Column
|
||||||
|
videoChannelPosition: number
|
||||||
|
|
||||||
@ForeignKey(() => AccountModel)
|
@ForeignKey(() => AccountModel)
|
||||||
@Column
|
@Column
|
||||||
ownerAccountId: number
|
ownerAccountId: number
|
||||||
|
@ -368,11 +374,13 @@ export class VideoPlaylistModel extends SequelizeModel<VideoPlaylistModel> {
|
||||||
})
|
})
|
||||||
Thumbnail: Awaited<ThumbnailModel>
|
Thumbnail: Awaited<ThumbnailModel>
|
||||||
|
|
||||||
static listForApi (options: AvailableForListOptions & {
|
static listForApi (
|
||||||
start: number
|
options: AvailableForListOptions & {
|
||||||
count: number
|
start: number
|
||||||
sort: string
|
count: number
|
||||||
}) {
|
sort: string
|
||||||
|
}
|
||||||
|
) {
|
||||||
const query = {
|
const query = {
|
||||||
offset: options.start,
|
offset: options.start,
|
||||||
limit: options.count,
|
limit: options.count,
|
||||||
|
@ -387,6 +395,7 @@ export class VideoPlaylistModel extends SequelizeModel<VideoPlaylistModel> {
|
||||||
'listMyPlaylists',
|
'listMyPlaylists',
|
||||||
'search',
|
'search',
|
||||||
'host',
|
'host',
|
||||||
|
'channelNameOneOf',
|
||||||
'uuids'
|
'uuids'
|
||||||
])
|
])
|
||||||
|
|
||||||
|
@ -427,11 +436,13 @@ export class VideoPlaylistModel extends SequelizeModel<VideoPlaylistModel> {
|
||||||
]).then(([ count, rows ]) => ({ total: count, data: rows }))
|
]).then(([ count, rows ]) => ({ total: count, data: rows }))
|
||||||
}
|
}
|
||||||
|
|
||||||
static searchForApi (options: Pick<AvailableForListOptions, 'followerActorId' | 'search' | 'host' | 'uuids'> & {
|
static searchForApi (
|
||||||
start: number
|
options: Pick<AvailableForListOptions, 'followerActorId' | 'search' | 'host' | 'uuids'> & {
|
||||||
count: number
|
start: number
|
||||||
sort: string
|
count: number
|
||||||
}) {
|
sort: string
|
||||||
|
}
|
||||||
|
) {
|
||||||
return VideoPlaylistModel.listForApi({
|
return VideoPlaylistModel.listForApi({
|
||||||
...options,
|
...options,
|
||||||
|
|
||||||
|
@ -508,6 +519,18 @@ export class VideoPlaylistModel extends SequelizeModel<VideoPlaylistModel> {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static listPlaylistOfChannel (channelId: number, transaction: Transaction): Promise<MVideoPlaylistFull[]> {
|
||||||
|
return VideoPlaylistModel
|
||||||
|
.scope([ ScopeNames.WITH_ACCOUNT_AND_CHANNEL, ScopeNames.WITH_VIDEOS_LENGTH, ScopeNames.WITH_THUMBNAIL ])
|
||||||
|
.findAll({
|
||||||
|
where: {
|
||||||
|
videoChannelId: channelId
|
||||||
|
},
|
||||||
|
limit: 150,
|
||||||
|
transaction
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
static doesPlaylistExist (url: string) {
|
static doesPlaylistExist (url: string) {
|
||||||
|
@ -597,6 +620,64 @@ export class VideoPlaylistModel extends SequelizeModel<VideoPlaylistModel> {
|
||||||
.findOne(query)
|
.findOne(query)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
static getNextPositionOf (options: {
|
||||||
|
videoChannelId: number
|
||||||
|
transaction?: Transaction
|
||||||
|
}) {
|
||||||
|
const { videoChannelId, transaction } = options
|
||||||
|
|
||||||
|
return getNextPositionOf({
|
||||||
|
model: VideoPlaylistModel,
|
||||||
|
columnName: 'videoChannelPosition',
|
||||||
|
where: { videoChannelId },
|
||||||
|
transaction
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
static reassignPositionOf (options: {
|
||||||
|
videoChannelId: number
|
||||||
|
firstPosition: number
|
||||||
|
endPosition: number
|
||||||
|
newPosition: number
|
||||||
|
transaction?: Transaction
|
||||||
|
}) {
|
||||||
|
const { videoChannelId, firstPosition, endPosition, newPosition, transaction } = options
|
||||||
|
|
||||||
|
return reassignPositionOf({
|
||||||
|
model: VideoPlaylistModel,
|
||||||
|
columnName: 'videoChannelPosition',
|
||||||
|
where: { videoChannelId },
|
||||||
|
transaction,
|
||||||
|
|
||||||
|
firstPosition,
|
||||||
|
endPosition,
|
||||||
|
newPosition
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
static increasePositionOf (options: {
|
||||||
|
videoChannelId: number
|
||||||
|
fromPosition: number
|
||||||
|
by: number
|
||||||
|
transaction?: Transaction
|
||||||
|
}) {
|
||||||
|
const { videoChannelId, fromPosition, by, transaction } = options
|
||||||
|
|
||||||
|
return increasePositionOf({
|
||||||
|
model: VideoPlaylistModel,
|
||||||
|
columnName: 'videoChannelPosition',
|
||||||
|
where: { videoChannelId },
|
||||||
|
transaction,
|
||||||
|
|
||||||
|
fromPosition,
|
||||||
|
by
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
static getPrivacyLabel (privacy: VideoPlaylistPrivacyType) {
|
static getPrivacyLabel (privacy: VideoPlaylistPrivacyType) {
|
||||||
return VIDEO_PLAYLIST_PRIVACIES[privacy] || 'Unknown'
|
return VIDEO_PLAYLIST_PRIVACIES[privacy] || 'Unknown'
|
||||||
}
|
}
|
||||||
|
@ -613,7 +694,11 @@ export class VideoPlaylistModel extends SequelizeModel<VideoPlaylistModel> {
|
||||||
transaction
|
transaction
|
||||||
}
|
}
|
||||||
|
|
||||||
return VideoPlaylistModel.update({ privacy: VideoPlaylistPrivacy.PRIVATE, videoChannelId: null }, query)
|
return VideoPlaylistModel.update({
|
||||||
|
privacy: VideoPlaylistPrivacy.PRIVATE,
|
||||||
|
videoChannelId: null,
|
||||||
|
videoChannelPosition: null
|
||||||
|
}, query)
|
||||||
}
|
}
|
||||||
|
|
||||||
async setAndSaveThumbnail (thumbnail: MThumbnail, t: Transaction) {
|
async setAndSaveThumbnail (thumbnail: MThumbnail, t: Transaction) {
|
||||||
|
@ -739,6 +824,8 @@ export class VideoPlaylistModel extends SequelizeModel<VideoPlaylistModel> {
|
||||||
updatedAt: this.updatedAt,
|
updatedAt: this.updatedAt,
|
||||||
|
|
||||||
ownerAccount: this.OwnerAccount.toFormattedSummaryJSON(),
|
ownerAccount: this.OwnerAccount.toFormattedSummaryJSON(),
|
||||||
|
|
||||||
|
videoChannelPosition: this.videoChannelPosition,
|
||||||
videoChannel: this.VideoChannel
|
videoChannel: this.VideoChannel
|
||||||
? this.VideoChannel.toFormattedSummaryJSON()
|
? this.VideoChannel.toFormattedSummaryJSON()
|
||||||
: null
|
: null
|
||||||
|
@ -769,6 +856,7 @@ export class VideoPlaylistModel extends SequelizeModel<VideoPlaylistModel> {
|
||||||
content: this.description,
|
content: this.description,
|
||||||
mediaType: 'text/markdown' as 'text/markdown',
|
mediaType: 'text/markdown' as 'text/markdown',
|
||||||
uuid: this.uuid,
|
uuid: this.uuid,
|
||||||
|
videoChannelPosition: this.videoChannelPosition,
|
||||||
published: this.createdAt.toISOString(),
|
published: this.createdAt.toISOString(),
|
||||||
updated: this.updatedAt.toISOString(),
|
updated: this.updatedAt.toISOString(),
|
||||||
attributedTo: this.VideoChannel ? [ this.VideoChannel.Actor.url ] : [],
|
attributedTo: this.VideoChannel ? [ this.VideoChannel.Actor.url ] : [],
|
||||||
|
|
|
@ -4767,6 +4767,42 @@ paths:
|
||||||
items:
|
items:
|
||||||
$ref: '#/components/schemas/VideoPlaylist'
|
$ref: '#/components/schemas/VideoPlaylist'
|
||||||
|
|
||||||
|
'/api/v1/video-channels/{channelHandle}/video-playlists/reorder':
|
||||||
|
post:
|
||||||
|
summary: Reorder channel playlists
|
||||||
|
operationId: reorderVideoPlaylistsOfChannel
|
||||||
|
security:
|
||||||
|
- OAuth2: []
|
||||||
|
tags:
|
||||||
|
- Video Playlists
|
||||||
|
- Video Channels
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/channelHandle'
|
||||||
|
responses:
|
||||||
|
'204':
|
||||||
|
description: successful operation
|
||||||
|
requestBody:
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
startPosition:
|
||||||
|
type: integer
|
||||||
|
description: 'Start position of the element to reorder'
|
||||||
|
minimum: 1
|
||||||
|
insertAfterPosition:
|
||||||
|
type: integer
|
||||||
|
description: 'New position for the block to reorder, to add the block before the first element'
|
||||||
|
minimum: 0
|
||||||
|
reorderLength:
|
||||||
|
type: integer
|
||||||
|
description: 'How many element from `startPosition` to reorder'
|
||||||
|
minimum: 1
|
||||||
|
required:
|
||||||
|
- startPosition
|
||||||
|
- insertAfterPosition
|
||||||
|
|
||||||
'/api/v1/video-channels/{channelHandle}/followers':
|
'/api/v1/video-channels/{channelHandle}/followers':
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
|
@ -5244,7 +5280,7 @@ paths:
|
||||||
|
|
||||||
/api/v1/video-playlists/{playlistId}/videos/reorder:
|
/api/v1/video-playlists/{playlistId}/videos/reorder:
|
||||||
post:
|
post:
|
||||||
summary: 'Reorder a playlist'
|
summary: Reorder playlist elements
|
||||||
operationId: reorderVideoPlaylist
|
operationId: reorderVideoPlaylist
|
||||||
security:
|
security:
|
||||||
- OAuth2: []
|
- OAuth2: []
|
||||||
|
@ -9084,6 +9120,10 @@ components:
|
||||||
$ref: '#/components/schemas/AccountSummary'
|
$ref: '#/components/schemas/AccountSummary'
|
||||||
videoChannel:
|
videoChannel:
|
||||||
$ref: '#/components/schemas/VideoChannelSummary'
|
$ref: '#/components/schemas/VideoChannelSummary'
|
||||||
|
videoChannelPosition:
|
||||||
|
type: integer
|
||||||
|
minimum: 1
|
||||||
|
description: Position of the playlist in the channel
|
||||||
VideoComment:
|
VideoComment:
|
||||||
properties:
|
properties:
|
||||||
id:
|
id:
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue