Merge branch 'develop' of https://github.com/ombi-app/Ombi into develop

pull/4210/head
tidusjar 3 years ago
commit d271efd09c

@ -40,8 +40,8 @@ Search the existing requests to see if your suggestion has already been submitte
___
<a href='https://play.google.com/store/apps/details?id=com.tidusjar.Ombi&pcampaignid=MKT-Other-global-all-co-prtnr-py-PartBadge-Mar2515-1'><img width="150" alt='Get it on Google Play' src='https://play.google.com/intl/en_gb/badges/images/generic/en_badge_web_generic.png'/></a>
<br>
_**Note:** There is no longer an iOS app due to complications outside of our control._
<a href='https://apps.apple.com/us/app/ombi/id1335260043'><img width="130" alt='Get it on the App Store' src='https://developer.apple.com/app-store/marketing/guidelines/images/badge-example-preferred.png'/></a>
<br>
# Features
Here are some of the features Ombi has:

@ -7,5 +7,9 @@ namespace Ombi.Core.Settings.Models.External
public bool ShowAdultMovies { get; set; }
public List<int> ExcludedKeywordIds { get; set; }
public List<int> ExcludedMovieGenreIds { get; set; }
public List<int> ExcludedTvGenreIds { get; set; }
}
}

@ -4,6 +4,10 @@ using System.Threading.Tasks;
using Ombi.Api.TheMovieDb.Models;
using Ombi.TheMovieDbApi.Models;
// Due to conflicting Genre models in
// Ombi.TheMovieDbApi.Models and Ombi.Api.TheMovieDb.Models
using Genre = Ombi.TheMovieDbApi.Models.Genre;
namespace Ombi.Api.TheMovieDb
{
public interface IMovieDbApi
@ -34,5 +38,6 @@ namespace Ombi.Api.TheMovieDb
Task<Keyword> GetKeyword(int keywordId);
Task<WatchProviders> GetMovieWatchProviders(int theMoviedbId, CancellationToken token);
Task<WatchProviders> GetTvWatchProviders(int theMoviedbId, CancellationToken token);
Task<List<Genre>> GetGenres(string media);
}
}
}

@ -36,4 +36,9 @@ namespace Ombi.TheMovieDbApi.Models
public int total_results { get; set; }
public int total_pages { get; set; }
}
}
public class GenreContainer<T>
{
public List<T> genres { get; set; }
}
}

@ -13,6 +13,10 @@ using Ombi.Core.Settings.Models.External;
using Ombi.Helpers;
using Ombi.TheMovieDbApi.Models;
// Due to conflicting Genre models in
// Ombi.TheMovieDbApi.Models and Ombi.Api.TheMovieDb.Models
using Genre = Ombi.TheMovieDbApi.Models.Genre;
namespace Ombi.Api.TheMovieDb
{
public class TheMovieDbApi : IMovieDbApi
@ -198,6 +202,7 @@ namespace Ombi.Api.TheMovieDb
request.AddQueryString("page", page.ToString());
}
await AddDiscoverSettings(request);
await AddGenreFilter(request, type);
AddRetry(request);
var result = await Api.Request<TheMovieDbContainer<SearchResult>>(request, cancellationToken);
return Mapper.Map<List<MovieDbSearchResult>>(result.results);
@ -233,6 +238,7 @@ namespace Ombi.Api.TheMovieDb
request.AddQueryString("vote_count.gte", "250");
await AddDiscoverSettings(request);
await AddGenreFilter(request, type);
AddRetry(request);
var result = await Api.Request<TheMovieDbContainer<SearchResult>>(request);
return Mapper.Map<List<MovieDbSearchResult>>(result.results);
@ -269,6 +275,7 @@ namespace Ombi.Api.TheMovieDb
request.AddQueryString("page", page.ToString());
}
await AddDiscoverSettings(request);
await AddGenreFilter(request, type);
AddRetry(request);
var result = await Api.Request<TheMovieDbContainer<SearchResult>>(request);
return Mapper.Map<List<MovieDbSearchResult>>(result.results);
@ -297,6 +304,7 @@ namespace Ombi.Api.TheMovieDb
}
await AddDiscoverSettings(request);
await AddGenreFilter(request, "movie");
AddRetry(request);
var result = await Api.Request<TheMovieDbContainer<SearchResult>>(request);
return Mapper.Map<List<MovieDbSearchResult>>(result.results);
@ -344,6 +352,16 @@ namespace Ombi.Api.TheMovieDb
return keyword == null || keyword.Id == 0 ? null : keyword;
}
public async Task<List<Genre>> GetGenres(string media)
{
var request = new Request($"genre/{media}/list", BaseUri, HttpMethod.Get);
request.AddQueryString("api_key", ApiToken);
AddRetry(request);
var result = await Api.Request<GenreContainer<Genre>>(request);
return result.genres ?? new List<Genre>();
}
public Task<TheMovieDbContainer<MultiSearch>> MultiSearch(string searchTerm, string languageCode, CancellationToken cancellationToken)
{
var request = new Request("search/multi", BaseUri, HttpMethod.Get);
@ -380,6 +398,28 @@ namespace Ombi.Api.TheMovieDb
}
}
private async Task AddGenreFilter(Request request, string media_type)
{
var settings = await Settings;
List<int> excludedGenres;
switch (media_type) {
case "tv":
excludedGenres = settings.ExcludedTvGenreIds;
break;
case "movie":
excludedGenres = settings.ExcludedMovieGenreIds;
break;
default:
return;
}
if (excludedGenres?.Any() == true)
{
request.AddQueryString("without_genres", string.Join(",", excludedGenres));
}
}
private static void AddRetry(Request request)
{
request.Retry = true;

@ -170,7 +170,17 @@
<div>
<app-my-nav id="main-container dark" [showNav]="showNav" [isAdmin]="isAdmin" [applicationName]="applicationName" [applicationLogo]="customizationSettings?.logo" [username]="username" [email]="user?.email " (logoutClick)="logOut();">
<app-my-nav id="main-container dark"
[showNav]="showNav"
[isAdmin]="isAdmin"
[applicationName]="applicationName"
[applicationLogo]="customizationSettings?.logo"
[username]="username"
[email]="user?.email"
[accessToken]="accessToken"
[applicationUrl]="customizationSettings?.applicationUrl"
(logoutClick)="logOut();"
>
</app-my-nav>

@ -33,6 +33,7 @@ export class AppComponent implements OnInit {
public applicationName: string = "Ombi"
public isAdmin: boolean;
public username: string;
public accessToken: string;
private hubConnected: boolean;
@ -55,6 +56,7 @@ export class AppComponent implements OnInit {
if (this.authService.loggedIn()) {
this.user = this.authService.claims();
this.username = this.user.name;
this.identity.getAccessToken().subscribe(x => this.accessToken = x);
if (!this.hubConnected) {
this.signalrNotification.initialize();
this.hubConnected = true;

@ -284,6 +284,8 @@ export interface IVoteSettings extends ISettings {
export interface ITheMovieDbSettings extends ISettings {
showAdultMovies: boolean;
excludedKeywordIds: number[];
excludedMovieGenreIds: number[];
excludedTvGenreIds: number[]
}
export interface IUpdateModel

@ -69,10 +69,11 @@
<button *ngIf="!request.available" mat-raised-button color="warn" (click)="changeAvailability(request, true);">{{ 'Requests.MarkAvailable' | translate }}</button>
<button *ngIf="request.available" mat-raised-button color="warn" (click)="changeAvailability(request, false);">{{ 'Requests.MarkUnavailable' | translate }}</button>
<button *ngIf="!request.denied" mat-raised-button color="danger" (click)="deny(request);">{{ 'Requests.Deny' | translate }}</button>
<button mat-raised-button color="danger" (click)="delete(request);">{{ 'Requests.RequestPanel.Delete' | translate }}</button>
<button mat-raised-button color="accent" (click)="reProcessRequest(request);">{{ 'MediaDetails.ReProcessRequest' | translate }}</button>
</div>
<div *ngIf="isAdmin || manageOwnRequests">
<button mat-raised-button color="danger" (click)="delete(request);">{{ 'Requests.RequestPanel.Delete' | translate }}</button>
</div>
</mat-expansion-panel>

@ -1,9 +1,10 @@
import { Component, Input } from "@angular/core";
import { IChildRequests, RequestType } from "../../../../../interfaces";
import { RequestService } from "../../../../../services/request.service";
import { MessageService } from "../../../../../services";
import { MatDialog } from "@angular/material/dialog";
import { DenyDialogComponent } from "../../../shared/deny-dialog/deny-dialog.component";
import { MatDialog } from "@angular/material/dialog";
import { MessageService } from "../../../../../services";
import { RequestService } from "../../../../../services/request.service";
import { RequestServiceV2 } from "../../../../../services/requestV2.service";
@Component({
@ -14,6 +15,7 @@ import { RequestServiceV2 } from "../../../../../services/requestV2.service";
export class TvRequestsPanelComponent {
@Input() public tvRequest: IChildRequests[];
@Input() public isAdmin: boolean;
@Input() public manageOwnRequests: boolean;
public displayedColumns: string[] = ['number', 'title', 'airDate', 'status'];

@ -126,7 +126,7 @@
{{'Requests.Title' | translate}}
</mat-panel-title>
</mat-expansion-panel-header>
<tv-requests-panel [tvRequest]="tvRequest" [isAdmin]="isAdmin"></tv-requests-panel>
<tv-requests-panel [tvRequest]="tvRequest" [isAdmin]="isAdmin" [manageOwnRequests]="manageOwnRequests"></tv-requests-panel>
</mat-expansion-panel>
</mat-accordion>

@ -27,6 +27,7 @@ export class TvDetailsComponent implements OnInit {
public showRequest: ITvRequests;
public fromSearch: boolean;
public isAdmin: boolean;
public manageOwnRequests: boolean;
public advancedOptions: IAdvancedData;
public showAdvanced: boolean; // Set on the UI
public requestType = RequestType.tvShow;
@ -53,6 +54,7 @@ export class TvDetailsComponent implements OnInit {
this.issuesEnabled = this.settingsState.getIssue();
this.isAdmin = this.auth.hasRole("admin") || this.auth.hasRole("poweruser");
this.manageOwnRequests = this.auth.hasRole('ManageOwnRequests');
if (this.isAdmin) {
this.showAdvanced = await this.sonarrService.isEnabled();

@ -28,6 +28,13 @@
</div>
</span>
<span mat-list-item >
<a mat-list-item [disableRipple]="true" id="nav-openMobile" [routerLinkActive]="'active-list-item'"
aria-label="Toggle sidenav" (click)="openMobileApp($event);">
<i class="fa-lg fas fa-mobile-alt icon-spacing"></i>
&nbsp;{{ 'NavigationBar.OpenMobileApp' | translate }}
</a></span>
<a mat-list-item [disableRipple]="true" class="menu-spacing" id="nav-logout" [routerLinkActive]="'active-list-item'"
aria-label="Toggle sidenav" (click)="logOut();">
<i class="fa-lg fas fa-sign-out-alt icon-spacing"></i>

@ -107,10 +107,21 @@
margin-right:5px;
}
#nav-openMobile {
display: none;
}
@media (max-width: 600px) {
.profile-username{
display:none;
}
}
@media (max-width: 950px) {
#nav-openMobile {
display: block;
}
}
.profile-img img {

@ -1,16 +1,17 @@
import { Component, Input, Output, EventEmitter, OnInit } from '@angular/core';
import { BreakpointObserver, Breakpoints } from '@angular/cdk/layout';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { INavBar } from '../interfaces/ICommon';
import { StorageService } from '../shared/storage/storage-service';
import { SettingsService, SettingsStateService } from '../services';
import { MatSlideToggleChange } from '@angular/material/slide-toggle';
import { SearchFilter } from './SearchFilter';
import { Md5 } from 'ts-md5/dist/md5';
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
import { IUser, RequestType, UserType } from '../interfaces';
import { SettingsService, SettingsStateService } from '../services';
import { FilterService } from '../discover/services/filter-service';
import { ILocalUser } from '../auth/IUserLogin';
import { INavBar } from '../interfaces/ICommon';
import { MatSlideToggleChange } from '@angular/material/slide-toggle';
import { Md5 } from 'ts-md5/dist/md5';
import { Observable } from 'rxjs';
import { SearchFilter } from './SearchFilter';
import { StorageService } from '../shared/storage/storage-service';
import { map } from 'rxjs/operators';
export enum SearchFilterType {
Movie = 1,
@ -34,6 +35,8 @@ export class MyNavComponent implements OnInit {
@Input() public showNav: boolean;
@Input() public applicationName: string;
@Input() public applicationLogo: string;
@Input() public applicationUrl: string;
@Input() public accessToken: string;
@Input() public username: string;
@Input() public isAdmin: string;
@Input() public email: string;
@ -122,4 +125,12 @@ export class MyNavComponent implements OnInit {
var fallback = this.applicationLogo ? this.applicationLogo : 'https://raw.githubusercontent.com/Ombi-app/Ombi/gh-pages/img/android-chrome-512x512.png';
return `https://www.gravatar.com/avatar/${this.emailHash}?d=${fallback}`;
}
public openMobileApp(event: any) {
event.preventDefault();
const url = `ombi://${this.applicationUrl}|${this.accessToken}`;
window.location.assign(url);
}
}

@ -76,7 +76,7 @@
<th mat-header-cell *matHeaderCellDef> </th>
<td mat-cell *matCellDef="let element">
<button id="detailsButton{{element.id}}" mat-raised-button color="accent" [routerLink]="'/details/movie/' + element.theMovieDbId">{{ 'Requests.Details' | translate}}</button>
<button id="optionsButton{{element.id}}" mat-raised-button color="warn" (click)="openOptions(element)" *ngIf="isAdmin"> {{ 'Requests.Options' | translate}}</button>
<button id="optionsButton{{element.id}}" mat-raised-button color="warn" (click)="openOptions(element)" *ngIf="isAdmin || manageOwnRequests"> {{ 'Requests.Options' | translate}}</button>
</td>
</ng-container>

@ -1,18 +1,18 @@
import { Component, AfterViewInit, ViewChild, EventEmitter, Output, ChangeDetectorRef, OnInit } from "@angular/core";
import { AfterViewInit, ChangeDetectorRef, Component, EventEmitter, OnInit, Output, ViewChild } from "@angular/core";
import { IMovieRequests, IRequestEngineResult, IRequestsViewModel } from "../../../interfaces";
import { MatPaginator } from "@angular/material/paginator";
import { MatSort } from "@angular/material/sort";
import { merge, Observable, of as observableOf, forkJoin } from 'rxjs';
import { NotificationService, RequestService } from "../../../services";
import { Observable, forkJoin, merge, of as observableOf } from 'rxjs';
import { catchError, map, startWith, switchMap } from 'rxjs/operators';
import { RequestServiceV2 } from "../../../services/requestV2.service";
import { AuthService } from "../../../auth/auth.service";
import { StorageService } from "../../../shared/storage/storage-service";
import { MatPaginator } from "@angular/material/paginator";
import { MatSort } from "@angular/material/sort";
import { MatTableDataSource } from "@angular/material/table";
import { RequestFilterType } from "../../models/RequestFilterType";
import { RequestServiceV2 } from "../../../services/requestV2.service";
import { SelectionModel } from "@angular/cdk/collections";
import { NotificationService, RequestService } from "../../../services";
import { StorageService } from "../../../shared/storage/storage-service";
import { TranslateService } from "@ngx-translate/core";
import { MatTableDataSource } from "@angular/material/table";
@Component({
templateUrl: "./movies-grid.component.html",
@ -26,6 +26,7 @@ export class MoviesGridComponent implements OnInit, AfterViewInit {
public displayedColumns: string[] = ['title', 'requestedUser.requestedBy', 'status', 'requestStatus','requestedDate', 'actions'];
public gridCount: string = "15";
public isAdmin: boolean;
public manageOwnRequests: boolean;
public defaultSort: string = "requestedDate";
public defaultOrder: string = "desc";
public currentFilter: RequestFilterType = RequestFilterType.All;
@ -39,7 +40,7 @@ export class MoviesGridComponent implements OnInit, AfterViewInit {
private storageKeyGridCount = "Movie_DefaultGridCount";
private storageKeyCurrentFilter = "Movie_DefaultFilter";
@Output() public onOpenOptions = new EventEmitter<{ request: any, filter: any, onChange: any }>();
@Output() public onOpenOptions = new EventEmitter<{ request: any, filter: any, onChange: any, manageOwnRequests: boolean, isAdmin: boolean }>();
@ViewChild(MatPaginator) paginator: MatPaginator;
@ViewChild(MatSort) sort: MatSort;
@ -53,6 +54,7 @@ export class MoviesGridComponent implements OnInit, AfterViewInit {
public ngOnInit() {
this.isAdmin = this.auth.hasRole("admin") || this.auth.hasRole("poweruser");
this.manageOwnRequests = this.auth.hasRole("ManageOwnRequests")
if (this.isAdmin) {
this.displayedColumns.unshift('select');
}
@ -135,7 +137,7 @@ export class MoviesGridComponent implements OnInit, AfterViewInit {
this.ref.detectChanges();
};
this.onOpenOptions.emit({ request: request, filter: filter, onChange: onChange });
this.onOpenOptions.emit({ request: request, filter: filter, onChange: onChange, manageOwnRequests: this.manageOwnRequests, isAdmin: this.isAdmin });
}
public switchFilter(type: RequestFilterType) {

@ -1,11 +1,11 @@
<mat-nav-list>
<a id="requestDelete" (click)="delete()" mat-list-item>
<a id="requestDelete" *ngIf="data.isAdmin || data.manageOwnRequests" (click)="delete()" mat-list-item>
<span mat-line>{{'Requests.RequestPanel.Delete' | translate}}</span>
</a>
<a id="requestApprove" *ngIf="data.canApprove" (click)="approve()" mat-list-item>
<a id="requestApprove" *ngIf="data.canApprove && data.isAdmin" (click)="approve()" mat-list-item>
<span mat-line>{{'Requests.RequestPanel.Approve' | translate}}</span>
</a>
<a id="requestChangeAvailability" *ngIf="data.type !== RequestType.tvShow" (click)="changeAvailability()" mat-list-item>
<a id="requestChangeAvailability" *ngIf="data.type !== RequestType.tvShow && data.isAdmin" (click)="changeAvailability()" mat-list-item>
<span mat-line>{{'Requests.RequestPanel.ChangeAvailability' | translate}}</span>
</a>
</mat-nav-list>

@ -1,8 +1,9 @@
import { Component, ViewChild } from "@angular/core";
import { MatBottomSheet } from "@angular/material/bottom-sheet";
import { MoviesGridComponent } from "./movies-grid/movies-grid.component";
import { RequestOptionsComponent } from "./options/request-options.component";
import { UpdateType } from "../models/UpdateType";
import { MoviesGridComponent } from "./movies-grid/movies-grid.component";
@Component({
templateUrl: "./requests-list.component.html",
@ -12,7 +13,7 @@ export class RequestsListComponent {
constructor(private bottomSheet: MatBottomSheet) { }
public onOpenOptions(event: { request: any, filter: any, onChange: any }) {
public onOpenOptions(event: { request: any, filter: any, onChange: any, manageOwnRequests: boolean, isAdmin: boolean }) {
const ref = this.bottomSheet.open(RequestOptionsComponent, { data: { id: event.request.id, type: event.request.requestType, canApprove: event.request.canApprove } });
ref.afterDismissed().subscribe((result) => {

@ -22,4 +22,8 @@ export class TheMovieDbService extends ServiceHelpers {
return this.http.get<IMovieDbKeyword>(`${this.url}/Keywords/${keywordId}`, { headers: this.headers })
.pipe(catchError((error: HttpErrorResponse) => error.status === 404 ? empty() : throwError(error)));
}
public getGenres(media: string): Observable<IMovieDbKeyword[]> {
return this.http.get<IMovieDbKeyword[]>(`${this.url}/Genres/${media}`, { headers: this.headers })
}
}

@ -1,4 +1,4 @@
<settings-menu></settings-menu>
<settings-menu></settings-menu>
<div class="small-middle-container">
<wiki [path]="'/settings/customization/'"></wiki>
<fieldset *ngIf="settings">
@ -12,9 +12,8 @@
</mat-form-field>
</div>
<div class="md-form-field">
<mat-hint>The application url should be your Externally Accessible URL for example, your internal URL is http://192.168.1.50/ but your Externally
Accessible URL is 'https://mydomain.com/requests' Please ensure this field is correct as it drives a lot of functionality include the QR code for the
mobile app and it affects the way email notifications are sent.
<mat-hint>The application url should be your Externally Accessible URL (the address you use to reach Ombi from outside your system). For example, 'https://example.com/requests'. <br />Please ensure this field is correct as it drives a lot of functionality, including the QR code for the
mobile app, and the way email notifications are sent.
</mat-hint>
<mat-form-field appearance="outline">
<mat-label>Application URL</mat-label>
@ -81,4 +80,4 @@
</fieldset>
</div>
</div>

@ -15,7 +15,7 @@
<form [formGroup]='tagForm'>
<mat-form-field class="example-full-width">
<input type="text" placeholder="Excluded Keyword IDs for Movie Suggestions" matInput
<input type="text" placeholder="Excluded Keyword IDs for Movie & TV Suggestions" matInput
formControlName="input" [matAutocomplete]="auto"
matTooltip="Prevent movies with certain keywords from being suggested. May require a restart to take effect.">
<mat-autocomplete (optionSelected)="optionSelected($event.option.value)" autoActiveFirstOption
@ -28,7 +28,45 @@
<mat-chip-list #chipList>
<mat-chip *ngFor="let key of excludedKeywords" [selectable]="false" [removable]="true"
(removed)="remove(key)">
(removed)="remove(key, 'keyword')">
{{key.name}}
<i matChipRemove class="fas fa-times fa-lg"></i>
</mat-chip>
</mat-chip-list>
<div class="md-form-field" style="margin-top:1em;">
<mat-form-field appearance="outline" >
<mat-label>Movie Genres</mat-label>
<mat-select formControlName="excludedMovieGenres" multiple>
<mat-option *ngFor="let genre of filteredMovieGenres" [value]="genre.id">
{{genre.name}}
</mat-option>
</mat-select>
</mat-form-field>
</div>
<mat-chip-list #chipList>
<mat-chip *ngFor="let key of excludedMovieGenres" [selectable]="false" [removable]="true"
(removed)="remove(key, 'movieGenre')">
{{key.name}}
<i matChipRemove class="fas fa-times fa-lg"></i>
</mat-chip>
</mat-chip-list>
<div class="md-form-field" style="margin-top:1em;">
<mat-form-field appearance="outline" >
<mat-label>Tv Genres</mat-label>
<mat-select formControlName="excludedTvGenres" multiple>
<mat-option *ngFor="let genre of filteredTvGenres" [value]="genre.id">
{{genre.name}}
</mat-option>
</mat-select>
</mat-form-field>
</div>
<mat-chip-list #chipList>
<mat-chip *ngFor="let key of excludedTvGenres" [selectable]="false" [removable]="true"
(removed)="remove(key, 'tvGenre')">
{{key.name}}
<i matChipRemove class="fas fa-times fa-lg"></i>
</mat-chip>
@ -52,4 +90,4 @@
</div>
</div>
</fieldset>
</div>
</div>

@ -23,8 +23,13 @@ export class TheMovieDbComponent implements OnInit {
public settings: ITheMovieDbSettings;
public excludedKeywords: IKeywordTag[];
public excludedMovieGenres: IKeywordTag[];
public excludedTvGenres: IKeywordTag[];
public tagForm: FormGroup;
public filteredTags: IMovieDbKeyword[];
public filteredMovieGenres: IMovieDbKeyword[];
public filteredTvGenres: IMovieDbKeyword[];
@ViewChild('fruitInput') public fruitInput: ElementRef<HTMLInputElement>;
constructor(private settingsService: SettingsService,
@ -35,9 +40,13 @@ export class TheMovieDbComponent implements OnInit {
public ngOnInit() {
this.tagForm = this.fb.group({
input: null,
excludedMovieGenres: null,
excludedTvGenres: null,
});
this.settingsService.getTheMovieDbSettings().subscribe(settings => {
this.settings = settings;
// Map Keyword ids -> keyword name
this.excludedKeywords = settings.excludedKeywordIds
? settings.excludedKeywordIds.map(id => ({
id,
@ -45,13 +54,56 @@ export class TheMovieDbComponent implements OnInit {
initial: true,
}))
: [];
this.excludedKeywords.forEach(key => {
this.tmdbService.getKeyword(key.id).subscribe(keyResult => {
this.excludedKeywords.filter((val, idx) => {
val.name = keyResult.name;
})
this.excludedKeywords.forEach(key => {
this.tmdbService.getKeyword(key.id).subscribe(keyResult => {
this.excludedKeywords.filter((val, idx) => {
val.name = keyResult.name;
})
});
});
// Map Movie Genre ids -> genre name
this.excludedMovieGenres = settings.excludedMovieGenreIds
? settings.excludedMovieGenreIds.map(id => ({
id,
name: "",
initial: true,
}))
: [];
this.tmdbService.getGenres("movie").subscribe(results => {
this.filteredMovieGenres = results;
this.excludedMovieGenres.forEach(genre => {
results.forEach(result => {
if (genre.id == result.id) {
genre.name = result.name;
}
});
});
});
// Map Tv Genre ids -> genre name
this.excludedTvGenres = settings.excludedTvGenreIds
? settings.excludedTvGenreIds.map(id => ({
id,
name: "",
initial: true,
}))
: [];
this.tmdbService.getGenres("tv").subscribe(results => {
this.filteredTvGenres = results;
this.excludedTvGenres.forEach(genre => {
results.forEach(result => {
if (genre.id == result.id) {
genre.name = result.name;
}
});
});
});
});
this.tagForm
@ -65,19 +117,48 @@ export class TheMovieDbComponent implements OnInit {
})
)
.subscribe((r) => (this.filteredTags = r));
}
public remove(tag: IKeywordTag): void {
const index = this.excludedKeywords.indexOf(tag);
public remove(tag: IKeywordTag, tag_type: string): void {
var exclusion_list;
switch (tag_type) {
case "keyword":
exclusion_list = this.excludedKeywords;
break;
case "movieGenre":
exclusion_list = this.excludedMovieGenres;
break;
case "tvGenre":
exclusion_list = this.excludedTvGenres;
break;
default:
return;
}
const index = exclusion_list.indexOf(tag);
if (index >= 0) {
this.excludedKeywords.splice(index, 1);
exclusion_list.splice(index, 1);
}
}
public save() {
var selectedMovieGenres: number[] = this.tagForm.controls.excludedMovieGenres.value ?? [];
var selectedTvGenres: number[] = this.tagForm.controls.excludedTvGenres.value ?? [];
var movieIds: number[] = this.excludedMovieGenres.map(k => k.id);
var tvIds: number[] = this.excludedTvGenres.map(k => k.id)
// Concat and dedup already excluded genres + newly selected ones
selectedMovieGenres = movieIds.concat(selectedMovieGenres.filter(item => movieIds.indexOf(item) < 0));
selectedTvGenres = tvIds.concat(selectedTvGenres.filter(item => tvIds.indexOf(item) < 0));
this.settings.excludedKeywordIds = this.excludedKeywords.map(k => k.id);
this.settings.excludedMovieGenreIds = selectedMovieGenres;
this.settings.excludedTvGenreIds = selectedTvGenres;
this.settingsService.saveTheMovieDbSettings(this.settings).subscribe(x => {
if (x) {
this.notificationService.success("Successfully saved The Movie Database settings");

@ -56,17 +56,17 @@
<qrcode id="qrCode" *ngIf="qrCodeEnabled" [qrdata]="qrCode" [size]="256" [level]="'L'"></qrcode>
<div class="row">
<div class="col-12">
<a href='https://play.google.com/store/apps/details?id=com.tidusjar.Ombi&pcampaignid=pcampaignidMKT-Other-global-all-co-prtnr-py-PartBadge-Mar2515-1'
target="_blank"><img width="200px" alt='Get it on Google Play'
src='https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png' /></a>
<a href="https://play.google.com/store/apps/details?id=com.tidusjar.Ombi&pcampaignid=pcampaignidMKT-Other-global-all-co-prtnr-py-PartBadge-Mar2515-1"
target="_blank"><img width="200" alt="Get it on Google Play"
src="https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png" /></a>
</div>
<div class="col-12">
<a href='https://apps.apple.com/us/app/ombi/id1335260043' target="_blank"><img
style="margin-left:13px" width="170px" alt='Get it from the App Store'
src='{{baseUrl}}/images/appstore.svg' /></a>
<a href="https://apps.apple.com/us/app/ombi/id1335260043" target="_blank"><img
style="margin-left:13px" width="170" alt="Get it from the App Store"
src="{{baseUrl}}/images/appstore.svg" /></a>
</div>
<div class="col-12">
<button style="margin-left:13px; margin-top: 20px;" mat-raised-button color="accent" type="button" (click)="openMobileApp($event)">Open Mobile App</button>
<button style="margin-left:13px; margin-top: 20px;" mat-raised-button color="accent" type="button" (click)="openMobileApp($event)">Open Mobile App</button>
</div>
</div>
</div>

@ -145,13 +145,14 @@
<div class="row">
<div class="col-md-3 col-sm-12">
<div class="col-md-6 col-sm-12">
<button *ngIf="!edit" type="button" mat-raised-button color="accent" data-test="createuserbtn" (click)="create()">Create</button>
<div *ngIf="edit">
<button type="button" data-test="updatebtn" mat-raised-button color="accent" class="btn btn-primary-outline" (click)="update()">Update</button>
<button type="button" data-test="deletebtn" mat-raised-button color="warn" class="btn btn-danger-outline" (click)="delete()">Delete</button>
<button type="button" style="float:right;" mat-raised-button color="primary" class="btn btn-info-outline" (click)="resetPassword()" pTooltip="You need your SMTP settings setup">Send
<button type="button" style="float:right;" mat-raised-button color="primary" class="btn btn-info-outline" (click)="resetPassword()" matTooltip="You need your SMTP settings setup">Send
Reset Password Link</button>
<button *ngIf="customization.applicationUrl" type="button" mat-raised-button color="accent" class="btn btn-info-outline" (click)="appLink()" matTooltip="Send this link to the user and they can then open the app and directly login">Copy users App Link</button>
</div>

@ -1,9 +1,10 @@
import { Location } from "@angular/common";
import { AfterViewInit, Component, OnInit } from "@angular/core";
import { ActivatedRoute, Router } from "@angular/router";
import { ActivatedRoute, Router } from "@angular/router";
import { Component, OnInit } from "@angular/core";
import { ICheckbox, ICustomizationSettings, INotificationAgent, INotificationPreferences, IRadarrProfile, IRadarrRootFolder, ISonarrProfile, ISonarrRootFolder, IUser, UserType } from "../interfaces";
import { IdentityService, MessageService, RadarrService, SettingsService, SonarrService } from "../services";
import { ICheckbox, INotificationAgent, INotificationPreferences, IRadarrProfile, IRadarrRootFolder, ISonarrProfile, ISonarrRootFolder, IUser, UserType } from "../interfaces";
import { IdentityService, RadarrService, SonarrService, MessageService } from "../services";
import { Clipboard } from '@angular/cdk/clipboard';
import { Location } from "@angular/common";
@Component({
templateUrl: "./usermanagement-user.component.html",
@ -27,12 +28,17 @@ export class UserManagementUserComponent implements OnInit {
public countries: string[];
private customization: ICustomizationSettings;
private accessToken: string;
constructor(private identityService: IdentityService,
private notificationService: MessageService,
private readonly settingsService: SettingsService,
private router: Router,
private route: ActivatedRoute,
private sonarrService: SonarrService,
private radarrService: RadarrService,
private clipboard: Clipboard,
private location: Location) {
this.route.params.subscribe((params: any) => {
@ -60,6 +66,9 @@ export class UserManagementUserComponent implements OnInit {
this.radarrService.getQualityProfilesFromSettings().subscribe(x => this.radarrQualities = x);
this.radarrService.getRootFoldersFromSettings().subscribe(x => this.radarrRootFolders = x);
this.settingsService.getCustomization().subscribe(x => this.customization = x);
this.identityService.getAccessToken().subscribe(x => this.accessToken = x);
if(!this.edit) {
this.user = {
alias: "",
@ -178,7 +187,12 @@ export class UserManagementUserComponent implements OnInit {
}
});
}
public async appLink() {
this.clipboard.copy(`ombi://${this.customization.applicationUrl}|${this.accessToken}`);
this.notificationService.send("Copied!");
}
public back() {
this.location.back();
}

@ -1,23 +1,19 @@
import { CommonModule } from "@angular/common";
import { NgModule } from "@angular/core";
import { FormsModule, ReactiveFormsModule } from "@angular/forms";
import { RouterModule, Routes } from "@angular/router";
import { ConfirmDialogModule } from "primeng/confirmdialog";
import { MultiSelectModule } from "primeng/multiselect";
import { SidebarModule } from "primeng/sidebar";
import { TooltipModule } from "primeng/tooltip";
import { UserManagementUserComponent } from "./usermanagement-user.component";
import { UserManagementComponent } from "./usermanagement.component";
import { PipeModule } from "../pipes/pipe.module";
import { FormsModule, ReactiveFormsModule } from "@angular/forms";
import { IdentityService, PlexService, RadarrService, SonarrService } from "../services";
import { RouterModule, Routes } from "@angular/router";
import { AuthGuard } from "../auth/auth.guard";
import { CommonModule } from "@angular/common";
import { ConfirmDialogModule } from "primeng/confirmdialog";
import { MultiSelectModule } from "primeng/multiselect";
import { NgModule } from "@angular/core";
import { OrderModule } from "ngx-order-pipe";
import { PipeModule } from "../pipes/pipe.module";
import { SharedModule } from "../shared/shared.module";
import { SidebarModule } from "primeng/sidebar";
import { TooltipModule } from "primeng/tooltip";
import { UserManagementComponent } from "./usermanagement.component";
import { UserManagementUserComponent } from "./usermanagement-user.component";
const routes: Routes = [
{ path: "", component: UserManagementComponent, canActivate: [AuthGuard] },

@ -5,6 +5,10 @@ using Ombi.Attributes;
using System.Collections.Generic;
using System.Threading.Tasks;
// Due to conflicting Genre models in
// Ombi.TheMovieDbApi.Models and Ombi.Api.TheMovieDb.Models
using Genre = Ombi.TheMovieDbApi.Models.Genre;
namespace Ombi.Controllers.External
{
[Admin]
@ -34,5 +38,13 @@ namespace Ombi.Controllers.External
var keyword = await TmdbApi.GetKeyword(keywordId);
return keyword == null ? NotFound() : (IActionResult)Ok(keyword);
}
/// <summary>
/// Gets the genres for either Tv or Movies depending on media type
/// </summary>
/// <param name="media">Either `tv` or `movie`.</param>
[HttpGet("Genres/{media}")]
public async Task<IEnumerable<Genre>> GetGenres(string media) =>
await TmdbApi.GetGenres(media);
}
}

Loading…
Cancel
Save