1
0
Fork 0
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:
Chocobozzz 2025-06-19 15:56:06 +02:00
parent 546bd42240
commit 0adafa0fc0
No known key found for this signature in database
GPG key ID: 583A612D890159BE
48 changed files with 1776 additions and 607 deletions

View file

@ -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>

View file

@ -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);
} }

View file

@ -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

View file

@ -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()

View file

@ -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>

View file

@ -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);
}
} }

View file

@ -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)
})
} }
} }

View file

@ -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>

View file

@ -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) {

View file

@ -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 {

View file

@ -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)
}) })
} }
} }

View file

@ -89,7 +89,7 @@ export default {
} }
}, },
dropPoint: { dropPoint: {
color: '{primary.color}' color: 'var(--border-primary)'
}, },
columnResizer: { columnResizer: {
width: '0.5rem' width: '0.5rem'

View file

@ -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')
} }

View file

@ -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>

View file

@ -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()({

View file

@ -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>

View file

@ -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')

View file

@ -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)

View file

@ -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

View file

@ -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()
}
}

View file

@ -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()
}
}

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-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

View file

@ -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);
}

View file

@ -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;
}

View file

@ -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
} }

View file

@ -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

View file

@ -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'

View file

@ -31,5 +31,7 @@ export interface VideoPlaylist {
updatedAt: Date | string updatedAt: Date | string
ownerAccount: AccountSummary ownerAccount: AccountSummary
videoChannelPosition: number
videoChannel?: VideoChannelSummary videoChannel?: VideoChannelSummary
} }

View file

@ -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
}

View file

@ -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'

View file

@ -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)
}) })
}) })

View file

@ -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)
}) })

View file

@ -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))

View file

@ -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()

View file

@ -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 })

View file

@ -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'

View file

@ -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' ],

View file

@ -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
}

View file

@ -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),

View file

@ -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
} }

View file

@ -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)
}
}

View file

@ -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')

View 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)
}

View file

@ -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)

View file

@ -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 } = {}

View file

@ -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 ] : [],

View file

@ -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: