Merge remote-tracking branch 'origin' into feat-4769-my-videos-contained-playlists

This commit is contained in:
kontrollanten 2022-09-29 07:29:08 +02:00
commit e72bb69350
602 changed files with 70325 additions and 53689 deletions

View File

@ -7,11 +7,11 @@ root = true
end_of_line = lf
charset = utf-8
[*.yml]
[*.{yml,html}]
indent_style = space
indent_size = 2
[{client,server,shared}/**.{ts,json,js}]
[{client,server,shared,scripts}/**.{ts,json,js}]
trim_trailing_whitespace = true
insert_final_newline = true
indent_style = space

View File

@ -116,6 +116,9 @@ $ sudo -u postgres psql -c "CREATE EXTENSION pg_trgm;" peertube_dev
$ sudo -u postgres psql -c "CREATE EXTENSION unaccent;" peertube_dev
```
Peertube also requires a running redis server, no special setup is needed for
this.
In dev mode, administrator username is **root** and password is **test**.
### Online development

View File

@ -75,6 +75,8 @@ jobs:
- name: Run Test
# external-plugins tests only run on schedule
if: github.event_name == 'schedule' || matrix.test_suite != 'external-plugins'
env:
AKISMET_KEY: ${{ secrets.AKISMET_KEY }}
run: npm run ci -- ${{ matrix.test_suite }}
- name: Display errors

View File

@ -1,5 +1,137 @@
# Changelog
## v4.3.0
### IMPORTANT NOTES
* Redis **<** 5.x is not supported anymore
* FFmpeg **<** 4.3 is not supported anymore
### Maintenance
* Use `yt-dlp` by default instead of `youtube-dl` for new installations (because of much more dev activity)
* Support NodeJS 18
* Improved PeerTube logs:
* Reduce amount of PeerTube error logs
* Introduce `log.log_tracker_unknown_infohash` setting to disable "Unknown infoHash" warnings
* Web browsers send their error logs to the server that writes them in its own logs. Can be disabled by `log.accept_client_log` setting
* Introduce experimental support of [OpenTelemetry](https://opentelemetry.io/)
* Enable metrics export using a Prometheus exporter
* Enable tracing export using a Jaeger exporter
* Automatically rebuild native plugin modules on NodeJS ABI change
### Docker
* Add ability to easily use the docker compose stack on localhost
### Plugins/Themes/Embed API
* Theme:
* Removed unused `--secondaryColor` CSS variable
* Add client plugin hooks (https://docs.joinpeertube.org/api-plugins):
* `filter:api.my-library.video-playlist-elements.list.params` & `filter:api.my-library.video-playlist-elements.list.result` [#5098](https://github.com/Chocobozzz/PeerTube/pull/5098)
* `action:video-channel-create.init`
* `action:video-channel-update.init` & `action:video-channel-update.video-channel.loaded`
* `action:video-channel-videos.init` & `action:video-channel-videos.video-channel.loaded` & `action:video-channel-videos.videos.loaded`
* `action:video-channel-playlists.init` & `action:video-channel-playlists.video-channel.loaded` & `action:video-channel-playlists.playlists.loaded`
* `filter:share.video-embed-code.build.params` & `filter:share.video-embed-code.build.result` & `filter:share.video-playlist-embed-code.build.params` & `filter:share.video-playlist-embed-code.build.result`
* `filter:share.video-embed-url.build.params` & `filter:share.video-embed-url.build.result` & `filter:share.video-playlist-embed-url.build.params` & `filter:share.video-playlist-embed-url.build.result`
* `filter:share.video-url.build.params` & `filter:share.video-url.build.result` & `filter:share.video-playlist-url.build.params` & `filter:share.video-playlist-url.build.result`
* `action:modal.share.shown`
* Add server plugin hooks (https://docs.joinpeertube.org/api-plugins):
* `filter:job-queue.process.params` & `filter:job-queue.process.result`
* `filter:transcoding.manual.resolutions-to-transcode.result` & `filter:transcoding.auto.resolutions-to-transcode.result`
* `action:api.video-channel.created` & `action:api.video-channel.updated` & `action:api.video-channel.deleted`
* `action:notifier.notification.created`
* Add HTML placeholder (https://docs.joinpeertube.org/contribute-plugins?id=html-placeholder-elements):
* `share-modal-playlist-settings` & `share-modal-video-settings`
### Features
* :tada: Add ability for users to synchronize a remote channel [#5135](https://github.com/Chocobozzz/PeerTube/pull/5135) :tada:
* Automatically import all videos of a remote channel in your PeerTube channel
* PeerTube will watch for new publications and automatically import these new videos
* UI:
* Redesigned *Create an account* steps
* Improved *Login* page
* Use a lighter font color
* Use a bigger font size
* Don't display form errors in red while typing but only when we unfocus the input
* Display an error message when the user is unauthorized to view a page [#5097](https://github.com/Chocobozzz/PeerTube/pull/5097)
* Display latest upload date for captions
* Add an information if the live will be saved as a replay when displaying live sessions
* Move search bar at the center of the header
* Add *Toki Pona* and *Croatian* locales in client
* Embed:
* Display a message and automatically start live streams in embed
* Use the instance name instead of "PeerTube" in embed control bar
* Reuse current watch page query parameters for embed when using OEmbed [#5023](https://github.com/Chocobozzz/PeerTube/pull/5023)
* Instance follows:
* Introduce a *Rejected* state for follow requests to not reprocess already rejected follow requests
* Add bulk actions on instance following/followers ()
* Admins:
* Add ability to disable original resolution transcoding of the uploaded video/live stream
* Add ability to delete a specific video file in videos overview
* Display *Last Login* column by default in users overview
* Remember last selected columns in users overview
* Add ability to set a custom video import timeout
* Add ability to set the default feed (Atom, RSS...) items count
* Admins and moderators now bypass API rate limits
* Add ability to list comments on local videos in comments overview
* Limit video import resolution depending on enabled VOD transcoding resolutions
* Store and display the uploaded video original filename [#4885](https://github.com/Chocobozzz/PeerTube/pull/4885)
* Add *Total views* in the my channels list [#5007](https://github.com/Chocobozzz/PeerTube/pull/5007)
* Add *Original Publication Date* video sort option [#4959](https://github.com/Chocobozzz/PeerTube/pull/4959)
* Performance:
* Optimized view/watching endpoint
* Optimized video feed SQL query
* Process images (resize, convert...) in a dedicated worker thread
* Optimized emoji markup list rendering in client
* Use a worker thread to send ActivityPub Broadcast requests
* Suffix external auth username/channel name on conflict instead of throwing an exception
### Bug fixes
* Fix users overview *Last login* sort in admin
* More robust *move to object storage* job failure
* Fix comment add avatar with a unauthenticated user
* Fix fetching unlisted video in client
* Fix comments/download enabled attributes when importing a video
* Fix total instance views stats
* Fix HLS player infinite buffering on seek
* Reset table pagination on search
* *Host* search filter can also search into channels and playlists in global search
* Fix *My videos* invalid counter
* Prevent error on highlighted thread
* Fix *Jobs*, *Account blocklist* and *Server blocklist* hidden columns on Safari
* Fix live stream max bitrate
* Fix incompatibility with OpenSSL 3
* Don't crash on redis connection error
* Transcoding:
* Fix failed transcoding with a mp3 file that contains a cover image
* Prevent duplicated HLS playlist when running transcoding
* Regenerate video file names when running transcoding manually
* Prevent job failures resulting in broken videos on concurrent transcoding
* Fix transcoding of videos with quad audio channels
* ActivityPub
* Fix random invalid HTTP signature generation
* Use unique AP id for *Accept*/*Reject* activities
* Correctly handle remote actors that don't have follow counters
* Correctly handle unknown remote actor image size
* Add years in graph legend when grouping video views stats by month
* Prevent creating multiple lives when clicking multiple times on the "Go Live" button
* Fix *undefined" resolution in player *Stats for nerds*
* Fix not displayed error message in administrator web config
* More robust S3 upload [#5231](https://github.com/Chocobozzz/PeerTube/pull/5231)
* Fix broken saved live stream with only one resolution
* Fix `removeEventListener` player embed api
* Progressively cleanup actor images without width from the database
* Fix broken dates on localized pages
* Prevent job queue to be started before plugins
* Fix old database enum names
* Don't display remove file icon in admin videos overviews if we can't delete the file
## v4.2.2
### IMPORTANT NOTES

View File

@ -2,7 +2,8 @@
"root": true,
"ignorePatterns": [
"projects/**/*",
"node_modules/"
"node_modules/",
"src/standalone/player/dist"
],
"overrides": [
{

View File

@ -82,7 +82,7 @@ module.exports = {
{
browserName: 'Chrome',
...buildBStackMobileOptions('Latest Chrome Android', 'Samsung Galaxy S6', '5.0')
...buildBStackMobileOptions('Latest Chrome Android', 'Samsung Galaxy S8', '7.0')
},
{
browserName: 'Safari',

View File

@ -1,6 +1,6 @@
{
"name": "peertube-client",
"version": "4.2.2",
"version": "4.3.0",
"private": true,
"license": "AGPL-3.0",
"author": {
@ -27,11 +27,11 @@
"typings": "*.d.ts",
"devDependencies": {
"@angular-devkit/build-angular": "^14.0.1",
"@angular-eslint/builder": "14.0.2",
"@angular-eslint/eslint-plugin": "14.0.2",
"@angular-eslint/eslint-plugin-template": "14.0.2",
"@angular-eslint/schematics": "14.0.2",
"@angular-eslint/template-parser": "14.0.2",
"@angular-eslint/builder": "^14.0.2",
"@angular-eslint/eslint-plugin": "^14.0.2",
"@angular-eslint/eslint-plugin-template": "^14.0.2",
"@angular-eslint/schematics": "^14.0.2",
"@angular-eslint/template-parser": "^14.0.2",
"@angular/animations": "^14.0.1",
"@angular/cdk": "^14.0.1",
"@angular/cli": "^14.0.1",
@ -47,7 +47,7 @@
"@angular/service-worker": "^14.0.1",
"@babel/core": "^7.18.5",
"@babel/preset-env": "^7.18.2",
"@ng-bootstrap/ng-bootstrap": "^12.1.2",
"@ng-bootstrap/ng-bootstrap": "^13.0.0",
"@ng-select/ng-select": "^9.0.1",
"@ngx-loading-bar/core": "^6.0.0",
"@ngx-loading-bar/http-client": "^6.0.0",
@ -69,8 +69,8 @@
"@types/sha.js": "^2.4.0",
"@types/video.js": "^7.3.40",
"@types/webtorrent": "^0.109.0",
"@typescript-eslint/eslint-plugin": "5.31.0",
"@typescript-eslint/parser": "5.31.0",
"@typescript-eslint/eslint-plugin": "^5.31.0",
"@typescript-eslint/parser": "^5.31.0",
"@wdio/browserstack-service": "^7.20.2",
"@wdio/cli": "^7.20.2",
"@wdio/local-runner": "^7.20.2",
@ -84,7 +84,7 @@
"cache-chunk-store": "^3.0.0",
"chart.js": "^3.8.0",
"chartjs-plugin-zoom": "^1.2.1",
"chromedriver": "^103.0.0",
"chromedriver": "^105.0.0",
"core-js": "^3.22.8",
"css-loader": "^6.2.0",
"debug": "^4.3.1",
@ -96,7 +96,7 @@
"expect-webdriverio": "^3.4.0",
"focus-visible": "^5.0.2",
"geckodriver": "^3.0.1",
"hls.js": "1.2.0",
"hls.js": "1.2.2",
"html-loader": "^4.1.0",
"html-webpack-plugin": "^5.3.1",
"https-browserify": "^1.0.0",
@ -128,7 +128,7 @@
"stylelint-config-sass-guidelines": "^9.0.1",
"ts-loader": "^9.3.0",
"tslib": "^2.4.0",
"typescript": "~4.7.3",
"typescript": "~4.8.3",
"url": "^0.11.0",
"video.js": "^7.19.2",
"videostream": "~3.2.1",
@ -137,7 +137,7 @@
"webpack": "^5.73.0",
"webpack-bundle-analyzer": "^4.4.2",
"webpack-cli": "^4.10.0",
"webtorrent": "^1.8.22",
"webtorrent": "1.8.26",
"whatwg-fetch": "^3.0.0",
"zone.js": "~0.11.4"
},

View File

@ -49,6 +49,7 @@ import {
PluginSearchComponent,
PluginShowInstalledComponent
} from './plugins'
import { SharedAdminModule } from './shared'
import { JobService, LogsComponent, LogsService } from './system'
import { DebugComponent, DebugService } from './system/debug'
import { JobsComponent } from './system/jobs/jobs.component'
@ -69,6 +70,7 @@ import { JobsComponent } from './system/jobs/jobs.component'
SharedVideoMiniatureModule,
SharedTablesModule,
SharedUsersModule,
SharedAdminModule,
TableModule,
ChartModule

View File

@ -218,6 +218,8 @@
[clearable]="false"
></my-select-custom-value>
<my-user-real-quota-info [videoQuota]="getUserVideoQuota()"></my-user-real-quota-info>
<div *ngIf="formErrors.user.videoQuota" class="form-error">{{ formErrors.user.videoQuota }}</div>
</div>
@ -285,6 +287,22 @@
</div>
</ng-container>
<ng-container formGroupName="videoChannelSynchronization">
<div class="form-group">
<my-peertube-checkbox
inputName="importSynchronizationEnabled" formControlName="enabled"
i18n-labelText labelText="Allow channel synchronization with channel of other platforms like YouTube (requires allowing import with HTTP URL)"
>
<ng-container ngProjectAs="description">
<span i18n [hidden]="isImportVideosHttpEnabled()">
⛔ You need to allow import with HTTP URL to be able to activate this feature.
</span>
</ng-container>
</my-peertube-checkbox>
</div>
</ng-container>
</ng-container>
<ng-container formGroupName="autoBlacklist">

View File

@ -25,11 +25,12 @@ export class EditBasicConfigurationComponent implements OnInit, OnChanges {
private configService: ConfigService,
private menuService: MenuService,
private themeService: ThemeService
) { }
) {}
ngOnInit () {
this.buildLandingPageOptions()
this.checkSignupField()
this.checkImportSyncField()
this.availableThemes = this.themeService.buildAvailableThemes()
}
@ -59,6 +60,10 @@ export class EditBasicConfigurationComponent implements OnInit, OnChanges {
return !!enabled.find((e: string) => e === algorithm)
}
getUserVideoQuota () {
return this.form.value['user']['videoQuota']
}
isSignupEnabled () {
return this.form.value['signup']['enabled'] === true
}
@ -67,6 +72,14 @@ export class EditBasicConfigurationComponent implements OnInit, OnChanges {
return { 'disabled-checkbox-extra': !this.isSignupEnabled() }
}
isImportVideosHttpEnabled (): boolean {
return this.form.value['import']['videos']['http']['enabled'] === true
}
importSynchronizationChecked () {
return this.isImportVideosHttpEnabled() && this.form.value['import']['videoChannelSynchronization']['enabled']
}
hasUnlimitedSignup () {
return this.form.value['signup']['limit'] === -1
}
@ -97,6 +110,21 @@ export class EditBasicConfigurationComponent implements OnInit, OnChanges {
return this.themeService.getDefaultThemeLabel()
}
private checkImportSyncField () {
const importSyncControl = this.form.get('import.videoChannelSynchronization.enabled')
const importVideosHttpControl = this.form.get('import.videos.http.enabled')
importVideosHttpControl.valueChanges
.subscribe((httpImportEnabled) => {
importSyncControl.setValue(httpImportEnabled && importSyncControl.value)
if (httpImportEnabled) {
importSyncControl.enable()
} else {
importSyncControl.disable()
}
})
}
private checkSignupField () {
const signupControl = this.form.get('signup.enabled')

View File

@ -36,6 +36,10 @@ input[type=number] {
position: absolute;
top: 0.2em;
right: 2.5rem;
@media screen and (max-width: $mobile-view) {
display: none;
}
}
input[disabled] {
@ -146,3 +150,9 @@ ngb-tabset:not(.previews) ::ng-deep {
padding: 0 .3em;
}
}
my-user-real-quota-info {
display: block;
margin-top: 5px;
font-size: 11px;
}

View File

@ -144,6 +144,9 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
torrent: {
enabled: null
}
},
videoChannelSynchronization: {
enabled: null
}
},
trending: {
@ -289,6 +292,9 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
}
formValidated () {
this.forceCheck()
if (!this.form.valid) return
const value: ComponentCustomConfig = this.form.getRawValue()
forkJoin([
@ -378,8 +384,7 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
this.customConfig = { ...config, instanceCustomHomepage: homepage }
this.updateForm()
// Force form validation
this.forceCheck()
this.markAllAsDirty()
},
error: err => this.notifier.error(err.message)

View File

@ -108,7 +108,7 @@
</ng-container>
<div class="form-group" [ngClass]="getTranscodingDisabledClass()">
<label i18n>Resolutions to generate per enabled format</label>
<label i18n>Resolutions to generate</label>
<div class="ms-2 d-flex flex-column">
<my-peertube-checkbox

View File

@ -1,6 +1,6 @@
<h1>
<my-global-icon iconName="following" aria-hidden="true"></my-global-icon>
<ng-container i18n>Your instance subscriptions</ng-container>
<ng-container i18n>Subscriptions of your instance</ng-container>
</h1>
<p-table
@ -45,8 +45,8 @@
</tr>
</ng-template>
<ng-template pSelectableRow="follow" pTemplate="body" let-follow>
<tr>
<ng-template pTemplate="body" let-follow>
<tr pSelectableRow="follow">
<td class="checkbox-cell">
<p-tableCheckbox [value]="follow" ariaLabel="Select this row" i18n-ariaLabel></p-tableCheckbox>
</td>

View File

@ -145,15 +145,15 @@ export class VideoBlockListComponent extends RestTable implements OnInit {
}
getVideoEmbed (entry: VideoBlacklist) {
return buildVideoOrPlaylistEmbed(
decorateVideoLink({
return buildVideoOrPlaylistEmbed({
embedUrl: decorateVideoLink({
url: buildVideoEmbedLink(entry.video, environment.originServerUrl),
title: false,
warningTitle: false
}),
entry.video.name
)
embedTitle: entry.video.name
})
}
protected reloadData () {

View File

@ -54,6 +54,10 @@ export class VideoCommentListComponent extends RestTable implements OnInit {
{
value: 'local:false',
label: $localize`Remote comments`
},
{
value: 'localVideo:true',
label: $localize`Comments on local videos`
}
]
}

View File

@ -152,10 +152,7 @@
[clearable]="false"
></my-select-custom-value>
<div i18n class="transcoding-information" *ngIf="isTranscodingInformationDisplayed()">
Transcoding is enabled. The video quota only takes into account <strong>original</strong> video size. <br />
At most, this user could upload ~ {{ computeQuotaWithTranscoding() | bytes: 0 }}.
</div>
<my-user-real-quota-info [videoQuota]="getUserVideoQuota()"></my-user-real-quota-info>
<div *ngIf="formErrors.videoQuota" class="form-error">
{{ formErrors.videoQuota }}

View File

@ -41,7 +41,8 @@ button {
margin-top: 10px;
}
.transcoding-information {
my-user-real-quota-info {
display: block;
margin-top: 5px;
font-size: 11px;
}

View File

@ -3,7 +3,7 @@ import { ConfigService } from '@app/+admin/config/shared/config.service'
import { AuthService, ScreenService, ServerService, User } from '@app/core'
import { FormReactive } from '@app/shared/shared-forms'
import { USER_ROLE_LABELS } from '@shared/core-utils/users'
import { HTMLServerConfig, UserAdminFlag, UserRole, VideoResolution } from '@shared/models'
import { HTMLServerConfig, UserAdminFlag, UserRole } from '@shared/models'
import { SelectOptionsItem } from '../../../../../types/select-options-item.model'
@Directive()
@ -60,33 +60,14 @@ export abstract class UserEdit extends FormReactive implements OnInit {
]
}
isTranscodingInformationDisplayed () {
const formVideoQuota = parseInt(this.form.value['videoQuota'], 10)
return this.serverConfig.transcoding.enabledResolutions.length !== 0 &&
formVideoQuota > 0
}
computeQuotaWithTranscoding () {
const transcodingConfig = this.serverConfig.transcoding
const resolutions = transcodingConfig.enabledResolutions
const higherResolution = VideoResolution.H_4K
let multiplier = 0
for (const resolution of resolutions) {
multiplier += resolution / higherResolution
}
if (transcodingConfig.hls.enabled) multiplier *= 2
return multiplier * parseInt(this.form.value['videoQuota'], 10)
}
resetPassword () {
return
}
getUserVideoQuota () {
return this.form.value['videoQuota']
}
protected buildAdminFlags (formValue: any) {
return formValue.byPassAutoBlock ? UserAdminFlag.BYPASS_VIDEO_AUTO_BLACKLIST : UserAdminFlag.NONE
}

View File

@ -109,6 +109,7 @@
{{ file.resolution.label }}: {{ file.size | bytes: 1 }}
<my-global-icon
*ngIf="canRemoveOneFile(video)"
i18n-ngbTooltip ngbTooltip="Delete this file" iconName="delete" role="button"
(click)="removeVideoFile(video, file, 'webtorrent')"
></my-global-icon>
@ -124,6 +125,7 @@
{{ file.resolution.label }}: {{ file.size | bytes: 1 }}
<my-global-icon
*ngIf="canRemoveOneFile(video)"
i18n-ngbTooltip ngbTooltip="Delete this file" iconName="delete" role="button"
(click)="removeVideoFile(video, file, 'hls')"
></my-global-icon>

View File

@ -166,6 +166,10 @@ export class VideoListComponent extends RestTable implements OnInit {
return video.files.length !== 0
}
canRemoveOneFile (video: Video) {
return video.canRemoveOneFile(this.authUser)
}
getFilesSize (video: Video) {
let files = video.files

View File

@ -15,10 +15,15 @@
<my-button
class="update-button" *ngIf="isUpdateAvailable(plugin)" (click)="update(plugin)" [loading]="isUpdating(plugin)"
[label]="getUpdateLabel(plugin)" icon="refresh" [attr.disabled]="isUpdating(plugin)" [responsiveLabel]="true"
[attr.disabled]="isUpdating(plugin) || isUninstalling(plugin)"
[label]="getUpdateLabel(plugin)" icon="refresh" [responsiveLabel]="true"
></my-button>
<my-delete-button (click)="uninstall(plugin)" label="Uninstall" i18n-label [responsiveLabel]="true"></my-delete-button>
<my-delete-button
(click)="uninstall(plugin)"
label="Uninstall" i18n-label [responsiveLabel]="true"
[disabled]="isUpdating(plugin) || isUninstalling(plugin)"
></my-delete-button>
</div>
</my-plugin-card>
</ng-container>

View File

@ -24,6 +24,7 @@ export class PluginListInstalledComponent implements OnInit {
plugins: PeerTubePlugin[] = []
updating: { [name: string]: boolean } = {}
uninstalling: { [name: string]: boolean } = {}
onDataSubject = new Subject<any[]>()
@ -99,7 +100,11 @@ export class PluginListInstalledComponent implements OnInit {
}
isUpdating (plugin: PeerTubePlugin) {
return !!this.updating[this.getUpdatingKey(plugin)]
return !!this.updating[this.getPluginKey(plugin)]
}
isUninstalling (plugin: PeerTubePlugin) {
return !!this.uninstall[this.getPluginKey(plugin)]
}
isTheme (plugin: PeerTubePlugin) {
@ -107,12 +112,17 @@ export class PluginListInstalledComponent implements OnInit {
}
async uninstall (plugin: PeerTubePlugin) {
const pluginKey = this.getPluginKey(plugin)
if (this.uninstalling[pluginKey]) return
const res = await this.confirmService.confirm(
$localize`Do you really want to uninstall ${plugin.name}?`,
$localize`Uninstall`
)
if (res === false) return
this.uninstalling[pluginKey] = true
this.pluginApiService.uninstall(plugin.name, plugin.type)
.subscribe({
next: () => {
@ -120,15 +130,20 @@ export class PluginListInstalledComponent implements OnInit {
this.plugins = this.plugins.filter(p => p.name !== plugin.name)
this.pagination.totalItems--
this.uninstalling[pluginKey] = false
},
error: err => this.notifier.error(err.message)
error: err => {
this.notifier.error(err.message)
this.uninstalling[pluginKey] = false
}
})
}
async update (plugin: PeerTubePlugin) {
const updatingKey = this.getUpdatingKey(plugin)
if (this.updating[updatingKey]) return
const pluginKey = this.getPluginKey(plugin)
if (this.updating[pluginKey]) return
if (this.isMajorUpgrade(plugin)) {
const res = await this.confirmService.confirm(
@ -140,20 +155,23 @@ export class PluginListInstalledComponent implements OnInit {
if (res === false) return
}
this.updating[updatingKey] = true
this.updating[pluginKey] = true
this.pluginApiService.update(plugin.name, plugin.type)
.pipe()
.subscribe({
next: res => {
this.updating[updatingKey] = false
this.updating[pluginKey] = false
this.notifier.success($localize`${plugin.name} updated.`)
Object.assign(plugin, res)
},
error: err => this.notifier.error(err.message)
error: err => {
this.notifier.error(err.message)
this.updating[pluginKey] = false
}
})
}
@ -165,7 +183,7 @@ export class PluginListInstalledComponent implements OnInit {
return this.pluginApiService.getPluginOrThemeHref(this.pluginType, name)
}
private getUpdatingKey (plugin: PeerTubePlugin) {
private getPluginKey (plugin: PeerTubePlugin) {
return plugin.name + plugin.type
}

View File

@ -46,7 +46,7 @@
></my-edit-button>
<my-button
class="update-button" *ngIf="plugin.installed === false" (click)="install(plugin)"
*ngIf="plugin.installed === false" (click)="install(plugin)"
[loading]="isInstalling(plugin)" label="Install" [responsiveLabel]="true"
icon="cloud-download" [attr.disabled]="isInstalling(plugin)"
></my-button>

View File

@ -145,7 +145,11 @@ export class PluginSearchComponent implements OnInit {
plugin.installed = true
},
error: err => this.notifier.error(err.message)
error: err => {
this.installing[plugin.npmName] = false
this.notifier.error(err.message)
}
})
}
}

View File

@ -0,0 +1,3 @@
export * from './user-real-quota-info.component'
export * from './shared-admin.module'

View File

@ -0,0 +1,20 @@
import { NgModule } from '@angular/core'
import { SharedMainModule } from '../../shared/shared-main/shared-main.module'
import { UserRealQuotaInfoComponent } from './user-real-quota-info.component'
@NgModule({
imports: [
SharedMainModule
],
declarations: [
UserRealQuotaInfoComponent
],
exports: [
UserRealQuotaInfoComponent
],
providers: []
})
export class SharedAdminModule { }

View File

@ -0,0 +1,4 @@
<div i18n class="transcoding-information" *ngIf="isTranscodingInformationDisplayed()">
The video quota only takes into account <strong>original</strong> video size. <br />
Since transcoding is enabled, videos size can be at most ~ {{ computeQuotaWithTranscoding() | bytes: 0 }}.
</div>

View File

@ -0,0 +1,2 @@
@use '_variables' as *;
@use '_mixins' as *;

View File

@ -0,0 +1,44 @@
import { Component, Input, OnInit } from '@angular/core'
import { ServerService } from '@app/core'
import { HTMLServerConfig, VideoResolution } from '@shared/models/index'
@Component({
selector: 'my-user-real-quota-info',
templateUrl: './user-real-quota-info.component.html',
styleUrls: [ './user-real-quota-info.component.scss' ]
})
export class UserRealQuotaInfoComponent implements OnInit {
@Input() videoQuota: number | string
private serverConfig: HTMLServerConfig
constructor (private server: ServerService) { }
ngOnInit () {
this.serverConfig = this.server.getHTMLConfig()
}
isTranscodingInformationDisplayed () {
return this.serverConfig.transcoding.enabledResolutions.length !== 0 && this.getQuotaAsNumber() > 0
}
computeQuotaWithTranscoding () {
const transcodingConfig = this.serverConfig.transcoding
const resolutions = transcodingConfig.enabledResolutions
const higherResolution = VideoResolution.H_4K
let multiplier = 0
for (const resolution of resolutions) {
multiplier += resolution / higherResolution
}
if (transcodingConfig.hls.enabled) multiplier *= 2
return multiplier * this.getQuotaAsNumber()
}
private getQuotaAsNumber () {
return parseInt(this.videoQuota + '', 10)
}
}

View File

@ -34,9 +34,7 @@ export class JobService {
return this.authHttp.get<ResultList<Job>>(JobService.BASE_JOB_URL + `/${jobState || ''}`, { params })
.pipe(
map(res => {
return this.restExtractor.convertResultListDateToHuman(res, [ 'createdAt', 'processedOn', 'finishedOn' ])
}),
map(res => this.restExtractor.convertResultListDateToHuman(res, [ 'createdAt', 'processedOn', 'finishedOn' ], 'precise')),
map(res => this.restExtractor.applyToResultListData(res, this.prettyPrintData)),
map(res => this.restExtractor.applyToResultListData(res, this.buildUniqId)),
catchError(err => this.restExtractor.handleError(err))

View File

@ -69,7 +69,7 @@
<ng-container *ngIf="hasProgress(job)">{{ getProgress(job) }}</ng-container>
</td>
<td class="job-date c-hand" [pRowToggler]="job">{{ job.createdAt | date: 'short' }}</td>
<td class="job-date c-hand" [pRowToggler]="job">{{ job.createdAt }}</td>
</tr>
</ng-template>

View File

@ -23,22 +23,28 @@ export class JobsComponent extends RestTable implements OnInit {
jobTypes: JobTypeClient[] = [
'all',
'activitypub-cleaner',
'activitypub-follow',
'activitypub-http-broadcast',
'activitypub-http-broadcast-parallel',
'activitypub-http-broadcast',
'activitypub-http-fetcher',
'activitypub-http-unicast',
'activitypub-refresher',
'activitypub-cleaner',
'actor-keys',
'after-video-channel-import',
'email',
'federate-video',
'manage-video-torrent',
'move-to-object-storage',
'notify',
'video-channel-import',
'video-file-import',
'video-import',
'video-live-ending',
'video-redundancy',
'video-studio-edition',
'video-transcoding',
'videos-views-stats',
'move-to-object-storage'
'videos-views-stats'
]
jobs: Job[] = []

View File

@ -61,7 +61,7 @@
</div>
<div class="form-group">
<label for="support">Support</label>
<label i18n for="support">Support</label>
<my-help
helpType="markdownEnhanced" i18n-preHtml preHtml="Short text to tell people how they can support the channel (membership platform...).<br /><br />
When a video is uploaded in this channel, the video support field will be automatically filled by this text."

View File

@ -5,7 +5,7 @@
<strong>{{ user.pendingEmail }}</strong> is awaiting email verification
</div>
<form role="form" class="change-email" (ngSubmit)="changeEmail()" [formGroup]="form" *ngIf="user.pluginAuth === null">
<form role="form" class="change-email" (ngSubmit)="changeEmail()" [formGroup]="form">
<div class="form-group">
<label i18n for="new-email">Change your email</label>

View File

@ -62,7 +62,7 @@
</div>
</div>
<div class="row mt-5"> <!-- email grid -->
<div class="row mt-5" *ngIf="user.pluginAuth === null"> <!-- email grid -->
<div class="col-12 col-lg-4 col-xl-3">
<h2 i18n class="account-title">EMAIL</h2>
</div>

View File

@ -1,7 +1,16 @@
<h1>
<span>
<my-global-icon iconName="channel" aria-hidden="true"></my-global-icon>
<ng-container i18n>My channels</ng-container>
<span *ngIf="totalItems" class="pt-badge badge-secondary">{{ totalItems }}</span>
</span>
<div>
<a routerLink="/my-library/video-channel-syncs" class="button-link">
<my-global-icon iconName="repeat" aria-hidden="true"></my-global-icon>
<ng-container i18n>My synchronizations</ng-container>
</a>
</div>
</h1>
<my-channels-setup-message [hideLink]="true"></my-channels-setup-message>

View File

@ -1,9 +1,20 @@
@use '_variables' as *;
@use '_mixins' as *;
h1 my-global-icon {
h1 {
display: flex;
justify-content: space-between;
my-global-icon {
position: relative;
top: -2px;
}
.button-link {
@include peertube-button-link;
@include grey-button;
@include button-with-icon(18px, 3px, -1px);
}
}
.create-button {

View File

@ -6,6 +6,8 @@ import { MySubscriptionsComponent } from './my-follows/my-subscriptions.componen
import { MyHistoryComponent } from './my-history/my-history.component'
import { MyLibraryComponent } from './my-library.component'
import { MyOwnershipComponent } from './my-ownership/my-ownership.component'
import { MyVideoChannelSyncsComponent } from './my-video-channel-syncs/my-video-channel-syncs.component'
import { VideoChannelSyncEditComponent } from './my-video-channel-syncs/video-channel-sync-edit/video-channel-sync-edit.component'
import { MyVideoImportsComponent } from './my-video-imports/my-video-imports.component'
import { MyVideoPlaylistCreateComponent } from './my-video-playlists/my-video-playlist-create.component'
import { MyVideoPlaylistElementsComponent } from './my-video-playlists/my-video-playlist-elements.component'
@ -131,6 +133,26 @@ const myLibraryRoutes: Routes = [
key: 'my-videos-history-list'
}
}
},
{
path: 'video-channel-syncs',
component: MyVideoChannelSyncsComponent,
data: {
meta: {
title: $localize`My synchronizations`
}
}
},
{
path: 'video-channel-syncs/create',
component: VideoChannelSyncEditComponent,
data: {
meta: {
title: $localize`Create new synchronization`
}
}
}
]
}

View File

@ -29,6 +29,8 @@ import { MyVideoPlaylistUpdateComponent } from './my-video-playlists/my-video-pl
import { MyVideoPlaylistsComponent } from './my-video-playlists/my-video-playlists.component'
import { VideoChangeOwnershipComponent } from './my-videos/modals/video-change-ownership.component'
import { MyVideosComponent } from './my-videos/my-videos.component'
import { MyVideoChannelSyncsComponent } from './my-video-channel-syncs/my-video-channel-syncs.component'
import { VideoChannelSyncEditComponent } from './my-video-channel-syncs/video-channel-sync-edit/video-channel-sync-edit.component'
@NgModule({
imports: [
@ -63,6 +65,8 @@ import { MyVideosComponent } from './my-videos/my-videos.component'
MyOwnershipComponent,
MyAcceptOwnershipComponent,
MyVideoImportsComponent,
MyVideoChannelSyncsComponent,
VideoChannelSyncEditComponent,
MySubscriptionsComponent,
MyFollowersComponent,
MyHistoryComponent,

View File

@ -0,0 +1,90 @@
<div *ngIf="error" class="alert alert-danger">{{ error }}</div>
<h1>
<my-global-icon iconName="refresh" aria-hidden="true"></my-global-icon>
<ng-container i18n>My synchronizations</ng-container>
</h1>
<div *ngIf="!syncEnabled()">
<p class="muted" i18n>⚠️ The instance doesn't allow channel synchronization</p>
</div>
<p-table
*ngIf="syncEnabled()" [value]="channelSyncs" [lazy]="true"
[paginator]="totalRecords > 0" [totalRecords]="totalRecords" [rows]="rowsPerPage" [rowsPerPageOptions]="rowsPerPageOptions"
[first]="pagination.start" [sortField]="sort.field" [sortOrder]="sort.order" (onLazyLoad)="loadLazy($event)" dataKey="id"
[showCurrentPageReport]="true" i18n-currentPageReportTemplate
currentPageReportTemplate="Showing {{'{first}'}} to {{'{last}'}} of {{'{totalRecords}'}} synchronizations"
[expandedRowKeys]="expandedRows"
>
<ng-template pTemplate="caption">
<div class="caption">
<div class="left-buttons">
<a class="add-sync" routerLink="{{ getSyncCreateLink() }}">
<my-global-icon iconName="add" aria-hidden="true"></my-global-icon>
<ng-container i18n>Add synchronization</ng-container>
</a>
</div>
</div>
</ng-template>
<ng-template pTemplate="header">
<tr>
<th style="width: 10%"></th>
<th style="width: 25%" i18n pSortableColumn="externalChannelUrl">External Channel <p-sortIcon field="externalChannelUrl"></p-sortIcon></th>
<th style="width: 25%" i18n pSortableColumn="videoChannel">Channel <p-sortIcon field="videoChannel"></p-sortIcon></th>
<th style="width: 10%" i18n pSortableColumn="state">State <p-sortIcon field="state"></p-sortIcon></th>
<th style="width: 10%" i18n pSortableColumn="createdAt">Created <p-sortIcon field="createdAt"></p-sortIcon></th>
<th style="width: 10%" i18n pSortableColumn="lastSyncAt">Last synchronization at <p-sortIcon field="lastSyncAt"></p-sortIcon></th>
<th></th>
</tr>
</ng-template>
<ng-template pTemplate="body" let-expanded="expanded" let-videoChannelSync>
<tr>
<td class="action-cell">
<my-action-dropdown
container="body"
[actions]="videoChannelSyncActions" [entry]="videoChannelSync"
></my-action-dropdown>
</td>
<td>
<a [href]="videoChannelSync.externalChannelUrl" target="_blank" rel="noopener noreferrer">{{ videoChannelSync.externalChannelUrl }}</a>
</td>
<td>
<div class="actor">
<my-actor-avatar
class="channel"
[actor]="videoChannelSync.channel" actorType="channel"
[internalHref]="[ '/c', videoChannelSync.channel.name ]"
size="25"
></my-actor-avatar>
<div class="actor-info">
<a [routerLink]="[ '/c', videoChannelSync.channel.name ]" class="actor-names" i18n-title title="Channel page">
<div class="actor-display-name">{{ videoChannelSync.channel.displayName }}</div>
<div class="actor-name">{{ videoChannelSync.channel.name }}</div>
</a>
</div>
</div>
</td>
<td>
<span [ngClass]="getSyncStateClass(videoChannelSync.state.id)">
{{ videoChannelSync.state.label }}
</span>
</td>
<td>{{ videoChannelSync.createdAt | date: 'short' }}</td>
<td>{{ videoChannelSync.lastSyncAt | date: 'short' }}</td>
<td>
<a i18n routerLink="/my-library/video-imports" [queryParams]="{ search: 'videoChannelSyncId:' + videoChannelSync.id }" class="peertube-button-link grey-button">
List imports
</a>
</td>
</tr>
</ng-template>
</p-table>

View File

@ -0,0 +1,14 @@
@use '_mixins' as *;
@use '_variables' as *;
@use '_actor' as *;
.add-sync {
@include create-button;
}
.actor {
@include actor-row($min-height: auto, $separator: true);
margin-bottom: 0;
padding-bottom: 0;
border: 0;
}

View File

@ -0,0 +1,130 @@
import { Component, OnInit } from '@angular/core'
import { AuthService, Notifier, RestPagination, RestTable, ServerService } from '@app/core'
import { DropdownAction, VideoChannelService, VideoChannelSyncService } from '@app/shared/shared-main'
import { HTMLServerConfig } from '@shared/models/server'
import { VideoChannelSync, VideoChannelSyncState } from '@shared/models/videos'
import { SortMeta } from 'primeng/api'
import { mergeMap } from 'rxjs'
@Component({
templateUrl: './my-video-channel-syncs.component.html',
styleUrls: [ './my-video-channel-syncs.component.scss' ]
})
export class MyVideoChannelSyncsComponent extends RestTable implements OnInit {
error: string
channelSyncs: VideoChannelSync[] = []
totalRecords = 0
videoChannelSyncActions: DropdownAction<VideoChannelSync>[][] = []
sort: SortMeta = { field: 'createdAt', order: 1 }
pagination: RestPagination = { count: this.rowsPerPage, start: 0 }
private static STATE_CLASS_BY_ID = {
[VideoChannelSyncState.FAILED]: 'badge-red',
[VideoChannelSyncState.PROCESSING]: 'badge-blue',
[VideoChannelSyncState.SYNCED]: 'badge-green',
[VideoChannelSyncState.WAITING_FIRST_RUN]: 'badge-yellow'
}
private serverConfig: HTMLServerConfig
constructor (
private videoChannelsSyncService: VideoChannelSyncService,
private serverService: ServerService,
private notifier: Notifier,
private authService: AuthService,
private videoChannelService: VideoChannelService
) {
super()
}
ngOnInit () {
this.serverConfig = this.serverService.getHTMLConfig()
this.initialize()
this.videoChannelSyncActions = [
[
{
label: $localize`Delete`,
iconName: 'delete',
handler: videoChannelSync => this.deleteSync(videoChannelSync)
},
{
label: $localize`Fully synchronize the channel`,
description: $localize`This fetches any missing videos on the local channel`,
iconName: 'refresh',
handler: videoChannelSync => this.fullySynchronize(videoChannelSync)
}
]
]
}
protected reloadData () {
this.error = undefined
this.authService.userInformationLoaded
.pipe(mergeMap(() => {
const user = this.authService.getUser()
return this.videoChannelsSyncService.listAccountVideoChannelsSyncs({
sort: this.sort,
account: user.account,
pagination: this.pagination
})
}))
.subscribe({
next: res => {
this.channelSyncs = res.data
this.totalRecords = res.total
},
error: err => {
this.error = err.message
}
})
}
syncEnabled () {
return this.serverConfig.import.videoChannelSynchronization.enabled
}
deleteSync (videoChannelSync: VideoChannelSync) {
this.videoChannelsSyncService.deleteSync(videoChannelSync.id)
.subscribe({
next: () => {
this.notifier.success($localize`Synchronization removed successfully for ${videoChannelSync.channel.displayName}.`)
this.reloadData()
},
error: err => {
this.error = err.message
}
})
}
fullySynchronize (videoChannelSync: VideoChannelSync) {
this.videoChannelService.importVideos(videoChannelSync.channel.name, videoChannelSync.externalChannelUrl, videoChannelSync.id)
.subscribe({
next: () => {
this.notifier.success($localize`Full synchronization requested successfully for ${videoChannelSync.channel.displayName}.`)
},
error: err => {
this.error = err.message
}
})
}
getSyncCreateLink () {
return '/my-library/video-channel-syncs/create'
}
getSyncStateClass (stateId: number) {
return [ 'pt-badge', MyVideoChannelSyncsComponent.STATE_CLASS_BY_ID[stateId] ]
}
getIdentifier () {
return 'MyVideoChannelsSyncComponent'
}
getChannelUrl (name: string) {
return '/c/' + name
}
}

View File

@ -0,0 +1,64 @@
<div *ngIf="error" class="alert alert-danger">{{ error }}</div>
<div class="margin-content">
<form role="form" (ngSubmit)="formValidated()" [formGroup]="form">
<div class="row">
<div class="col-12 col-lg-4 col-xl-3">
<div class="video-channel-sync-title" i18n>NEW SYNCHRONIZATION</div>
</div>
<div class="col-12 col-lg-8 col-xl-9">
<div class="form-group">
<label i18n for="externalChannelUrl">Remote channel URL</label>
<div class="input-group">
<input
type="text"
id="externalChannelUrl"
i18n-placeholder
placeholder="Example: https://youtube.com/channel/UC_fancy_channel"
formControlName="externalChannelUrl"
[ngClass]="{ 'input-error': formErrors['externalChannelUrl'] }"
class="form-control"
>
</div>
<div *ngIf="formErrors['externalChannelUrl']" class="form-error">
{{ formErrors['externalChannelUrl'] }}
</div>
</div>
<div class="form-group">
<label i18n for="videoChannel">Video Channel</label>
<my-select-channel required [items]="userVideoChannels" formControlName="videoChannel"></my-select-channel>
<div *ngIf="formErrors['videoChannel']" class="form-error">
{{ formErrors['videoChannel'] }}
</div>
</div>
<div class="form-group">
<label for="existingVideoStrategy" i18n>Options for existing videos on remote channel:</label>
<div class="peertube-radio-container">
<input type="radio" name="existingVideoStrategy" id="import" value="import" formControlName="existingVideoStrategy" required />
<label for="import" i18n>Import all and watch for new publications</label>
</div>
<div class="peertube-radio-container">
<input type="radio" name="existingVideoStrategy" id="doNothing" value="nothing" formControlName="existingVideoStrategy" required />
<label for="doNothing" i18n>Only watch for new publications</label>
</div>
</div>
</div>
</div>
<div class="row"> <!-- submit placement block -->
<div class="col-md-7 col-xl-5"></div>
<div class="col-md-5 col-xl-5 d-inline-flex">
<input type="submit" class="peertube-button orange-button ms-auto" value="{{ getFormButtonTitle() }}" [disabled]="!form.valid">
</div>
</div>
</form>
</div>

View File

@ -0,0 +1,17 @@
@use '_variables' as *;
@use '_mixins' as *;
$form-base-input-width: 480px;
input[type=text] {
@include peertube-input-text($form-base-input-width);
}
.video-channel-sync-title {
@include settings-big-title;
}
my-select-channel {
display: block;
max-width: $form-base-input-width;
}

View File

@ -0,0 +1,76 @@
import { mergeMap } from 'rxjs'
import { SelectChannelItem } from 'src/types'
import { Component, OnInit } from '@angular/core'
import { Router } from '@angular/router'
import { AuthService, Notifier } from '@app/core'
import { listUserChannelsForSelect } from '@app/helpers'
import { VIDEO_CHANNEL_EXTERNAL_URL_VALIDATOR } from '@app/shared/form-validators/video-channel-validators'
import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
import { VideoChannelService, VideoChannelSyncService } from '@app/shared/shared-main'
import { VideoChannelSyncCreate } from '@shared/models/videos'
@Component({
selector: 'my-video-channel-sync-edit',
templateUrl: './video-channel-sync-edit.component.html',
styleUrls: [ './video-channel-sync-edit.component.scss' ]
})
export class VideoChannelSyncEditComponent extends FormReactive implements OnInit {
error: string
userVideoChannels: SelectChannelItem[] = []
existingVideosStrategy: string
constructor (
protected formValidatorService: FormValidatorService,
private authService: AuthService,
private router: Router,
private notifier: Notifier,
private videoChannelSyncService: VideoChannelSyncService,
private videoChannelService: VideoChannelService
) {
super()
}
ngOnInit () {
this.buildForm({
externalChannelUrl: VIDEO_CHANNEL_EXTERNAL_URL_VALIDATOR,
videoChannel: null,
existingVideoStrategy: null
})
listUserChannelsForSelect(this.authService)
.subscribe(channels => this.userVideoChannels = channels)
}
getFormButtonTitle () {
return $localize`Create`
}
formValidated () {
this.error = undefined
const body = this.form.value
const videoChannelSyncCreate: VideoChannelSyncCreate = {
externalChannelUrl: body.externalChannelUrl,
videoChannelId: body.videoChannel
}
const importExistingVideos = body['existingVideoStrategy'] === 'import'
this.videoChannelSyncService.createSync(videoChannelSyncCreate)
.pipe(mergeMap(({ videoChannelSync }) => {
return importExistingVideos
? this.videoChannelService.importVideos(videoChannelSync.channel.name, videoChannelSync.externalChannelUrl, videoChannelSync.id)
: Promise.resolve(null)
}))
.subscribe({
next: () => {
this.notifier.success($localize`Synchronization created successfully.`)
this.router.navigate([ '/my-library', 'video-channel-syncs' ])
},
error: err => {
this.error = err.message
}
})
}
}

View File

@ -1,11 +1,22 @@
<h1>
<h1 class="d-flex justify-content-between">
<span>
<my-global-icon iconName="cloud-download" aria-hidden="true"></my-global-icon>
<ng-container i18n>My imports</ng-container>
</span>
<a routerLink="/my-library/video-channel-syncs" class="button-link">
<my-global-icon iconName="repeat" aria-hidden="true"></my-global-icon>
<ng-container i18n>My synchronizations</ng-container>
</a>
</h1>
<div class="mb-4 d-flex justify-content-between">
<my-advanced-input-filter (search)="onSearch($event)"></my-advanced-input-filter>
</div>
<p-table
[value]="videoImports" [lazy]="true" [paginator]="totalRecords > 0" [totalRecords]="totalRecords" [rows]="rowsPerPage" [first]="pagination.start"
[rowsPerPageOptions]="rowsPerPageOptions" [sortField]="sort.field" [sortOrder]="sort.order" (onLazyLoad)="loadLazy($event)" dataKey="id"
[rowsPerPageOptions]="rowsPerPageOptions" [sortField]="sort.field" [sortOrder]="sort.order" (onLazyLoad)="loadLazy($event)" [lazyLoadOnInit]="false" dataKey="id"
[showCurrentPageReport]="true" i18n-currentPageReportTemplate
currentPageReportTemplate="Showing {{'{first}'}} to {{'{last}'}} of {{'{totalRecords}'}} imports"
[expandedRowKeys]="expandedRows"

View File

@ -8,3 +8,9 @@ pre {
.video-import-error {
color: #ff0000;
}
.button-link {
@include peertube-button-link;
@include grey-button;
@include button-with-icon(18px, 3px, -1px);
}

View File

@ -33,12 +33,16 @@ export class MyVideoImportsComponent extends RestTable implements OnInit {
switch (state) {
case VideoImportState.FAILED:
return 'badge-red'
case VideoImportState.REJECTED:
return 'badge-banned'
case VideoImportState.PENDING:
return 'badge-yellow'
case VideoImportState.PROCESSING:
return 'badge-blue'
default:
return 'badge-green'
}
@ -87,7 +91,7 @@ export class MyVideoImportsComponent extends RestTable implements OnInit {
}
protected reloadData () {
this.videoImportService.getMyVideoImports(this.pagination, this.sort)
this.videoImportService.getMyVideoImports(this.pagination, this.sort, this.search)
.subscribe({
next: resultList => {
this.videoImports = resultList.data

View File

@ -43,7 +43,8 @@ export class MyVideosComponent implements OnInit, DisableForReuseHook {
privacyLabel: false,
privacyText: true,
state: true,
blacklistInfo: true
blacklistInfo: true,
forceChannelInBy: true
}
videoDropdownDisplayOptions: VideoActionsDisplayType = {
playlist: false,

View File

@ -98,7 +98,7 @@ export class SearchComponent implements OnInit, OnDestroy {
this.search()
},
error: err => this.notifier.error(err.text)
error: err => this.notifier.error(err.message)
})
this.userService.getAnonymousOrLoggedUser()

View File

@ -55,7 +55,7 @@
</div>
</cdk-step>
<cdk-step [stepControl]="formStepUser" label="My account" [editable]="!signupSuccess">
<cdk-step [stepControl]="formStepUser" i18n-label label="My account" [editable]="!signupSuccess">
<my-signup-step-title mascotImageName="account" i18n>
<strong>Setup</strong>
<div>your account</div>

View File

@ -528,7 +528,7 @@ export class VideoStatsComponent implements OnInit {
const date = new Date(label)
if (data.groupInterval.match(/ month?$/)) {
return date.toLocaleDateString([], { month: 'numeric' })
return date.toLocaleDateString([], { year: '2-digit', month: 'numeric' })
}
if (data.groupInterval.match(/ days?$/)) {

View File

@ -0,0 +1,34 @@
<ng-container [formGroup]="form">
<div class="modal-header">
<h4 i18n class="modal-title">Edit caption</h4>
<my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hide()"></my-global-icon>
</div>
<div class="modal-body">
<label i18n for="captionFileContent">Caption</label>
<textarea
id="captionFileContent"
formControlName="captionFileContent"
class="form-control caption-textarea"
[ngClass]="{ 'input-error': formErrors['captionFileContent'] }"
#textarea
>
</textarea>
<div *ngIf="formErrors.captionFileContent" class="form-error">
{{ formErrors.captionFileContent }}
</div>
</div>
<div class="modal-footer inputs">
<input
type="button" role="button" i18n-value value="Cancel" class="peertube-button grey-button"
(click)="cancel()" (key.enter)="cancel()"
>
<input
type="submit" i18n-value value="Edit this caption" class="peertube-button orange-button"
[disabled]="!form.valid" (click)="updateCaption()"
>
</div>
</ng-container>

View File

@ -2,28 +2,33 @@ import { Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild }
import { VIDEO_CAPTION_FILE_CONTENT_VALIDATOR } from '@app/shared/form-validators/video-captions-validators'
import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
import { VideoCaptionEdit, VideoCaptionService, VideoCaptionWithPathEdit } from '@app/shared/shared-main'
import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'
import { NgbModal, NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { HTMLServerConfig, VideoConstant } from '@shared/models'
import { ServerService } from '../../../../core'
/**
* https://github.com/valor-software/ngx-bootstrap/issues/3825
* https://stackblitz.com/edit/angular-t5dfp7
* https://medium.com/@izzatnadiri/how-to-pass-data-to-and-receive-from-ng-bootstrap-modals-916f2ad5d66e
*/
@Component({
selector: 'my-video-caption-edit-modal',
styleUrls: [ './video-caption-edit-modal.component.scss' ],
templateUrl: './video-caption-edit-modal.component.html'
selector: 'my-video-caption-edit-modal-content',
styleUrls: [ './video-caption-edit-modal-content.component.scss' ],
templateUrl: './video-caption-edit-modal-content.component.html'
})
export class VideoCaptionEditModalComponent extends FormReactive implements OnInit {
export class VideoCaptionEditModalContentComponent extends FormReactive implements OnInit {
@Input() videoCaption: VideoCaptionWithPathEdit
@Input() serverConfig: HTMLServerConfig
@Output() captionEdited = new EventEmitter<VideoCaptionEdit>()
@ViewChild('modal', { static: true }) modal: ElementRef
@ViewChild('textarea', { static: true }) textarea!: ElementRef
videoCaptionLanguages: VideoConstant<string>[] = []
private openedModal: NgbModalRef
constructor (
protected openedModal: NgbActiveModal,
protected formValidatorService: FormValidatorService,
private modalService: NgbModal,
private videoCaptionService: VideoCaptionService,
@ -49,11 +54,14 @@ export class VideoCaptionEditModalComponent extends FormReactive implements OnIn
this.form.patchValue({
captionFileContent: res
})
this.resetTextarea()
})
}
show () {
this.openedModal = this.modalService.open(this.modal, { centered: true, keyboard: false })
resetTextarea () {
this.textarea.nativeElement.scrollTop = 0
this.textarea.nativeElement.selectionStart = 0
this.textarea.nativeElement.selectionEnd = 0
}
hide () {

View File

@ -1,36 +0,0 @@
<ng-template #modal>
<ng-container [formGroup]="form">
<div class="modal-header">
<h4 i18n class="modal-title">Edit caption</h4>
<my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hide()"></my-global-icon>
</div>
<div class="modal-body">
<label i18n for="captionFileContent">Caption</label>
<textarea
id="captionFileContent"
formControlName="captionFileContent"
class="form-control caption-textarea"
[ngClass]="{ 'input-error': formErrors['captionFileContent'] }"
>
</textarea>
<div *ngIf="formErrors.captionFileContent" class="form-error">
{{ formErrors.captionFileContent }}
</div>
</div>
<div class="modal-footer inputs">
<input
type="button" role="button" i18n-value value="Cancel" class="peertube-button grey-button"
(click)="cancel()" (key.enter)="cancel()"
>
<input
type="submit" i18n-value value="Edit this caption" class="peertube-button orange-button"
[disabled]="!form.valid" (click)="updateCaption()"
>
</div>
</ng-container>
</ng-template>

View File

@ -147,7 +147,7 @@
</ng-template>
<ng-template ptTemplate="help">
<ng-container i18n>If you decide not to wait for transcoding before publishing the video, it could be unplayable until transcoding ends.</ng-container>
<ng-container i18n>The video may be unplayable during the transcoding process. It's the reason why we prefer to publish publicly the video after transcoding.</ng-container>
</ng-template>
</my-peertube-checkbox>
@ -185,7 +185,7 @@
<div i18n class="caption-entry-state">Already uploaded on {{ videoCaption.updatedAt | date }} &#10004;</div>
<span i18n class="caption-entry-edit" (click)="videoCaptionEditModal.show()">Edit</span>
<span i18n class="caption-entry-edit" (click)="openEditCaptionModal(videoCaption)">Edit</span>
<span i18n class="caption-entry-delete" (click)="deleteCaption(videoCaption)">Delete</span>
</ng-container>
@ -212,13 +212,6 @@
<span i18n class="caption-entry-delete" (click)="deleteCaption(videoCaption)">Cancel deletion</span>
</ng-container>
<my-video-caption-edit-modal
#videoCaptionEditModal
[videoCaption]="videoCaption"
[serverConfig]="serverConfig"
(captionEdited)="onCaptionEdited($event)"
></my-video-caption-edit-modal>
</div>
</div>
@ -314,7 +307,7 @@
<div class="col-md-12 col-xl-8">
<div class="form-group">
<label i18n for="previewfile">Video preview</label>
<label i18n for="previewfile">Video thumbnail</label>
<my-preview-upload
i18n-inputLabel inputLabel="Edit" inputName="previewfile" formControlName="previewfile"

View File

@ -35,10 +35,11 @@ import {
} from '@shared/models'
import { I18nPrimengCalendarService } from './i18n-primeng-calendar.service'
import { VideoCaptionAddModalComponent } from './video-caption-add-modal.component'
import { VideoCaptionEditModalComponent } from './video-caption-edit-modal/video-caption-edit-modal.component'
import { VideoCaptionEditModalContentComponent } from './video-caption-edit-modal-content/video-caption-edit-modal-content.component'
import { VideoEditType } from './video-edit.type'
import { VideoSource } from '@shared/models/videos/video-source'
import { logger } from '@root-helpers/logger'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
type VideoLanguages = VideoConstant<string> & { group?: string }
type PluginField = {
@ -70,7 +71,6 @@ export class VideoEditComponent implements OnInit, OnDestroy {
@Input() liveVideo: LiveVideo
@ViewChild('videoCaptionAddModal', { static: true }) videoCaptionAddModal: VideoCaptionAddModalComponent
@ViewChild('videoCaptionEditModal', { static: true }) editCaptionModal: VideoCaptionEditModalComponent
@Output() formBuilt = new EventEmitter<void>()
@Output() pluginFieldsAdded = new EventEmitter<void>()
@ -128,7 +128,8 @@ export class VideoEditComponent implements OnInit, OnDestroy {
private i18nPrimengCalendarService: I18nPrimengCalendarService,
private ngZone: NgZone,
private hooks: HooksService,
private cd: ChangeDetectorRef
private cd: ChangeDetectorRef,
private modalService: NgbModal
) {
this.calendarTimezone = this.i18nPrimengCalendarService.getTimezone()
this.calendarDateFormat = this.i18nPrimengCalendarService.getDateFormat()
@ -286,6 +287,13 @@ export class VideoEditComponent implements OnInit, OnDestroy {
this.videoCaptionAddModal.show()
}
openEditCaptionModal (videoCaption: VideoCaptionWithPathEdit) {
const modalRef = this.modalService.open(VideoCaptionEditModalContentComponent, { centered: true, keyboard: false })
modalRef.componentInstance.videoCaption = videoCaption
modalRef.componentInstance.serverConfig = this.serverConfig
modalRef.componentInstance.captionEdited.subscribe(this.onCaptionEdited.bind(this))
}
isSaveReplayEnabled () {
return this.serverConfig.live.allowReplay
}

View File

@ -6,7 +6,7 @@ import { SharedMainModule } from '@app/shared/shared-main'
import { SharedVideoLiveModule } from '@app/shared/shared-video-live'
import { I18nPrimengCalendarService } from './i18n-primeng-calendar.service'
import { VideoCaptionAddModalComponent } from './video-caption-add-modal.component'
import { VideoCaptionEditModalComponent } from './video-caption-edit-modal/video-caption-edit-modal.component'
import { VideoCaptionEditModalContentComponent } from './video-caption-edit-modal-content/video-caption-edit-modal-content.component'
import { VideoEditComponent } from './video-edit.component'
@NgModule({
@ -22,7 +22,7 @@ import { VideoEditComponent } from './video-edit.component'
declarations: [
VideoEditComponent,
VideoCaptionAddModalComponent,
VideoCaptionEditModalComponent
VideoCaptionEditModalContentComponent
],
exports: [

View File

@ -61,7 +61,7 @@
<div class="submit-container">
<my-button className="orange-button" i18n-label label="Update" icon="circle-tick"
(click)="updateSecondStep()"
[disabled]="!form.valid"
[disabled]="!form.valid || isUpdatingVideo === true"
></my-button>
</div>
</form>

View File

@ -2,11 +2,3 @@
margin-top: 15px;
}
.peertube-radio-container {
width: 250px;
.form-group-description {
white-space: nowrap;
}
}

View File

@ -27,6 +27,8 @@ export class VideoGoLiveComponent extends VideoSend implements OnInit, AfterView
firstStepPermanentLive: boolean
isInUpdateForm = false
isUpdatingVideo = false
isOrHasGoingLive = false
liveVideo: LiveVideo
@ -64,6 +66,9 @@ export class VideoGoLiveComponent extends VideoSend implements OnInit, AfterView
}
goLive () {
if (this.isOrHasGoingLive) return
this.isOrHasGoingLive = true
const name = 'Live'
const video: LiveVideoCreate = {
@ -115,6 +120,8 @@ export class VideoGoLiveComponent extends VideoSend implements OnInit, AfterView
async updateSecondStep () {
if (!await this.isFormValid()) return
this.isUpdatingVideo = true
const video = new VideoEdit()
video.patch(this.form.value)
video.id = this.videoId
@ -134,6 +141,8 @@ export class VideoGoLiveComponent extends VideoSend implements OnInit, AfterView
this.liveVideoService.updateLive(this.videoId, liveVideoUpdate)
]).subscribe({
next: () => {
this.isUpdatingVideo = false
this.notifier.success($localize`Live published.`)
this.router.navigateByUrl(Video.buildWatchUrl(video))

View File

@ -16,6 +16,10 @@
</my-help>
<input type="text" id="targetUrl" [(ngModel)]="targetUrl" class="form-control" />
<div *ngIf="isChannelSyncEnabled()" class="form-group-description" i18n>
You can also synchronize a remote channel in <a routerLink="/my-library/video-channel-syncs">your library</a>
</div>
</div>
<div class="form-group">

View File

@ -64,6 +64,10 @@ export class VideoImportUrlComponent extends VideoSend implements OnInit, AfterV
return this.targetUrl?.match(/https?:\/\//)
}
isChannelSyncEnabled () {
return this.serverConfig.import.videoChannelSynchronization.enabled
}
importVideo () {
this.isImportingVideo = true

View File

@ -1,7 +1,7 @@
@use '_variables' as *;
@use '_mixins' as *;
$width-size: 250px;
$width-size: 275px;
.alert.alert-danger {
text-align: center;
@ -27,7 +27,9 @@ $width-size: 250px;
@include peertube-select-container($width-size);
}
my-select-options ::ng-deep ng-select,
my-select-channel ::ng-deep ng-select {
my-select-channel ::ng-deep ng-select,
.peertube-radio-container,
.form-group-description {
width: $width-size;
@media screen and (max-width: $width-size) {
@ -35,6 +37,10 @@ $width-size: 250px;
}
}
.form-group-description {
white-space: nowrap;
}
input[type=text] {
@include peertube-input-text($width-size);
display: block;

View File

@ -13,6 +13,7 @@ import { isIOS } from '@root-helpers/web-browser'
import { HttpStatusCode, VideoCreateResult } from '@shared/models'
import { UploaderXFormData } from './uploaderx-form-data'
import { VideoSend } from './video-send'
import { Subscription } from 'rxjs'
@Component({
selector: 'my-video-upload',
@ -56,6 +57,8 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy
private alreadyRefreshedToken = false
private uploadServiceSubscription: Subscription
constructor (
protected formValidatorService: FormValidatorService,
protected loadingBar: LoadingBarService,
@ -87,7 +90,7 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy
this.userVideoQuotaUsedDaily = data.videoQuotaUsedDaily
})
this.resumableUploadService.events
this.uploadServiceSubscription = this.resumableUploadService.events
.subscribe(state => this.onUploadVideoOngoing(state))
}
@ -96,7 +99,9 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy
}
ngOnDestroy () {
this.cancelUpload()
this.resumableUploadService.disconnect()
if (this.uploadServiceSubscription) this.uploadServiceSubscription.unsubscribe()
}
canDeactivate () {

View File

@ -148,7 +148,7 @@ export class VideoCommentAddComponent extends FormReactive implements OnChanges,
error: err => {
this.addingComment = false
this.notifier.error(err.text)
this.notifier.error(err.message)
}
})
}

View File

@ -628,6 +628,8 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
: null,
authorizationHeader: this.authService.getRequestHeaderValue(),
metricsUrl: environment.apiUrl + '/api/v1/metrics/playback',
embedUrl: video.embedUrl,
embedTitle: video.name,
instanceName: this.serverConfig.instance.name,

View File

@ -97,7 +97,7 @@ export class AuthService {
let errorMessage = err.message
if (err.status === HttpStatusCode.FORBIDDEN_403) {
errorMessage = $localize`Cannot retrieve OAuth Client credentials: ${err.text}.
errorMessage = $localize`Cannot retrieve OAuth Client credentials: ${err.message}.
Ensure you have correctly configured PeerTube (config/ directory), in particular the "webserver" section.`
}

View File

@ -7,6 +7,7 @@ import { ScreenService } from '../wrappers'
export type MenuLink = {
icon: GlobalIconName
iconClass?: string
label: string
// Used by the left menu for example
@ -70,6 +71,14 @@ export class MenuService {
let links: MenuLink[] = []
if (userCanSeeVideosLink) {
links.push({
path: '/my-library/video-channels',
icon: 'channel' as GlobalIconName,
iconClass: 'channel-icon',
shortLabel: $localize`Channels`,
label: $localize`My channels`
})
links.push({
path: '/my-library/videos',
icon: 'videos' as GlobalIconName,

View File

@ -1,14 +1,17 @@
import { throwError as observableThrowError } from 'rxjs'
import { Injectable } from '@angular/core'
import { Inject, Injectable, LOCALE_ID } from '@angular/core'
import { Router } from '@angular/router'
import { dateToHuman } from '@app/helpers'
import { HttpStatusCode, ResultList } from '@shared/models'
import { DateFormat, dateToHuman } from '@app/helpers'
import { logger } from '@root-helpers/logger'
import { HttpStatusCode, ResultList } from '@shared/models'
@Injectable()
export class RestExtractor {
constructor (private router: Router) { }
constructor (
@Inject(LOCALE_ID) private localeId: string,
private router: Router
) { }
applyToResultListData <T, A, U> (
result: ResultList<T>,
@ -23,13 +26,17 @@ export class RestExtractor {
}
}
convertResultListDateToHuman <T> (result: ResultList<T>, fieldsToConvert: string[] = [ 'createdAt' ]): ResultList<T> {
return this.applyToResultListData(result, this.convertDateToHuman, [ fieldsToConvert ])
convertResultListDateToHuman <T> (
result: ResultList<T>,
fieldsToConvert: string[] = [ 'createdAt' ],
format?: DateFormat
): ResultList<T> {
return this.applyToResultListData(result, this.convertDateToHuman, [ fieldsToConvert, format ])
}
convertDateToHuman (target: any, fieldsToConvert: string[]) {
convertDateToHuman (target: any, fieldsToConvert: string[], format?: DateFormat) {
fieldsToConvert.forEach(field => {
target[field] = dateToHuman(target[field])
target[field] = dateToHuman(this.localeId, new Date(target[field]), format)
})
return target

View File

@ -1,4 +1,4 @@
<my-search-typeahead class="w-100 d-flex justify-content-end"></my-search-typeahead>
<my-search-typeahead class="w-100 d-flex justify-content-center"></my-search-typeahead>
<a class="publish-button" routerLink="/videos/upload">
<my-global-icon iconName="upload" aria-hidden="true"></my-global-icon>

View File

@ -1,14 +1,6 @@
@use '_variables' as *;
@use '_mixins' as *;
my-search-typeahead {
@include margin-right(80px);
@media screen and (max-width: $small-view) {
@include margin-right(15px);
}
}
.publish-button {
@include peertube-button-link;
@include orange-button;

View File

@ -75,6 +75,7 @@ li.suggestion {
#typeahead-container {
font-size: 14px;
margin: 0 10px;
input {
border: 1px solid pvar(--mainBackgroundColor) !important;
@ -83,15 +84,15 @@ li.suggestion {
transition: box-shadow .3s ease, width .2s ease;
}
@media screen and (min-width: $mobile-view) {
@include margin-left(10px);
@media screen and (max-width: $small-view) {
input {
width: 200px;
}
}
@media screen and (max-width: $small-view) {
flex: 1;
@media screen and (max-width: $mobile-view) {
input {
width: 70px;
width: 150px;
}
}

View File

@ -1,8 +1,29 @@
import { DatePipe } from '@angular/common'
const datePipe = new DatePipe('en')
function dateToHuman (date: string) {
return datePipe.transform(date, 'medium')
let datePipe: DatePipe
let intl: Intl.DateTimeFormat
type DateFormat = 'medium' | 'precise'
function dateToHuman (localeId: string, date: Date, format: 'medium' | 'precise' = 'medium') {
if (!datePipe) {
datePipe = new DatePipe(localeId)
}
if (!intl) {
intl = new Intl.DateTimeFormat(localeId, {
hour: 'numeric',
minute: 'numeric',
second: 'numeric',
year: '2-digit',
month: 'numeric',
day: 'numeric',
fractionalSecondDigits: 3
})
}
if (format === 'medium') return datePipe.transform(date, format)
if (format === 'precise') return intl.format(date)
}
function durationToString (duration: number) {
@ -20,6 +41,8 @@ function durationToString (duration: number) {
}
export {
DateFormat,
durationToString,
dateToHuman
}

View File

@ -18,7 +18,7 @@ function sortBy (obj: any[], key1: string, key2?: string) {
})
}
function intoArray (value: any) {
function splitIntoArray (value: any) {
if (!value) return undefined
if (Array.isArray(value)) return value
@ -42,6 +42,6 @@ export {
sortBy,
immutableAssign,
removeElementFromArray,
intoArray,
splitIntoArray,
toBoolean
}

View File

@ -88,7 +88,7 @@
</a>
<a class="menu-link" routerLink="/my-library" routerLinkActive="active" #libraryLink (click)="onActiveLinkScrollToAnchor(libraryLink)">
<my-global-icon iconName="channel" aria-hidden="true"></my-global-icon>
<my-global-icon class="channel-icon" iconName="channel" aria-hidden="true"></my-global-icon>
<ng-container i18n>My library</ng-container>
</a>
@ -111,7 +111,7 @@
<div i18n class="block-title">{{ menuSection.title }}</div>
<a class="menu-link" *ngFor="let link of menuSection.links" [routerLink]="link.path" routerLinkActive="active">
<my-global-icon *ngIf="link.icon" [iconName]="link.icon" aria-hidden="true"></my-global-icon>
<my-global-icon *ngIf="link.icon" [iconName]="link.icon" [ngClass]="link.iconClass" aria-hidden="true"></my-global-icon>
<ng-container>{{ link.shortLabel }}</ng-container>
</a>
</div>

View File

@ -391,26 +391,17 @@ my-actor-avatar {
}
my-global-icon {
&[iconName=playlists] {
position: relative;
top: -1px;
.playlist-icon {
@include margin-right(16px);
height: 24px;
width: 24px;
}
&[iconName=videos] {
position: relative;
right: -1px;
}
&[iconName=channel] {
margin-top: -2px;
}
&[iconName='sign-out'] {
position: relative;
right: -2px;
height: 20px;
width: 20px;
&.channel-icon {
top: -2px;
}
}

View File

@ -9,9 +9,9 @@ export const INSTANCE_NAME_VALIDATOR: BuildFormValidator = {
}
export const INSTANCE_SHORT_DESCRIPTION_VALIDATOR: BuildFormValidator = {
VALIDATORS: [ Validators.max(250) ],
VALIDATORS: [ Validators.maxLength(250) ],
MESSAGES: {
max: $localize`Short description should not be longer than 250 characters.`
maxlength: $localize`Short description should not be longer than 250 characters.`
}
}

View File

@ -48,3 +48,16 @@ export const VIDEO_CHANNEL_SUPPORT_VALIDATOR: BuildFormValidator = {
maxlength: $localize`Support text cannot be more than 1000 characters long.`
}
}
export const VIDEO_CHANNEL_EXTERNAL_URL_VALIDATOR: BuildFormValidator = {
VALIDATORS: [
Validators.required,
Validators.pattern(/^https?:\/\//),
Validators.maxLength(1000)
],
MESSAGES: {
required: $localize`Remote channel url is required.`,
pattern: $localize`External channel URL must begin with "https://" or "http://"`,
maxlength: $localize`External channel URL cannot be more than 1000 characters long`
}
}

View File

@ -21,6 +21,6 @@ export class EmbedMarkupComponent implements CustomMarkupComponent, OnInit {
? buildVideoEmbedLink({ uuid: this.uuid }, environment.originServerUrl)
: buildPlaylistEmbedLink({ uuid: this.uuid }, environment.originServerUrl)
this.el.nativeElement.innerHTML = buildVideoOrPlaylistEmbed(link, this.uuid)
this.el.nativeElement.innerHTML = buildVideoOrPlaylistEmbed({ embedUrl: link, embedTitle: this.uuid })
}
}

View File

@ -1,5 +1,5 @@
import { FormGroup } from '@angular/forms'
import { AbstractControl, FormGroup } from '@angular/forms'
import { wait } from '@root-helpers/utils'
import { BuildFormArgument, BuildFormDefaultValues } from '../form-validators/form-validator.model'
import { FormValidatorService } from './form-validator.service'
@ -44,6 +44,21 @@ export abstract class FormReactive {
} while (this.form.status === 'PENDING')
}
protected markAllAsDirty (controlsArg?: { [ key: string ]: AbstractControl }) {
const controls = controlsArg || this.form.controls
for (const key of Object.keys(controls)) {
const control = controls[key]
if (control instanceof FormGroup) {
this.markAllAsDirty(control.controls)
continue
}
control.markAsDirty()
}
}
protected forceCheck () {
this.onStatusChanged(this.form, this.formErrors, this.validationMessages, false)
}
@ -59,7 +74,8 @@ export abstract class FormReactive {
this.onStatusChanged(
form.controls[field] as FormGroup,
formErrors[field] as FormReactiveErrors,
validationMessages[field] as FormReactiveValidationMessages
validationMessages[field] as FormReactiveValidationMessages,
onlyDirty
)
continue
}

View File

@ -106,6 +106,13 @@
</td>
</tr>
<tr>
<th i18n class="sub-label" scope="row">Channel synchronization with other platforms (YouTube, Vimeo, ...)</th>
<td>
<my-feature-boolean [value]="serverConfig.import.videoChannelSynchronization.enabled"></my-feature-boolean>
</td>
</tr>
<tr>
<th i18n class="label" colspan="2">Search</th>
</tr>

View File

@ -1,6 +1,6 @@
import { SortMeta } from 'primeng/api'
import { from, Observable } from 'rxjs'
import { catchError, concatMap, map, toArray } from 'rxjs/operators'
import { catchError, concatMap, toArray } from 'rxjs/operators'
import { HttpClient, HttpParams } from '@angular/common/http'
import { Injectable } from '@angular/core'
import { RestExtractor, RestPagination, RestService } from '@app/core'
@ -40,10 +40,7 @@ export class InstanceFollowService {
if (actorType) params = params.append('actorType', actorType)
return this.authHttp.get<ResultList<ActorFollow>>(InstanceFollowService.BASE_APPLICATION_URL + '/following', { params })
.pipe(
map(res => this.restExtractor.convertResultListDateToHuman(res)),
catchError(res => this.restExtractor.handleError(res))
)
.pipe(catchError(res => this.restExtractor.handleError(res)))
}
getFollowers (options: {
@ -66,10 +63,7 @@ export class InstanceFollowService {
if (actorType) params = params.append('actorType', actorType)
return this.authHttp.get<ResultList<ActorFollow>>(InstanceFollowService.BASE_APPLICATION_URL + '/followers', { params })
.pipe(
map(res => this.restExtractor.convertResultListDateToHuman(res)),
catchError(res => this.restExtractor.handleError(res))
)
.pipe(catchError(res => this.restExtractor.handleError(res)))
}
follow (hostsOrHandles: string[]) {

View File

@ -18,7 +18,7 @@
<ng-template #templateActionLabel let-action>
<my-global-icon *ngIf="action.iconName" [iconName]="action.iconName" [ngClass]="'icon-' + action.iconName" aria-hidden="true"></my-global-icon>
<div class="d-flex flex-column">
<div class="item-label">
<span i18n>{{ action.label }}</span>
<small class="muted" *ngIf="action.description">{{ action.description }}</small>
</div>

View File

@ -53,6 +53,8 @@
}
.dropdown-menu {
max-width: 75vw;
.dropdown-header {
padding: 0.2rem 1rem;
}
@ -72,3 +74,13 @@
}
}
}
.item-label {
display: flex;
flex-direction: column;
min-width: 1px;
> * {
@include ellipsis;
}
}

View File

@ -3,13 +3,18 @@ import { Component, Input, OnInit } from '@angular/core'
@Component({
selector: 'my-delete-button',
template: `
<my-button icon="delete" className="grey-button" [label]="label" [title]="title" [responsiveLabel]="responsiveLabel"></my-button>
<my-button
icon="delete" className="grey-button"
[disabled]="disabled" [label]="label" [title]="title"
[responsiveLabel]="responsiveLabel"
></my-button>
`
})
export class DeleteButtonComponent implements OnInit {
@Input() label: string
@Input() title: string
@Input() responsiveLabel = false
@Input() disabled: boolean
ngOnInit () {
if (this.label === undefined && !this.title) {

View File

@ -13,3 +13,4 @@ export * from './video'
export * from './video-caption'
export * from './video-channel'
export * from './shared-main.module'
export * from './video-channel-sync'

View File

@ -6,7 +6,6 @@ import { NgModule } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { RouterModule } from '@angular/router'
import {
NgbButtonsModule,
NgbCollapseModule,
NgbDropdownModule,
NgbModalModule,
@ -66,7 +65,6 @@ import { VideoChannelService } from './video-channel'
NgbNavModule,
NgbTooltipModule,
NgbCollapseModule,
NgbButtonsModule,
ClipboardModule,
@ -129,7 +127,6 @@ import { VideoChannelService } from './video-channel'
NgbNavModule,
NgbTooltipModule,
NgbCollapseModule,
NgbButtonsModule,
ClipboardModule,

View File

@ -40,7 +40,6 @@ export class UserNotificationService {
return this.authHttp.get<ResultList<UserNotification>>(UserNotificationService.BASE_NOTIFICATIONS_URL, { params, context })
.pipe(
map(res => this.restExtractor.convertResultListDateToHuman(res)),
map(res => this.restExtractor.applyToResultListData(res, this.formatNotification.bind(this))),
catchError(err => this.restExtractor.handleError(err))
)

View File

@ -0,0 +1 @@
export * from './video-channel-sync.service'

View File

@ -0,0 +1,50 @@
import { SortMeta } from 'primeng/api'
import { catchError, Observable } from 'rxjs'
import { environment } from 'src/environments/environment'
import { HttpClient, HttpParams } from '@angular/common/http'
import { Injectable } from '@angular/core'
import { RestExtractor, RestPagination, RestService } from '@app/core'
import { ResultList } from '@shared/models/common'
import { VideoChannelSync, VideoChannelSyncCreate } from '@shared/models/videos'
import { Account, AccountService } from '../account'
@Injectable({
providedIn: 'root'
})
export class VideoChannelSyncService {
static BASE_VIDEO_CHANNEL_URL = environment.apiUrl + '/api/v1/video-channel-syncs'
constructor (
private authHttp: HttpClient,
private restExtractor: RestExtractor,
private restService: RestService
) { }
listAccountVideoChannelsSyncs (parameters: {
sort: SortMeta
pagination: RestPagination
account: Account
}): Observable<ResultList<VideoChannelSync>> {
const { pagination, sort, account } = parameters
let params = new HttpParams()
params = this.restService.addRestGetParams(params, pagination, sort)
const url = AccountService.BASE_ACCOUNT_URL + account.nameWithHost + '/video-channel-syncs'
return this.authHttp.get<ResultList<VideoChannelSync>>(url, { params })
.pipe(catchError(err => this.restExtractor.handleError(err)))
}
createSync (body: VideoChannelSyncCreate) {
return this.authHttp.post<{ videoChannelSync: VideoChannelSync }>(VideoChannelSyncService.BASE_VIDEO_CHANNEL_URL, body)
.pipe(catchError(err => this.restExtractor.handleError(err)))
}
deleteSync (videoChannelsSyncId: number) {
const url = `${VideoChannelSyncService.BASE_VIDEO_CHANNEL_URL}/${videoChannelsSyncId}`
return this.authHttp.delete(url)
.pipe(catchError(err => this.restExtractor.handleError(err)))
}
}

View File

@ -3,7 +3,14 @@ import { catchError, map, tap } from 'rxjs/operators'
import { HttpClient, HttpParams } from '@angular/common/http'
import { Injectable } from '@angular/core'
import { ComponentPaginationLight, RestExtractor, RestService } from '@app/core'
import { ActorImage, ResultList, VideoChannel as VideoChannelServer, VideoChannelCreate, VideoChannelUpdate } from '@shared/models'
import {
ActorImage,
ResultList,
VideoChannel as VideoChannelServer,
VideoChannelCreate,
VideoChannelUpdate,
VideosImportInChannelCreate
} from '@shared/models'
import { environment } from '../../../../environments/environment'
import { Account } from '../account'
import { AccountService } from '../account/account.service'
@ -95,4 +102,16 @@ export class VideoChannelService {
return this.authHttp.delete(VideoChannelService.BASE_VIDEO_CHANNEL_URL + videoChannel.nameWithHost)
.pipe(catchError(err => this.restExtractor.handleError(err)))
}
importVideos (videoChannelName: string, externalChannelUrl: string, syncId?: number) {
const path = VideoChannelService.BASE_VIDEO_CHANNEL_URL + videoChannelName + '/import-videos'
const body: VideosImportInChannelCreate = {
externalChannelUrl,
videoChannelSyncId: syncId
}
return this.authHttp.post(path, body)
.pipe(catchError(err => this.restExtractor.handleError(err)))
}
}

View File

@ -20,15 +20,15 @@ export class EmbedComponent implements OnInit {
}
ngOnInit () {
const html = buildVideoOrPlaylistEmbed(
decorateVideoLink({
const html = buildVideoOrPlaylistEmbed({
embedUrl: decorateVideoLink({
url: buildVideoEmbedLink(this.video, environment.originServerUrl),
title: false,
warningTitle: false
}),
this.video.name
)
embedTitle: this.video.name
})
this.embedHTML = this.sanitizer.bypassSecurityTrustHtml(html)
}

View File

@ -43,15 +43,27 @@ export class VideoImportService {
.pipe(catchError(res => this.restExtractor.handleError(res)))
}
getMyVideoImports (pagination: RestPagination, sort: SortMeta): Observable<ResultList<VideoImport>> {
getMyVideoImports (pagination: RestPagination, sort: SortMeta, search?: string): Observable<ResultList<VideoImport>> {
let params = new HttpParams()
params = this.restService.addRestGetParams(params, pagination, sort)
if (search) {
const filters = this.restService.parseQueryStringFilter(search, {
videoChannelSyncId: {
prefix: 'videoChannelSyncId:'
},
targetUrl: {
prefix: 'targetUrl:'
}
})
params = this.restService.addObjectParams(params, filters)
}
return this.authHttp
.get<ResultList<VideoImport>>(UserService.BASE_USERS_URL + '/me/videos/imports', { params })
.pipe(
switchMap(res => this.extractVideoImports(res)),
map(res => this.restExtractor.convertResultListDateToHuman(res)),
catchError(err => this.restExtractor.handleError(err))
)
}

View File

@ -1,6 +1,6 @@
import { SortMeta } from 'primeng/api'
import { Observable } from 'rxjs'
import { catchError, map } from 'rxjs/operators'
import { catchError } from 'rxjs/operators'
import { HttpClient, HttpParams } from '@angular/common/http'
import { Injectable } from '@angular/core'
import { RestExtractor, RestPagination, RestService } from '@app/core'
@ -35,10 +35,7 @@ export class VideoOwnershipService {
params = this.restService.addRestGetParams(params, pagination, sort)
return this.authHttp.get<ResultList<VideoChangeOwnership>>(url, { params })
.pipe(
map(res => this.restExtractor.convertResultListDateToHuman(res)),
catchError(res => this.restExtractor.handleError(res))
)
.pipe(catchError(res => this.restExtractor.handleError(res)))
}
acceptOwnership (id: number, input: VideoChangeOwnershipAccept) {

View File

@ -1,8 +1,8 @@
import { AuthUser } from '@app/core'
import { User } from '@app/core/users/user.model'
import { durationToString, prepareIcu, getAbsoluteAPIUrl, getAbsoluteEmbedUrl } from '@app/helpers'
import { durationToString, getAbsoluteAPIUrl, getAbsoluteEmbedUrl, prepareIcu } from '@app/helpers'
import { Actor } from '@app/shared/shared-main/account/actor.model'
import { buildVideoWatchPath } from '@shared/core-utils'
import { buildVideoWatchPath, getAllFiles } from '@shared/core-utils'
import { peertubeTranslate } from '@shared/core-utils/i18n'
import {
ActorImage,
@ -240,6 +240,13 @@ export class Video implements VideoServerModel {
return user && this.isLocal === true && (this.account.name === user.username || user.hasRight(UserRight.SEE_ALL_VIDEOS))
}
canRemoveOneFile (user: AuthUser) {
return this.isLocal &&
user && user.hasRight(UserRight.MANAGE_VIDEO_FILES) &&
this.state.id !== VideoState.TO_TRANSCODE &&
getAllFiles(this).length > 1
}
canRemoveFiles (user: AuthUser) {
return this.isLocal &&
user && user.hasRight(UserRight.MANAGE_VIDEO_FILES) &&

View File

@ -53,7 +53,6 @@ export class BlocklistService {
return this.authHttp.get<ResultList<AccountBlock>>(BlocklistService.BASE_USER_BLOCKLIST_URL + '/accounts', { params })
.pipe(
map(res => this.restExtractor.convertResultListDateToHuman(res)),
map(res => this.restExtractor.applyToResultListData(res, this.formatAccountBlock.bind(this))),
catchError(err => this.restExtractor.handleError(err))
)
@ -84,10 +83,7 @@ export class BlocklistService {
if (search) params = params.append('search', search)
return this.authHttp.get<ResultList<ServerBlock>>(BlocklistService.BASE_USER_BLOCKLIST_URL + '/servers', { params })
.pipe(
map(res => this.restExtractor.convertResultListDateToHuman(res)),
catchError(err => this.restExtractor.handleError(err))
)
.pipe(catchError(err => this.restExtractor.handleError(err)))
}
blockServerByUser (host: string) {
@ -116,7 +112,6 @@ export class BlocklistService {
return this.authHttp.get<ResultList<AccountBlock>>(BlocklistService.BASE_SERVER_BLOCKLIST_URL + '/accounts', { params })
.pipe(
map(res => this.restExtractor.convertResultListDateToHuman(res)),
map(res => this.restExtractor.applyToResultListData(res, this.formatAccountBlock.bind(this))),
catchError(err => this.restExtractor.handleError(err))
)
@ -151,10 +146,7 @@ export class BlocklistService {
if (search) params = params.append('search', search)
return this.authHttp.get<ResultList<ServerBlock>>(BlocklistService.BASE_SERVER_BLOCKLIST_URL + '/servers', { params })
.pipe(
map(res => this.restExtractor.convertResultListDateToHuman(res)),
catchError(err => this.restExtractor.handleError(err))
)
.pipe(catchError(err => this.restExtractor.handleError(err)))
}
blockServerByInstance (host: string) {

View File

@ -1,6 +1,6 @@
import { SortMeta } from 'primeng/api'
import { from as observableFrom, Observable } from 'rxjs'
import { catchError, concatMap, map, toArray } from 'rxjs/operators'
import { catchError, concatMap, toArray } from 'rxjs/operators'
import { HttpClient, HttpParams } from '@angular/common/http'
import { Injectable } from '@angular/core'
import { RestExtractor, RestPagination, RestService } from '@app/core'
@ -47,10 +47,7 @@ export class VideoBlockService {
if (type) params = params.append('type', type.toString())
return this.authHttp.get<ResultList<VideoBlacklist>>(VideoBlockService.BASE_VIDEOS_URL + 'blacklist', { params })
.pipe(
map(res => this.restExtractor.convertResultListDateToHuman(res)),
catchError(res => this.restExtractor.handleError(res))
)
.pipe(catchError(res => this.restExtractor.handleError(res)))
}
unblockVideo (videoIdArgs: number | number[]) {

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