mirror of
https://github.com/DanielnetoDotCom/YouPHPTube
synced 2025-10-05 10:49:36 +02:00
Update
This commit is contained in:
parent
c940cd61ac
commit
59a20745e7
2101 changed files with 1312074 additions and 30292 deletions
369
node_modules/@videojs/http-streaming/src/playlist-controller.js
generated
vendored
369
node_modules/@videojs/http-streaming/src/playlist-controller.js
generated
vendored
|
@ -28,6 +28,7 @@ import logger from './util/logger';
|
|||
import {merge, createTimeRanges} from './util/vjs-compat';
|
||||
import { addMetadata, createMetadataTrackIfNotExists, addDateRangeMetadata } from './util/text-tracks';
|
||||
import ContentSteeringController from './content-steering-controller';
|
||||
import { bufferToHexString } from './util/string.js';
|
||||
|
||||
const ABORT_EARLY_EXCLUSION_SECONDS = 10;
|
||||
|
||||
|
@ -235,12 +236,12 @@ export class PlaylistController extends videojs.EventTarget {
|
|||
this.sourceUpdater_ = new SourceUpdater(this.mediaSource);
|
||||
this.inbandTextTracks_ = {};
|
||||
this.timelineChangeController_ = new TimelineChangeController();
|
||||
this.keyStatusMap_ = new Map();
|
||||
|
||||
const segmentLoaderSettings = {
|
||||
vhs: this.vhs_,
|
||||
parse708captions: options.parse708captions,
|
||||
useDtsForTimestampOffset: options.useDtsForTimestampOffset,
|
||||
calculateTimestampOffsetForEachSegment: options.calculateTimestampOffsetForEachSegment,
|
||||
captionServices,
|
||||
mediaSource: this.mediaSource,
|
||||
currentTime: this.tech_.currentTime.bind(this.tech_),
|
||||
|
@ -404,7 +405,7 @@ export class PlaylistController extends videojs.EventTarget {
|
|||
switchMedia_(playlist, cause, delay) {
|
||||
const oldMedia = this.media();
|
||||
const oldId = oldMedia && (oldMedia.id || oldMedia.uri);
|
||||
const newId = playlist.id || playlist.uri;
|
||||
const newId = playlist && (playlist.id || playlist.uri);
|
||||
|
||||
if (oldId && oldId !== newId) {
|
||||
this.logger_(`switch media ${oldId} -> ${newId} from ${cause}`);
|
||||
|
@ -608,6 +609,8 @@ export class PlaylistController extends videojs.EventTarget {
|
|||
let updatedPlaylist = this.mainPlaylistLoader_.media();
|
||||
|
||||
if (!updatedPlaylist) {
|
||||
// Add content steering listeners on first load and init.
|
||||
this.attachContentSteeringListeners_();
|
||||
this.initContentSteeringController_();
|
||||
// exclude any variants that are not supported by the browser before selecting
|
||||
// an initial media as the playlist selectors do not consider browser support
|
||||
|
@ -671,15 +674,23 @@ export class PlaylistController extends videojs.EventTarget {
|
|||
this.requestOptions_.timeout = requestTimeout;
|
||||
}
|
||||
|
||||
this.mainPlaylistLoader_.load();
|
||||
if (this.sourceType_ === 'dash') {
|
||||
// we don't want to re-request the same hls playlist right after it was changed
|
||||
this.mainPlaylistLoader_.load();
|
||||
}
|
||||
|
||||
// TODO: Create a new event on the PlaylistLoader that signals
|
||||
// that the segments have changed in some way and use that to
|
||||
// update the SegmentLoader instead of doing it twice here and
|
||||
// on `loadedplaylist`
|
||||
this.mainSegmentLoader_.pause();
|
||||
this.mainSegmentLoader_.playlist(media, this.requestOptions_);
|
||||
|
||||
this.mainSegmentLoader_.load();
|
||||
if (this.waitingForFastQualityPlaylistReceived_) {
|
||||
this.runFastQualitySwitch_();
|
||||
} else {
|
||||
this.mainSegmentLoader_.load();
|
||||
}
|
||||
|
||||
this.tech_.trigger({
|
||||
type: 'mediachange',
|
||||
|
@ -741,7 +752,12 @@ export class PlaylistController extends videojs.EventTarget {
|
|||
// that the segments have changed in some way and use that to
|
||||
// update the SegmentLoader instead of doing it twice here and
|
||||
// on `mediachange`
|
||||
this.mainSegmentLoader_.pause();
|
||||
this.mainSegmentLoader_.playlist(updatedPlaylist, this.requestOptions_);
|
||||
if (this.waitingForFastQualityPlaylistReceived_) {
|
||||
this.runFastQualitySwitch_();
|
||||
}
|
||||
|
||||
this.updateDuration(!updatedPlaylist.endList);
|
||||
|
||||
// If the player isn't paused, ensure that the segment loader is running,
|
||||
|
@ -956,39 +972,39 @@ export class PlaylistController extends videojs.EventTarget {
|
|||
|
||||
/**
|
||||
* Re-tune playback quality level for the current player
|
||||
* conditions. This will reset the main segment loader
|
||||
* and the next segment position to the currentTime.
|
||||
* This is good for manual quality changes.
|
||||
* conditions. This method will perform destructive actions like removing
|
||||
* already buffered content in order to readjust the currently active
|
||||
* playlist quickly. This is good for manual quality changes
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
fastQualityChange_(media = this.selectPlaylist()) {
|
||||
if (media === this.mainPlaylistLoader_.media()) {
|
||||
if (media && media === this.mainPlaylistLoader_.media()) {
|
||||
this.logger_('skipping fastQualityChange because new media is same as old');
|
||||
return;
|
||||
}
|
||||
|
||||
this.switchMedia_(media, 'fast-quality');
|
||||
// Reset main segment loader properties and next segment position information.
|
||||
// Don't need to reset audio as it is reset when media changes.
|
||||
// We resetLoaderProperties separately here as we want to fetch init segments if
|
||||
// necessary and ensure we're not in an ended state when we switch playlists.
|
||||
this.resetMainLoaderReplaceSegments();
|
||||
|
||||
// we would like to avoid race condition when we call fastQuality,
|
||||
// reset everything and start loading segments from prev segments instead of new because new playlist is not received yet
|
||||
this.waitingForFastQualityPlaylistReceived_ = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the replaceUntil flag on the main segment soader to the buffered end
|
||||
* and resets the main segment loaders properties.
|
||||
*/
|
||||
resetMainLoaderReplaceSegments() {
|
||||
const buffered = this.tech_.buffered();
|
||||
const bufferedEnd = buffered.end(buffered.length - 1);
|
||||
runFastQualitySwitch_() {
|
||||
this.waitingForFastQualityPlaylistReceived_ = false;
|
||||
// Delete all buffered data to allow an immediate quality switch, then seek to give
|
||||
// the browser a kick to remove any cached frames from the previous rendtion (.04 seconds
|
||||
// ahead was roughly the minimum that will accomplish this across a variety of content
|
||||
// in IE and Edge, but seeking in place is sufficient on all other browsers)
|
||||
// Edge/IE bug: https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/14600375/
|
||||
// Chrome bug: https://bugs.chromium.org/p/chromium/issues/detail?id=651904
|
||||
this.mainSegmentLoader_.pause();
|
||||
this.mainSegmentLoader_.resetEverything(() => {
|
||||
this.tech_.setCurrentTime(this.tech_.currentTime());
|
||||
});
|
||||
|
||||
// Set the replace segments flag to the buffered end, this forces fetchAtBuffer
|
||||
// on the main loader to remain, false after the resetLoader call, until we have
|
||||
// replaced all content buffered ahead of the currentTime.
|
||||
this.mainSegmentLoader_.replaceSegmentsUntil = bufferedEnd;
|
||||
this.mainSegmentLoader_.resetLoaderProperties();
|
||||
this.mainSegmentLoader_.resetLoader();
|
||||
// don't need to reset audio as it is reset when media changes
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -1450,11 +1466,14 @@ export class PlaylistController extends videojs.EventTarget {
|
|||
|
||||
// cancel outstanding requests so we begin buffering at the new
|
||||
// location
|
||||
this.mainSegmentLoader_.pause();
|
||||
this.mainSegmentLoader_.resetEverything();
|
||||
if (this.mediaTypes_.AUDIO.activePlaylistLoader) {
|
||||
this.audioSegmentLoader_.pause();
|
||||
this.audioSegmentLoader_.resetEverything();
|
||||
}
|
||||
if (this.mediaTypes_.SUBTITLES.activePlaylistLoader) {
|
||||
this.subtitleSegmentLoader_.pause();
|
||||
this.subtitleSegmentLoader_.resetEverything();
|
||||
}
|
||||
|
||||
|
@ -1691,6 +1710,7 @@ export class PlaylistController extends videojs.EventTarget {
|
|||
this.mainPlaylistLoader_.dispose();
|
||||
this.mainSegmentLoader_.dispose();
|
||||
this.contentSteeringController_.dispose();
|
||||
this.keyStatusMap_.clear();
|
||||
|
||||
if (this.loadOnPlay_) {
|
||||
this.tech_.off('play', this.loadOnPlay_);
|
||||
|
@ -2104,51 +2124,84 @@ export class PlaylistController extends videojs.EventTarget {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility for getting the pathway or service location from an HLS or DASH playlist.
|
||||
*
|
||||
* @param {Object} playlist for getting pathway from.
|
||||
* @return the pathway attribute of a playlist
|
||||
*/
|
||||
pathwayAttribute_(playlist) {
|
||||
return playlist.attributes['PATHWAY-ID'] || playlist.attributes.serviceLocation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize content steering listeners and apply the tag properties.
|
||||
* Initialize available pathways and apply the tag properties.
|
||||
*/
|
||||
initContentSteeringController_() {
|
||||
const initialMain = this.main();
|
||||
const main = this.main();
|
||||
|
||||
if (!initialMain.contentSteering) {
|
||||
if (!main.contentSteering) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updateSteeringValues = (main) => {
|
||||
for (const playlist of main.playlists) {
|
||||
this.contentSteeringController_.addAvailablePathway(this.pathwayAttribute_(playlist));
|
||||
}
|
||||
|
||||
this.contentSteeringController_.assignTagProperties(main.uri, main.contentSteering);
|
||||
};
|
||||
|
||||
updateSteeringValues(initialMain);
|
||||
|
||||
this.contentSteeringController_.on('content-steering', this.excludeThenChangePathway_.bind(this));
|
||||
|
||||
// We need to ensure we update the content steering values when a new
|
||||
// manifest is loaded in live DASH with content steering.
|
||||
if (this.sourceType_ === 'dash') {
|
||||
this.mainPlaylistLoader_.on('mediaupdatetimeout', () => {
|
||||
this.mainPlaylistLoader_.refreshMedia_(this.mainPlaylistLoader_.media().id);
|
||||
|
||||
// clear past values
|
||||
this.contentSteeringController_.abort();
|
||||
this.contentSteeringController_.clearTTLTimeout_();
|
||||
this.contentSteeringController_.clearAvailablePathways();
|
||||
|
||||
updateSteeringValues(this.main());
|
||||
});
|
||||
for (const playlist of main.playlists) {
|
||||
this.contentSteeringController_.addAvailablePathway(this.pathwayAttribute_(playlist));
|
||||
}
|
||||
this.contentSteeringController_.assignTagProperties(main.uri, main.contentSteering);
|
||||
// request the steering manifest immediately if queryBeforeStart is set.
|
||||
if (this.contentSteeringController_.queryBeforeStart) {
|
||||
// When queryBeforeStart is true, initial request should omit steering parameters.
|
||||
this.contentSteeringController_.requestSteeringManifest(true);
|
||||
return;
|
||||
}
|
||||
// otherwise start content steering after playback starts
|
||||
this.tech_.one('canplay', () => {
|
||||
this.contentSteeringController_.requestSteeringManifest();
|
||||
});
|
||||
}
|
||||
|
||||
// Do this at startup only, after that the steering requests are managed by the Content Steering class.
|
||||
// DASH queryBeforeStart scenarios will be handled by the Content Steering class.
|
||||
if (!this.contentSteeringController_.queryBeforeStart) {
|
||||
this.tech_.one('canplay', () => {
|
||||
this.contentSteeringController_.requestSteeringManifest();
|
||||
/**
|
||||
* Reset the content steering controller and re-init.
|
||||
*/
|
||||
resetContentSteeringController_() {
|
||||
this.contentSteeringController_.clearAvailablePathways();
|
||||
this.contentSteeringController_.dispose();
|
||||
this.initContentSteeringController_();
|
||||
}
|
||||
|
||||
/**
|
||||
* Attaches the listeners for content steering.
|
||||
*/
|
||||
attachContentSteeringListeners_() {
|
||||
this.contentSteeringController_.on('content-steering', this.excludeThenChangePathway_.bind(this));
|
||||
if (this.sourceType_ === 'dash') {
|
||||
this.mainPlaylistLoader_.on('loadedplaylist', () => {
|
||||
const main = this.main();
|
||||
// check if steering tag or pathways changed.
|
||||
const didDashTagChange = this.contentSteeringController_.didDASHTagChange(main.uri, main.contentSteering);
|
||||
const didPathwaysChange = () => {
|
||||
const availablePathways = this.contentSteeringController_.getAvailablePathways();
|
||||
const newPathways = [];
|
||||
|
||||
for (const playlist of main.playlists) {
|
||||
const serviceLocation = playlist.attributes.serviceLocation;
|
||||
|
||||
if (serviceLocation) {
|
||||
newPathways.push(serviceLocation);
|
||||
if (!availablePathways.has(serviceLocation)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
// If we have no new serviceLocations and previously had availablePathways
|
||||
if (!newPathways.length && availablePathways.size) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
if (didDashTagChange || didPathwaysChange()) {
|
||||
this.resetContentSteeringController_();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -2162,6 +2215,9 @@ export class PlaylistController extends videojs.EventTarget {
|
|||
if (!currentPathway) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.handlePathwayClones_();
|
||||
|
||||
const main = this.main();
|
||||
const playlists = main.playlists;
|
||||
const ids = new Set();
|
||||
|
@ -2211,6 +2267,106 @@ export class PlaylistController extends videojs.EventTarget {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add, update, or delete playlists and media groups for
|
||||
* the pathway clones for HLS Content Steering.
|
||||
*
|
||||
* See https://datatracker.ietf.org/doc/draft-pantos-hls-rfc8216bis/
|
||||
*
|
||||
* NOTE: Pathway cloning does not currently support the `PER_VARIANT_URIS` and
|
||||
* `PER_RENDITION_URIS` as we do not handle `STABLE-VARIANT-ID` or
|
||||
* `STABLE-RENDITION-ID` values.
|
||||
*/
|
||||
handlePathwayClones_() {
|
||||
const main = this.main();
|
||||
const playlists = main.playlists;
|
||||
const currentPathwayClones = this.contentSteeringController_.currentPathwayClones;
|
||||
const nextPathwayClones = this.contentSteeringController_.nextPathwayClones;
|
||||
|
||||
const hasClones = (currentPathwayClones && currentPathwayClones.size) || (nextPathwayClones && nextPathwayClones.size);
|
||||
|
||||
if (!hasClones) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [id, clone] of currentPathwayClones.entries()) {
|
||||
const newClone = nextPathwayClones.get(id);
|
||||
|
||||
// Delete the old pathway clone.
|
||||
if (!newClone) {
|
||||
this.mainPlaylistLoader_.updateOrDeleteClone(clone);
|
||||
this.contentSteeringController_.excludePathway(id);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [id, clone] of nextPathwayClones.entries()) {
|
||||
const oldClone = currentPathwayClones.get(id);
|
||||
|
||||
// Create a new pathway if it is a new pathway clone object.
|
||||
if (!oldClone) {
|
||||
const playlistsToClone = playlists.filter(p => {
|
||||
return p.attributes['PATHWAY-ID'] === clone['BASE-ID'];
|
||||
});
|
||||
|
||||
playlistsToClone.forEach((p) => {
|
||||
this.mainPlaylistLoader_.addClonePathway(clone, p);
|
||||
});
|
||||
|
||||
this.contentSteeringController_.addAvailablePathway(id);
|
||||
continue;
|
||||
}
|
||||
|
||||
// There have not been changes to the pathway clone object, so skip.
|
||||
if (this.equalPathwayClones_(oldClone, clone)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Update a preexisting cloned pathway.
|
||||
// True is set for the update flag.
|
||||
this.mainPlaylistLoader_.updateOrDeleteClone(clone, true);
|
||||
this.contentSteeringController_.addAvailablePathway(id);
|
||||
}
|
||||
|
||||
// Deep copy contents of next to current pathways.
|
||||
this.contentSteeringController_.currentPathwayClones = new Map(JSON.parse(JSON.stringify([...nextPathwayClones])));
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether two pathway clone objects are equivalent.
|
||||
*
|
||||
* @param {Object} a The first pathway clone object.
|
||||
* @param {Object} b The second pathway clone object.
|
||||
* @return {boolean} True if the pathway clone objects are equal, false otherwise.
|
||||
*/
|
||||
equalPathwayClones_(a, b) {
|
||||
if (
|
||||
a['BASE-ID'] !== b['BASE-ID'] ||
|
||||
a.ID !== b.ID ||
|
||||
a['URI-REPLACEMENT'].HOST !== b['URI-REPLACEMENT'].HOST
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const aParams = a['URI-REPLACEMENT'].PARAMS;
|
||||
const bParams = b['URI-REPLACEMENT'].PARAMS;
|
||||
|
||||
// We need to iterate through both lists of params because one could be
|
||||
// missing a parameter that the other has.
|
||||
for (const p in aParams) {
|
||||
if (aParams[p] !== bParams[p]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
for (const p in bParams) {
|
||||
if (aParams[p] !== bParams[p]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes the current playlists for audio, video and subtitles after a new pathway
|
||||
* is chosen from content steering.
|
||||
|
@ -2227,4 +2383,97 @@ export class PlaylistController extends videojs.EventTarget {
|
|||
|
||||
this.switchMedia_(nextPlaylist, 'content-steering');
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterates through playlists and check their keyId set and compare with the
|
||||
* keyStatusMap, only enable playlists that have a usable key. If the playlist
|
||||
* has no keyId leave it enabled by default.
|
||||
*/
|
||||
excludeNonUsablePlaylistsByKeyId_() {
|
||||
if (!this.mainPlaylistLoader_ || !this.mainPlaylistLoader_.main) {
|
||||
return;
|
||||
}
|
||||
|
||||
let nonUsableKeyStatusCount = 0;
|
||||
const NON_USABLE = 'non-usable';
|
||||
|
||||
this.mainPlaylistLoader_.main.playlists.forEach((playlist) => {
|
||||
const keyIdSet = this.mainPlaylistLoader_.getKeyIdSet(playlist);
|
||||
|
||||
// If the playlist doesn't have keyIDs lets not exclude it.
|
||||
if (!keyIdSet || !keyIdSet.size) {
|
||||
return;
|
||||
}
|
||||
keyIdSet.forEach((key) => {
|
||||
const USABLE = 'usable';
|
||||
const hasUsableKeyStatus = this.keyStatusMap_.has(key) && this.keyStatusMap_.get(key) === USABLE;
|
||||
const nonUsableExclusion = playlist.lastExcludeReason_ === NON_USABLE && playlist.excludeUntil === Infinity;
|
||||
|
||||
if (!hasUsableKeyStatus) {
|
||||
// Only exclude playlists that haven't already been excluded as non-usable.
|
||||
if (playlist.excludeUntil !== Infinity && playlist.lastExcludeReason_ !== NON_USABLE) {
|
||||
playlist.excludeUntil = Infinity;
|
||||
playlist.lastExcludeReason_ = NON_USABLE;
|
||||
this.logger_(`excluding playlist ${playlist.id} because the key ID ${key} doesn't exist in the keyStatusMap or is not ${USABLE}`);
|
||||
}
|
||||
// count all nonUsableKeyStatus
|
||||
nonUsableKeyStatusCount++;
|
||||
} else if (hasUsableKeyStatus && nonUsableExclusion) {
|
||||
delete playlist.excludeUntil;
|
||||
delete playlist.lastExcludeReason_;
|
||||
this.logger_(`enabling playlist ${playlist.id} because key ID ${key} is ${USABLE}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// If for whatever reason every playlist has a non usable key status. Lets try re-including the SD renditions as a failsafe.
|
||||
if (nonUsableKeyStatusCount >= this.mainPlaylistLoader_.main.playlists.length) {
|
||||
this.mainPlaylistLoader_.main.playlists.forEach((playlist) => {
|
||||
const isNonHD = playlist && playlist.attributes && playlist.attributes.RESOLUTION && playlist.attributes.RESOLUTION.height < 720;
|
||||
const excludedForNonUsableKey = playlist.excludeUntil === Infinity && playlist.lastExcludeReason_ === NON_USABLE;
|
||||
|
||||
if (isNonHD && excludedForNonUsableKey) {
|
||||
// Only delete the excludeUntil so we don't try and re-exclude these playlists.
|
||||
delete playlist.excludeUntil;
|
||||
videojs.log.warn(`enabling non-HD playlist ${playlist.id} because all playlists were excluded due to ${NON_USABLE} key IDs`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a keystatus to the keystatus map, tries to convert to string if necessary.
|
||||
*
|
||||
* @param {any} keyId the keyId to add a status for
|
||||
* @param {string} status the status of the keyId
|
||||
*/
|
||||
addKeyStatus_(keyId, status) {
|
||||
const isString = typeof keyId === 'string';
|
||||
const keyIdHexString = isString ? keyId : bufferToHexString(keyId);
|
||||
const formattedKeyIdString = keyIdHexString.slice(0, 32).toLowerCase();
|
||||
|
||||
this.logger_(`KeyStatus '${status}' with key ID ${formattedKeyIdString} added to the keyStatusMap`);
|
||||
this.keyStatusMap_.set(formattedKeyIdString, status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function for adding key status to the keyStatusMap and filtering usable encrypted playlists.
|
||||
*
|
||||
* @param {any} keyId the keyId from the keystatuschange event
|
||||
* @param {string} status the key status string
|
||||
*/
|
||||
updatePlaylistByKeyStatus(keyId, status) {
|
||||
this.addKeyStatus_(keyId, status);
|
||||
if (!this.waitingForFastQualityPlaylistReceived_) {
|
||||
this.excludeNonUsableThenChangePlaylist_();
|
||||
}
|
||||
// Listen to loadedplaylist with a single listener and check for new contentProtection elements when a playlist is updated.
|
||||
this.mainPlaylistLoader_.off('loadedplaylist', this.excludeNonUsableThenChangePlaylist_.bind(this));
|
||||
this.mainPlaylistLoader_.on('loadedplaylist', this.excludeNonUsableThenChangePlaylist_.bind(this));
|
||||
}
|
||||
|
||||
excludeNonUsableThenChangePlaylist_() {
|
||||
this.excludeNonUsablePlaylistsByKeyId_();
|
||||
this.fastQualityChange_();
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue