mirror of
https://github.com/Chocobozzz/PeerTube.git
synced 2025-10-03 09:49:20 +02:00
Add ability to order playlists
This commit is contained in:
parent
546bd42240
commit
0adafa0fc0
48 changed files with 1776 additions and 607 deletions
|
@ -35,7 +35,7 @@
|
|||
<span class="pt-badge badge-purple" i18n>Remote</span>
|
||||
}
|
||||
|
||||
<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>
|
||||
|
||||
|
|
|
@ -8,7 +8,7 @@ my-embed {
|
|||
}
|
||||
|
||||
.pt-badge,
|
||||
my-video-privacy-badge,
|
||||
my-privacy-badge,
|
||||
my-video-nsfw-badge {
|
||||
@include margin-right(5px);
|
||||
}
|
||||
|
|
|
@ -11,6 +11,7 @@ import { Video } from '@app/shared/shared-main/video/video.model'
|
|||
import { VideoService } from '@app/shared/shared-main/video/video.service'
|
||||
import { 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
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -89,7 +89,7 @@ export default {
|
|||
}
|
||||
},
|
||||
dropPoint: {
|
||||
color: '{primary.color}'
|
||||
color: 'var(--border-primary)'
|
||||
},
|
||||
columnResizer: {
|
||||
width: '0.5rem'
|
||||
|
|
|
@ -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')
|
||||
}
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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()({
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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')
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -0,0 +1,72 @@
|
|||
import { CommonModule } from '@angular/common'
|
||||
import { Component, inject, input, LOCALE_ID, OnChanges } from '@angular/core'
|
||||
import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { Video, VideoPlaylistPrivacy, VideoPlaylistPrivacyType, VideoPrivacy, VideoPrivacyType } from '@peertube/peertube-models'
|
||||
import { VideoPlaylist } from '../shared-video-playlist/video-playlist.model'
|
||||
|
||||
@Component({
|
||||
selector: 'my-privacy-badge',
|
||||
templateUrl: './privacy-badge.component.html',
|
||||
imports: [ CommonModule, NgbTooltipModule ]
|
||||
})
|
||||
export class PrivacyBadgeComponent implements OnChanges {
|
||||
private readonly localeId = inject(LOCALE_ID)
|
||||
|
||||
readonly video = input<Pick<Video, 'privacy' | 'scheduledUpdate'>>(undefined)
|
||||
readonly playlist = input<Pick<VideoPlaylist, 'privacy'>>(undefined)
|
||||
|
||||
private videoBadges: { [id in VideoPrivacyType]: string } = {
|
||||
[VideoPrivacy.PUBLIC]: 'badge-green',
|
||||
[VideoPrivacy.INTERNAL]: 'badge-yellow',
|
||||
[VideoPrivacy.PRIVATE]: 'badge-grey',
|
||||
[VideoPrivacy.PASSWORD_PROTECTED]: 'badge-purple',
|
||||
[VideoPrivacy.UNLISTED]: 'badge-blue'
|
||||
}
|
||||
|
||||
private playlistBadges: { [id in VideoPlaylistPrivacyType]: string } = {
|
||||
[VideoPlaylistPrivacy.PUBLIC]: 'badge-green',
|
||||
[VideoPlaylistPrivacy.PRIVATE]: 'badge-grey',
|
||||
[VideoPlaylistPrivacy.UNLISTED]: 'badge-blue'
|
||||
}
|
||||
|
||||
label: string
|
||||
badgeClass: string
|
||||
tooltip: string
|
||||
|
||||
ngOnChanges (): void {
|
||||
this.label = this.buildLabel()
|
||||
this.badgeClass = this.buildBadgeClass()
|
||||
this.tooltip = this.buildTooltip()
|
||||
}
|
||||
|
||||
buildBadgeClass () {
|
||||
if (this.video()) return this.videoBadges[this.video().privacy.id]
|
||||
|
||||
if (this.playlist()) return this.playlistBadges[this.playlist().privacy.id]
|
||||
|
||||
throw new Error('Missing video or playlist input in PrivacyBadgeComponent')
|
||||
}
|
||||
|
||||
private buildLabel () {
|
||||
if (this.video()) {
|
||||
if (this.video().scheduledUpdate) return $localize`Scheduled`
|
||||
|
||||
return this.video().privacy.label
|
||||
}
|
||||
|
||||
if (this.playlist()) {
|
||||
return this.playlist().privacy.label
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
private buildTooltip () {
|
||||
if (this.video()?.scheduledUpdate) {
|
||||
const updateAt = new Date(this.video().scheduledUpdate.updateAt.toString()).toLocaleString(this.localeId)
|
||||
return $localize`Scheduled on ${updateAt}`
|
||||
}
|
||||
|
||||
return this.buildLabel()
|
||||
}
|
||||
}
|
|
@ -1,55 +0,0 @@
|
|||
import { CommonModule } from '@angular/common'
|
||||
import { Component, inject, input, LOCALE_ID, OnChanges } from '@angular/core'
|
||||
import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { Video, VideoPrivacy, VideoPrivacyType } from '@peertube/peertube-models'
|
||||
|
||||
@Component({
|
||||
selector: 'my-video-privacy-badge',
|
||||
templateUrl: './video-privacy-badge.component.html',
|
||||
imports: [ CommonModule, NgbTooltipModule ]
|
||||
})
|
||||
export class VideoPrivacyBadgeComponent implements OnChanges {
|
||||
private readonly localeId = inject(LOCALE_ID)
|
||||
readonly video = input.required<Pick<Video, 'privacy' | 'scheduledUpdate'>>()
|
||||
|
||||
private badges: { [id in VideoPrivacyType]: string } = {
|
||||
[VideoPrivacy.PUBLIC]: 'badge-green',
|
||||
[VideoPrivacy.INTERNAL]: 'badge-yellow',
|
||||
[VideoPrivacy.PRIVATE]: 'badge-grey',
|
||||
[VideoPrivacy.PASSWORD_PROTECTED]: 'badge-purple',
|
||||
[VideoPrivacy.UNLISTED]: 'badge-blue'
|
||||
}
|
||||
|
||||
label: string
|
||||
badgeClass: string
|
||||
tooltip: string
|
||||
|
||||
ngOnChanges (): void {
|
||||
this.label = this.buildLabel()
|
||||
this.badgeClass = this.buildBadgeClass()
|
||||
this.tooltip = this.buildTooltip()
|
||||
}
|
||||
|
||||
buildBadgeClass () {
|
||||
return this.badges[this.video().privacy.id]
|
||||
}
|
||||
|
||||
private buildLabel () {
|
||||
const video = this.video()
|
||||
|
||||
if (video.scheduledUpdate) return $localize`Scheduled`
|
||||
|
||||
return video.privacy.label
|
||||
}
|
||||
|
||||
private buildTooltip () {
|
||||
const video = this.video()
|
||||
|
||||
if (video.scheduledUpdate) {
|
||||
const updateAt = new Date(video.scheduledUpdate.updateAt.toString()).toLocaleString(this.localeId)
|
||||
return $localize`Scheduled on ${updateAt}`
|
||||
}
|
||||
|
||||
return this.buildLabel()
|
||||
}
|
||||
}
|
1
client/src/assets/images/feather/grip-horizontal.svg
Normal file
1
client/src/assets/images/feather/grip-horizontal.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-grip-horizontal-icon lucide-grip-horizontal"><circle cx="12" cy="9" r="1"/><circle cx="19" cy="9" r="1"/><circle cx="5" cy="9" r="1"/><circle cx="12" cy="15" r="1"/><circle cx="19" cy="15" r="1"/><circle cx="5" cy="15" r="1"/></svg>
|
After Width: | Height: | Size: 435 B |
|
@ -1,8 +1,8 @@
|
|||
@use 'sass:color';
|
||||
@use '_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);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -35,7 +35,7 @@ export interface ActivityPubActor {
|
|||
|
||||
published?: string
|
||||
|
||||
// For export
|
||||
// Used by the user export feature
|
||||
likes?: string
|
||||
dislikes?: string
|
||||
}
|
||||
|
|
|
@ -19,6 +19,8 @@ export interface PlaylistObject {
|
|||
published: string
|
||||
updated: string
|
||||
|
||||
videoChannelPosition: number
|
||||
|
||||
orderedItems?: string[]
|
||||
|
||||
partOf?: string
|
||||
|
|
|
@ -10,3 +10,4 @@ export * from './video-playlist-reorder.model.js'
|
|||
export * from './video-playlist-type.model.js'
|
||||
export * from './video-playlist-update.model.js'
|
||||
export * from './video-playlist.model.js'
|
||||
export * from './video-playlists-list-query.model.js'
|
||||
|
|
|
@ -31,5 +31,7 @@ export interface VideoPlaylist {
|
|||
updatedAt: Date | string
|
||||
|
||||
ownerAccount: AccountSummary
|
||||
|
||||
videoChannelPosition: number
|
||||
videoChannel?: VideoChannelSummary
|
||||
}
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
import { VideoPlaylistType_Type } from './video-playlist-type.model.js'
|
||||
|
||||
export interface VideoPlaylistsListQuery {
|
||||
start?: number
|
||||
count?: number
|
||||
sort?: string
|
||||
search?: string
|
||||
playlistType?: VideoPlaylistType_Type
|
||||
}
|
|
@ -22,7 +22,6 @@ import { unwrapBody } from '../requests/index.js'
|
|||
import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js'
|
||||
|
||||
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'
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -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 })
|
||||
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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' ],
|
||||
|
||||
|
|
|
@ -0,0 +1,38 @@
|
|||
import * as Sequelize from 'sequelize'
|
||||
|
||||
async function up (utils: {
|
||||
transaction: Sequelize.Transaction
|
||||
queryInterface: Sequelize.QueryInterface
|
||||
sequelize: Sequelize.Sequelize
|
||||
}): Promise<void> {
|
||||
const { transaction } = utils
|
||||
|
||||
{
|
||||
await utils.queryInterface.addColumn('videoPlaylist', 'videoChannelPosition', {
|
||||
type: Sequelize.INTEGER,
|
||||
defaultValue: null,
|
||||
allowNull: true
|
||||
}, { transaction })
|
||||
}
|
||||
|
||||
{
|
||||
await utils.sequelize.query(
|
||||
`UPDATE "videoPlaylist" SET "videoChannelPosition" = tmp.position FROM (` +
|
||||
`SELECT tmp.*, ROW_NUMBER () ` +
|
||||
`OVER (PARTITION BY "videoChannelId" ORDER BY "updatedAt" DESC) AS "position" from "videoPlaylist" "tmp" ` +
|
||||
`WHERE "tmp"."videoChannelPosition" IS NULL AND "tmp"."videoChannelId" IS NOT NULL` +
|
||||
`) tmp ` +
|
||||
`WHERE "videoPlaylist"."id" = tmp.id`,
|
||||
{ transaction }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function down (options) {
|
||||
throw new Error('Not implemented.')
|
||||
}
|
||||
|
||||
export {
|
||||
up,
|
||||
down
|
||||
}
|
|
@ -16,6 +16,7 @@ export function playlistObjectToDBAttributes (playlistObject: PlaylistObject, to
|
|||
privacy,
|
||||
url: playlistObject.id,
|
||||
uuid: playlistObject.uuid,
|
||||
videoChannelPosition: playlistObject.videoChannelPosition,
|
||||
videoChannelId: null,
|
||||
ownerAccountId: null,
|
||||
createdAt: new Date(playlistObject.published),
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { Transaction } from 'sequelize'
|
||||
import { getServerActor } from '@server/models/application/application.js'
|
||||
import { ActivityAudience, ActivityDelete } from '@peertube/peertube-models'
|
||||
import { 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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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')
|
||||
|
|
78
server/core/models/shared/position.ts
Normal file
78
server/core/models/shared/position.ts
Normal file
|
@ -0,0 +1,78 @@
|
|||
import { forceNumber } from '@peertube/peertube-core-utils'
|
||||
import { AggregateOptions, Attributes, Model, ModelStatic, Op, Sequelize, Transaction, WhereOptions } from 'sequelize'
|
||||
|
||||
export async function getNextPositionOf<T extends Model> (options: {
|
||||
model: ModelStatic<T>
|
||||
columnName: keyof Attributes<T>
|
||||
where: WhereOptions<Attributes<T>>
|
||||
transaction: Transaction
|
||||
}) {
|
||||
const { columnName, model, where, transaction } = options
|
||||
|
||||
const query: AggregateOptions<number> = {
|
||||
where,
|
||||
transaction
|
||||
}
|
||||
|
||||
const position = await model.max(columnName, query)
|
||||
|
||||
return position
|
||||
? position + 1
|
||||
: 1
|
||||
}
|
||||
|
||||
export function reassignPositionOf<T extends Model> (options: {
|
||||
model: ModelStatic<T>
|
||||
columnName: keyof Attributes<T>
|
||||
where: WhereOptions<Attributes<T>>
|
||||
transaction: Transaction
|
||||
|
||||
firstPosition: number
|
||||
endPosition: number
|
||||
newPosition: number
|
||||
}) {
|
||||
const { firstPosition, endPosition, newPosition, model, where, columnName, transaction } = options
|
||||
|
||||
const query = {
|
||||
where: {
|
||||
...where,
|
||||
|
||||
[columnName]: {
|
||||
[Op.gte]: firstPosition,
|
||||
[Op.lte]: endPosition
|
||||
}
|
||||
},
|
||||
transaction,
|
||||
validate: false // We use a literal to update the position
|
||||
}
|
||||
|
||||
const escapedColumnName = model.sequelize.escape(columnName as string).replace(/'/g, '')
|
||||
const positionQuery = Sequelize.literal(`${forceNumber(newPosition)} + "${escapedColumnName}" - ${forceNumber(firstPosition)}`)
|
||||
|
||||
return model.update({ [columnName]: positionQuery } as Record<typeof columnName, typeof positionQuery>, query)
|
||||
}
|
||||
|
||||
export function increasePositionOf<T extends Model> (options: {
|
||||
model: ModelStatic<T>
|
||||
columnName: keyof Attributes<T>
|
||||
where: WhereOptions<Attributes<T>>
|
||||
transaction: Transaction
|
||||
|
||||
fromPosition: number
|
||||
by: number
|
||||
}) {
|
||||
const { model, where, transaction, fromPosition, by, columnName } = options
|
||||
|
||||
const query = {
|
||||
where: {
|
||||
...where,
|
||||
|
||||
[columnName]: {
|
||||
[Op.gte]: fromPosition
|
||||
}
|
||||
},
|
||||
transaction
|
||||
}
|
||||
|
||||
return model.increment({ [columnName]: by } as Record<typeof columnName, number>, query)
|
||||
}
|
|
@ -38,7 +38,7 @@ export function getPlaylistSort (value: string, lastSort: OrderItem = [ 'id', 'A
|
|||
const { direction, field } = buildSortDirectionAndField(value)
|
||||
|
||||
if (field.toLowerCase() === 'name') {
|
||||
return [ [ 'displayName', direction ], lastSort ]
|
||||
return [ [ 'name', direction ], lastSort ]
|
||||
}
|
||||
|
||||
return getSort(value, lastSort)
|
||||
|
|
|
@ -1,19 +1,3 @@
|
|||
import { AggregateOptions, Op, ScopeOptions, Sequelize, Transaction } from 'sequelize'
|
||||
import {
|
||||
AllowNull,
|
||||
BelongsTo,
|
||||
Column,
|
||||
CreatedAt,
|
||||
DataType,
|
||||
Default,
|
||||
ForeignKey,
|
||||
Is,
|
||||
IsInt,
|
||||
Min, Table,
|
||||
UpdatedAt
|
||||
} from 'sequelize-typescript'
|
||||
import validator from 'validator'
|
||||
import { forceNumber } from '@peertube/peertube-core-utils'
|
||||
import {
|
||||
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 } = {}
|
||||
|
|
|
@ -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 ] : [],
|
||||
|
|
|
@ -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:
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue