Merge pull request #2295 from tidusjar/develop

Develop
pull/2357/head v3.0.3368
Jamie 6 years ago committed by GitHub
commit 1b482f000b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -4,10 +4,45 @@
### **New Features**
- Added the subscribe on the sarch page. [Jamie Rees]
- Added the subscribe button to the search page if we have an existing request. [Jamie Rees]
### **Fixes**
- Use selected episodes in submitRequest. [Calvin]
- Fixed where the test button wouldn't work on the mobile notifications page. [Jamie]
- Fixed the sorting and filtering on the Movie Requests page, it all functions correctly now. [Jamie]
- Fixed #2288. [Jamie]
- Fixed the SickRage/Medusa Issue where it was always being set as Skipped/Ignore #2084. [Jamie]
- Fixed UI typo refrencing discord in mattermost notifications #2175. [Anojh]
- Fix #2175. [Anojh]
- Fixed #2013. [Jamie Rees]
- Fixed #2147. [Jamie Rees]
## v3.0.3346 (2018-05-26)
### **New Features**
- Added a default set of root folders and qualities for Anime in Sonarr. [Jamie Rees]
### **Fixes**
- Made the Open on Mobile link less hidden. [Jamie Rees]
- Fixed #2263. [Jamie Rees]
- !changelog. [Jamie Rees]
- Fixed #2243 The refresh metadata was being run everytime we launched Ombi... [Jamie]
- Fixed a issue where the Plex Content Sync wouldn't pick up new shows #2276 #2244 #2261. [Jamie]

@ -120,8 +120,10 @@ namespace Ombi.Api.Mattermost.Models
var attIndex = outMessages[msgCount].Attachments.Count - 1;
//Get the text lines
lines = att.Text.Split(new[] { "\r\n", "\r", "\n" }, StringSplitOptions.None);
if (!String.IsNullOrEmpty(att.Text))
{
lines = att.Text.Split(new[] { "\r\n", "\r", "\n" }, StringSplitOptions.None);
}
foreach (var line in lines)
{
//Get the total length of all attachments on the current outgoing message
@ -153,8 +155,9 @@ namespace Ombi.Api.Mattermost.Models
foreach (var msg in outMessages)
{
var request = new Request("", _webhookUrl.ToString(), HttpMethod.Post);
var request = new Request(_webhookUrl.ToString(), "", HttpMethod.Post);
request.AddJsonBody(msg);
request.AddHeader("Host", _webhookUrl.Host);
await api.Request(request);
}
}

@ -33,7 +33,7 @@ namespace Ombi.Api.Mattermost.Models
/// Bot/User Icon
/// </summary>
[JsonProperty(PropertyName = "icon_url")]
public Uri IconUrl { get; set; }
public string IconUrl { get; set; }
/// <summary>
/// Message body. Supports Markdown
@ -142,7 +142,7 @@ namespace Ombi.Api.Mattermost.Models
/// Large images are resized to a maximum width of 400px or a maximum height of 300px, while still maintaining the original aspect ratio.
/// </summary>
[JsonProperty(PropertyName = "image_url")]
public Uri ImageUrl { get; set; }
public string ImageUrl { get; set; }
/// <summary>
/// An optional URL to an image file(GIF, JPEG, PNG, or BMP) that is displayed as a 75x75 pixel thumbnail on the right side of an attachment.

@ -17,7 +17,6 @@ namespace Ombi.Core.Engine.Interfaces
Task<RequestEngineResult> ApproveMovie(MovieRequests request);
Task<RequestEngineResult> ApproveMovieById(int requestId);
Task<RequestEngineResult> DenyMovieById(int modelId);
Task<FilterResult<MovieRequests>> Filter(FilterViewModel vm);
}

@ -1,6 +1,7 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using Ombi.Core.Models.Requests;
using Ombi.Core.Models.UI;
using Ombi.Store.Entities;
namespace Ombi.Core.Engine.Interfaces
@ -12,7 +13,7 @@ namespace Ombi.Core.Engine.Interfaces
//Task<IEnumerable<T>> GetNewRequests();
//Task<IEnumerable<T>> GetAvailableRequests();
RequestCountModel RequestCount();
Task<IEnumerable<T>> GetRequests(int count, int position);
Task<RequestsViewModel<T>> GetRequests(int count, int position, OrderFilterModel model);
Task<IEnumerable<T>> GetRequests();
Task<bool> UserHasRequest(string userId);

@ -13,6 +13,7 @@ using Microsoft.Extensions.Logging;
using Ombi.Api.TheMovieDb.Models;
using Ombi.Core.Authentication;
using Ombi.Core.Engine.Interfaces;
using Ombi.Core.Models.UI;
using Ombi.Core.Rule.Interfaces;
using Ombi.Core.Settings;
using Ombi.Settings.Settings.Models;
@ -25,7 +26,8 @@ namespace Ombi.Core.Engine
{
public MovieRequestEngine(IMovieDbApi movieApi, IRequestServiceMain requestService, IPrincipal user,
INotificationHelper helper, IRuleEvaluator r, IMovieSender sender, ILogger<MovieRequestEngine> log,
OmbiUserManager manager, IRepository<RequestLog> rl, ICacheService cache, ISettingsService<OmbiSettings> ombiSettings, IRepository<RequestSubscription> sub)
OmbiUserManager manager, IRepository<RequestLog> rl, ICacheService cache,
ISettingsService<OmbiSettings> ombiSettings, IRepository<RequestSubscription> sub)
: base(user, requestService, r, manager, cache, ombiSettings, sub)
{
MovieApi = movieApi;
@ -58,6 +60,7 @@ namespace Ombi.Core.Engine
ErrorMessage = $"Please try again later"
};
}
var fullMovieName =
$"{movieInfo.Title}{(!string.IsNullOrEmpty(movieInfo.ReleaseDate) ? $" ({DateTime.Parse(movieInfo.ReleaseDate).Year})" : string.Empty)}";
@ -82,7 +85,8 @@ namespace Ombi.Core.Engine
};
var usDates = movieInfo.ReleaseDates?.Results?.FirstOrDefault(x => x.IsoCode == "US");
requestModel.DigitalReleaseDate = usDates?.ReleaseDate?.FirstOrDefault(x => x.Type == ReleaseDateType.Digital)?.ReleaseDate;
requestModel.DigitalReleaseDate = usDates?.ReleaseDate
?.FirstOrDefault(x => x.Type == ReleaseDateType.Digital)?.ReleaseDate;
var ruleResults = (await RunRequestRules(requestModel)).ToList();
if (ruleResults.Any(x => !x.Success))
@ -125,25 +129,93 @@ namespace Ombi.Core.Engine
/// </summary>
/// <param name="count">The count.</param>
/// <param name="position">The position.</param>
/// <param name="orderFilter">The order/filter type.</param>
/// <returns></returns>
public async Task<IEnumerable<MovieRequests>> GetRequests(int count, int position)
public async Task<RequestsViewModel<MovieRequests>> GetRequests(int count, int position,
OrderFilterModel orderFilter)
{
var shouldHide = await HideFromOtherUsers();
List<MovieRequests> allRequests;
IQueryable<MovieRequests> allRequests;
if (shouldHide.Hide)
{
allRequests = await MovieRepository.GetWithUser(shouldHide.UserId).Skip(position).Take(count).OrderByDescending(x => x.ReleaseDate).ToListAsync();
allRequests =
MovieRepository.GetWithUser(shouldHide
.UserId); //.Skip(position).Take(count).OrderByDescending(x => x.ReleaseDate).ToListAsync();
}
else
{
allRequests = await MovieRepository.GetWithUser().Skip(position).Take(count).OrderByDescending(x => x.ReleaseDate).ToListAsync();
allRequests =
MovieRepository
.GetWithUser(); //.Skip(position).Take(count).OrderByDescending(x => x.ReleaseDate).ToListAsync();
}
allRequests.ForEach(async x =>
switch (orderFilter.AvailabilityFilter)
{
case FilterType.None:
break;
case FilterType.Available:
allRequests = allRequests.Where(x => x.Available);
break;
case FilterType.NotAvailable:
allRequests = allRequests.Where(x => !x.Available);
break;
default:
throw new ArgumentOutOfRangeException();
}
switch (orderFilter.StatusFilter)
{
case FilterType.None:
break;
case FilterType.Approved:
allRequests = allRequests.Where(x => x.Approved);
break;
case FilterType.Processing:
allRequests = allRequests.Where(x => x.Approved && !x.Available);
break;
case FilterType.PendingApproval:
allRequests = allRequests.Where(x => !x.Approved && !x.Available && !(x.Denied ?? false));
break;
default:
throw new ArgumentOutOfRangeException();
}
var total = allRequests.Count();
var requests = await (OrderMovies(allRequests, orderFilter.OrderType)).Skip(position).Take(count)
.ToListAsync();
requests.ForEach(async x =>
{
x.PosterPath = PosterPathHelper.FixPosterPath(x.PosterPath);
await CheckForSubscription(shouldHide, x);
});
return allRequests;
return new RequestsViewModel<MovieRequests>
{
Collection = requests,
Total = total
};
}
private IQueryable<MovieRequests> OrderMovies(IQueryable<MovieRequests> allRequests, OrderType type)
{
switch (type)
{
case OrderType.RequestedDateAsc:
return allRequests.OrderBy(x => x.RequestedDate);
case OrderType.RequestedDateDesc:
return allRequests.OrderByDescending(x => x.RequestedDate);
case OrderType.TitleAsc:
return allRequests.OrderBy(x => x.Title);
case OrderType.TitleDesc:
return allRequests.OrderByDescending(x => x.Title);
case OrderType.StatusAsc:
return allRequests.OrderBy(x => x.Status);
case OrderType.StatusDesc:
return allRequests.OrderByDescending(x => x.Status);
default:
throw new ArgumentOutOfRangeException(nameof(type), type, null);
}
}
public async Task<int> GetTotal()
@ -216,6 +288,7 @@ namespace Ombi.Core.Engine
{
allRequests = await MovieRepository.GetWithUser().ToListAsync();
}
var results = allRequests.Where(x => x.Title.Contains(search, CompareOptions.IgnoreCase)).ToList();
results.ForEach(async x =>
{
@ -241,6 +314,7 @@ namespace Ombi.Core.Engine
ErrorMessage = "Request does not exist"
};
}
request.Denied = true;
// We are denying a request
NotificationHelper.Notify(request, NotificationType.RequestDeclined);
@ -261,6 +335,7 @@ namespace Ombi.Core.Engine
ErrorMessage = "Request does not exist"
};
}
request.Approved = true;
request.Denied = false;
await MovieRepository.Update(request);
@ -281,6 +356,7 @@ namespace Ombi.Core.Engine
Result = true
};
}
if (!result.Success)
{
Logger.LogWarning("Tried auto sending movie but failed. Message: {0}", result.Message);
@ -291,6 +367,7 @@ namespace Ombi.Core.Engine
Result = false
};
}
// If there are no providers then it's successful but movie has not been sent
}
@ -352,6 +429,7 @@ namespace Ombi.Core.Engine
ErrorMessage = "Request does not exist"
};
}
request.Available = false;
await MovieRepository.Update(request);
@ -372,6 +450,7 @@ namespace Ombi.Core.Engine
ErrorMessage = "Request does not exist"
};
}
request.Available = true;
NotificationHelper.Notify(request, NotificationType.RequestAvailable);
await MovieRepository.Update(request);
@ -401,55 +480,7 @@ namespace Ombi.Core.Engine
RequestType = RequestType.Movie,
});
return new RequestEngineResult { Result = true, Message = $"{movieName} has been successfully added!" };
}
public async Task<FilterResult<MovieRequests>> Filter(FilterViewModel vm)
{
var shouldHide = await HideFromOtherUsers();
var requests = shouldHide.Hide
? MovieRepository.GetWithUser(shouldHide.UserId)
: MovieRepository.GetWithUser();
switch (vm.AvailabilityFilter)
{
case FilterType.None:
break;
case FilterType.Available:
requests = requests.Where(x => x.Available);
break;
case FilterType.NotAvailable:
requests = requests.Where(x => !x.Available);
break;
default:
throw new ArgumentOutOfRangeException();
}
switch (vm.StatusFilter)
{
case FilterType.None:
break;
case FilterType.Approved:
requests = requests.Where(x => x.Approved);
break;
case FilterType.Processing:
requests = requests.Where(x => x.Approved && !x.Available);
break;
case FilterType.PendingApproval:
requests = requests.Where(x => !x.Approved && !x.Available && !(x.Denied ?? false));
break;
default:
throw new ArgumentOutOfRangeException();
}
var count = await requests.CountAsync();
requests = requests.Skip(vm.Position).Take(vm.Count);
var retVal = new FilterResult<MovieRequests>
{
Total = count,
Collection = requests
};
return retVal;
return new RequestEngineResult {Result = true, Message = $"{movieName} has been successfully added!"};
}
}
}

@ -9,6 +9,7 @@ using System.Collections.Generic;
using System.Linq;
using System.Security.Principal;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using Ombi.Core.Rule.Interfaces;
using Microsoft.Extensions.Caching.Memory;
using Ombi.Core.Authentication;
@ -166,10 +167,31 @@ namespace Ombi.Core.Engine
viewMovie.TheMovieDbId = viewMovie.Id.ToString();
await RunSearchRules(viewMovie);
// This requires the rules to be run first to populate the RequestId property
await CheckForSubscription(viewMovie);
return viewMovie;
}
private async Task CheckForSubscription(SearchMovieViewModel viewModel)
{
// Check if this user requested it
var user = await GetUser();
var request = await RequestService.MovieRequestService.GetAll()
.AnyAsync(x => x.RequestedUserId.Equals(user.Id) && x.TheMovieDbId == viewModel.Id);
if (request)
{
viewModel.ShowSubscribe = false;
}
else
{
viewModel.ShowSubscribe = true;
var sub = await _subscriptionRepository.GetAll().FirstOrDefaultAsync(s => s.UserId == user.Id
&& s.RequestId == viewModel.RequestId && s.RequestType == RequestType.Movie);
viewModel.Subscribed = sub != null;
}
}
private async Task<SearchMovieViewModel> ProcessSingleMovie(MovieSearchResult movie)
{

@ -15,6 +15,7 @@ using Microsoft.EntityFrameworkCore;
using Ombi.Core.Authentication;
using Ombi.Core.Engine.Interfaces;
using Ombi.Core.Helpers;
using Ombi.Core.Models.UI;
using Ombi.Core.Rule;
using Ombi.Core.Rule.Interfaces;
using Ombi.Core.Senders;
@ -132,7 +133,7 @@ namespace Ombi.Core.Engine
return await AddRequest(newRequest.NewRequest);
}
public async Task<IEnumerable<TvRequests>> GetRequests(int count, int position)
public async Task<RequestsViewModel<TvRequests>> GetRequests(int count, int position, OrderFilterModel type)
{
var shouldHide = await HideFromOtherUsers();
List<TvRequests> allRequests;
@ -161,7 +162,10 @@ namespace Ombi.Core.Engine
allRequests.ForEach(async r => { await CheckForSubscription(shouldHide, r); });
return allRequests;
return new RequestsViewModel<TvRequests>
{
Collection = allRequests
};
}
public async Task<IEnumerable<TreeNode<TvRequests, List<ChildRequests>>>> GetRequestsTreeNode(int count, int position)

@ -56,7 +56,6 @@ namespace Ombi.Core.Models.Search
public bool FullyAvailable { get; set; }
// We only have some episodes
public bool PartlyAvailable { get; set; }
public override RequestType Type => RequestType.TvShow;
}
}

@ -8,13 +8,13 @@ namespace Ombi.Core.Models.Search
public int Id { get; set; }
public bool Approved { get; set; }
public bool Requested { get; set; }
public int RequestId { get; set; }
public bool Available { get; set; }
public string PlexUrl { get; set; }
public string EmbyUrl { get; set; }
public string Quality { get; set; }
public abstract RequestType Type { get; }
/// <summary>
/// This is used for the PlexAvailabilityCheck/EmbyAvailabilityRule rule
/// </summary>
@ -27,5 +27,11 @@ namespace Ombi.Core.Models.Search
public string TheTvDbId { get; set; }
[NotMapped]
public string TheMovieDbId { get; set; }
[NotMapped]
public bool Subscribed { get; set; }
[NotMapped]
public bool ShowSubscribe { get; set; }
}
}

@ -0,0 +1,11 @@
using Ombi.Core.Models.Requests;
namespace Ombi.Core.Models.UI
{
public class OrderFilterModel
{
public FilterType AvailabilityFilter { get; set; }
public FilterType StatusFilter { get; set; }
public OrderType OrderType { get; set; }
}
}

@ -0,0 +1,12 @@
namespace Ombi.Core.Models.UI
{
public enum OrderType
{
RequestedDateAsc =1,
RequestedDateDesc =2,
TitleAsc =3,
TitleDesc=4,
StatusAsc=5,
StatusDesc=6
}
}

@ -0,0 +1,10 @@
using System.Collections.Generic;
namespace Ombi.Core.Models.UI
{
public class RequestsViewModel<T>
{
public IEnumerable<T> Collection { get; set; }
public int Total { get; set; }
}
}

@ -29,6 +29,7 @@ namespace Ombi.Core.Rule.Rules.Search
{
obj.Requested = true;
obj.RequestId = movieRequests.Id;
obj.Approved = movieRequests.Approved;
obj.Available = movieRequests.Available;
@ -67,6 +68,7 @@ namespace Ombi.Core.Rule.Rules.Search
existingRequestChildRequest.SeasonRequests.FirstOrDefault(x => x.SeasonNumber == season.SeasonNumber);
if (existingSeason == null) continue;
foreach (var ep in existingSeason.Episodes)
{
// Find the episode from what we are searching
@ -92,7 +94,6 @@ namespace Ombi.Core.Rule.Rules.Search
request.PartlyAvailable = true;
}
return Task.FromResult(Success());
}
}

@ -314,9 +314,17 @@ namespace Ombi.Core.Senders
foreach (var seasonRequests in model.SeasonRequests)
{
var srEpisodes = await SickRageApi.GetEpisodesForSeason(tvdbid, seasonRequests.SeasonNumber, settings.ApiKey, settings.FullUri);
while (srEpisodes.message.Equals("Show not found", StringComparison.CurrentCultureIgnoreCase) && srEpisodes.data.Count <= 0)
int retryTimes = 10;
var currentRetry = 0;
while (srEpisodes.message.Equals("Show not found", StringComparison.CurrentCultureIgnoreCase) || srEpisodes.message.Equals("Season not found", StringComparison.CurrentCultureIgnoreCase) && srEpisodes.data.Count <= 0)
{
if (currentRetry > retryTimes)
{
Logger.LogWarning("Couldnt find the SR Season or Show, message: {0}", srEpisodes.message);
break;
}
await Task.Delay(TimeSpan.FromSeconds(1));
currentRetry++;
srEpisodes = await SickRageApi.GetEpisodesForSeason(tvdbid, seasonRequests.SeasonNumber, settings.ApiKey, settings.FullUri);
}

@ -203,13 +203,13 @@ namespace Ombi.Notifications.Agents
Username = string.IsNullOrEmpty(settings.Username) ? "Ombi" : settings.Username,
Channel = settings.Channel,
Text = model.Message,
IconUrl = new Uri(settings.IconUrl),
IconUrl = settings.IconUrl,
Attachments = new List<MattermostAttachment>
{
new MattermostAttachment
{
Title = model.Other.ContainsKey("title") ? model.Other["title"] : string.Empty,
ImageUrl = model.Other.ContainsKey("image") ? new Uri(model.Other["image"]) : null,
ImageUrl = model.Other.ContainsKey("image") ? model.Other["image"] : string.Empty,
}
}
};

@ -20,7 +20,7 @@ export interface IMovieRequests extends IFullBaseRequest {
qualityOverrideTitle: string;
}
export interface IFilterResult<T> {
export interface IRequestsViewModel<T> {
total: number;
collection: T[];
}
@ -87,6 +87,15 @@ export interface ITvUpdateModel {
id: number;
}
export enum OrderType {
RequestedDateAsc = 1,
RequestedDateDesc = 2,
TitleAsc = 3,
TitleDesc = 4,
StatusAsc = 5,
StatusDesc = 6,
}
export interface INewSeasonRequests {
id: number;
seasonNumber: number;
@ -112,8 +121,6 @@ export interface IMovieRequestModel {
export interface IFilter {
availabilityFilter: FilterType;
statusFilter: FilterType;
position: number;
count: number;
}
export enum FilterType {

@ -19,11 +19,14 @@
imdbId: string;
approved: boolean;
requested: boolean;
requestId: number;
available: boolean;
plexUrl: string;
embyUrl: string;
quality: string;
digitalReleaseDate: Date;
subscribed: boolean;
showSubscribe: boolean;
// for the UI
requestProcessing: boolean;

@ -47,6 +47,7 @@ export interface IResetPasswordToken {
export interface IMobileUsersViewModel {
username: string;
userId: string;
devices: number;
}

@ -117,8 +117,10 @@ export class IssueDetailsComponent implements OnInit {
} else {
this.imageService.getTvBackground(Number(issue.providerId)).subscribe(x => {
this.backgroundPath = this.sanitizer.bypassSecurityTrustStyle
("url(" + x + ")");
if(x) {
this.backgroundPath = this.sanitizer.bypassSecurityTrustStyle
("url(" + x + ")");
}
});
this.imageService.getTvPoster(Number(issue.providerId)).subscribe(x => {
if (x.length === 0) {

@ -88,7 +88,9 @@ export class RecentlyAddedComponent implements OnInit {
this.tv.forEach((t) => {
this.imageService.getTvPoster(t.tvDbId).subscribe(p => {
t.posterPath = p;
if(p) {
t.posterPath = p;
}
});
});
});
@ -98,7 +100,9 @@ export class RecentlyAddedComponent implements OnInit {
this.tv.forEach((t) => {
this.imageService.getTvPoster(t.tvDbId).subscribe(p => {
t.posterPath = p;
if(p) {
t.posterPath = p;
}
});
});
});

@ -1,57 +1,45 @@
<div class="form-group">
<div class="input-group">
<input type="text" id="search" class="form-control form-control-custom searchwidth" placeholder="Search" (keyup)="search($event)">
<span class="input-group-btn">
<button id="filterBtn" class="btn btn-sm btn-info-outline" (click)="filterDisplay = !filterDisplay" >
<span class="input-group-btn">
<button id="filterBtn" class="btn btn-sm btn-info-outline" (click)="filterDisplay = !filterDisplay">
<i class="fa fa-filter"></i> {{ 'Requests.Filter' | translate }}
</button>
<button class="btn btn-sm btn-primary-outline dropdown-toggle" type="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="true">
<i class="fa fa-sort"></i> {{ 'Requests.Sort' | translate }}
<span class="caret"></span>
</button>
<ul class="dropdown-menu" aria-labelledby="dropdownMenu2">
<li>
<a class="active" (click)="setOrder('requestedDate', $event)">{{ 'Requests.SortRequestDate' | translate }}
<span *ngIf="order === 'requestedDate'">
<span [hidden]="reverse"><small><i class="fa fa-arrow-down" aria-hidden="true"></i></small></span>
<span [hidden]="!reverse"><small><i class="fa fa-arrow-up" aria-hidden="true"></i></small></span>
</span>
</a>
<a (click)="setOrder('title', $event)">{{ 'Requests.SortTitle' | translate}}
<span *ngIf="order === 'title'">
<span [hidden]="reverse"><small><i class="fa fa-arrow-down" aria-hidden="true"></i></small></span>
<span [hidden]="!reverse"><small><i class="fa fa-arrow-up" aria-hidden="true"></i></small></span>
</span>
</a>
<a (click)="setOrder('releaseDate', $event)">{{ 'Requests.TheatricalReleaseSort' | translate }}
<span *ngIf="order === 'releaseDate'">
<span [hidden]="reverse"><small><i class="fa fa-arrow-down" aria-hidden="true"></i></small></span>
<span [hidden]="!reverse"><small><i class="fa fa-arrow-up" aria-hidden="true"></i></small></span>
</span>
</a>
<a (click)="setOrder('requestedUser.userAlias', $event)">{{ 'Requests.SortRequestedBy' | translate }}
<span *ngIf="order === 'requestedUser.userAlias'">
<span [hidden]="reverse"><small><i class="fa fa-arrow-down" aria-hidden="true"></i></small></span>
<span [hidden]="!reverse"><small><i class="fa fa-arrow-up" aria-hidden="true"></i></small></span>
</span>
</a>
<a (click)="setOrder('status', $event)">{{ 'Requests.SortStatus' | translate }}
<span *ngIf="order === 'status'">
<span [hidden]="reverse"><small><i class="fa fa-arrow-down" aria-hidden="true"></i></small></span>
<span [hidden]="!reverse"><small><i class="fa fa-arrow-up" aria-hidden="true"></i></small></span>
</span>
</a>
</li>
</ul>
</span>
</div>
<button class="btn btn-sm btn-primary-outline dropdown-toggle" type="button" data-toggle="dropdown" aria-haspopup="true"
aria-expanded="true">
<i class="fa fa-sort"></i> {{ 'Requests.Sort' | translate }}
<span class="caret"></span>
</button>
<ul class="dropdown-menu" aria-labelledby="dropdownMenu2">
<li>
<a (click)="setOrder(OrderType.RequestedDateAsc, $event)">{{ 'Requests.SortRequestDateAsc' | translate }}
</a>
<a class="active" (click)="setOrder(OrderType.RequestedDateDesc, $event)">{{ 'Requests.SortRequestDateDesc' | translate }}
</a>
<a (click)="setOrder(OrderType.TitleAsc, $event)">{{ 'Requests.SortTitleAsc' | translate}}
</a>
<a (click)="setOrder(OrderType.TitleDesc, $event)">{{ 'Requests.SortTitleDesc' | translate}}
</a>
<a (click)="setOrder(OrderType.StatusAsc, $event)">{{ 'Requests.SortStatusAsc' | translate}}
</a>
<a (click)="setOrder(OrderType.StatusDesc, $event)">{{ 'Requests.SortStatusDesc' | translate}}
</a>
</li>
</ul>
</span>
</div>
</div>
<br />
@ -59,9 +47,7 @@
<div>
<div *ngFor="let request of movieRequests | orderBy: order : reverse : 'case-insensitive'">
<div *ngFor="let request of movieRequests">
<div class="row">
<div class="myBg backdrop" [style.background-image]="request.backgroundPath"></div>
<div class="tint" style="background-image: linear-gradient(to bottom, rgba(0,0,0,0.6) 0%,rgba(0,0,0,0.6) 100%);"></div>
@ -99,7 +85,7 @@
<i class="fa fa-info-circle"></i>
</span>
<span *ngIf="!request.approved && !request.availble && !request.denied" id="pendingApprovalLabel" class="label label-warning"
[translate]="'Common.PendingApproval'"></span>
[translate]="'Common.PendingApproval'"></span>
</div>
<div *ngIf="request.denied" id="requestDenied">
@ -125,11 +111,15 @@
</div>
<div class="col-sm-3 col-sm-push-3 small-padding">
<div class="row">
<div class="row">
<div class="col-md-2 col-md-push-6">
<a *ngIf="request.showSubscribe && !request.subscribed" style="color:white" (click)="subscribe(request)" pTooltip="Subscribe for notifications"> <i class="fa fa-rss"></i></a>
<a *ngIf="request.showSubscribe && request.subscribed" style="color:red" (click)="unSubscribe(request)" pTooltip="Unsubscribe notification"> <i class="fa fa-rss"></i></a>
<a *ngIf="request.showSubscribe && !request.subscribed" style="color:white" (click)="subscribe(request)" pTooltip="Subscribe for notifications">
<i class="fa fa-rss"></i>
</a>
<a *ngIf="request.showSubscribe && request.subscribed" style="color:red" (click)="unSubscribe(request)" pTooltip="Unsubscribe notification">
<i class="fa fa-rss"></i>
</a>
</div>
</div>
<div *ngIf="isAdmin">
@ -171,7 +161,7 @@
</li>
</ul>
</div>
<div *ngIf="!request.denied" id="denyBtn">
<button type="button" (click)="deny(request)" class="btn btn-sm btn-danger-outline deny">
<i class="fa fa-times"></i> {{ 'Requests.Deny' | translate }}
@ -185,10 +175,12 @@
</form>
<form id="markBtnGroup">
<button id="unavailableBtn" *ngIf="request.available" (click)="changeAvailability(request, false)" style="text-align: right" value="false" class="btn btn-sm btn-info-outline change">
<button id="unavailableBtn" *ngIf="request.available" (click)="changeAvailability(request, false)" style="text-align: right"
value="false" class="btn btn-sm btn-info-outline change">
<i class="fa fa-minus"></i> {{ 'Requests.MarkUnavailable' | translate }}
</button>
<button id="availableBtn" *ngIf="!request.available" (click)="changeAvailability(request, true)" style="text-align: right" value="true" class="btn btn-sm btn-success-outline change">
<button id="availableBtn" *ngIf="!request.available" (click)="changeAvailability(request, true)" style="text-align: right"
value="true" class="btn btn-sm btn-success-outline change">
<i class="fa fa-plus"></i> {{ 'Requests.MarkAvailable' | translate }}
</button>
</form>
@ -198,7 +190,7 @@
</div>
<div class="dropdown" *ngIf="issueCategories && issuesEnabled" id="issuesBtn">
<button class="btn btn-sm btn-primary-outline dropdown-toggle" type="button" data-toggle="dropdown" aria-haspopup="true"
aria-expanded="true">
aria-expanded="true">
<i class="fa fa-plus"></i> {{ 'Requests.ReportIssue' | translate }}
<span class="caret"></span>
</button>
@ -218,7 +210,7 @@
</div>
<p-paginator [rows]="10" [totalRecords]="totalMovies" (onPageChange)="paginate($event)"></p-paginator>
</div>

@ -9,7 +9,7 @@ import { Subject } from "rxjs/Subject";
import { AuthService } from "../auth/auth.service";
import { NotificationService, RadarrService, RequestService } from "../services";
import { FilterType, IFilter, IIssueCategory, IMovieRequests, IPagenator, IRadarrProfile, IRadarrRootFolder } from "../interfaces";
import { FilterType, IFilter, IIssueCategory, IMovieRequests, IPagenator, IRadarrProfile, IRadarrRootFolder, OrderType } from "../interfaces";
@Component({
selector: "movie-requests",
@ -38,9 +38,9 @@ export class MovieRequestsComponent implements OnInit {
public filter: IFilter;
public filterType = FilterType;
public order: string = "requestedDate";
public reverse = true;
public orderType: OrderType = OrderType.RequestedDateDesc;
public OrderType = OrderType;
public totalMovies: number = 100;
private currentlyLoaded: number;
private amountToLoad: number;
@ -75,20 +75,17 @@ export class MovieRequestsComponent implements OnInit {
public ngOnInit() {
this.amountToLoad = 10;
this.currentlyLoaded = 10;
this.loadInit();
this.isAdmin = this.auth.hasRole("admin") || this.auth.hasRole("poweruser");
this.currentlyLoaded = 10;
this.filter = {
availabilityFilter: FilterType.None,
statusFilter: FilterType.None,
count: this.amountToLoad,
position: 0,
};
this.loadInit();
this.isAdmin = this.auth.hasRole("admin") || this.auth.hasRole("poweruser");
}
public paginate(event: IPagenator) {
const skipAmount = event.first;
const skipAmount = event.first;
this.loadRequests(this.amountToLoad, skipAmount);
}
@ -99,7 +96,7 @@ export class MovieRequestsComponent implements OnInit {
public removeRequest(request: IMovieRequests) {
this.requestService.removeMovieRequest(request);
this.removeRequestFromUi(request);
this.loadRequests(1, this.currentlyLoaded);
this.loadRequests(this.amountToLoad, this.currentlyLoaded = 0);
}
public changeAvailability(request: IMovieRequests, available: boolean) {
@ -183,38 +180,27 @@ export class MovieRequestsComponent implements OnInit {
public filterAvailability(filter: FilterType, el: any) {
this.filterActiveStyle(el);
this.filter.availabilityFilter = filter;
this.requestService.filterMovies(this.filter)
.subscribe(x => {
this.totalMovies = x.total;
this.setOverrides(x.collection);
this.movieRequests = x.collection;
});
this.loadInit();
}
public filterStatus(filter: FilterType, el: any) {
this.filterActiveStyle(el);
this.filter.statusFilter = filter;
this.requestService.filterMovies(this.filter)
.subscribe(x => {
this.totalMovies = x.total;
this.setOverrides(x.collection);
this.movieRequests = x.collection;
});
this.loadInit();
}
public setOrder(value: string, el: any) {
public setOrder(value: OrderType, el: any) {
el = el.toElement || el.relatedTarget || el.target || el.srcElement;
const parent = el.parentElement;
const previousFilter = parent.querySelector(".active");
if (this.order === value) {
this.reverse = !this.reverse;
} else {
previousFilter.className = "";
el.className = "active";
}
this.order = value;
previousFilter.className = "";
el.className = "active";
this.orderType = value;
this.loadInit();
}
public subscribe(request: IMovieRequests) {
@ -252,26 +238,16 @@ export class MovieRequestsComponent implements OnInit {
}
private loadRequests(amountToLoad: number, currentlyLoaded: number) {
if(this.filter.availabilityFilter === FilterType.None && this.filter.statusFilter === FilterType.None) {
this.requestService.getMovieRequests(amountToLoad, currentlyLoaded + 1)
this.requestService.getMovieRequests(amountToLoad, currentlyLoaded, this.orderType, this.filter)
.subscribe(x => {
this.setOverrides(x);
this.setOverrides(x.collection);
if(!this.movieRequests) {
this.movieRequests = [];
}
this.movieRequests = x;
this.movieRequests = x.collection;
this.totalMovies = x.total;
this.currentlyLoaded = currentlyLoaded + amountToLoad;
});
} else {
this.filter.position = currentlyLoaded;
this.requestService.filterMovies(this.filter)
.subscribe(x => {
this.setOverrides(x.collection);
this.totalMovies = x.total;
this.movieRequests = x.collection;
this.currentlyLoaded = currentlyLoaded + amountToLoad;
});
}
}
private updateRequest(request: IMovieRequests) {
@ -310,23 +286,25 @@ export class MovieRequestsComponent implements OnInit {
}
private loadInit() {
this.requestService.getTotalMovies().subscribe(x => this.totalMovies = x);
this.requestService.getMovieRequests(this.amountToLoad, 0)
this.requestService.getMovieRequests(this.amountToLoad, 0, this.orderType, this.filter)
.subscribe(x => {
this.movieRequests = x;
this.movieRequests = x.collection;
this.totalMovies = x.total;
this.movieRequests.forEach((req) => {
this.setBackground(req);
this.setPoster(req);
});
this.radarrService.getQualityProfilesFromSettings().subscribe(c => {
this.radarrProfiles = c;
this.movieRequests.forEach((req) => this.setQualityOverrides(req));
});
this.radarrService.getRootFoldersFromSettings().subscribe(c => {
this.radarrRootFolders = c;
this.movieRequests.forEach((req) => this.setRootFolderOverrides(req));
this.setBackground(req);
this.setPoster(req);
});
if (this.isAdmin) {
this.radarrService.getQualityProfilesFromSettings().subscribe(c => {
this.radarrProfiles = c;
this.movieRequests.forEach((req) => this.setQualityOverrides(req));
});
this.radarrService.getRootFoldersFromSettings().subscribe(c => {
this.radarrRootFolders = c;
this.movieRequests.forEach((req) => this.setRootFolderOverrides(req));
});
}
});
}

@ -240,8 +240,10 @@ export class TvRequestsComponent implements OnInit {
("url(https://image.tmdb.org/t/p/w1280" + val.data.background + ")");
} else {
this.imageService.getTvBanner(val.data.tvDbId).subscribe(x => {
val.data.background = this.sanitizer.bypassSecurityTrustStyle
if(x) {
val.data.background = this.sanitizer.bypassSecurityTrustStyle
("url(" + x + ")");
}
});
}
}

@ -65,7 +65,13 @@
<div class="col-sm-2 small-padding">
<div class="row" *ngIf="result.requested">
<div class="col-md-2 col-md-push-10">
<a *ngIf="result.showSubscribe && !result.subscribed" style="color:white" (click)="subscribe(result)" pTooltip="Subscribe for notifications"> <i class="fa fa-rss"></i></a>
<a *ngIf="result.showSubscribe && result.subscribed" style="color:red" (click)="unSubscribe(result)" pTooltip="Unsubscribe notification"> <i class="fa fa-rss"></i></a>
</div>
</div>
<div *ngIf="result.available">
<button style="text-align: right" class="btn btn-success-outline disabled" disabled><i class="fa fa-check"></i> {{ 'Common.Available' | translate }}</button>
</div>

@ -79,6 +79,7 @@ export class MovieSearchComponent implements OnInit {
public request(searchResult: ISearchMovieResult) {
searchResult.requested = true;
searchResult.requestProcessing = true;
searchResult.showSubscribe = false;
if (this.authService.hasRole("admin") || this.authService.hasRole("AutoApproveMovie")) {
searchResult.approved = true;
}
@ -103,6 +104,7 @@ export class MovieSearchComponent implements OnInit {
searchResult.approved = false;
searchResult.processed = false;
searchResult.requestProcessing = false;
}
});
} catch (e) {
@ -162,6 +164,22 @@ export class MovieSearchComponent implements OnInit {
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 getExtraInfo() {

@ -11,7 +11,7 @@ import { SearchComponent } from "./search.component";
import { SeriesInformationComponent } from "./seriesinformation.component";
import { TvSearchComponent } from "./tvsearch.component";
import { SidebarModule, TreeTableModule } from "primeng/primeng";
import { SidebarModule, TooltipModule, TreeTableModule } from "primeng/primeng";
import { RequestService } from "../services";
import { SearchService } from "../services";
@ -33,6 +33,7 @@ const routes: Routes = [
TreeTableModule,
SharedModule,
SidebarModule,
TooltipModule,
],
declarations: [
SearchComponent,

@ -52,7 +52,7 @@ export class SeriesInformationComponent implements OnInit {
const seasonsViewModel = <ISeasonsViewModel>{seasonNumber: season.seasonNumber, episodes: []};
season.episodes.forEach(ep => {
if(!this.series.latestSeason || !this.series.requestAll || !this.series.firstSeason) {
if(ep.requested) {
if(ep.selected) {
seasonsViewModel.episodes.push({episodeNumber: ep.episodeNumber});
}
}

@ -138,9 +138,11 @@ export class TvSearchComponent implements OnInit {
public getExtraInfo() {
this.tvResults.forEach((val, index) => {
this.imageService.getTvBanner(val.data.id).subscribe(x => {
val.data.background = this.sanitizer.
bypassSecurityTrustStyle
("url(" + x + ")");
if(x) {
val.data.background = this.sanitizer.
bypassSecurityTrustStyle
("url(" + x + ")");
}
});
this.searchService.getShowInformationTreeNode(val.data.id)
.subscribe(x => {

@ -6,7 +6,7 @@ import { Observable } from "rxjs/Rx";
import { TreeNode } from "primeng/primeng";
import { IRequestEngineResult } from "../interfaces";
import { IChildRequests, IFilter, IFilterResult, IMovieRequestModel, IMovieRequests, IMovieUpdateModel, ITvRequests,ITvUpdateModel } from "../interfaces";
import { IChildRequests, IFilter, IMovieRequestModel, IMovieRequests, IMovieUpdateModel, IRequestsViewModel, ITvRequests,ITvUpdateModel, OrderType } from "../interfaces";
import { ITvRequestViewModel } from "../interfaces";
import { ServiceHelpers } from "./service.helpers";
@ -48,8 +48,8 @@ export class RequestService extends ServiceHelpers {
return this.http.post<IRequestEngineResult>(`${this.url}Movie/unavailable`, JSON.stringify(movie), {headers: this.headers});
}
public getMovieRequests(count: number, position: number): Observable<IMovieRequests[]> {
return this.http.get<IMovieRequests[]>(`${this.url}movie/${count}/${position}`, {headers: this.headers});
public getMovieRequests(count: number, position: number, order: OrderType, filter: IFilter): Observable<IRequestsViewModel<IMovieRequests>> {
return this.http.get<IRequestsViewModel<IMovieRequests>>(`${this.url}movie/${count}/${position}/${order}/${filter.statusFilter}/${filter.availabilityFilter}`, {headers: this.headers});
}
public searchMovieRequests(search: string): Observable<IMovieRequests[]> {
@ -114,10 +114,7 @@ export class RequestService extends ServiceHelpers {
public deleteChild(child: IChildRequests): Observable<boolean> {
return this.http.delete<boolean>(`${this.url}tv/child/${child.id}`, {headers: this.headers});
}
public filterMovies(filter: IFilter): Observable<IFilterResult<IMovieRequests>> {
return this.http.post<IFilterResult<IMovieRequests>>(`${this.url}movie/filter`, JSON.stringify(filter), {headers: this.headers});
}
public subscribeToMovie(requestId: number): Observable<boolean> {
return this.http.post<boolean>(`${this.url}movie/subscribe/${requestId}`, {headers: this.headers});
}

@ -62,7 +62,7 @@ export class MattermostComponent implements OnInit {
this.testerService.mattermostTest(form.value).subscribe(x => {
if (x) {
this.notificationService.success( "Successfully sent a Mattermost message, please check the discord channel");
this.notificationService.success( "Successfully sent a Mattermost message, please check the appropriate channel");
} else {
this.notificationService.error("There was an error when sending the Mattermost message. Please check your settings");
}

@ -39,7 +39,7 @@
<div>
<select class="form-control form-control-custom" id="select" [(ngModel)]="testUserId" [ngModelOptions]="{standalone: true}">
<option value="">Please select</option>
<option *ngFor="let x of userList" [value]="x.id">{{x.username}}</option>
<option *ngFor="let x of userList" [value]="x.userId">{{x.username}}</option>
</select>
</div>
</div>

@ -34,7 +34,7 @@ export class MobileComponent implements OnInit {
this.mobileService.getUserDeviceList().subscribe(x => {
if(x.length <= 0) {
this.userList = [];
this.userList.push({username:"None",devices:0});
this.userList.push({username:"None",devices:0, userId:""});
} else {
this.userList = x;
}

@ -35,6 +35,10 @@ namespace Ombi.Controllers
[HttpGet("tv/{tvdbid}")]
public async Task<string> GetTvBanner(int tvdbid)
{
if (tvdbid <= 0)
{
return string.Empty;
}
var key = await _cache.GetOrAdd(CacheKeys.FanartTv, async () => await Config.Get(Store.Entities.ConfigurationTypes.FanartTv), DateTime.Now.AddDays(1));
var images = await FanartTvApi.GetTvImages(tvdbid, key.Value);
@ -90,6 +94,10 @@ namespace Ombi.Controllers
[HttpGet("poster/tv/{tvdbid}")]
public async Task<string> GetTvPoster(int tvdbid)
{
if (tvdbid <= 0)
{
return string.Empty;
}
var key = await _cache.GetOrAdd(CacheKeys.FanartTv, async () => await Config.Get(Store.Entities.ConfigurationTypes.FanartTv), DateTime.Now.AddDays(1));
var images = await FanartTvApi.GetTvImages(tvdbid, key.Value);
@ -145,6 +153,10 @@ namespace Ombi.Controllers
[HttpGet("background/tv/{tvdbid}")]
public async Task<string> GetTvBackground(int tvdbid)
{
if (tvdbid <= 0)
{
return string.Empty;
}
var key = await _cache.GetOrAdd(CacheKeys.FanartTv, async () => await Config.Get(Store.Entities.ConfigurationTypes.FanartTv), DateTime.Now.AddDays(1));
var images = await FanartTvApi.GetTvImages(tvdbid, key.Value);

@ -133,7 +133,11 @@ namespace Ombi.Controllers
i.IssueCategory = null;
i.UserReportedId = (await _userManager.Users.FirstOrDefaultAsync(x => x.UserName == User.Identity.Name)).Id;
await _issues.Add(i);
var category = await _categories.GetAll().FirstOrDefaultAsync(x => i.IssueCategoryId == x.Id);
if (category != null)
{
i.IssueCategory = category;
}
var notificationModel = new NotificationOptions
{
RequestId = i.RequestId ?? 0,
@ -142,7 +146,7 @@ namespace Ombi.Controllers
RequestType = i.RequestType,
Recipient = string.Empty,
AdditionalInformation = $"{i.Subject} | {i.Description}",
UserId = i.UserReportedId
UserId = i.UserReportedId,
};
AddIssueNotificationSubstitutes(notificationModel, i, User.Identity.Name);
@ -195,7 +199,7 @@ namespace Ombi.Controllers
{
var user = await _userManager.Users.Where(x => User.Identity.Name == x.UserName)
.FirstOrDefaultAsync();
var issue = await _issues.GetAll().Include(x => x.UserReported).FirstOrDefaultAsync(x => x.Id == comment.IssueId);
var issue = await _issues.GetAll().Include(x => x.UserReported).Include(x => x.IssueCategory).FirstOrDefaultAsync(x => x.Id == comment.IssueId);
if (issue == null)
{
return null;
@ -242,7 +246,7 @@ namespace Ombi.Controllers
{
var user = await _userManager.Users.Where(x => User.Identity.Name == x.UserName)
.FirstOrDefaultAsync();
var issue = await _issues.GetAll().Include(x => x.UserReported).FirstOrDefaultAsync(x => x.Id == model.IssueId);
var issue = await _issues.GetAll().Include(x => x.UserReported).Include(x => x.IssueCategory).FirstOrDefaultAsync(x => x.Id == model.IssueId);
if (issue == null)
{
return false;

@ -68,6 +68,7 @@ namespace Ombi.Controllers
{
vm.Add(new MobileUsersViewModel
{
UserId = u.Id,
Username = u.UserAlias,
Devices = u.NotificationUserIds.Count
});

@ -8,6 +8,7 @@ using System.Collections.Generic;
using System.Threading.Tasks;
using Ombi.Store.Entities.Requests;
using System.Diagnostics;
using Ombi.Core.Models.UI;
using Ombi.Models;
using Ombi.Store.Entities;
@ -32,10 +33,18 @@ namespace Ombi.Controllers
/// </summary>
/// <param name="count">The count of items you want to return.</param>
/// <param name="position">The position.</param>
[HttpGet("movie/{count:int}/{position:int}")]
public async Task<IEnumerable<MovieRequests>> GetRequests(int count, int position)
/// <param name="orderType"> The way we want to order.</param>
/// <param name="statusType"></param>
/// <param name="availabilityType"></param>
[HttpGet("movie/{count:int}/{position:int}/{orderType:int}/{statusType:int}/{availabilityType:int}")]
public async Task<RequestsViewModel<MovieRequests>> GetRequests(int count, int position, int orderType, int statusType, int availabilityType)
{
return await MovieRequestEngine.GetRequests(count, position);
return await MovieRequestEngine.GetRequests(count, position, new OrderFilterModel
{
OrderType = (OrderType)orderType,
AvailabilityFilter = (FilterType)availabilityType,
StatusFilter = (FilterType)statusType,
});
}
/// <summary>
@ -170,11 +179,19 @@ namespace Ombi.Controllers
/// </summary>
/// <param name="count">The count of items you want to return.</param>
/// <param name="position">The position.</param>
/// <param name="orderType"></param>
/// <param name="statusType"></param>
/// <param name="availabilityType"></param>
/// <returns></returns>
[HttpGet("tv/{count:int}/{position:int}")]
public async Task<IEnumerable<TvRequests>> GetTvRequests(int count, int position)
[HttpGet("tv/{count:int}/{position:int}/{orderType:int}/{statusFilterType:int}/{availabilityFilterType:int}")]
public async Task<RequestsViewModel<TvRequests>> GetTvRequests(int count, int position, int orderType, int statusType, int availabilityType)
{
return await TvRequestEngine.GetRequests(count, position);
return await TvRequestEngine.GetRequests(count, position, new OrderFilterModel
{
OrderType = (OrderType)orderType,
AvailabilityFilter = (FilterType) availabilityType,
StatusFilter = (FilterType) statusType,
});
}
/// <summary>
@ -347,17 +364,6 @@ namespace Ombi.Controllers
return movies || tv;
}
/// <summary>
/// Returns a filtered list
/// </summary>
/// <param name="vm"></param>
/// <returns></returns>
[HttpPost("movie/filter")]
public async Task<FilterResult<MovieRequests>> Filter([FromBody] FilterViewModel vm)
{
return await MovieRequestEngine.Filter(vm);
}
/// <summary>
/// Subscribes for notifications to a movie request
/// </summary>

@ -2,6 +2,7 @@
{
public class MobileUsersViewModel
{
public string UserId { get; set; }
public string Username { get; set; }
public int Devices { get; set; }
}

@ -138,10 +138,12 @@
"Filter":"Filter",
"Sort":"Sort",
"SeasonNumberHeading":"Season: {seasonNumber}",
"SortTitle":"Title",
"SortRequestDate": "Request Date",
"SortRequestedBy":"Requested By",
"SortStatus":"Status"
"SortTitleAsc":"Title ▲",
"SortTitleDesc":"Title ▼",
"SortRequestDateAsc": "Request Date ▲",
"SortRequestDateDesc": "Request Date ▼",
"SortStatusAsc":"Status ▲",
"SortStatusDesc":"Status ▼"
},
"Issues":{
"Title":"Issues",

Loading…
Cancel
Save