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

View file

@ -8,7 +8,7 @@ my-embed {
}
.pt-badge,
my-video-privacy-badge,
my-privacy-badge,
my-video-nsfw-badge {
@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 { VideoBlockComponent } from '@app/shared/shared-moderation/video-block.component'
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 { FileStorage, NSFWFlag, UserRight, VideoFile, VideoState, VideoStreamingPlaylistType } from '@peertube/peertube-models'
import { videoRequiresFileToken } from '@root-helpers/video'
@ -29,7 +30,6 @@ import {
VideoActionsDropdownComponent
} from '../../../shared/shared-video-miniature/video-actions-dropdown.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'
type ColumnName =
@ -54,7 +54,7 @@ type ColumnName =
PTDatePipe,
RouterLink,
BytesPipe,
VideoPrivacyBadgeComponent,
PrivacyBadgeComponent,
VideoNSFWBadgeComponent,
TableComponent,
NumberFormatterPipe

View file

@ -103,7 +103,7 @@ export class MyVideoPlaylistElementsComponent implements OnInit, OnDestroy {
this.playlistElements.splice(previousIndex, 1)
this.playlistElements.splice(newIndex, 0, element)
this.videoPlaylistService.reorderPlaylist(this.playlist.id, oldPosition, insertAfter)
this.videoPlaylistService.reorderVideosOfPlaylist(this.playlist.id, oldPosition, insertAfter)
.subscribe({
next: () => {
this.reorderClientPositions()

View file

@ -1,25 +1,92 @@
<div class="video-playlists-header d-flex align-items-end gap-2 flex-wrap">
<span class="total-items" *ngIf="pagination.totalItems"> {{ getTotalTitle() }}</span>
@if (user.videoChannels.length > 1) {
<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-global-icon iconName="add" aria-hidden="true"></my-global-icon>
<ng-container i18n>Create playlist</ng-container>
</a>
</div>
<my-table
#table
key="MyVideoPlaylistsComponent"
[defaultColumns]="columns"
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()">
<div *ngFor="let playlist of videoPlaylists" class="video-playlist">
<my-video-playlist-miniature
[playlist]="playlist" [toManage]="true" [displayChannel]="true"
[displayDescription]="true" [displayPrivacy]="true" [displayAsRow]="true"
></my-video-playlist-miniature>
<ng-template #captionRight>
<my-advanced-input-filter
inputId="table-search" icon="true" emitOnInit="false" i18n-placeholder placeholder="Search your playlists"
(search)="table.onSearch($event)"
></my-advanced-input-filter>
<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-edit-button label [ptRouterLink]="[ 'update', playlist.shortUUID ]"></my-edit-button>
</div>
</div>
</div>
}
</ng-template>
<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 '_mixins' as *;
@use '_form-mixins' as *;
@use "_variables" as *;
@use "_mixins" as *;
@use "_form-mixins" as *;
@use "_actor" as *;
input[type=text] {
@include peertube-input-text(300px);
.channel-label {
@include channel-label;
}
.video-playlist {
@include row-blocks($column-responsive: false);
.channel-filters {
@include channel-filters;
}
.video-playlist-buttons {
display: flex;
align-self: flex-end;
@include margin-left(10px);
}
.video-playlists-header {
margin-bottom: 30px;
.name {
color: var(--fg);
line-height: 1rem;
font-weight: $font-bold;
text-decoration: none;
}
my-video-playlist-miniature {
display: block;
flex-grow: 1;
}
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);
}
width: 130px;
}

View file

@ -1,52 +1,145 @@
import { NgFor, NgIf } from '@angular/common'
import { Component, inject } from '@angular/core'
import { DragDropModule } from '@angular/cdk/drag-drop'
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 { AuthService, ComponentPagination, ConfirmService, Notifier, resetCurrentPage, updatePaginationOnDelete } from '@app/core'
import { formatICU } from '@app/helpers'
import { AuthService, AuthUser, ConfirmService, Notifier, RestPagination, ScreenService } from '@app/core'
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 { VideoPlaylistService } from '@app/shared/shared-video-playlist/video-playlist.service'
import { VideoPlaylistType } from '@peertube/peertube-models'
import { Subject } from 'rxjs'
import { mergeMap } from 'rxjs/operators'
import { VideoChannel, VideoPlaylistType } from '@peertube/peertube-models'
import debug from 'debug'
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 { GlobalIconComponent } from '../../shared/shared-icons/global-icon.component'
import { DeleteButtonComponent } from '../../shared/shared-main/buttons/delete-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 { 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({
templateUrl: './my-video-playlists.component.html',
styleUrls: [ './my-video-playlists.component.scss' ],
imports: [
CommonModule,
FormsModule,
GlobalIconComponent,
NgIf,
AdvancedInputFilterComponent,
RouterLink,
InfiniteScrollerDirective,
NgFor,
VideoPlaylistMiniatureComponent,
DeleteButtonComponent,
EditButtonComponent
EditButtonComponent,
ChannelToggleComponent,
TableComponent,
NumberFormatterPipe,
PrivacyBadgeComponent,
PTDatePipe,
DragDropModule
]
})
export class MyVideoPlaylistsComponent {
private authService = inject(AuthService)
export class MyVideoPlaylistsComponent implements OnInit, OnDestroy {
private notifier = inject(Notifier)
private confirmService = inject(ConfirmService)
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 = {
currentPage: 1,
itemsPerPage: 5,
totalItems: null
}
user: AuthUser
channels: (VideoChannel & { selected: boolean })[] = []
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) {
const res = await this.confirmService.confirm(
@ -58,8 +151,7 @@ export class MyVideoPlaylistsComponent {
this.videoPlaylistService.removeVideoPlaylist(videoPlaylist)
.subscribe({
next: () => {
this.videoPlaylists = this.videoPlaylists.filter(p => p.id !== videoPlaylist.id)
updatePaginationOnDelete(this.pagination)
this.table().loadData()
this.notifier.success($localize`Playlist ${videoPlaylist.displayName} deleted.`)
},
@ -72,41 +164,95 @@ export class MyVideoPlaylistsComponent {
return playlist.type.id === VideoPlaylistType.REGULAR
}
onNearOfBottom () {
// Last page
if (this.pagination.totalItems <= (this.pagination.currentPage * this.pagination.itemsPerPage)) return
onRowReorder (event: TableRowReorderEvent) {
const { dragIndex, dropIndex } = event
this.pagination.currentPage += 1
this.loadVideoPlaylists()
// PrimeNG index takes into account the pagination
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) {
this.search = search
resetCurrentPage(this.pagination)
onChannelFilter (channel: VideoChannel & { selected: boolean }) {
for (const c of this.channels) {
if (c.id !== channel.id) {
c.selected = false
}
}
this.loadVideoPlaylists(true)
this.table().onFilter()
}
getTotalTitle () {
return formatICU(
$localize`${this.pagination.totalItems} {total, plural, =1 {playlist} other {playlists}}`,
{ total: this.pagination.totalItems }
hasReorderableRows () {
return !!this.getFilteredChannel() || this.user.videoChannels.length === 1
}
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) {
this.authService.userInformationLoaded
.pipe(mergeMap(() => {
const user = this.authService.getUser()
private _hasExpandedRow (playlist: VideoPlaylist) {
return !!playlist.description
}
return this.videoPlaylistService.listAccountPlaylists(user.account, this.pagination, '-updatedAt', this.search)
})).subscribe(res => {
if (reset) this.videoPlaylists = []
this.videoPlaylists = this.videoPlaylists.concat(res.data)
this.pagination.totalItems = res.total
this.onDataSubject.next(res.data)
})
getFilteredChannel () {
return this.channels.find(c => c.selected)
}
}

View file

@ -1,5 +1,5 @@
<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) {
<my-channel-toggle [channel]="channel" [(ngModel)]="channel.selected" (ngModelChange)="table.onFilter()"></my-channel-toggle>
@ -64,7 +64,7 @@
<td *ngIf="table.isColumnDisplayed('privacy')">
<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>
</div>

View file

@ -1,5 +1,6 @@
@use "_variables" as *;
@use "_mixins" as *;
@use "_actor" as *;
my-select-checkbox {
min-width: 250px;
@ -34,24 +35,12 @@ td {
padding-bottom: 0.75rem;
}
.channel-label {
@include channel-label;
}
.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;
}
.channels-label {
color: pvar(--fg-200);
font-weight: $font-bold;
white-space: nowrap;
}
@include channel-filters;
}
@media screen and (min-width: $small-view) {

View file

@ -28,8 +28,8 @@ import {
VideoActionsDisplayType,
VideoActionsDropdownComponent
} 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 { VideoPrivacyBadgeComponent } from '../../shared/shared-video/video-privacy-badge.component'
import { VideoStateBadgeComponent } from '../../shared/shared-video/video-state-badge.component'
import { VideoChangeOwnershipComponent } from './modals/video-change-ownership.component'
@ -40,7 +40,6 @@ type VideoType = 'live' | 'vod'
type QueryParams = TableQueryParams & {
channelNameOneOf?: string[]
privacyOneOf?: string[]
search?: string
videoType?: VideoType
}
@ -59,13 +58,13 @@ type QueryParams = TableQueryParams & {
RouterLink,
NumberFormatterPipe,
VideoChangeOwnershipComponent,
VideoPrivacyBadgeComponent,
VideoStateBadgeComponent,
ChannelToggleComponent,
SelectCheckboxComponent,
PTDatePipe,
VideoNSFWBadgeComponent,
TableComponent
TableComponent,
PrivacyBadgeComponent
]
})
export class MyVideosComponent implements OnInit, OnDestroy {

View file

@ -1,5 +1,5 @@
import { NgFor, NgIf } from '@angular/common'
import { AfterViewInit, Component, OnDestroy, OnInit, inject } from '@angular/core'
import { CommonModule } from '@angular/common'
import { AfterViewInit, Component, inject, OnDestroy, OnInit } from '@angular/core'
import { ComponentPagination, hasMoreItems, HooksService, resetCurrentPage, ScreenService } from '@app/core'
import { VideoChannel } from '@app/shared/shared-main/channel/video-channel.model'
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',
templateUrl: './video-channel-playlists.component.html',
styleUrls: [ './video-channel-playlists.component.scss' ],
imports: [ NgIf, InfiniteScrollerDirective, NgFor, VideoPlaylistMiniatureComponent ]
imports: [ CommonModule, InfiniteScrollerDirective, VideoPlaylistMiniatureComponent ]
})
export class VideoChannelPlaylistsComponent implements OnInit, AfterViewInit, OnDestroy {
private videoPlaylistService = inject(VideoPlaylistService)
@ -68,14 +68,17 @@ export class VideoChannelPlaylistsComponent implements OnInit, AfterViewInit, On
}
private loadVideoPlaylists () {
this.videoPlaylistService.listChannelPlaylists(this.videoChannel, this.pagination)
.subscribe(res => {
this.videoPlaylists = this.videoPlaylists.concat(res.data)
this.pagination.totalItems = res.total
this.videoPlaylistService.listChannelPlaylists({
videoChannel: this.videoChannel,
componentPagination: this.pagination,
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: {
color: '{primary.color}'
color: 'var(--border-primary)'
},
columnResizer: {
width: '0.5rem'

View file

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

View file

@ -19,6 +19,7 @@
[expandedRowKeys]="expandedRows"
(onRowExpand)="rowExpand.emit($event)"
[ngClass]="{ loading: loading, 'sticky-table': actionCell }"
(onRowReorder)="rowReorder.emit($event)"
>
<ng-template #caption>
<div class="caption">
@ -62,6 +63,10 @@
<p-tableHeaderCheckbox ariaLabel="Select all rows" i18n-ariaLabel></p-tableHeaderCheckbox>
</th>
@if (reorderableRows()) {
<th scope="col" width="25px"></th>
}
<th *ngIf="expandedRow">
<div class="visually-hidden">{{ expandedIconTooltip() }}</div>
</th>
@ -69,9 +74,7 @@
@for (column of columns; track column.id) {
@if (isColumnDisplayed(column.id)) {
@if (column.sortable) {
<th
scope="col" [ngbTooltip]="sortTooltip" container="body" [pSortableColumn]="getUntypedColumnId(column)" [ngClass]="column.class"
>
<th scope="col" [ngbTooltip]="sortTooltip" container="body" [pSortableColumn]="getUntypedColumnId(column)" [ngClass]="column.class">
{{ column.label }}
<small *ngIf="column.labelSmall">{{ column.labelSmall }}</small>
@ -126,14 +129,39 @@
</tr>
</ng-template>
<ng-template #body let-item let-expanded="expanded">
<tr [pSelectableRow]="item">
<ng-template #body let-item let-expanded="expanded" let-index="rowIndex">
<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">
<p-tableCheckbox [value]="item" ariaLabel="Select this row" i18n-ariaLabel></p-tableCheckbox>
</td>
<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>
<ng-template *ngTemplateOutlet="tableCells; context: { $implicit: item }"></ng-template>

View file

@ -14,16 +14,17 @@ import {
} from '@angular/core'
import { FormsModule } from '@angular/forms'
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 { ResultList } from '@peertube/peertube-models'
import { logger } from '@root-helpers/logger'
import { peertubeLocalStorage } from '@root-helpers/peertube-web-storage'
import debug from 'debug'
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 { 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 { ButtonComponent } from '../shared-main/buttons/button.component'
import { AutoColspanDirective } from '../shared-main/common/auto-colspan.directive'
@ -77,7 +78,8 @@ type BulkActions<Data> = DropdownAction<Data[]>[][] | DropdownAction<Data[]>[]
NgbDropdownModule,
PeertubeCheckboxComponent,
AutoColspanDirective,
TableExpanderIconComponent
TableExpanderIconComponent,
GlobalIconComponent
]
})
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 peertubeRouter = inject(PeerTubeRouterService)
private notifier = inject(Notifier)
private screenService = inject(ScreenService)
readonly key = input.required<string>()
readonly dataKey = input<string>('id')
readonly defaultColumns = input.required<TableColumnInfo<ColumnName>[]>()
readonly dataLoader = input.required<DataLoader<Data>>()
readonly reorderableRows = input(false, { transform: booleanAttribute })
readonly dragHandleTitle = input<string>(undefined)
readonly defaultSort = input<string>('createdAt')
readonly defaultSortOrder = input<'asc' | 'desc'>('desc')
@ -132,6 +138,7 @@ export class TableComponent<Data, ColumnName = string, QueryParams extends Table
expandedRow: TemplateRef<any>
readonly rowExpand = output<TableRowExpandEvent>()
readonly rowReorder = output<TableRowReorderEvent>()
selectedRows: Data[] = []
expandedRows = {}
@ -256,28 +263,30 @@ export class TableComponent<Data, ColumnName = string, QueryParams extends Table
// ---------------------------------------------------------------------------
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 () {
const enabledString = this.peertubeLocalStorage.getItem(this.getColumnLocalStorageKey())
const disabledString = this.peertubeLocalStorage.getItem(this.getColumnDisabledLocalStorageKey())
if (!disabledString) return
if (!enabledString) return
try {
const enabled = JSON.parse(enabledString)
const disabled = JSON.parse(disabledString)
for (const column of this.columns) {
column.selected = enabled.includes(column.id)
if (!disabled.includes(column.id)) continue
column.selected = false
}
} catch (err) {
logger.error('Cannot load selected columns.', err)
}
}
private getColumnLocalStorageKey () {
return 'rest-table-columns-' + this.key()
private getColumnDisabledLocalStorageKey () {
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
}
inInTouchScreen () {
return this.screenService.isInTouchScreen()
}
getPaginationTemplate () {
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)
}
loadData () {
this.loading = true
loadData (options: {
skipLoader?: boolean // default false
} = {}) {
const { skipLoader = false } = options
if (!skipLoader) this.loading = true
return new Promise<void>((res, rej) => {
this.dataLoader()({

View file

@ -14,28 +14,30 @@
</div>
</my-link>
<div class="miniature-info">
<my-link
[internalLink]="playlistRouterLink" [href]="playlistHref" [target]="playlistTarget" inheritParentStyle="true" inheritParentDimension="true"
[title]="playlist().description" class="miniature-name" className="ellipsis-multiline-2"
>
{{ playlist().displayName }}
</my-link>
@if (!thumbnailOnly()) {
<div class="miniature-info">
<my-link
[internalLink]="playlistRouterLink" [href]="playlistHref" [target]="playlistTarget" inheritParentStyle="true" inheritParentDimension="true"
[title]="playlist().description" class="miniature-name" className="ellipsis-multiline-2"
>
{{ playlist().displayName }}
</my-link>
<my-link
*ngIf="displayChannel() && playlist().videoChannelBy"
class="by"
[internalLink]="ownerRouterLink" [href]="ownerHref" [target]="ownerTarget" inheritParentStyle="true"
>
{{ playlist().videoChannelBy }}
</my-link>
<my-link
*ngIf="displayChannel() && playlist().videoChannelBy"
class="by"
[internalLink]="ownerRouterLink" [href]="ownerHref" [target]="ownerTarget" inheritParentStyle="true"
>
{{ playlist().videoChannelBy }}
</my-link>
<div class="privacy-date">
<span class="privacy" *ngIf="displayPrivacy()">{{ playlist().privacy.label }}</span>
<div class="privacy-date">
<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 *ngIf="displayDescription()" class="description" [innerHTML]="playlistDescription"></div>
</div>
}
</div>

View file

@ -1,5 +1,5 @@
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 { LinkType } from 'src/types/link.type'
import { LinkComponent } from '../shared-main/common/link.component'
@ -19,10 +19,12 @@ export class VideoPlaylistMiniatureComponent implements OnInit {
readonly toManage = input(false)
readonly displayChannel = input(false)
readonly displayDescription = input(false)
readonly displayPrivacy = input(false)
readonly displayAsRow = input(false)
readonly thumbnailOnly = input(false, { transform: booleanAttribute })
readonly displayChannel = input(false, { transform: booleanAttribute })
readonly displayDescription = input(false, { transform: booleanAttribute })
readonly displayPrivacy = input(false, { transform: booleanAttribute })
readonly displayAsRow = input(false, { transform: booleanAttribute })
readonly linkType = input<LinkType>('internal')

View file

@ -32,6 +32,8 @@ export class VideoPlaylist implements ServerVideoPlaylist {
updatedAt: Date | string
ownerAccount: AccountSummary
videoChannelPosition: number
videoChannel?: VideoChannelSummary
thumbnailPath: string
@ -80,6 +82,8 @@ export class VideoPlaylist implements ServerVideoPlaylist {
this.ownerAccount = hash.ownerAccount
this.ownerBy = Actor.CREATE_BY_STRING(hash.ownerAccount.name, hash.ownerAccount.host)
this.videoChannelPosition = hash.videoChannelPosition
if (hash.videoChannel) {
this.videoChannel = hash.videoChannel
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 { Injectable, inject } from '@angular/core'
import { AuthService, AuthUser, ComponentPaginationLight, RestExtractor, RestService, ServerService } from '@app/core'
import { inject, Injectable } from '@angular/core'
import { AuthService, AuthUser, ComponentPaginationLight, RestExtractor, RestPagination, RestService, ServerService } from '@app/core'
import { buildBulkObservable, objectToFormData } from '@app/helpers'
import { NGX_LOADING_BAR_IGNORED } from '@ngx-loading-bar/http-client'
import {
CachedVideoExistInPlaylist,
CachedVideosExistInPlaylists,
ResultList,
VideoExistInPlaylist,
VideoPlaylist as VideoPlaylistServerModel,
VideoPlaylistCreate,
VideoPlaylistElement as ServerVideoPlaylistElement,
VideoExistInPlaylist,
VideoPlaylistCreate,
VideoPlaylistElementCreate,
VideoPlaylistElementUpdate,
VideoPlaylistReorder,
VideoPlaylist as VideoPlaylistServerModel,
VideoPlaylistUpdate,
VideosExistInPlaylists
} 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 { VideoPlaylistElement } from './video-playlist-element.model'
import { VideoPlaylist } from './video-playlist.model'
import { Account } from '../shared-main/account/account.model'
import { AccountService } from '../shared-main/account/account.service'
import { VideoChannel } from '../shared-main/channel/video-channel.model'
import { VideoChannelService } from '../shared-main/channel/video-channel.service'
import { AccountService } from '../shared-main/account/account.service'
import { Account } from '../shared-main/account/account.model'
import { VideoPlaylistElement } from './video-playlist-element.model'
import { VideoPlaylist } from './video-playlist.model'
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 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()
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 })
.pipe(
@ -93,42 +130,25 @@ export class VideoPlaylistService {
if (this.myAccountPlaylistCache) return of(this.myAccountPlaylistCache)
}
const obs = this.listAccountPlaylists(user.account, undefined, '-updatedAt', search)
.pipe(
tap(result => {
if (!search) {
this.myAccountPlaylistCacheRunning = undefined
this.myAccountPlaylistCache = result
}
}),
share()
)
const obs = this.listAccountPlaylists({
account: user.account,
sort: '-updatedAt',
search
}).pipe(
tap(result => {
if (!search) {
this.myAccountPlaylistCacheRunning = undefined
this.myAccountPlaylistCache = result
}
}),
share()
)
if (!search) this.myAccountPlaylistCacheRunning = 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) {
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 = {
startPosition: oldPosition,
insertAfterPosition: newPosition
@ -268,6 +300,8 @@ export class VideoPlaylistService {
.pipe(catchError(err => this.restExtractor.handleError(err)))
}
// ---------------------------------------------------------------------------
getPlaylistVideos (options: {
videoPlaylistId: number | string
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 '_badges' as *;
@use '_icons' as *;
@use '_variables' as *;
@use '_mixins' as *;
@use "sass:color";
@use "_badges" as *;
@use "_icons" as *;
@use "_variables" as *;
@use "_mixins" as *;
.scale-x--1 {
transform: scaleX(-1);
@ -11,3 +11,7 @@
.scale-y--1 {
transform: scaleY(-1);
}
.rotate-180 {
transform: rotate(180deg);
}

View file

@ -1,7 +1,7 @@
@use '_variables' as *;
@use '_mixins' as *;
@use "_variables" 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);
> 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
// For export
// Used by the user export feature
likes?: string
dislikes?: string
}

View file

@ -19,6 +19,8 @@ export interface PlaylistObject {
published: string
updated: string
videoChannelPosition: number
orderedItems?: 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-update.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
ownerAccount: AccountSummary
videoChannelPosition: number
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'
export class PlaylistsCommand extends AbstractCommand {
list (options: OverrideCommandOptions & {
start?: number
count?: number
@ -42,13 +41,15 @@ export class PlaylistsCommand extends AbstractCommand {
})
}
listByChannel (options: OverrideCommandOptions & {
handle: string
start?: number
count?: number
sort?: string
playlistType?: VideoPlaylistType_Type
}) {
listByChannel (
options: OverrideCommandOptions & {
handle: string
start?: number
count?: number
sort?: string
playlistType?: VideoPlaylistType_Type
}
) {
const path = '/api/v1/video-channels/' + options.handle + '/video-playlists'
const query = pick(options, [ 'start', 'count', 'sort', 'playlistType' ])
@ -62,14 +63,16 @@ export class PlaylistsCommand extends AbstractCommand {
})
}
listByAccount (options: OverrideCommandOptions & {
handle: string
start?: number
count?: number
sort?: string
search?: string
playlistType?: VideoPlaylistType_Type
}) {
listByAccount (
options: OverrideCommandOptions & {
handle: string
start?: number
count?: number
sort?: string
search?: string
playlistType?: VideoPlaylistType_Type
}
) {
const path = '/api/v1/accounts/' + options.handle + '/video-playlists'
const query = pick(options, [ 'start', 'count', 'sort', 'search', 'playlistType' ])
@ -85,9 +88,11 @@ export class PlaylistsCommand extends AbstractCommand {
// ---------------------------------------------------------------------------
get (options: OverrideCommandOptions & {
playlistId: number | string
}) {
get (
options: OverrideCommandOptions & {
playlistId: number | string
}
) {
const { playlistId } = options
const path = '/api/v1/video-playlists/' + playlistId
@ -100,9 +105,11 @@ export class PlaylistsCommand extends AbstractCommand {
})
}
async getWatchLater (options: OverrideCommandOptions & {
handle: string
}) {
async getWatchLater (
options: OverrideCommandOptions & {
handle: string
}
) {
const { data: playlists } = await this.listByAccount({
...options,
@ -114,12 +121,14 @@ export class PlaylistsCommand extends AbstractCommand {
// ---------------------------------------------------------------------------
listVideos (options: OverrideCommandOptions & {
playlistId: number | string
start?: number
count?: number
query?: { nsfw?: BooleanBothQuery }
}) {
listVideos (
options: OverrideCommandOptions & {
playlistId: number | string
start?: number
count?: number
query?: { nsfw?: BooleanBothQuery }
}
) {
const path = '/api/v1/video-playlists/' + options.playlistId + '/videos'
const query = options.query ?? {}
@ -137,9 +146,11 @@ export class PlaylistsCommand extends AbstractCommand {
})
}
delete (options: OverrideCommandOptions & {
playlistId: number | string
}) {
delete (
options: OverrideCommandOptions & {
playlistId: number | string
}
) {
const path = '/api/v1/video-playlists/' + options.playlistId
return this.deleteRequest({
@ -151,9 +162,11 @@ export class PlaylistsCommand extends AbstractCommand {
})
}
async create (options: OverrideCommandOptions & {
attributes: VideoPlaylistCreate
}) {
async create (
options: OverrideCommandOptions & {
attributes: VideoPlaylistCreate
}
) {
const path = '/api/v1/video-playlists'
const fields = omit(options.attributes, [ 'thumbnailfile' ])
@ -175,10 +188,12 @@ export class PlaylistsCommand extends AbstractCommand {
return body.videoPlaylist
}
async quickCreate (options: OverrideCommandOptions & {
displayName: string
privacy?: VideoPlaylistPrivacyType
}) {
async quickCreate (
options: OverrideCommandOptions & {
displayName: string
privacy?: VideoPlaylistPrivacyType
}
) {
const { displayName, privacy = VideoPlaylistPrivacy.PUBLIC } = options
const { videoChannels } = await this.server.users.getMyInfo({ token: options.token })
@ -196,10 +211,12 @@ export class PlaylistsCommand extends AbstractCommand {
})
}
update (options: OverrideCommandOptions & {
attributes: VideoPlaylistUpdate
playlistId: number | string
}) {
update (
options: OverrideCommandOptions & {
attributes: VideoPlaylistUpdate
playlistId: number | string
}
) {
const path = '/api/v1/video-playlists/' + options.playlistId
const fields = omit(options.attributes, [ 'thumbnailfile' ])
@ -219,10 +236,12 @@ export class PlaylistsCommand extends AbstractCommand {
})
}
async addElement (options: OverrideCommandOptions & {
playlistId: number | string
attributes: VideoPlaylistElementCreate | { videoId: string }
}) {
async addElement (
options: OverrideCommandOptions & {
playlistId: number | string
attributes: VideoPlaylistElementCreate | { videoId: string }
}
) {
const attributes = {
...options.attributes,
@ -243,11 +262,13 @@ export class PlaylistsCommand extends AbstractCommand {
return body.videoPlaylistElement
}
updateElement (options: OverrideCommandOptions & {
playlistId: number | string
elementId: number | string
attributes: VideoPlaylistElementUpdate
}) {
updateElement (
options: OverrideCommandOptions & {
playlistId: number | string
elementId: number | string
attributes: VideoPlaylistElementUpdate
}
) {
const path = '/api/v1/video-playlists/' + options.playlistId + '/videos/' + options.elementId
return this.putBodyRequest({
@ -260,10 +281,12 @@ export class PlaylistsCommand extends AbstractCommand {
})
}
removeElement (options: OverrideCommandOptions & {
playlistId: number | string
elementId: number
}) {
removeElement (
options: OverrideCommandOptions & {
playlistId: number | string
elementId: number
}
) {
const path = '/api/v1/video-playlists/' + options.playlistId + '/videos/' + options.elementId
return this.deleteRequest({
@ -275,10 +298,30 @@ export class PlaylistsCommand extends AbstractCommand {
})
}
reorderElements (options: OverrideCommandOptions & {
playlistId: number | string
attributes: VideoPlaylistReorder
}) {
reorderPlaylistsOfChannel (
options: OverrideCommandOptions & {
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'
return this.postBodyRequest({
@ -294,7 +337,7 @@ export class PlaylistsCommand extends AbstractCommand {
getPrivacies (options: OverrideCommandOptions = {}) {
const path = '/api/v1/video-playlists/privacies'
return this.getRequestBody<{ [ id: number ]: string }>({
return this.getRequestBody<{ [id: number]: string }>({
...options,
path,
@ -303,9 +346,11 @@ export class PlaylistsCommand extends AbstractCommand {
})
}
videosExist (options: OverrideCommandOptions & {
videoIds: number[]
}) {
videosExist (
options: OverrideCommandOptions & {
videoIds: number[]
}
) {
const { videoIds } = options
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 */
import { checkBadCountPagination, checkBadSortPagination, checkBadStartPagination } from '@tests/shared/checks.js'
import {
HttpStatusCode,
VideoPlaylistCreate,
@ -20,6 +19,7 @@ import {
setAccessTokensToServers,
setDefaultVideoChannel
} from '@peertube/peertube-server-commands'
import { checkBadCountPagination, checkBadSortPagination, checkBadStartPagination } from '@tests/shared/checks.js'
describe('Test video playlists API validator', function () {
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 videoId4: number
const getBase = (
attributes?: Partial<VideoPlaylistReorder>,
wrapper?: Partial<Parameters<PlaylistsCommand['reorderElements']>[0]>
wrapper?: Partial<Parameters<PlaylistsCommand['reorderElements'] | PlaylistsCommand['reorderPlaylistsOfChannel']>[0]>
) => {
return {
attributes: {
@ -495,6 +495,7 @@ describe('Test video playlists API validator', function () {
...attributes
},
channelName: server.store.channel.name,
playlistId: playlist.shortUUID,
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
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 ]) {
await command.addElement({ playlistId: playlist.shortUUID, attributes: { videoId: id } })
}
})
it('Should fail with an unauthenticated user', async function () {
const params = getBase({}, { token: null, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
await command.reorderElements(params)
describe('Common checks', function () {
it('Should fail with an unauthenticated user', async function () {
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 () {
const params = getBase({}, { token: userAccessToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
await command.reorderElements(params)
describe('Reordering elements of a playlist checks', function () {
it('Should fail with the playlist of another user', async function () {
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 () {
{
const params = getBase({}, { playlistId: 'toto' })
await command.reorderElements(params)
}
describe('Reordering playlists of a channel checks', function () {
it('Should fail with the channel of another user', async function () {
const params = getBase({}, { token: userAccessToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
await command.reorderPlaylistsOfChannel(params)
})
{
const params = getBase({}, { playlistId: 42, expectedStatus: HttpStatusCode.NOT_FOUND_404 })
await command.reorderElements(params)
}
})
it('Should fail with an unknown channel', async function () {
const params = getBase({}, { channelName: 'toto', expectedStatus: HttpStatusCode.NOT_FOUND_404 })
await command.reorderPlaylistsOfChannel(params)
})
it('Should fail with an invalid start position', async function () {
{
const params = getBase({ startPosition: -1 })
await command.reorderElements(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)
it('Should fail with an invalid channel', async function () {
{
const params = getBase({}, { channelName: 42 as any, expectedStatus: HttpStatusCode.NOT_FOUND_404 })
await command.reorderPlaylistsOfChannel(params)
}
})
})
})

View file

@ -1,6 +1,5 @@
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
import { expect } from 'chai'
import { wait } from '@peertube/peertube-core-utils'
import {
HttpStatusCode,
@ -26,6 +25,7 @@ import {
} from '@peertube/peertube-server-commands'
import { testImageGeneratedByFFmpeg } from '@tests/shared/checks.js'
import { checkPlaylistFilesWereRemoved } from '@tests/shared/video-playlists.js'
import { expect } from 'chai'
async function checkPlaylistElementType (
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 () {
await cleanupTests(servers)
})

View file

@ -1,3 +1,4 @@
import { VideoPlaylistsListQuery } from '@peertube/peertube-models'
import { pickCommonVideoQuery } from '@server/helpers/query.js'
import { ActorFollowModel } from '@server/models/actor/actor-follow.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) {
const serverActor = await getServerActor()
const query = req.query as VideoPlaylistsListQuery
// Allow users to see their private/unlisted video playlists
let listMyPlaylists = false
@ -190,18 +192,19 @@ async function listAccountPlaylists (req: express.Request, res: express.Response
}
const resultList = await VideoPlaylistModel.listForApi({
search: req.query.search,
followerActorId: isUserAbleToSearchRemoteURI(res)
? null
: serverActor.id,
start: req.query.start,
count: req.query.count,
sort: req.query.sort,
accountId: res.locals.account.id,
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))

View file

@ -3,10 +3,13 @@ import {
HttpStatusCode,
VideoChannelCreate,
VideoChannelUpdate,
VideoPlaylistReorder,
VideoPlaylistsListQuery,
VideosImportInChannelCreate
} from '@peertube/peertube-models'
import { pickCommonVideoQuery } from '@server/helpers/query.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 { getServerActor } from '@server/models/application/application.js'
import { MChannelBannerAccountDefault } from '@server/types/models/index.js'
@ -48,7 +51,10 @@ import {
videoChannelsListValidator,
videosSortValidator
} 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 { guessAdditionalAttributesFromQuery } from '../../models/video/formatter/index.js'
import { VideoChannelModel } from '../../models/video/video-channel.js'
@ -129,6 +135,8 @@ videoChannelRouter.get(
asyncMiddleware(getVideoChannel)
)
// ---------------------------------------------------------------------------
videoChannelRouter.get(
'/:handle/video-playlists',
optionalAuthenticate,
@ -141,6 +149,16 @@ videoChannelRouter.get(
asyncMiddleware(listVideoChannelPlaylists)
)
videoChannelRouter.post(
'/:handle/video-playlists/reorder',
authenticate,
asyncMiddleware(videoChannelsHandleValidatorFactory({ checkIsLocal: true, checkManage: true })),
asyncMiddleware(videoPlaylistsReorderInChannelValidator),
asyncRetryTransactionMiddleware(reorderPlaylistsInChannel)
)
// ---------------------------------------------------------------------------
videoChannelRouter.get(
'/:handle/videos',
asyncMiddleware(videoChannelsHandleValidatorFactory({ checkIsLocal: false, checkManage: false })),
@ -360,24 +378,76 @@ async function getVideoChannel (req: express.Request, res: express.Response) {
return res.json(videoChannel.toFormattedJSON())
}
// ---------------------------------------------------------------------------
// Playlists
// ---------------------------------------------------------------------------
async function listVideoChannelPlaylists (req: express.Request, res: express.Response) {
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({
followerActorId: isUserAbleToSearchRemoteURI(res)
? null
: serverActor.id,
start: req.query.start,
count: req.query.count,
sort: req.query.sort,
videoChannelId: res.locals.videoChannel.id,
type: req.query.playlistType
videoChannelId: videoChannel.id,
listMyPlaylists,
start,
count,
sort,
search,
type: playlistType
})
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) {
const serverActor = await getServerActor()

View file

@ -14,7 +14,11 @@ import {
import { uuidToShort } from '@peertube/peertube-node-utils'
import { scheduleRefreshIfNeeded } from '@server/lib/activitypub/playlists/index.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 { MVideoPlaylistFull, MVideoPlaylistThumbnail } from '@server/types/models/index.js'
import express from 'express'
@ -95,6 +99,10 @@ videoPlaylistRouter.delete(
asyncRetryTransactionMiddleware(removeVideoPlaylist)
)
// ---------------------------------------------------------------------------
// Playlist elements
// ---------------------------------------------------------------------------
videoPlaylistRouter.get(
'/:playlistId/videos',
asyncMiddleware(videoPlaylistsGetValidator('summary')),
@ -115,7 +123,7 @@ videoPlaylistRouter.post(
'/:playlistId/videos/reorder',
authenticate,
asyncMiddleware(videoPlaylistsReorderVideosValidator),
asyncRetryTransactionMiddleware(reorderVideosPlaylist)
asyncRetryTransactionMiddleware(reorderVideosOfPlaylist)
)
videoPlaylistRouter.put(
@ -196,6 +204,13 @@ async function createVideoPlaylist (req: express.Request, res: express.Response)
const videoPlaylistCreated = await retryTransactionWrapper(() => {
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
if (thumbnailModel) {
@ -222,50 +237,65 @@ async function createVideoPlaylist (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 wasPrivatePlaylist = videoPlaylistInstance.privacy === VideoPlaylistPrivacy.PRIVATE
const wasNotPrivatePlaylist = videoPlaylistInstance.privacy !== VideoPlaylistPrivacy.PRIVATE
const wasPrivatePlaylist = playlist.privacy === VideoPlaylistPrivacy.PRIVATE
const wasNotPrivatePlaylist = playlist.privacy !== VideoPlaylistPrivacy.PRIVATE
let removedFromChannel: { id: number, position: number }
const thumbnailField = req.files?.['thumbnailfile']
const thumbnailModel = thumbnailField
? await updateLocalPlaylistMiniatureFromExisting({
inputPath: thumbnailField[0].path,
playlist: videoPlaylistInstance,
playlist,
automaticallyGenerated: false
})
: undefined
try {
await sequelizeTypescript.transaction(async t => {
const sequelizeOptions = {
transaction: t
}
if (videoPlaylistInfoToUpdate.videoChannelId !== undefined) {
if (videoPlaylistInfoToUpdate.videoChannelId === null) {
videoPlaylistInstance.videoChannelId = null
removedFromChannel = {
id: playlist.videoChannelId,
position: playlist.videoChannelPosition
}
playlist.videoChannelId = null
} else {
const videoChannel = res.locals.videoChannel
videoPlaylistInstance.videoChannelId = videoChannel.id
videoPlaylistInstance.VideoChannel = videoChannel
if (playlist.videoChannelId !== videoPlaylistInfoToUpdate.videoChannelId) {
removedFromChannel = {
id: playlist.videoChannelId,
position: playlist.videoChannelPosition
}
playlist.videoChannelPosition = await VideoPlaylistModel.getNextPositionOf({
videoChannelId: videoChannel.id,
transaction: t
})
}
playlist.videoChannelId = videoChannel.id
playlist.VideoChannel = videoChannel
}
}
if (videoPlaylistInfoToUpdate.displayName !== undefined) videoPlaylistInstance.name = videoPlaylistInfoToUpdate.displayName
if (videoPlaylistInfoToUpdate.description !== undefined) videoPlaylistInstance.description = videoPlaylistInfoToUpdate.description
if (videoPlaylistInfoToUpdate.displayName !== undefined) playlist.name = videoPlaylistInfoToUpdate.displayName
if (videoPlaylistInfoToUpdate.description !== undefined) playlist.description = videoPlaylistInfoToUpdate.description
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) {
await sendDeleteVideoPlaylist(videoPlaylistInstance, t)
if (wasNotPrivatePlaylist === true && playlist.privacy === VideoPlaylistPrivacy.PRIVATE) {
await sendDeleteVideoPlaylist(playlist, t)
}
}
const playlistUpdated = await videoPlaylistInstance.save(sequelizeOptions)
const playlistUpdated = await playlist.save({ transaction: t })
if (thumbnailModel) {
thumbnailModel.automaticallyGenerated = false
@ -280,7 +310,18 @@ async function updateVideoPlaylist (req: express.Request, res: express.Response)
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
})
@ -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
// So we need to restore the previous fields
await resetSequelizeInstance(videoPlaylistInstance)
await resetSequelizeInstance(playlist)
throw err
}
@ -299,11 +340,27 @@ async function updateVideoPlaylist (req: express.Request, res: express.Response)
async function removeVideoPlaylist (req: express.Request, res: express.Response) {
const videoPlaylistInstance = res.locals.videoPlaylistSummary
const positionToDelete = videoPlaylistInstance.videoChannelPosition
await sequelizeTypescript.transaction(async 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)
})
@ -311,6 +368,10 @@ async function removeVideoPlaylist (req: express.Request, res: express.Response)
return res.type('json').status(HttpStatusCode.NO_CONTENT_204).end()
}
// ---------------------------------------------------------------------------
// Videos in playlist
// ---------------------------------------------------------------------------
async function addVideoInPlaylist (req: express.Request, res: express.Response) {
const body: VideoPlaylistElementCreate = req.body
const videoPlaylist = res.locals.videoPlaylistFull
@ -388,7 +449,12 @@ async function removeVideoFromPlaylist (req: express.Request, res: express.Respo
await videoPlaylistElement.destroy({ transaction: t })
// 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)
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()
}
async function reorderVideosPlaylist (req: express.Request, res: express.Response) {
async function reorderVideosOfPlaylist (req: express.Request, res: express.Response) {
const videoPlaylist = res.locals.videoPlaylistFull
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()
}
// 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 => {
const newPosition = insertAfter + 1
// Add space after the position when we want to insert our reordered elements (increase)
await VideoPlaylistElementModel.increasePositionOf(videoPlaylist.id, newPosition, reorderLength, t)
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 VideoPlaylistElementModel.reassignPositionOf({
videoPlaylistId: videoPlaylist.id,
firstPosition: oldPosition,
endPosition: endOldPosition,
newPosition,
await reorderPlaylistOrElementsPosition({
model: VideoPlaylistElementModel,
instance: videoPlaylist,
start,
insertAfter,
reorderLength,
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)
await videoPlaylist.save({ transaction: t })

View file

@ -191,6 +191,10 @@ const contextStore: { [id in ContextType]: (string | { [id: string]: string })[]
'@type': 'sc:Number',
'@id': 'pt:position'
},
videoChannelPosition: {
'@type': 'sc:Number',
'@id': 'pt:position'
},
startTimestamp: {
'@type': 'sc:Number',
'@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' ],
VIDEO_PLAYLISTS: [ 'name', 'displayName', 'createdAt', 'updatedAt' ],
VIDEO_PLAYLISTS: [ 'name', 'displayName', 'createdAt', 'updatedAt', 'videoChannelPosition' ],
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,
url: playlistObject.id,
uuid: playlistObject.uuid,
videoChannelPosition: playlistObject.videoChannelPosition,
videoChannelId: null,
ownerAccountId: null,
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 { 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 { ActorModel } from '../../../models/actor/actor.js'
import { VideoCommentModel } from '../../../models/video/video-comment.js'
@ -11,7 +12,6 @@ import { audiencify } from '../audience.js'
import { getDeleteActivityPubUrl } from '../url.js'
import { getActorsInvolvedInVideo, getVideoCommentAudience } from './shared/index.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) {
logger.info('Creating job to broadcast delete of video %s.', video.url)
@ -140,8 +140,8 @@ async function sendDeleteVideoPlaylist (videoPlaylist: MVideoPlaylistFullSummary
// ---------------------------------------------------------------------------
export {
sendDeleteVideo,
sendDeleteActor,
sendDeleteVideo,
sendDeleteVideoComment,
sendDeleteVideoPlaylist
}

View file

@ -1,14 +1,17 @@
import * as Sequelize from 'sequelize'
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 { MAccount, MVideoThumbnail } from '../types/models/index.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 { VideoMiniaturePermanentFileCache } from './files-cache/video-miniature-permanent-file-cache.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({
name: 'Watch later',
privacy: VideoPlaylistPrivacy.PRIVATE,
@ -51,3 +54,63 @@ export async function generateThumbnailForPlaylist (videoPlaylist: MVideoPlaylis
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,
VideoPlaylistUpdate
} from '@peertube/peertube-models'
import { VideoPlaylistModel } from '@server/models/video/video-playlist.js'
import { ExpressPromiseHandler } from '@server/types/express-handler.js'
import { MUserAccountId } from '@server/types/models/index.js'
import express from 'express'
@ -44,7 +45,7 @@ import {
VideoPlaylistFetchType
} from '../shared/index.js'
const videoPlaylistsAddValidator = getCommonPlaylistEditAttributes().concat([
export const videoPlaylistsAddValidator = getCommonPlaylistEditAttributes().concat([
body('displayName')
.custom(isVideoPlaylistNameValid),
@ -69,7 +70,7 @@ const videoPlaylistsAddValidator = getCommonPlaylistEditAttributes().concat([
}
])
const videoPlaylistsUpdateValidator = getCommonPlaylistEditAttributes().concat([
export const videoPlaylistsUpdateValidator = getCommonPlaylistEditAttributes().concat([
isValidPlaylistIdParam('playlistId'),
body('displayName')
@ -116,7 +117,7 @@ const videoPlaylistsUpdateValidator = getCommonPlaylistEditAttributes().concat([
}
])
const videoPlaylistsDeleteValidator = [
export const videoPlaylistsDeleteValidator = [
isValidPlaylistIdParam('playlistId'),
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 [
isValidPlaylistIdParam('playlistId'),
@ -181,7 +182,7 @@ const videoPlaylistsGetValidator = (fetchType: VideoPlaylistFetchType) => {
]
}
const videoPlaylistsSearchValidator = [
export const videoPlaylistsSearchValidator = [
query('search')
.optional()
.not().isEmpty(),
@ -193,7 +194,7 @@ const videoPlaylistsSearchValidator = [
}
]
const videoPlaylistsAddVideoValidator = [
export const videoPlaylistsAddVideoValidator = [
isValidPlaylistIdParam('playlistId'),
body('videoId')
@ -222,7 +223,7 @@ const videoPlaylistsAddVideoValidator = [
}
]
const videoPlaylistsUpdateOrRemoveVideoValidator = [
export const videoPlaylistsUpdateOrRemoveVideoValidator = [
isValidPlaylistIdParam('playlistId'),
param('playlistElementId')
.customSanitizer(toCompleteUUID)
@ -257,7 +258,7 @@ const videoPlaylistsUpdateOrRemoveVideoValidator = [
}
]
const videoPlaylistElementAPGetValidator = [
export const videoPlaylistElementAPGetValidator = [
isValidPlaylistIdParam('playlistId'),
param('playlistElementId')
.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'),
body('startPosition')
@ -328,7 +360,7 @@ const videoPlaylistsReorderVideosValidator = [
}
]
const commonVideoPlaylistFiltersValidator = [
export const commonVideoPlaylistFiltersValidator = [
query('playlistType')
.optional()
.custom(isVideoPlaylistTypeValid),
@ -340,7 +372,7 @@ const commonVideoPlaylistFiltersValidator = [
}
]
const doVideosInPlaylistExistValidator = [
export const doVideosInPlaylistExistValidator = [
query('videoIds')
.customSanitizer(toIntArray)
.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 () {
return [
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)
if (field.toLowerCase() === 'name') {
return [ [ 'displayName', direction ], lastSort ]
return [ [ 'name', direction ], 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 {
PlaylistElementObject,
VideoPlaylistElement,
@ -26,16 +10,33 @@ import {
MVideoPlaylistElement,
MVideoPlaylistElementAP,
MVideoPlaylistElementFormattable,
MVideoPlaylistElementVideoUrlPlaylistPrivacy,
MVideoPlaylistElementVideoThumbnail,
MVideoPlaylistElementVideoUrl
MVideoPlaylistElementVideoUrl,
MVideoPlaylistElementVideoUrlPlaylistPrivacy
} 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 { CONSTRAINTS_FIELDS, USER_EXPORT_MAX_ITEMS } from '../../initializers/constants.js'
import { AccountModel } from '../account/account.js'
import { SequelizeModel, getSort, throwIfNotValid } from '../shared/index.js'
import { getNextPositionOf, increasePositionOf, reassignPositionOf } from '../shared/position.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({
tableName: 'videoPlaylistElement',
@ -138,7 +139,8 @@ export class VideoPlaylistElementModel extends SequelizeModel<VideoPlaylistEleme
const forApiOptions: ForAPIOptions = { withAccountBlockerIds: accountIds }
videoScope.push({
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) {
const query: AggregateOptions<number> = {
where: {
videoPlaylistId
},
return getNextPositionOf({
model: VideoPlaylistElementModel,
columnName: 'position',
where: { videoPlaylistId },
transaction
}
return VideoPlaylistElementModel.max('position', query)
.then(position => position ? position + 1 : 1)
})
}
static reassignPositionOf (options: {
@ -297,41 +296,39 @@ export class VideoPlaylistElementModel extends SequelizeModel<VideoPlaylistEleme
}) {
const { videoPlaylistId, firstPosition, endPosition, newPosition, transaction } = options
const query = {
where: {
videoPlaylistId,
position: {
[Op.gte]: firstPosition,
[Op.lte]: endPosition
}
},
return reassignPositionOf({
model: VideoPlaylistElementModel,
columnName: 'position',
where: { videoPlaylistId },
transaction,
validate: false // We use a literal to update the position
}
const positionQuery = Sequelize.literal(`${forceNumber(newPosition)} + "position" - ${forceNumber(firstPosition)}`)
return VideoPlaylistElementModel.update({ position: positionQuery }, query)
firstPosition,
endPosition,
newPosition
})
}
static increasePositionOf (
videoPlaylistId: number,
fromPosition: number,
by = 1,
static increasePositionOf (options: {
videoPlaylistId: number
fromPosition: number
by: number
transaction?: Transaction
) {
const query = {
where: {
videoPlaylistId,
position: {
[Op.gte]: fromPosition
}
},
transaction
}
}) {
const { videoPlaylistId, fromPosition, by, transaction } = options
return VideoPlaylistElementModel.increment({ position: by }, query)
return increasePositionOf({
model: VideoPlaylistElementModel,
columnName: 'position',
where: { videoPlaylistId },
transaction,
fromPosition,
by
})
}
// ---------------------------------------------------------------------------
toFormattedJSON (
this: MVideoPlaylistElementFormattable,
options: { accountId?: number } = {}

View file

@ -24,7 +24,8 @@ import {
HasMany,
HasOne,
Is,
IsUUID, Scopes,
IsUUID,
Scopes,
Table,
UpdatedAt
} from 'sequelize-typescript'
@ -67,6 +68,7 @@ import {
setAsUpdated,
throwIfNotValid
} from '../shared/index.js'
import { getNextPositionOf, increasePositionOf, reassignPositionOf } from '../shared/position.js'
import { ThumbnailModel } from './thumbnail.js'
import { VideoChannelModel, ScopeNames as VideoChannelScopeNames } from './video-channel.js'
import { VideoPlaylistElementModel } from './video-playlist-element.js'
@ -89,6 +91,7 @@ type AvailableForListOptions = {
search?: string
host?: string
uuids?: string[]
channelNameOneOf?: string[]
withVideos?: boolean
forCount?: boolean
}
@ -269,7 +272,6 @@ function getVideoLengthSelect () {
} as FindOptions
}
}))
@Table({
tableName: 'videoPlaylist',
indexes: [
@ -325,6 +327,10 @@ export class VideoPlaylistModel extends SequelizeModel<VideoPlaylistModel> {
@Column
type: VideoPlaylistType_Type
@AllowNull(true)
@Column
videoChannelPosition: number
@ForeignKey(() => AccountModel)
@Column
ownerAccountId: number
@ -368,11 +374,13 @@ export class VideoPlaylistModel extends SequelizeModel<VideoPlaylistModel> {
})
Thumbnail: Awaited<ThumbnailModel>
static listForApi (options: AvailableForListOptions & {
start: number
count: number
sort: string
}) {
static listForApi (
options: AvailableForListOptions & {
start: number
count: number
sort: string
}
) {
const query = {
offset: options.start,
limit: options.count,
@ -387,6 +395,7 @@ export class VideoPlaylistModel extends SequelizeModel<VideoPlaylistModel> {
'listMyPlaylists',
'search',
'host',
'channelNameOneOf',
'uuids'
])
@ -427,11 +436,13 @@ export class VideoPlaylistModel extends SequelizeModel<VideoPlaylistModel> {
]).then(([ count, rows ]) => ({ total: count, data: rows }))
}
static searchForApi (options: Pick<AvailableForListOptions, 'followerActorId' | 'search' | 'host' | 'uuids'> & {
start: number
count: number
sort: string
}) {
static searchForApi (
options: Pick<AvailableForListOptions, 'followerActorId' | 'search' | 'host' | 'uuids'> & {
start: number
count: number
sort: string
}
) {
return VideoPlaylistModel.listForApi({
...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) {
@ -597,6 +620,64 @@ export class VideoPlaylistModel extends SequelizeModel<VideoPlaylistModel> {
.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) {
return VIDEO_PLAYLIST_PRIVACIES[privacy] || 'Unknown'
}
@ -613,7 +694,11 @@ export class VideoPlaylistModel extends SequelizeModel<VideoPlaylistModel> {
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) {
@ -739,6 +824,8 @@ export class VideoPlaylistModel extends SequelizeModel<VideoPlaylistModel> {
updatedAt: this.updatedAt,
ownerAccount: this.OwnerAccount.toFormattedSummaryJSON(),
videoChannelPosition: this.videoChannelPosition,
videoChannel: this.VideoChannel
? this.VideoChannel.toFormattedSummaryJSON()
: null
@ -769,6 +856,7 @@ export class VideoPlaylistModel extends SequelizeModel<VideoPlaylistModel> {
content: this.description,
mediaType: 'text/markdown' as 'text/markdown',
uuid: this.uuid,
videoChannelPosition: this.videoChannelPosition,
published: this.createdAt.toISOString(),
updated: this.updatedAt.toISOString(),
attributedTo: this.VideoChannel ? [ this.VideoChannel.Actor.url ] : [],

View file

@ -4767,6 +4767,42 @@ paths:
items:
$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':
get:
tags:
@ -5244,7 +5280,7 @@ paths:
/api/v1/video-playlists/{playlistId}/videos/reorder:
post:
summary: 'Reorder a playlist'
summary: Reorder playlist elements
operationId: reorderVideoPlaylist
security:
- OAuth2: []
@ -9084,6 +9120,10 @@ components:
$ref: '#/components/schemas/AccountSummary'
videoChannel:
$ref: '#/components/schemas/VideoChannelSummary'
videoChannelPosition:
type: integer
minimum: 1
description: Position of the playlist in the channel
VideoComment:
properties:
id: