mirror of
https://github.com/Ombi-app/Ombi.git
synced 2025-07-14 01:02:57 -07:00
!wip started the searching process
This commit is contained in:
parent
26d620cf01
commit
9156673f88
20 changed files with 151 additions and 106 deletions
|
@ -22,7 +22,7 @@ namespace Ombi.Api.Lidarr
|
|||
|
||||
public Task<List<LidarrProfile>> GetProfiles(string apiKey, string baseUrl)
|
||||
{
|
||||
var request = new Request($"{ApiVersion}/profile", baseUrl, HttpMethod.Get);
|
||||
var request = new Request($"{ApiVersion}/qualityprofile", baseUrl, HttpMethod.Get);
|
||||
|
||||
AddHeaders(request, apiKey);
|
||||
return Api.Request<List<LidarrProfile>>(request);
|
||||
|
|
|
@ -2,12 +2,6 @@
|
|||
|
||||
namespace Ombi.Api.Lidarr.Models
|
||||
{
|
||||
public class Cutoff
|
||||
{
|
||||
public int id { get; set; }
|
||||
public string name { get; set; }
|
||||
}
|
||||
|
||||
public class Quality
|
||||
{
|
||||
public int id { get; set; }
|
||||
|
@ -23,9 +17,7 @@ namespace Ombi.Api.Lidarr.Models
|
|||
public class LidarrProfile
|
||||
{
|
||||
public string name { get; set; }
|
||||
public Cutoff cutoff { get; set; }
|
||||
public List<Item> items { get; set; }
|
||||
public string language { get; set; }
|
||||
public int id { get; set; }
|
||||
}
|
||||
}
|
|
@ -7,6 +7,6 @@
|
|||
public int trackCount { get; set; }
|
||||
public int totalTrackCount { get; set; }
|
||||
public int sizeOnDisk { get; set; }
|
||||
public int percentOfTracks { get; set; }
|
||||
public decimal percentOfTracks { get; set; }
|
||||
}
|
||||
}
|
16
src/Ombi.Core/Engine/Interfaces/IMusicSearchEngine.cs
Normal file
16
src/Ombi.Core/Engine/Interfaces/IMusicSearchEngine.cs
Normal file
|
@ -0,0 +1,16 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Ombi.Api.Lidarr.Models;
|
||||
using Ombi.Core.Models.Search;
|
||||
|
||||
namespace Ombi.Core.Engine
|
||||
{
|
||||
public interface IMusicSearchEngine
|
||||
{
|
||||
Task<ArtistResult> GetAlbumArtist(string foreignArtistId);
|
||||
Task<ArtistResult> GetArtist(int artistId);
|
||||
Task<ArtistResult> GetArtistAlbums(string foreignArtistId);
|
||||
Task<IEnumerable<AlbumLookup>> SearchAlbum(string search);
|
||||
Task<IEnumerable<SearchArtistViewModel>> SearchArtist(string search);
|
||||
}
|
||||
}
|
|
@ -24,7 +24,7 @@ using Ombi.Store.Repository;
|
|||
|
||||
namespace Ombi.Core.Engine
|
||||
{
|
||||
public class MusicSearchEngine : BaseMediaEngine
|
||||
public class MusicSearchEngine : BaseMediaEngine, IMusicSearchEngine
|
||||
{
|
||||
public MusicSearchEngine(IPrincipal identity, IRequestServiceMain service, ILidarrApi lidarrApi, IMapper mapper,
|
||||
ILogger<MusicSearchEngine> logger, IRuleEvaluator r, OmbiUserManager um, ICacheService mem, ISettingsService<OmbiSettings> s, IRepository<RequestSubscription> sub,
|
||||
|
@ -60,12 +60,42 @@ namespace Ombi.Core.Engine
|
|||
/// </summary>
|
||||
/// <param name="search">The search.</param>
|
||||
/// <returns></returns>
|
||||
public async Task<IEnumerable<ArtistLookup>> SearchArtist(string search)
|
||||
public async Task<IEnumerable<SearchArtistViewModel>> SearchArtist(string search)
|
||||
{
|
||||
var settings = await GetSettings();
|
||||
var result = await _lidarrApi.ArtistLookup(search, settings.ApiKey, settings.FullUri);
|
||||
|
||||
return result;
|
||||
var vm = new List<SearchArtistViewModel>();
|
||||
foreach (var r in result)
|
||||
{
|
||||
vm.Add(MapIntoArtistVm(r));
|
||||
}
|
||||
|
||||
return vm;
|
||||
}
|
||||
|
||||
private SearchArtistViewModel MapIntoArtistVm(ArtistLookup a)
|
||||
{
|
||||
var vm = new SearchArtistViewModel
|
||||
{
|
||||
ArtistName = a.artistName,
|
||||
ArtistType = a.artistType,
|
||||
Banner = a.images?.FirstOrDefault(x => x.coverType.Equals("banner"))?.url,
|
||||
Logo = a.images?.FirstOrDefault(x => x.coverType.Equals("logo"))?.url,
|
||||
CleanName = a.cleanName,
|
||||
Disambiguation = a.disambiguation,
|
||||
ForignArtistId = a.foreignArtistId,
|
||||
Links = a.links,
|
||||
Overview = a.overview,
|
||||
};
|
||||
|
||||
var poster = a.images?.FirstOrDefault(x => x.coverType.Equals("poaster"));
|
||||
if (poster == null)
|
||||
{
|
||||
vm.Poster = a.remotePoster;
|
||||
}
|
||||
|
||||
return vm;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
@ -73,7 +103,7 @@ namespace Ombi.Core.Engine
|
|||
/// </summary>
|
||||
/// <param name="artistId"></param>
|
||||
/// <returns></returns>
|
||||
public async Task GetArtistAlbums(string foreignArtistId)
|
||||
public async Task<ArtistResult> GetArtistAlbums(string foreignArtistId)
|
||||
{
|
||||
var settings = await GetSettings();
|
||||
return await _lidarrApi.GetArtistByForignId(foreignArtistId, settings.ApiKey, settings.FullUri);
|
||||
|
@ -82,11 +112,12 @@ namespace Ombi.Core.Engine
|
|||
/// <summary>
|
||||
/// Returns the artist that produced the album
|
||||
/// </summary>
|
||||
/// <param name="albumId"></param>
|
||||
/// <param name="foreignArtistId"></param>
|
||||
/// <returns></returns>
|
||||
public async Task GetAlbumArtist(string foreignArtistId)
|
||||
public async Task<ArtistResult> GetAlbumArtist(string foreignArtistId)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
var settings = await GetSettings();
|
||||
return await _lidarrApi.GetArtistByForignId(foreignArtistId, settings.ApiKey, settings.FullUri);
|
||||
}
|
||||
|
||||
public async Task<ArtistResult> GetArtist(int artistId)
|
||||
|
|
21
src/Ombi.Core/Models/Search/SearchArtistViewModel.cs
Normal file
21
src/Ombi.Core/Models/Search/SearchArtistViewModel.cs
Normal file
|
@ -0,0 +1,21 @@
|
|||
using Ombi.Api.Lidarr.Models;
|
||||
|
||||
namespace Ombi.Core.Models.Search
|
||||
{
|
||||
public class SearchArtistViewModel
|
||||
{
|
||||
public string ArtistName { get; set; }
|
||||
public string ForignArtistId { get; set; }
|
||||
public string Overview { get; set; }
|
||||
public string Disambiguation { get; set; }
|
||||
public string Banner { get; set; }
|
||||
public string Poster { get; set; }
|
||||
public string Logo { get; set; }
|
||||
public bool Monitored { get; set; }
|
||||
public bool Available { get; set; }
|
||||
public bool Requested { get; set; }
|
||||
public string ArtistType { get; set; }
|
||||
public string CleanName { get; set; }
|
||||
public Link[] Links { get; set; } // Couldn't be bothered to map it
|
||||
}
|
||||
}
|
|
@ -83,6 +83,7 @@ namespace Ombi.DependencyInjection
|
|||
services.AddTransient<IUserStatsEngine, UserStatsEngine>();
|
||||
services.AddTransient<IMovieSender, MovieSender>();
|
||||
services.AddTransient<IRecentlyAddedEngine, RecentlyAddedEngine>();
|
||||
services.AddTransient<IMusicSearchEngine, MusicSearchEngine>();
|
||||
services.AddTransient<ITvSender, TvSender>();
|
||||
services.AddTransient<IMassEmailSender, MassEmailSender>();
|
||||
services.AddTransient<IPlexOAuthManager, PlexOAuthManager>();
|
||||
|
|
|
@ -4,13 +4,13 @@ import { TranslateService } from "@ngx-translate/core";
|
|||
import { Subject } from "rxjs";
|
||||
import { debounceTime, distinctUntilChanged } from "rxjs/operators";
|
||||
|
||||
import { AuthService } from "../auth/auth.service";
|
||||
import { IIssueCategory, IRequestEngineResult, ISearchMovieResult } from "../interfaces";
|
||||
import { NotificationService, RequestService, SearchService } from "../services";
|
||||
import { AuthService } from "../../auth/auth.service";
|
||||
import { IIssueCategory, IRequestEngineResult, ISearchMovieResult } from "../../interfaces";
|
||||
import { NotificationService, RequestService, SearchService } from "../../services";
|
||||
|
||||
@Component({
|
||||
selector: "music-search",
|
||||
templateUrl: "./music.component.html",
|
||||
templateUrl: "./musicsearch.component.html",
|
||||
})
|
||||
export class MusicSearchComponent implements OnInit {
|
||||
|
||||
|
@ -19,6 +19,7 @@ export class MusicSearchComponent implements OnInit {
|
|||
public movieResults: ISearchMovieResult[];
|
||||
public result: IRequestEngineResult;
|
||||
public searchApplied = false;
|
||||
public searchArtist: boolean;
|
||||
|
||||
@Input() public issueCategories: IIssueCategory[];
|
||||
@Input() public issuesEnabled: boolean;
|
||||
|
@ -44,11 +45,20 @@ export class MusicSearchComponent implements OnInit {
|
|||
this.clearResults();
|
||||
return;
|
||||
}
|
||||
this.searchService.searchMusic(this.searchText)
|
||||
if(this.searchArtist) {
|
||||
this.searchService.searchArtist(this.searchText)
|
||||
.subscribe(x => {
|
||||
this.movieResults = x;
|
||||
this.searchApplied = true;
|
||||
});
|
||||
} else {
|
||||
this.searchService.searchAlbum(this.searchText)
|
||||
.subscribe(x => {
|
||||
this.movieResults = x;
|
||||
this.searchApplied = true;
|
||||
});
|
||||
}
|
||||
|
||||
});
|
||||
this.defaultPoster = "../../../images/default_movie_poster.png";
|
||||
const base = this.platformLocation.getBaseHrefFromDOM();
|
||||
|
@ -65,7 +75,6 @@ export class MusicSearchComponent implements OnInit {
|
|||
result: false,
|
||||
errorMessage: "",
|
||||
};
|
||||
this.popularMovies();
|
||||
}
|
||||
|
||||
public search(text: any) {
|
||||
|
@ -111,77 +120,6 @@ export class MusicSearchComponent implements OnInit {
|
|||
}
|
||||
}
|
||||
|
||||
public popularMovies() {
|
||||
this.clearResults();
|
||||
this.searchService.popularMovies()
|
||||
.subscribe(x => {
|
||||
this.movieResults = x;
|
||||
});
|
||||
}
|
||||
public nowPlayingMovies() {
|
||||
this.clearResults();
|
||||
this.searchService.nowPlayingMovies()
|
||||
.subscribe(x => {
|
||||
this.movieResults = x;
|
||||
});
|
||||
}
|
||||
public topRatedMovies() {
|
||||
this.clearResults();
|
||||
this.searchService.topRatedMovies()
|
||||
.subscribe(x => {
|
||||
this.movieResults = x;
|
||||
});
|
||||
}
|
||||
public upcomingMovies() {
|
||||
this.clearResults();
|
||||
this.searchService.upcomingMovies()
|
||||
.subscribe(x => {
|
||||
this.movieResults = x;
|
||||
});
|
||||
}
|
||||
|
||||
public reportIssue(catId: IIssueCategory, req: ISearchMovieResult) {
|
||||
this.issueRequestId = req.id;
|
||||
this.issueRequestTitle = req.title + `(${req.releaseDate.getFullYear})`;
|
||||
this.issueCategorySelected = catId;
|
||||
this.issuesBarVisible = true;
|
||||
this.issueProviderId = req.id.toString();
|
||||
}
|
||||
|
||||
public similarMovies(theMovieDbId: number) {
|
||||
this.clearResults();
|
||||
this.searchService.similarMovies(theMovieDbId)
|
||||
.subscribe(x => {
|
||||
this.movieResults = x;
|
||||
this.getExtraInfo();
|
||||
});
|
||||
}
|
||||
|
||||
public subscribe(r: ISearchMovieResult) {
|
||||
r.subscribed = true;
|
||||
this.requestService.subscribeToMovie(r.requestId)
|
||||
.subscribe(x => {
|
||||
this.notificationService.success("Subscribed To Movie!");
|
||||
});
|
||||
}
|
||||
|
||||
public unSubscribe(r: ISearchMovieResult) {
|
||||
r.subscribed = false;
|
||||
this.requestService.unSubscribeToMovie(r.requestId)
|
||||
.subscribe(x => {
|
||||
this.notificationService.success("Unsubscribed Movie!");
|
||||
});
|
||||
}
|
||||
|
||||
private updateItem(key: ISearchMovieResult, updated: ISearchMovieResult) {
|
||||
const index = this.movieResults.indexOf(key, 0);
|
||||
if (index > -1) {
|
||||
const copy = { ...this.movieResults[index] };
|
||||
this.movieResults[index] = updated;
|
||||
this.movieResults[index].background = copy.background;
|
||||
this.movieResults[index].posterPath = copy.posterPath;
|
||||
}
|
||||
}
|
||||
private clearResults() {
|
||||
this.movieResults = [];
|
||||
this.searchApplied = false;
|
||||
|
|
|
@ -13,6 +13,9 @@
|
|||
<li role="presentation">
|
||||
<a id="tvTabButton" href="#TvShowTab" aria-controls="profile" role="tab" data-toggle="tab" (click)="selectTvTab()"><i class="fa fa-television"></i> {{ 'Search.TvTab' | translate }}</a>
|
||||
</li>
|
||||
<li role="presentation" *ngIf="musicEnabled">
|
||||
<a id="tvTabButton" href="#MusicTab" aria-controls="profile" role="tab" data-toggle="tab" (click)="selectMusicTab()"><i class="fa fa-music"></i> {{ 'Search.MusicTab' | translate }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- Tab panes -->
|
||||
|
@ -25,6 +28,9 @@
|
|||
<div [hidden]="!showTv">
|
||||
<tv-search [issueCategories]="issueCategories" [issuesEnabled]="issuesEnabled"></tv-search>
|
||||
</div>
|
||||
<div [hidden]="!showMusic">
|
||||
<music-search></music-search>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
|
|
@ -9,8 +9,10 @@ import { IssuesService, SettingsService } from "../services";
|
|||
export class SearchComponent implements OnInit {
|
||||
public showTv: boolean;
|
||||
public showMovie: boolean;
|
||||
public showMusic: boolean;
|
||||
public issueCategories: IIssueCategory[];
|
||||
public issuesEnabled = false;
|
||||
public musicEnabled: boolean;
|
||||
|
||||
constructor(private issuesService: IssuesService,
|
||||
private settingsService: SettingsService) {
|
||||
|
@ -18,8 +20,10 @@ export class SearchComponent implements OnInit {
|
|||
}
|
||||
|
||||
public ngOnInit() {
|
||||
this.settingsService.getLidarr().subscribe(x => this.musicEnabled = x.enabled);
|
||||
this.showMovie = true;
|
||||
this.showTv = false;
|
||||
this.showMusic = false;
|
||||
this.issuesService.getCategories().subscribe(x => this.issueCategories = x);
|
||||
this.settingsService.getIssueSettings().subscribe(x => this.issuesEnabled = x.enabled);
|
||||
}
|
||||
|
@ -27,10 +31,17 @@ export class SearchComponent implements OnInit {
|
|||
public selectMovieTab() {
|
||||
this.showMovie = true;
|
||||
this.showTv = false;
|
||||
this.showMusic = false;
|
||||
}
|
||||
|
||||
public selectTvTab() {
|
||||
this.showMovie = false;
|
||||
this.showTv = true;
|
||||
this.showMusic = false;
|
||||
}
|
||||
public selectMusicTab() {
|
||||
this.showMovie = false;
|
||||
this.showTv = false;
|
||||
this.showMusic = true;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,6 +7,7 @@ import { NgbModule } from "@ng-bootstrap/ng-bootstrap";
|
|||
|
||||
import { MovieSearchComponent } from "./moviesearch.component";
|
||||
import { MovieSearchGridComponent } from "./moviesearchgrid.component";
|
||||
import { MusicSearchComponent } from "./music/musicsearch.component";
|
||||
import { SearchComponent } from "./search.component";
|
||||
import { SeriesInformationComponent } from "./seriesinformation.component";
|
||||
import { TvSearchComponent } from "./tvsearch.component";
|
||||
|
@ -41,6 +42,7 @@ const routes: Routes = [
|
|||
TvSearchComponent,
|
||||
SeriesInformationComponent,
|
||||
MovieSearchGridComponent,
|
||||
MusicSearchComponent,
|
||||
],
|
||||
exports: [
|
||||
RouterModule,
|
||||
|
|
|
@ -69,7 +69,10 @@ export class SearchService extends ServiceHelpers {
|
|||
return this.http.get<ISearchTvResult[]>(`${this.url}/Tv/trending`, {headers: this.headers});
|
||||
}
|
||||
// Music
|
||||
public searchMusic(searchTerm: string): Observable<ISearchMovieResult[]> {
|
||||
return this.http.get<ISearchMovieResult[]>(`${this.url}/Music/` + searchTerm);
|
||||
public searchArtist(searchTerm: string): Observable<any> {
|
||||
return this.http.get<any>(`${this.url}/Music/Artist/` + searchTerm);
|
||||
}
|
||||
public searchAlbum(searchTerm: string): Observable<any> {
|
||||
return this.http.get<any>(`${this.url}/Music/Album/` + searchTerm);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,8 +3,7 @@ import { FormBuilder, FormGroup, Validators } from "@angular/forms";
|
|||
|
||||
import { ILidarrSettings, IMinimumAvailability, IRadarrProfile, IRadarrRootFolder } from "../../interfaces";
|
||||
import { IRadarrSettings } from "../../interfaces";
|
||||
import { RadarrService } from "../../services";
|
||||
import { TesterService } from "../../services";
|
||||
import { LidarrService, TesterService } from "../../services";
|
||||
import { NotificationService } from "../../services";
|
||||
import { SettingsService } from "../../services";
|
||||
|
||||
|
@ -22,7 +21,7 @@ export class LidarrComponent implements OnInit {
|
|||
public form: FormGroup;
|
||||
|
||||
constructor(private settingsService: SettingsService,
|
||||
private radarrService: RadarrService,
|
||||
private lidarrService: LidarrService,
|
||||
private notificationService: NotificationService,
|
||||
private fb: FormBuilder,
|
||||
private testerService: TesterService) { }
|
||||
|
@ -59,7 +58,7 @@ export class LidarrComponent implements OnInit {
|
|||
|
||||
public getProfiles(form: FormGroup) {
|
||||
this.profilesRunning = true;
|
||||
this.radarrService.getQualityProfiles(form.value).subscribe(x => {
|
||||
this.lidarrService.getQualityProfiles(form.value).subscribe(x => {
|
||||
this.qualities = x;
|
||||
this.qualities.unshift({ name: "Please Select", id: -1 });
|
||||
|
||||
|
@ -70,7 +69,7 @@ export class LidarrComponent implements OnInit {
|
|||
|
||||
public getRootFolders(form: FormGroup) {
|
||||
this.rootFoldersRunning = true;
|
||||
this.radarrService.getRootFolders(form.value).subscribe(x => {
|
||||
this.lidarrService.getRootFolders(form.value).subscribe(x => {
|
||||
this.rootFolders = x;
|
||||
this.rootFolders.unshift({ path: "Please Select", id: -1 });
|
||||
|
||||
|
|
|
@ -50,7 +50,7 @@
|
|||
|
||||
<li class="dropdown" [routerLinkActive]="['active']">
|
||||
<a href="ignore($event)" class="dropdown-toggle" data-toggle="dropdown">
|
||||
<i class="fa fa-film" aria-hidden="true"></i> Music <span class="caret"></span>
|
||||
<i class="fa fa-music" aria-hidden="true"></i> Music <span class="caret"></span>
|
||||
</a>
|
||||
<ul class="dropdown-menu">
|
||||
<li [routerLinkActive]="['active']"><a [routerLink]="['/Settings/Lidarr']">Lidarr</a></li>
|
||||
|
|
|
@ -4,7 +4,7 @@ using System.Threading.Tasks;
|
|||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
using Ombi.Api.Lidarr.Models;
|
||||
using Ombi.Core;
|
||||
using Ombi.Core.Engine;
|
||||
using Ombi.Core.Engine.Interfaces;
|
||||
|
@ -18,16 +18,18 @@ namespace Ombi.Controllers
|
|||
[Produces("application/json")]
|
||||
public class SearchController : Controller
|
||||
{
|
||||
public SearchController(IMovieEngine movie, ITvSearchEngine tvEngine, ILogger<SearchController> logger)
|
||||
public SearchController(IMovieEngine movie, ITvSearchEngine tvEngine, ILogger<SearchController> logger, IMusicSearchEngine music)
|
||||
{
|
||||
MovieEngine = movie;
|
||||
TvEngine = tvEngine;
|
||||
Logger = logger;
|
||||
MusicEngine = music;
|
||||
}
|
||||
private ILogger<SearchController> Logger { get; }
|
||||
|
||||
private IMovieEngine MovieEngine { get; }
|
||||
private ITvSearchEngine TvEngine { get; }
|
||||
private IMusicSearchEngine MusicEngine { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Searches for a movie.
|
||||
|
@ -182,5 +184,27 @@ namespace Ombi.Controllers
|
|||
{
|
||||
return await TvEngine.Trending();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the artist information we searched for
|
||||
/// </summary>
|
||||
/// <remarks>We use Lidarr as the Provider</remarks>
|
||||
/// <returns></returns>
|
||||
[HttpGet("music/artist/{searchTerm}")]
|
||||
public async Task<IEnumerable<SearchArtistViewModel>> SearchArtist(string searchTerm)
|
||||
{
|
||||
return await MusicEngine.SearchArtist(searchTerm);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the album information we searched for
|
||||
/// </summary>
|
||||
/// <remarks>We use Lidarr as the Provider</remarks>
|
||||
/// <returns></returns>
|
||||
[HttpGet("music/album/{searchTerm}")]
|
||||
public async Task<IEnumerable<AlbumLookup>> SearchAlbum(string searchTerm)
|
||||
{
|
||||
return await MusicEngine.SearchAlbum(searchTerm);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -333,7 +333,7 @@ namespace Ombi.Controllers
|
|||
/// </summary>
|
||||
/// <param name="settings">The settings.</param>
|
||||
/// <returns></returns>
|
||||
[HttpPost("sonarr")]
|
||||
[HttpPost("lidarr")]
|
||||
public async Task<bool> LidarrSettings([FromBody]LidarrSettings settings)
|
||||
{
|
||||
return await Save(settings);
|
||||
|
|
|
@ -78,6 +78,7 @@
|
|||
"Want to watch something that is not currently available? No problem, just search for it below and request it!",
|
||||
"MoviesTab": "Movies",
|
||||
"TvTab": "TV Shows",
|
||||
"MusicTab":"Music",
|
||||
"Suggestions": "Suggestions",
|
||||
"NoResults": "Sorry, we didn't find any results!",
|
||||
"DigitalDate": "Digital Release: {{date}}",
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue