Merge pull request #4121 from Ombi-app/bug/v1api-fix

Fixed the v1 API, added tests around that API to ensure we keep backw…
pull/4123/head v4.0.1290
Jamie 3 years ago committed by GitHub
commit 606fd8c919
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -1,6 +1,7 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Ombi.Core.Models.Search;
using Ombi.Core.Models.Search.V2;
namespace Ombi.Core
@ -10,5 +11,8 @@ namespace Ombi.Core
Task<SearchFullInfoTvShowViewModel> GetShowInformation(string tvdbid, CancellationToken token);
Task<SearchFullInfoTvShowViewModel> GetShowByRequest(int requestId, CancellationToken token);
Task<IEnumerable<StreamingData>> GetStreamInformation(int movieDbId, CancellationToken cancellationToken);
Task<IEnumerable<SearchTvShowViewModel>> Popular(int currentlyLoaded, int amountToLoad);
Task<IEnumerable<SearchTvShowViewModel>> Anticipated(int currentlyLoaded, int amountToLoad);
Task<IEnumerable<SearchTvShowViewModel>> Trending(int currentlyLoaded, int amountToLoad);
}
}

@ -22,6 +22,7 @@ using Ombi.Store.Entities;
using Ombi.Api.TheMovieDb;
using Ombi.Api.TheMovieDb.Models;
using System.Threading;
using TraktSharp.Entities;
namespace Ombi.Core.Engine
{
@ -51,18 +52,18 @@ namespace Ombi.Core.Engine
public async Task<IEnumerable<SearchTvShowViewModel>> Search(string searchTerm)
{
var searchResult = await _theMovieDbApi.SearchTv(searchTerm);
var searchResult = await TvMazeApi.Search(searchTerm);
if (searchResult != null)
{
var retVal = new List<SearchTvShowViewModel>();
foreach (var result in searchResult)
foreach (var tvMazeSearch in searchResult)
{
//if (tvMazeSearch.show.externals == null || !(tvMazeSearch.show.externals?.thetvdb.HasValue ?? false))
//{
// continue;
//}
var mappedResult = await ProcessResult(result, false);
if (tvMazeSearch.show.externals == null || !(tvMazeSearch.show.externals?.thetvdb.HasValue ?? false))
{
continue;
}
var mappedResult = await ProcessResult(tvMazeSearch, false);
if (mappedResult == null)
{
continue;
@ -74,61 +75,56 @@ namespace Ombi.Core.Engine
return null;
}
public async Task<SearchTvShowViewModel> GetShowInformation(string theMovieDbId, CancellationToken token)
public async Task<SearchTvShowViewModel> GetShowInformation(string tvdbid, CancellationToken token)
{
var show = await Cache.GetOrAdd(nameof(GetShowInformation) + theMovieDbId,
async () => await _theMovieDbApi.GetTVInfo(theMovieDbId), DateTime.Now.AddHours(12));
var show = await Cache.GetOrAdd(nameof(GetShowInformation) + tvdbid,
async () => await TvMazeApi.ShowLookupByTheTvDbId(int.Parse(tvdbid)), DateTime.Now.AddHours(12));
if (show == null)
{
// We don't have enough information
return null;
}
//var episodes = await Cache.GetOrAdd("TvMazeEpisodeLookup" + show.id,
// async () => await TvMazeApi.EpisodeLookup(show.id), DateTime.Now.AddHours(12));
//if (episodes == null || !episodes.Any())
//{
// // We don't have enough information
// return null;
//}
var episodes = await Cache.GetOrAdd("TvMazeEpisodeLookup" + show.id,
async () => await TvMazeApi.EpisodeLookup(show.id), DateTime.Now.AddHours(12));
if (episodes == null || !episodes.Any())
{
// We don't have enough information
return null;
}
var mapped = Mapper.Map<SearchTvShowViewModel>(show);
foreach(var tvSeason in show.seasons)
foreach (var e in episodes)
{
var seasonEpisodes = (await _theMovieDbApi.GetSeasonEpisodes(show.id, tvSeason.season_number, token));
foreach (var episode in seasonEpisodes.episodes)
var season = mapped.SeasonRequests.FirstOrDefault(x => x.SeasonNumber == e.season);
if (season == null)
{
var season = mapped.SeasonRequests.FirstOrDefault(x => x.SeasonNumber == episode.season_number);
if (season == null)
var newSeason = new SeasonRequests
{
var newSeason = new SeasonRequests
{
SeasonNumber = episode.season_number,
Episodes = new List<EpisodeRequests>()
};
newSeason.Episodes.Add(new EpisodeRequests
{
//Url = episode...ToHttpsUrl(),
Title = episode.name,
AirDate = episode.air_date.HasValue() ? DateTime.Parse(episode.air_date) : DateTime.MinValue,
EpisodeNumber = episode.episode_number,
});
mapped.SeasonRequests.Add(newSeason);
}
else
SeasonNumber = e.season,
Episodes = new List<EpisodeRequests>()
};
newSeason.Episodes.Add(new EpisodeRequests
{
// We already have the season, so just add the episode
season.Episodes.Add(new EpisodeRequests
{
//Url = e.url.ToHttpsUrl(),
Title = episode.name,
AirDate = episode.air_date.HasValue() ? DateTime.Parse(episode.air_date) : DateTime.MinValue,
EpisodeNumber = episode.episode_number,
});
}
Url = e.url.ToHttpsUrl(),
Title = e.name,
AirDate = e.airstamp.HasValue() ? DateTime.Parse(e.airstamp) : DateTime.MinValue,
EpisodeNumber = e.number,
});
mapped.SeasonRequests.Add(newSeason);
}
else
{
// We already have the season, so just add the episode
season.Episodes.Add(new EpisodeRequests
{
Url = e.url.ToHttpsUrl(),
Title = e.name,
AirDate = e.airstamp.HasValue() ? DateTime.Parse(e.airstamp) : DateTime.MinValue,
EpisodeNumber = e.number,
});
}
}
@ -147,11 +143,11 @@ namespace Ombi.Core.Engine
var langCode = await DefaultLanguageCode(null);
var pages = PaginationHelper.GetNextPages(currentlyLoaded, amountToLoad, ResultLimit);
var results = new List<MovieDbSearchResult>();
var results = new List<TraktShow>();
foreach (var pagesToLoad in pages)
{
var apiResult = await Cache.GetOrAdd(nameof(Popular) + langCode + pagesToLoad.Page,
async () => await _theMovieDbApi.PopularTv(langCode, pagesToLoad.Page), DateTime.Now.AddHours(12));
async () => await TraktApi.GetPopularShows(pagesToLoad.Page, ResultLimit), DateTime.Now.AddHours(12));
results.AddRange(apiResult.Skip(pagesToLoad.Skip).Take(pagesToLoad.Take));
}
@ -172,11 +168,11 @@ namespace Ombi.Core.Engine
var langCode = await DefaultLanguageCode(null);
var pages = PaginationHelper.GetNextPages(currentlyLoaded, amountToLoad, ResultLimit);
var results = new List<MovieDbSearchResult>();
var results = new List<TraktShow>();
foreach (var pagesToLoad in pages)
{
var apiResult = await Cache.GetOrAdd(nameof(Anticipated) + langCode + pagesToLoad.Page,
async () => await _theMovieDbApi.UpcomingTv(langCode, pagesToLoad.Page), DateTime.Now.AddHours(12));
async () => await TraktApi.GetAnticipatedShows(pagesToLoad.Page, ResultLimit), DateTime.Now.AddHours(12));
results.AddRange(apiResult.Skip(pagesToLoad.Skip).Take(pagesToLoad.Take));
}
var processed = ProcessResults(results);
@ -196,11 +192,11 @@ namespace Ombi.Core.Engine
var langCode = await DefaultLanguageCode(null);
var pages = PaginationHelper.GetNextPages(currentlyLoaded, amountToLoad, ResultLimit);
var results = new List<MovieDbSearchResult>();
var results = new List<TraktShow>();
foreach (var pagesToLoad in pages)
{
var apiResult = await Cache.GetOrAdd(nameof(Trending) + langCode + pagesToLoad.Page,
async () => await _theMovieDbApi.TopRatedTv(langCode, pagesToLoad.Page), DateTime.Now.AddHours(12));
async () => await TraktApi.GetTrendingShows(pagesToLoad.Page, ResultLimit), DateTime.Now.AddHours(12));
results.AddRange(apiResult.Skip(pagesToLoad.Skip).Take(pagesToLoad.Take));
}
var processed = ProcessResults(results);

@ -21,6 +21,7 @@ using TraktSharp.Entities;
using Microsoft.EntityFrameworkCore;
using System.Threading;
using Ombi.Api.TheMovieDb;
using Ombi.Api.TheMovieDb.Models;
namespace Ombi.Core.Engine.V2
{
@ -30,16 +31,18 @@ namespace Ombi.Core.Engine.V2
private readonly IMapper _mapper;
private readonly ITraktApi _traktApi;
private readonly IMovieDbApi _movieApi;
private readonly ISettingsService<CustomizationSettings> _customization;
public TvSearchEngineV2(IPrincipal identity, IRequestServiceMain service, ITvMazeApi tvMaze, IMapper mapper,
ITraktApi trakt, IRuleEvaluator r, OmbiUserManager um, ICacheService memCache, ISettingsService<OmbiSettings> s,
IRepository<RequestSubscription> sub, IMovieDbApi movieApi)
IRepository<RequestSubscription> sub, IMovieDbApi movieApi, ISettingsService<CustomizationSettings> customization)
: base(identity, service, r, um, memCache, s, sub)
{
_tvMaze = tvMaze;
_mapper = mapper;
_traktApi = trakt;
_movieApi = movieApi;
_customization = customization;
}
@ -104,6 +107,56 @@ namespace Ombi.Core.Engine.V2
return await ProcessResult(mapped);
}
public async Task<IEnumerable<SearchTvShowViewModel>> Popular(int currentlyLoaded, int amountToLoad)
{
var langCode = await DefaultLanguageCode(null);
var pages = PaginationHelper.GetNextPages(currentlyLoaded, amountToLoad, ResultLimit);
var results = new List<MovieDbSearchResult>();
foreach (var pagesToLoad in pages)
{
var apiResult = await Cache.GetOrAdd(nameof(Popular) + langCode + pagesToLoad.Page,
async () => await _movieApi.PopularTv(langCode, pagesToLoad.Page), DateTime.Now.AddHours(12));
results.AddRange(apiResult.Skip(pagesToLoad.Skip).Take(pagesToLoad.Take));
}
var processed = ProcessResults(results);
return await processed;
}
public async Task<IEnumerable<SearchTvShowViewModel>> Anticipated(int currentlyLoaded, int amountToLoad)
{
var langCode = await DefaultLanguageCode(null);
var pages = PaginationHelper.GetNextPages(currentlyLoaded, amountToLoad, ResultLimit);
var results = new List<MovieDbSearchResult>();
foreach (var pagesToLoad in pages)
{
var apiResult = await Cache.GetOrAdd(nameof(Anticipated) + langCode + pagesToLoad.Page,
async () => await _movieApi.UpcomingTv(langCode, pagesToLoad.Page), DateTime.Now.AddHours(12));
results.AddRange(apiResult.Skip(pagesToLoad.Skip).Take(pagesToLoad.Take));
}
var processed = ProcessResults(results);
return await processed;
}
public async Task<IEnumerable<SearchTvShowViewModel>> Trending(int currentlyLoaded, int amountToLoad)
{
var langCode = await DefaultLanguageCode(null);
var pages = PaginationHelper.GetNextPages(currentlyLoaded, amountToLoad, ResultLimit);
var results = new List<MovieDbSearchResult>();
foreach (var pagesToLoad in pages)
{
var apiResult = await Cache.GetOrAdd(nameof(Trending) + langCode + pagesToLoad.Page,
async () => await _movieApi.TopRatedTv(langCode, pagesToLoad.Page), DateTime.Now.AddHours(12));
results.AddRange(apiResult.Skip(pagesToLoad.Skip).Take(pagesToLoad.Take));
}
var processed = ProcessResults(results);
return await processed;
}
public async Task<IEnumerable<StreamingData>> GetStreamInformation(int movieDbId, CancellationToken cancellationToken)
{
var providers = await _movieApi.GetTvWatchProviders(movieDbId, cancellationToken);
@ -124,19 +177,28 @@ namespace Ombi.Core.Engine.V2
return data;
}
private IEnumerable<SearchTvShowViewModel> ProcessResults<T>(IEnumerable<T> items)
private async Task<IEnumerable<SearchTvShowViewModel>> ProcessResults<T>(IEnumerable<T> items)
{
var retVal = new List<SearchTvShowViewModel>();
var retVal = new List<SearchTvShowViewModel>();
var settings = await _customization.GetSettingsAsync();
foreach (var tvMazeSearch in items)
{
retVal.Add(ProcessResult(tvMazeSearch));
var result = await ProcessResult(tvMazeSearch);
if (result == null || settings.HideAvailableFromDiscover && result.Available)
{
continue;
}
retVal.Add(result);
}
return retVal;
}
private SearchTvShowViewModel ProcessResult<T>(T tvMazeSearch)
private async Task<SearchTvShowViewModel> ProcessResult<T>(T tvMazeSearch)
{
return _mapper.Map<SearchTvShowViewModel>(tvMazeSearch);
var item = _mapper.Map<SearchTvShowViewModel>(tvMazeSearch);
await RunSearchRules(item);
return item;
}
private async Task<SearchFullInfoTvShowViewModel> ProcessResult(SearchFullInfoTvShowViewModel item)

@ -18,7 +18,7 @@ namespace Ombi.Core.Rule.Rules.Search
// If we have all the episodes for this season, then this season is available
if (season.Episodes.All(x => x.Available))
{
season.SeasonAvailable = true;
season.SeasonAvailable = true;
}
}
if (search.SeasonRequests.All(x => x.Episodes.All(e => e.Available)))

@ -198,6 +198,7 @@ export class DiscoverCardComponent implements OnInit {
this.result.url = updated.imdbId;
this.result.overview = updated.overview;
this.result.approved = updated.approved;
this.result.available = updated.fullyAvailable;
this.fullyLoaded = true;
}

@ -22,12 +22,10 @@ namespace Ombi.Controllers.V2
{
public class SearchController : V2Controller
{
public SearchController(IMultiSearchEngine multiSearchEngine, ITvSearchEngine tvSearchEngine,
public SearchController(IMultiSearchEngine multiSearchEngine,
IMovieEngineV2 v2Movie, ITVSearchEngineV2 v2Tv, IMusicSearchEngineV2 musicEngine, IRottenTomatoesApi rottenTomatoesApi)
{
_multiSearchEngine = multiSearchEngine;
_tvSearchEngine = tvSearchEngine;
_tvSearchEngine.ResultLimit = 20;
_movieEngineV2 = v2Movie;
_movieEngineV2.ResultLimit = 20;
_tvEngineV2 = v2Tv;
@ -38,7 +36,6 @@ namespace Ombi.Controllers.V2
private readonly IMultiSearchEngine _multiSearchEngine;
private readonly IMovieEngineV2 _movieEngineV2;
private readonly ITVSearchEngineV2 _tvEngineV2;
private readonly ITvSearchEngine _tvSearchEngine;
private readonly IMusicSearchEngineV2 _musicEngine;
private readonly IRottenTomatoesApi _rottenTomatoesApi;
@ -258,19 +255,6 @@ namespace Ombi.Controllers.V2
return await _movieEngineV2.UpcomingMovies(currentPosition, amountToLoad);
}
/// <summary>
/// Returns Popular Tv Shows
/// </summary>
/// <remarks>We use Trakt.tv as the Provider</remarks>
/// <returns></returns>
[HttpGet("tv/popular")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesDefaultResponseType]
public async Task<IEnumerable<SearchTvShowViewModel>> PopularTv()
{
return await _tvSearchEngine.Popular();
}
/// <summary>
/// Returns Popular Tv Shows
/// </summary>
@ -281,33 +265,7 @@ namespace Ombi.Controllers.V2
[ProducesDefaultResponseType]
public async Task<IEnumerable<SearchTvShowViewModel>> PopularTv(int currentPosition, int amountToLoad)
{
return await _tvSearchEngine.Popular(currentPosition, amountToLoad);
}
/// <summary>
/// Returns Popular Tv Shows
/// </summary>
/// <remarks>We use Trakt.tv as the Provider</remarks>
/// <returns></returns>
[HttpGet("tv/popular/{currentPosition}/{amountToLoad}/images")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesDefaultResponseType]
public async Task<IEnumerable<SearchTvShowViewModel>> PopularTvWithImages(int currentPosition, int amountToLoad)
{
return await _tvSearchEngine.Popular(currentPosition, amountToLoad, true);
}
/// <summary>
/// Returns most Anticipated tv shows.
/// </summary>
/// <remarks>We use Trakt.tv as the Provider</remarks>
/// <returns></returns>
[HttpGet("tv/anticipated")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesDefaultResponseType]
public async Task<IEnumerable<SearchTvShowViewModel>> AnticipatedTv()
{
return await _tvSearchEngine.Anticipated();
return await _tvEngineV2.Popular(currentPosition, amountToLoad);
}
/// <summary>
@ -320,22 +278,7 @@ namespace Ombi.Controllers.V2
[ProducesDefaultResponseType]
public async Task<IEnumerable<SearchTvShowViewModel>> AnticipatedTv(int currentPosition, int amountToLoad)
{
return await _tvSearchEngine.Anticipated(currentPosition, amountToLoad);
}
/// <summary>
/// Returns Most watched shows.
/// </summary>
/// <remarks>We use Trakt.tv as the Provider</remarks>
/// <returns></returns>
[HttpGet("tv/mostwatched")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesDefaultResponseType]
[Obsolete("This method is obsolete, Trakt API no longer supports this")]
public async Task<IEnumerable<SearchTvShowViewModel>> MostWatched()
{
return await _tvSearchEngine.Popular();
return await _tvEngineV2.Anticipated(currentPosition, amountToLoad);
}
/// <summary>
@ -349,21 +292,9 @@ namespace Ombi.Controllers.V2
[Obsolete("This method is obsolete, Trakt API no longer supports this")]
public async Task<IEnumerable<SearchTvShowViewModel>> MostWatched(int currentPosition, int amountToLoad)
{
return await _tvSearchEngine.Popular(currentPosition, amountToLoad);
return await _tvEngineV2.Popular(currentPosition, amountToLoad);
}
/// <summary>
/// Returns trending shows
/// </summary>
/// <remarks>We use Trakt.tv as the Provider</remarks>
/// <returns></returns>
[HttpGet("tv/trending")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesDefaultResponseType]
public async Task<IEnumerable<SearchTvShowViewModel>> Trending()
{
return await _tvSearchEngine.Trending();
}
/// <summary>
/// Returns trending shows by page
@ -375,10 +306,9 @@ namespace Ombi.Controllers.V2
[ProducesDefaultResponseType]
public async Task<IEnumerable<SearchTvShowViewModel>> Trending(int currentPosition, int amountToLoad)
{
return await _tvSearchEngine.Trending(currentPosition, amountToLoad);
return await _tvEngineV2.Trending(currentPosition, amountToLoad);
}
/// <summary>
/// Returns all the movies that is by the actor id
/// </summary>

File diff suppressed because it is too large Load Diff

@ -17,6 +17,7 @@
import './commands'
import './request.commands';
import "cypress-real-events/support";
import '@bahmutov/cy-api/support';
// Alternatively you can use CommonJS syntax:
// require('./commands')

@ -0,0 +1,51 @@
import { movieDetailsPage as Page } from "@/integration/page-objects";
describe("TV Search V1 API tests", () => {
beforeEach(() => {
cy.login();
});
it("Get Extra TV Info", () => {
cy.api({url: '/api/v1/search/tv/info/287247', headers: { 'Authorization': 'Bearer ' + window.localStorage.getItem('id_token')} })
.then((res) => {
expect(res.status).equal(200);
cy.fixture('api/v1/tv-search-extra-info').then(x => {
expect(res.body).deep.equal(x);
})
});
});
it("TV Basic Search", () => {
cy.api({url: '/api/v1/search/tv/Shitts Creek', headers: { 'Authorization': 'Bearer ' + window.localStorage.getItem('id_token')} })
.then((res) => {
expect(res.status).equal(200);
const body = res.body;
expect(body[0].title).is.equal("Schitt's Creek")
expect(body[0].status).is.equal("Ended");
expect(body[0].id).is.not.null;
expect(body[0].id).to.be.an('number');
});
});
const types = [
'popular',
'trending',
'anticipated',
'mostwatched'
];
types.forEach((type) => {
// derive test name from data
it(`${type} TV List`, () => {
cy.api({url: '/api/v1/search/tv/'+type, headers: { 'Authorization': 'Bearer ' + window.localStorage.getItem('id_token')} })
.then((res) => {
expect(res.status).equal(200);
const body = res.body;
expect(body.length).is.greaterThan(0);
expect(body[0].title).is.not.null;
expect(body[0].id).is.not.null;
expect(body[0].id).to.be.an('number');
});
});
});
});

@ -227,6 +227,37 @@ describe("Discover Cards Requests Tests", () => {
});
});
it.only("Available TV (From Details Call) does not allow us to request", () => {
cy.intercept("GET", "**/search/Tv/popular/**").as("cardsResponse");
window.localStorage.setItem("DiscoverOptions2", "3");
Page.visit();
cy.wait("@cardsResponse").then((res) => {
const body = res.response.body;
var expectedId = body[1].id;
cy.intercept("GET", "**/search/Tv/moviedb/"+expectedId, (req) => {
req.reply((res2) => {
const body = res2.body;
body.fullyAvailable = true;
res2.send(body);
});
}).as("movieDbResponse");
var title = body[1].title;
cy.wait("@movieDbResponse")
const card = Page.popularCarousel.getCard(expectedId, true, DiscoverType.Popular);
card.title.realHover();
card.verifyTitle(title);
card.requestButton.should("not.exist");
card.availabilityText.should("have.text", "Available");
card.statusClass.should("have.class", "available");
});
});
it("Not available TV allow admin to request", () => {
cy.intercept("GET", "**/search/Tv/popular/**", (req) => {
req.reply((res) => {

@ -1,5 +1,6 @@
{
"devDependencies": {
"@bahmutov/cy-api": "^1.5.0",
"cypress": "6.8.0",
"cypress-wait-until": "^1.7.1",
"typescript": "^4.2.3"

@ -2,7 +2,7 @@
"compilerOptions": {
"target": "es5",
"lib": ["es2018", "dom"],
"types": ["cypress", "cypress-wait-until", "cypress-image-snapshot", "cypress-real-events"],
"types": ["cypress", "cypress-wait-until", "cypress-image-snapshot", "cypress-real-events", "@bahmutov/cy-api"],
"baseUrl": "./cypress",
"paths": {
"@/*": ["./*"]

@ -2,6 +2,13 @@
# yarn lockfile v1
"@bahmutov/cy-api@^1.5.0":
version "1.5.0"
resolved "https://registry.yarnpkg.com/@bahmutov/cy-api/-/cy-api-1.5.0.tgz#e6569f1d0f3040e55f97cf151a16932bfb10dcc6"
integrity sha512-N1pBawxcwXyDpJx0qwd78k/6yFEyHWVC71N7n78Rnaegs3LR1Z0odZJrKurpb56JFaP4abNm6EONXEEi5boMmQ==
dependencies:
common-tags "1.8.0"
"@cypress/listr-verbose-renderer@^0.4.1":
version "0.4.1"
resolved "https://registry.yarnpkg.com/@cypress/listr-verbose-renderer/-/listr-verbose-renderer-0.4.1.tgz#a77492f4b11dcc7c446a34b3e28721afd33c642a"
@ -330,7 +337,7 @@ commander@^5.1.0:
resolved "https://registry.yarnpkg.com/commander/-/commander-5.1.0.tgz#46abbd1652f8e059bddaef99bbdcb2ad9cf179ae"
integrity sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==
common-tags@^1.8.0:
common-tags@1.8.0, common-tags@^1.8.0:
version "1.8.0"
resolved "https://registry.yarnpkg.com/common-tags/-/common-tags-1.8.0.tgz#8e3153e542d4a39e9b10554434afaaf98956a937"
integrity sha512-6P6g0uetGpW/sdyUy/iQQCbFF0kWVMSIVSyYz7Zgjcgh8mgw8PQzDNZeyZ5DQ2gM7LBoZPHmnjz8rUthkBG5tw==

Loading…
Cancel
Save