mirror of
https://github.com/Ombi-app/Ombi.git
synced 2025-07-14 01:02:57 -07:00
Added advanced options onto the TV page to stop mophawka bitchin'
This commit is contained in:
parent
26b2a574be
commit
9e0986ce9f
23 changed files with 189 additions and 22 deletions
|
@ -24,6 +24,6 @@ namespace Ombi.Core.Engine.Interfaces
|
|||
Task<RequestsViewModel<MovieRequests>> GetUnavailableRequests(int count, int position, string sortProperty,
|
||||
string sortOrder);
|
||||
Task<RequestsViewModel<MovieRequests>> GetRequestsByStatus(int count, int position, string sortProperty, string sortOrder, RequestStatus status);
|
||||
Task<RequestEngineResult> UpdateAdvancedOptions(MovieAdvancedOptions options);
|
||||
Task<RequestEngineResult> UpdateAdvancedOptions(MediaAdvancedOptions options);
|
||||
}
|
||||
}
|
|
@ -26,5 +26,6 @@ namespace Ombi.Core.Engine.Interfaces
|
|||
Task UpdateRootPath(int requestId, int rootPath);
|
||||
Task<RequestsViewModel<ChildRequests>> GetRequests(int count, int position, string sortProperty, string sortOrder);
|
||||
Task<RequestsViewModel<ChildRequests>> GetRequests(int count, int position, string sortProperty, string sortOrder, RequestStatus status);
|
||||
Task<RequestEngineResult> UpdateAdvancedOptions(MediaAdvancedOptions options);
|
||||
}
|
||||
}
|
|
@ -347,7 +347,7 @@ namespace Ombi.Core.Engine
|
|||
}
|
||||
|
||||
|
||||
public async Task<RequestEngineResult> UpdateAdvancedOptions(MovieAdvancedOptions options)
|
||||
public async Task<RequestEngineResult> UpdateAdvancedOptions(MediaAdvancedOptions options)
|
||||
{
|
||||
var request = await MovieRepository.Find(options.RequestId);
|
||||
if (request == null)
|
||||
|
|
|
@ -852,5 +852,28 @@ namespace Ombi.Core.Engine
|
|||
NextRequest = DateTime.SpecifyKind(oldestRequestedAt.AddDays(7), DateTimeKind.Utc),
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<RequestEngineResult> UpdateAdvancedOptions(MediaAdvancedOptions options)
|
||||
{
|
||||
var request = await TvRepository.Find(options.RequestId);
|
||||
if (request == null)
|
||||
{
|
||||
return new RequestEngineResult
|
||||
{
|
||||
Result = false,
|
||||
ErrorMessage = "Request does not exist"
|
||||
};
|
||||
}
|
||||
|
||||
request.QualityOverride = options.QualityOverride;
|
||||
request.RootFolder = options.RootPathOverride;
|
||||
|
||||
await TvRepository.Update(request);
|
||||
|
||||
return new RequestEngineResult
|
||||
{
|
||||
Result = true
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
namespace Ombi.Core.Models.Requests
|
||||
{
|
||||
public class MovieAdvancedOptions
|
||||
public class MediaAdvancedOptions
|
||||
{
|
||||
public int RequestId { get; set; }
|
||||
public int RootPathOverride { get; set; }
|
||||
|
|
|
@ -6,7 +6,7 @@ using Ombi.Store.Entities.Requests;
|
|||
|
||||
namespace Ombi.Store.Repository.Requests
|
||||
{
|
||||
public interface ITvRequestRepository
|
||||
public interface ITvRequestRepository : IRepository<TvRequests>
|
||||
{
|
||||
OmbiContext Db { get; }
|
||||
Task<TvRequests> Add(TvRequests request);
|
||||
|
|
|
@ -11,13 +11,14 @@ import { DenyDialogComponent } from "./shared/deny-dialog/deny-dialog.component"
|
|||
import { TvRequestsPanelComponent } from "./tv/panels/tv-requests/tv-requests-panel.component";
|
||||
import { MovieAdminPanelComponent } from "./movie/panels/movie-admin-panel/movie-admin-panel.component";
|
||||
import { MovieAdvancedOptionsComponent } from "./movie/panels/movie-advanced-options/movie-advanced-options.component";
|
||||
import { SearchService, RequestService, RadarrService, IssuesService } from "../../services";
|
||||
import { SearchService, RequestService, RadarrService, IssuesService, SonarrService } from "../../services";
|
||||
import { RequestServiceV2 } from "../../services/requestV2.service";
|
||||
import { NewIssueComponent } from "./shared/new-issue/new-issue.component";
|
||||
import { ArtistDetailsComponent } from "./artist/artist-details.component";
|
||||
import { ArtistInformationPanel } from "./artist/panels/artist-information-panel/artist-information-panel.component";
|
||||
import { ArtistReleasePanel } from "./artist/panels/artist-release-panel/artist-release-panel.component";
|
||||
import { IssuesPanelComponent } from "./shared/issues-panel/issues-panel.component";
|
||||
import { TvAdminPanelComponent } from "./tv/panels/tv-admin-panel/tv-admin-panel.component";
|
||||
|
||||
export const components: any[] = [
|
||||
MovieDetailsComponent,
|
||||
|
@ -38,6 +39,7 @@ export const components: any[] = [
|
|||
ArtistInformationPanel,
|
||||
ArtistReleasePanel,
|
||||
IssuesPanelComponent,
|
||||
TvAdminPanelComponent,
|
||||
];
|
||||
|
||||
export const entryComponents: any[] = [
|
||||
|
@ -53,4 +55,5 @@ export const providers: any[] = [
|
|||
RadarrService,
|
||||
RequestServiceV2,
|
||||
IssuesService,
|
||||
SonarrService,
|
||||
];
|
||||
|
|
|
@ -83,7 +83,7 @@
|
|||
|
||||
<mat-card class="mat-elevation-z8">
|
||||
<mat-card-content class="medium-font">
|
||||
<movie-information-panel [movie]="movie" [request]="movieRequest"></movie-information-panel>
|
||||
<movie-information-panel [movie]="movie" [request]="movieRequest" [advancedOptions]="showAdvanced"></movie-information-panel>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
Advanced Options</h1>
|
||||
<div mat-dialog-content>
|
||||
<mat-form-field>
|
||||
<mat-label>{{'MediaDetails.RadarrProfile' | translate }}</mat-label>
|
||||
<mat-label>{{'MediaDetails.QualityProfilesSelect' | translate }}</mat-label>
|
||||
<mat-select [(value)]="data.profileId">
|
||||
<mat-option *ngFor="let profile of data.profiles" value="{{profile.id}}">{{profile.name}}</mat-option>
|
||||
</mat-select>
|
||||
|
@ -11,7 +11,7 @@
|
|||
</div>
|
||||
<div mat-dialog-content>
|
||||
<mat-form-field>
|
||||
<mat-label>{{'MediaDetails.RadarrFolder' | translate }}</mat-label>
|
||||
<mat-label>{{'MediaDetails.RootFolderSelect' | translate }}</mat-label>
|
||||
<mat-select [(value)]="data.rootFolderId">
|
||||
<mat-option *ngFor="let profile of data.rootFolders" value="{{profile.id}}">{{profile.path}}</mat-option>
|
||||
</mat-select>
|
||||
|
|
|
@ -11,4 +11,5 @@ import { IAdvancedData, IMovieRequests } from "../../../../interfaces";
|
|||
export class MovieInformationPanelComponent {
|
||||
@Input() public movie: ISearchMovieResultV2;
|
||||
@Input() public request: IMovieRequests;
|
||||
@Input() public advancedOptions: boolean;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
<div *ngIf="tv && sonarrEnabled" class="text-center">
|
||||
<button mat-raised-button color="warn" class="text-center" (click)="openAdvancedOptions();">{{'MediaDetails.AdvancedOptions' | translate }}</button>
|
||||
</div>
|
|
@ -0,0 +1,83 @@
|
|||
import { Component, Input, OnInit, EventEmitter, Output } from "@angular/core";
|
||||
import { RadarrService, SonarrService } from "../../../../../services";
|
||||
import { IRadarrProfile, IRadarrRootFolder, IAdvancedData, ITvRequests, ISonarrProfile, ISonarrRootFolder } from "../../../../../interfaces";
|
||||
import { MatDialog } from "@angular/material/dialog";
|
||||
|
||||
import { RequestServiceV2 } from "../../../../../services/requestV2.service";
|
||||
import { MovieAdvancedOptionsComponent } from "../../../movie/panels/movie-advanced-options/movie-advanced-options.component";
|
||||
|
||||
@Component({
|
||||
templateUrl: "./tv-admin-panel.component.html",
|
||||
selector: "tv-admin-panel",
|
||||
})
|
||||
export class TvAdminPanelComponent implements OnInit {
|
||||
|
||||
@Input() public tv: ITvRequests;
|
||||
@Output() public advancedOptionsChanged = new EventEmitter<IAdvancedData>();
|
||||
@Output() public sonarrEnabledChange = new EventEmitter<boolean>();
|
||||
|
||||
public sonarrEnabled: boolean;
|
||||
public radarrProfiles: IRadarrProfile[];
|
||||
public selectedRadarrProfile: IRadarrProfile;
|
||||
public radarrRootFolders: IRadarrRootFolder[];
|
||||
public selectRadarrRootFolders: IRadarrRootFolder;
|
||||
|
||||
|
||||
public sonarrProfiles: ISonarrProfile[];
|
||||
public sonarrRootFolders: ISonarrRootFolder[];
|
||||
|
||||
constructor(private sonarrService: SonarrService, private requestService: RequestServiceV2, private dialog: MatDialog) { }
|
||||
|
||||
public async ngOnInit() {
|
||||
this.sonarrEnabled = await this.sonarrService.isEnabled();
|
||||
if (this.sonarrEnabled) {
|
||||
this.sonarrService.getQualityProfilesWithoutSettings()
|
||||
.subscribe(x => {
|
||||
this.sonarrProfiles = x;
|
||||
this.setQualityOverrides();
|
||||
});
|
||||
this.sonarrService.getRootFoldersWithoutSettings()
|
||||
.subscribe(x => {
|
||||
this.sonarrRootFolders = x;
|
||||
this.setRootFolderOverrides();
|
||||
});
|
||||
}
|
||||
|
||||
this.sonarrEnabledChange.emit(this.sonarrEnabled);
|
||||
}
|
||||
|
||||
public async openAdvancedOptions() {
|
||||
const dialog = this.dialog.open(MovieAdvancedOptionsComponent, { width: "700px", data: <IAdvancedData>{ profiles: this.sonarrProfiles, rootFolders: this.sonarrRootFolders }, panelClass: 'modal-panel' })
|
||||
await dialog.afterClosed().subscribe(async result => {
|
||||
if (result) {
|
||||
// get the name and ids
|
||||
result.rootFolder = result.rootFolders.filter(f => f.id === +result.rootFolderId)[0];
|
||||
result.profile = result.profiles.filter(f => f.id === +result.profileId)[0];
|
||||
await this.requestService.updateTvAdvancedOptions({ qualityOverride: result.profileId, rootPathOverride: result.rootFolderId, requestId: this.tv.id }).toPromise();
|
||||
this.advancedOptionsChanged.emit(result);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private setQualityOverrides(): void {
|
||||
if (this.sonarrProfiles) {
|
||||
const profile = this.sonarrProfiles.filter((p) => {
|
||||
return p.id === this.tv.qualityOverride;
|
||||
});
|
||||
if (profile.length > 0) {
|
||||
this.tv.qualityOverrideTitle = profile[0].name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private setRootFolderOverrides(): void {
|
||||
if (this.sonarrRootFolders) {
|
||||
const path = this.sonarrRootFolders.filter((folder) => {
|
||||
return folder.id === this.tv.rootFolder;
|
||||
});
|
||||
if (path.length > 0) {
|
||||
this.tv.rootPathOverrideTitle = path[0].path;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -11,6 +11,14 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div *ngIf="advancedOptions && request.rootPathOverrideTitle">
|
||||
<strong>{{'MediaDetails.RootFolderOverride' | translate }}</strong>
|
||||
<div>{{request.rootPathOverrideTitle}}</div>
|
||||
</div>
|
||||
<div *ngIf="advancedOptions && request.qualityOverrideTitle">
|
||||
<strong>{{'MediaDetails.QualityOverride' | translate }}</strong>
|
||||
<div>{{request.qualityOverrideTitle}}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<strong>{{'MediaDetails.Runtime' | translate }}:</strong>
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { Component, ViewEncapsulation, Input, OnInit } from "@angular/core";
|
||||
import { ITvRequests } from "../../../../../interfaces";
|
||||
import { ISearchTvResultV2 } from "../../../../../interfaces/ISearchTvResultV2";
|
||||
|
||||
@Component({
|
||||
|
@ -9,6 +10,8 @@ import { ISearchTvResultV2 } from "../../../../../interfaces/ISearchTvResultV2";
|
|||
})
|
||||
export class TvInformationPanelComponent implements OnInit {
|
||||
@Input() public tv: ISearchTvResultV2;
|
||||
@Input() public request: ITvRequests;
|
||||
@Input() public advancedOptions: boolean;
|
||||
|
||||
public seasonCount: number;
|
||||
public totalEpisodes: number = 0;
|
||||
|
|
|
@ -48,10 +48,16 @@
|
|||
|
||||
<div class="row">
|
||||
<div class="col-12 col-md-2">
|
||||
<mat-card class="mat-elevation-z8 spacing-below" *ngIf="isAdmin && showRequest" [ngStyle]="{'display': showAdvanced ? '' : 'none' }">
|
||||
<mat-card-content class="medium-font">
|
||||
<tv-admin-panel [tv]="showRequest" (sonarrEnabledChange)="showAdvanced = $event" (advancedOptionsChanged)="setAdvancedOptions($event)">
|
||||
</tv-admin-panel>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
|
||||
<mat-card class="mat-elevation-z8">
|
||||
<mat-card-content class="medium-font">
|
||||
<tv-information-panel [tv]="tv"></tv-information-panel>
|
||||
<tv-information-panel [tv]="tv" [request]="showRequest" [advancedOptions]="showAdvanced"></tv-information-panel>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
import { Component, ViewEncapsulation, OnInit } from "@angular/core";
|
||||
import { ImageService, SearchV2Service, MessageService, RequestService } from "../../../services";
|
||||
import { ImageService, SearchV2Service, MessageService, RequestService, SonarrService } from "../../../services";
|
||||
import { ActivatedRoute } from "@angular/router";
|
||||
import { DomSanitizer } from "@angular/platform-browser";
|
||||
import { ISearchTvResultV2 } from "../../../interfaces/ISearchTvResultV2";
|
||||
import { MatDialog } from "@angular/material/dialog";
|
||||
import { YoutubeTrailerComponent } from "../shared/youtube-trailer.component";
|
||||
import { EpisodeRequestComponent } from "../../../shared/episode-request/episode-request.component";
|
||||
import { IChildRequests, RequestType } from "../../../interfaces";
|
||||
import { IAdvancedData, IChildRequests, ISonarrProfile, ISonarrRootFolder, ITvRequests, RequestType } from "../../../interfaces";
|
||||
import { AuthService } from "../../../auth/auth.service";
|
||||
import { NewIssueComponent } from "../shared/new-issue/new-issue.component";
|
||||
|
||||
|
@ -19,15 +19,18 @@ export class TvDetailsComponent implements OnInit {
|
|||
|
||||
public tv: ISearchTvResultV2;
|
||||
public tvRequest: IChildRequests[];
|
||||
public showRequest: ITvRequests;
|
||||
public fromSearch: boolean;
|
||||
public isAdmin: boolean;
|
||||
public advancedOptions: IAdvancedData;
|
||||
public showAdvanced: boolean; // Set on the UI
|
||||
|
||||
private tvdbId: number;
|
||||
|
||||
constructor(private searchService: SearchV2Service, private route: ActivatedRoute,
|
||||
private sanitizer: DomSanitizer, private imageService: ImageService,
|
||||
public dialog: MatDialog, public messageService: MessageService, private requestService: RequestService,
|
||||
private auth: AuthService) {
|
||||
private auth: AuthService, private sonarrService: SonarrService) {
|
||||
this.route.params.subscribe((params: any) => {
|
||||
this.tvdbId = params.tvdbId;
|
||||
this.fromSearch = params.search;
|
||||
|
@ -50,6 +53,7 @@ export class TvDetailsComponent implements OnInit {
|
|||
|
||||
if (this.tv.requestId) {
|
||||
this.tvRequest = await this.requestService.getChildRequests(this.tv.requestId).toPromise();
|
||||
this.showRequest = this.tvRequest.length > 0 ? this.tvRequest[0].parentRequest : undefined;
|
||||
}
|
||||
|
||||
const tvBanner = await this.imageService.getTvBanner(this.tvdbId).toPromise();
|
||||
|
@ -68,7 +72,6 @@ export class TvDetailsComponent implements OnInit {
|
|||
}
|
||||
|
||||
public openDialog() {
|
||||
debugger;
|
||||
let trailerLink = this.tv.trailer;
|
||||
trailerLink = trailerLink.split('?v=')[1];
|
||||
|
||||
|
@ -77,4 +80,15 @@ export class TvDetailsComponent implements OnInit {
|
|||
data: trailerLink
|
||||
});
|
||||
}
|
||||
|
||||
public setAdvancedOptions(data: IAdvancedData) {
|
||||
this.advancedOptions = data;
|
||||
console.log(this.advancedOptions);
|
||||
if (data.rootFolderId) {
|
||||
this.showRequest.qualityOverrideTitle = data.rootFolders.filter(x => x.id == data.rootFolderId)[0].path;
|
||||
}
|
||||
if (data.profileId) {
|
||||
this.showRequest.rootPathOverrideTitle = data.profiles.filter(x => x.id == data.profileId)[0].name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -31,4 +31,8 @@ export class SonarrService extends ServiceHelpers {
|
|||
public getV3LanguageProfiles(settings: ISonarrSettings): Observable<ILanguageProfiles[]> {
|
||||
return this.http.post<ILanguageProfiles[]>(`${this.url}/v3/languageprofiles/`, JSON.stringify(settings), {headers: this.headers});
|
||||
}
|
||||
|
||||
public isEnabled(): Promise<boolean> {
|
||||
return this.http.get<boolean>(`${this.url}/enabled/`, { headers: this.headers }).toPromise();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,7 +4,7 @@ import { Injectable, Inject } from "@angular/core";
|
|||
import { HttpClient } from "@angular/common/http";
|
||||
import { Observable } from "rxjs";
|
||||
import { ServiceHelpers } from "./service.helpers";
|
||||
import { IRequestsViewModel, IMovieRequests, IChildRequests, IMovieAdvancedOptions, IRequestEngineResult, IAlbumRequest } from "../interfaces";
|
||||
import { IRequestsViewModel, IMovieRequests, IChildRequests, IMovieAdvancedOptions as IMediaAdvancedOptions, IRequestEngineResult, IAlbumRequest } from "../interfaces";
|
||||
|
||||
|
||||
@Injectable()
|
||||
|
@ -53,10 +53,14 @@ export class RequestServiceV2 extends ServiceHelpers {
|
|||
return this.http.get<IRequestsViewModel<IChildRequests>>(`${this.url}tv/denied/${count}/${position}/${sortProperty}/${order}`, {headers: this.headers});
|
||||
}
|
||||
|
||||
public updateMovieAdvancedOptions(options: IMovieAdvancedOptions): Observable<IRequestEngineResult> {
|
||||
public updateMovieAdvancedOptions(options: IMediaAdvancedOptions): Observable<IRequestEngineResult> {
|
||||
return this.http.post<IRequestEngineResult>(`${this.url}movie/advancedoptions`, options, {headers: this.headers});
|
||||
}
|
||||
|
||||
public updateTvAdvancedOptions(options: IMediaAdvancedOptions): Observable<IRequestEngineResult> {
|
||||
return this.http.post<IRequestEngineResult>(`${this.url}tv/advancedoptions`, options, {headers: this.headers});
|
||||
}
|
||||
|
||||
public getMovieUnavailableRequests(count: number, position: number, sortProperty: string , order: string): Observable<IRequestsViewModel<IMovieRequests>> {
|
||||
return this.http.get<IRequestsViewModel<IMovieRequests>>(`${this.url}movie/unavailable/${count}/${position}/${sortProperty}/${order}`, {headers: this.headers});
|
||||
}
|
||||
|
|
|
@ -143,5 +143,13 @@ namespace Ombi.Controllers.V1.External
|
|||
return await SonarrV3Api.LanguageProfiles(settings.ApiKey, settings.FullUri);
|
||||
}
|
||||
|
||||
[HttpGet("enabled")]
|
||||
[PowerUser]
|
||||
public async Task<bool> Enabled()
|
||||
{
|
||||
var settings = await SonarrSettings.GetSettingsAsync();
|
||||
return settings.Enabled;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
|
@ -128,11 +128,17 @@ namespace Ombi.Controllers.V2
|
|||
}
|
||||
|
||||
[HttpPost("movie/advancedoptions")]
|
||||
public async Task<RequestEngineResult> UpdateAdvancedOptions([FromBody] MovieAdvancedOptions options)
|
||||
public async Task<RequestEngineResult> UpdateAdvancedOptions([FromBody] MediaAdvancedOptions options)
|
||||
{
|
||||
return await _movieRequestEngine.UpdateAdvancedOptions(options);
|
||||
}
|
||||
|
||||
[HttpPost("tv/advancedoptions")]
|
||||
public async Task<RequestEngineResult> UpdateTvAdvancedOptions([FromBody] MediaAdvancedOptions options)
|
||||
{
|
||||
return await _tvRequestEngine.UpdateAdvancedOptions(options);
|
||||
}
|
||||
|
||||
[HttpGet("albums/available/{count:int}/{position:int}/{sort}/{sortOrder}")]
|
||||
public async Task<RequestsViewModel<AlbumRequest>> GetAvailableAlbumRequests(int count, int position, string sort, string sortOrder)
|
||||
{
|
||||
|
|
|
@ -22,7 +22,7 @@
|
|||
},
|
||||
"Ombi": {
|
||||
"commandName": "Project",
|
||||
"commandLineArgs": "--host http://localhost:3577",
|
||||
"commandLineArgs": "--host http://localhost:3577 --baseurl /ombi",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
},
|
||||
|
|
|
@ -164,7 +164,7 @@ namespace Ombi
|
|||
var baseUrl = appConfig.Get(ConfigurationTypes.BaseUrl);
|
||||
if (baseUrl != null)
|
||||
{
|
||||
if (baseUrl.Value.HasValue())
|
||||
if (baseUrl.Value.HasValue() && settings.BaseUrl != baseUrl.Value)
|
||||
{
|
||||
settings.BaseUrl = baseUrl.Value;
|
||||
ombiService.SaveSettings(settings);
|
||||
|
|
|
@ -238,8 +238,8 @@
|
|||
"ViewCollection":"View Collection",
|
||||
"NotEnoughInfo": "Unfortunately there is not enough information about this show yet!",
|
||||
"AdvancedOptions":"Advanced Options",
|
||||
"RadarrProfile":"Radarr Quality Profile",
|
||||
"RadarrFolder":"Radarr Root Folder",
|
||||
"QualityProfilesSelect":"Select A Quality Profile",
|
||||
"RootFolderSelect":"Select A Root Folder",
|
||||
"Status":"Status",
|
||||
"Availability":"Availability",
|
||||
"RequestStatus":"Request Status",
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue