diff --git a/src/Ombi.Settings/Settings/Models/FeatureSettings.cs b/src/Ombi.Settings/Settings/Models/FeatureSettings.cs new file mode 100644 index 000000000..6a285d422 --- /dev/null +++ b/src/Ombi.Settings/Settings/Models/FeatureSettings.cs @@ -0,0 +1,24 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Ombi.Settings.Settings.Models +{ + public class FeatureSettings : Settings + { + public List Features { get; set; } + } + + public class FeatureEnablement + { + public string Name { get; set; } + public bool Enabled { get; set; } + } + + public static class FeatureNames + { + public const string Movie4KRequests = nameof(Movie4KRequests); + } +} diff --git a/src/Ombi.Store/Context/SettingsContext.cs b/src/Ombi.Store/Context/SettingsContext.cs index 83ffa792d..39883cb92 100644 --- a/src/Ombi.Store/Context/SettingsContext.cs +++ b/src/Ombi.Store/Context/SettingsContext.cs @@ -1,5 +1,6 @@ using System.Linq; using Microsoft.EntityFrameworkCore; +using Newtonsoft.Json; using Ombi.Store.Entities; namespace Ombi.Store.Context diff --git a/src/Ombi/ClientApp/src/app/app.module.ts b/src/Ombi/ClientApp/src/app/app.module.ts index 082ce80d2..735dce36b 100644 --- a/src/Ombi/ClientApp/src/app/app.module.ts +++ b/src/Ombi/ClientApp/src/app/app.module.ts @@ -21,6 +21,8 @@ import { CustomPageComponent } from "./custompage/custompage.component"; import { CustomizationState } from "./state/customization/customization.state"; import { DataViewModule } from "primeng/dataview"; import { DialogModule } from "primeng/dialog"; +import { FEATURES_INITIALIZER } from "./state/features/features-initializer"; +import { FeatureState } from "./state/features"; import { JwtModule } from "@auth0/angular-jwt"; import { LandingPageComponent } from "./landingpage/landingpage.component"; import { LandingPageService } from "./services"; @@ -38,6 +40,8 @@ import { MatInputModule } from "@angular/material/input"; import { MatListModule } from '@angular/material/list'; import { MatMenuModule } from "@angular/material/menu"; import { MatNativeDateModule } from '@angular/material/core'; +import { MatPaginatorI18n } from "./localization/MatPaginatorI18n"; +import { MatPaginatorIntl } from "@angular/material/paginator"; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatSidenavModule } from '@angular/material/sidenav'; import { MatSlideToggleModule } from "@angular/material/slide-toggle"; @@ -63,11 +67,9 @@ import { StorageService } from "./shared/storage/storage-service"; import { TokenResetPasswordComponent } from "./login/tokenresetpassword.component"; import { TooltipModule } from "primeng/tooltip"; import { TranslateHttpLoader } from "@ngx-translate/http-loader"; +import { TranslateService } from "@ngx-translate/core"; import { UnauthorizedInterceptor } from "./auth/unauthorized.interceptor"; import { environment } from "../environments/environment"; -import { MatPaginatorIntl } from "@angular/material/paginator"; -import { TranslateService } from "@ngx-translate/core"; -import { MatPaginatorI18n } from "./localization/MatPaginatorI18n"; const routes: Routes = [ { path: "*", component: PageNotFoundComponent }, @@ -162,10 +164,10 @@ export function JwtTokenGetter() { }), SidebarModule, MatNativeDateModule, MatIconModule, MatSidenavModule, MatListModule, MatToolbarModule, LayoutModule, MatSlideToggleModule, - NgxsModule.forRoot([CustomizationState], { + NgxsModule.forRoot([CustomizationState, FeatureState], { developmentMode: !environment.production, }), - ...environment.production ? [] : + ...environment.production ? [] : [ NgxsReduxDevtoolsPluginModule.forRoot(), ] @@ -205,6 +207,7 @@ export function JwtTokenGetter() { StorageService, RequestService, SignalRNotificationService, + FEATURES_INITIALIZER, CUSTOMIZATION_INITIALIZER, { provide: APP_BASE_HREF, diff --git a/src/Ombi/ClientApp/src/app/interfaces/IUser.ts b/src/Ombi/ClientApp/src/app/interfaces/IUser.ts index 97882e783..069b1e506 100644 --- a/src/Ombi/ClientApp/src/app/interfaces/IUser.ts +++ b/src/Ombi/ClientApp/src/app/interfaces/IUser.ts @@ -146,3 +146,8 @@ export enum INotificationAgent { Webhook = 9, WhatsApp = 10 } + +export interface IFeatureEnablement { + name: string; + enabled: boolean; +} \ No newline at end of file diff --git a/src/Ombi/ClientApp/src/app/services/feature.service.ts b/src/Ombi/ClientApp/src/app/services/feature.service.ts new file mode 100644 index 000000000..0ea285b46 --- /dev/null +++ b/src/Ombi/ClientApp/src/app/services/feature.service.ts @@ -0,0 +1,19 @@ +import { APP_BASE_HREF } from "@angular/common"; +import { Injectable, Inject } from "@angular/core"; + +import { HttpClient } from "@angular/common/http"; +import { Observable } from "rxjs"; + +import { IFeatureEnablement } from "../interfaces"; +import { ServiceHelpers } from "./service.helpers"; + +@Injectable({ providedIn: "root" }) +export class FeatureService extends ServiceHelpers { + constructor(http: HttpClient, @Inject(APP_BASE_HREF) href:string) { + super(http, "/api/v2/Features/", href); + } + + public getFeatures(): Observable { + return this.http.get(this.url, {headers: this.headers}); + } +} diff --git a/src/Ombi/ClientApp/src/app/state/customization/customization.state.ts b/src/Ombi/ClientApp/src/app/state/customization/customization.state.ts index 57b3f5908..5261183c3 100644 --- a/src/Ombi/ClientApp/src/app/state/customization/customization.state.ts +++ b/src/Ombi/ClientApp/src/app/state/customization/customization.state.ts @@ -6,7 +6,6 @@ import { ICustomizationSettings } from "../../interfaces"; import { Injectable } from "@angular/core"; import { Observable } from "rxjs"; import { SettingsService } from "../../services"; -import { produce } from 'immer'; import { tap } from "rxjs/operators"; @State({ diff --git a/src/Ombi/ClientApp/src/app/state/features/features-initializer.ts b/src/Ombi/ClientApp/src/app/state/features/features-initializer.ts new file mode 100644 index 000000000..2324259f3 --- /dev/null +++ b/src/Ombi/ClientApp/src/app/state/features/features-initializer.ts @@ -0,0 +1,10 @@ +import { APP_INITIALIZER } from "@angular/core"; +import { FeaturesFacade } from "./features.facade"; +import { Observable } from "rxjs"; + +export const FEATURES_INITIALIZER = { + provide: APP_INITIALIZER, + useFactory: (featureFacade: FeaturesFacade) => (): Observable => featureFacade.loadFeatures(), + multi: true, + deps: [FeaturesFacade], +}; \ No newline at end of file diff --git a/src/Ombi/ClientApp/src/app/state/features/features.actions.ts b/src/Ombi/ClientApp/src/app/state/features/features.actions.ts new file mode 100644 index 000000000..87f050354 --- /dev/null +++ b/src/Ombi/ClientApp/src/app/state/features/features.actions.ts @@ -0,0 +1,3 @@ +export class LoadFeatures { + public static readonly type = '[Features] LoadAll'; +} diff --git a/src/Ombi/ClientApp/src/app/state/features/features.facade.ts b/src/Ombi/ClientApp/src/app/state/features/features.facade.ts new file mode 100644 index 000000000..bd6e4edbb --- /dev/null +++ b/src/Ombi/ClientApp/src/app/state/features/features.facade.ts @@ -0,0 +1,21 @@ +import { FeaturesSelectors } from "./features.selectors"; +import { IFeatureEnablement } from "../../interfaces"; +import { Injectable } from "@angular/core"; +import { LoadFeatures } from "./features.actions"; +import { Observable } from "rxjs"; +import { Store } from "@ngxs/store"; + +@Injectable({ + providedIn: 'root', +}) +export class FeaturesFacade { + + public constructor(private store: Store) {} + + public features$ = (): Observable => this.store.select(FeaturesSelectors.features); + + public loadFeatures = (): Observable => this.store.dispatch(new LoadFeatures()); + + public is4kEnabled = (): boolean => this.store.selectSnapshot(FeaturesSelectors.is4kEnabled); + +} \ No newline at end of file diff --git a/src/Ombi/ClientApp/src/app/state/features/features.selectors.ts b/src/Ombi/ClientApp/src/app/state/features/features.selectors.ts new file mode 100644 index 000000000..143dfb875 --- /dev/null +++ b/src/Ombi/ClientApp/src/app/state/features/features.selectors.ts @@ -0,0 +1,18 @@ +import { ICustomizationSettings, IFeatureEnablement } from "../../interfaces"; + +import { FEATURES_STATE_TOKEN } from "./types"; +import { Selector } from "@ngxs/store"; + +export class FeaturesSelectors { + + @Selector([FEATURES_STATE_TOKEN]) + public static features(features: IFeatureEnablement[]): IFeatureEnablement[] { + return features; + } + + @Selector([FeaturesSelectors.features]) + public static is4kEnabled(features: IFeatureEnablement[]): boolean { + return features.filter(x => x.name === "Movie4KRequests")[0].enabled; + } + +} \ No newline at end of file diff --git a/src/Ombi/ClientApp/src/app/state/features/features.state.ts b/src/Ombi/ClientApp/src/app/state/features/features.state.ts new file mode 100644 index 000000000..741523c6b --- /dev/null +++ b/src/Ombi/ClientApp/src/app/state/features/features.state.ts @@ -0,0 +1,26 @@ +import { Action, State, StateContext } from "@ngxs/store"; + +import { FEATURES_STATE_TOKEN } from "./types"; +import { FeatureService } from "../../services/feature.service"; +import { IFeatureEnablement } from "../../interfaces"; +import { Injectable } from "@angular/core"; +import { LoadFeatures } from "./features.actions"; +import { Observable } from "rxjs"; +import { tap } from "rxjs/operators"; + +@State({ + name: FEATURES_STATE_TOKEN +}) +@Injectable() +export class FeatureState { + constructor(private featuresService: FeatureService) { } + + @Action(LoadFeatures) + public load({ setState }: StateContext): Observable { + return this.featuresService.getFeatures().pipe( + tap(features => + setState(features) + ) + ); + } +} \ No newline at end of file diff --git a/src/Ombi/ClientApp/src/app/state/features/index.ts b/src/Ombi/ClientApp/src/app/state/features/index.ts new file mode 100644 index 000000000..e0fec5274 --- /dev/null +++ b/src/Ombi/ClientApp/src/app/state/features/index.ts @@ -0,0 +1,4 @@ +export * from './features.state'; +export * from './features.actions'; +export * from './features.facade'; +export * from './features.selectors'; diff --git a/src/Ombi/ClientApp/src/app/state/features/types.ts b/src/Ombi/ClientApp/src/app/state/features/types.ts new file mode 100644 index 000000000..d8fdc09a1 --- /dev/null +++ b/src/Ombi/ClientApp/src/app/state/features/types.ts @@ -0,0 +1,4 @@ +import { IFeatureEnablement } from "../../interfaces"; +import { StateToken } from "@ngxs/store"; + +export const FEATURES_STATE_TOKEN = new StateToken('featureEnablement'); \ No newline at end of file diff --git a/src/Ombi/Controllers/V2/FeaturesController.cs b/src/Ombi/Controllers/V2/FeaturesController.cs new file mode 100644 index 000000000..ecab28f79 --- /dev/null +++ b/src/Ombi/Controllers/V2/FeaturesController.cs @@ -0,0 +1,54 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Ombi.Core.Settings; +using Ombi.Settings.Settings.Models; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; + +namespace Ombi.Controllers.V2 +{ + public class FeaturesController : V2Controller + { + private readonly ISettingsService _features; + + public FeaturesController(ISettingsService features) => _features = features; + + [HttpGet] + [AllowAnonymous] + public async Task> GetFeatures() + { + var features = await _features.GetSettingsAsync(); + return PopulateFeatures(features?.Features ?? new List()); + } + + private List PopulateFeatures(List existingFeatures) + { + var supported = GetSupportedFeatures().ToList(); + if (supported.Count == existingFeatures.Count) + { + return existingFeatures; + } + var diff = supported.Except(existingFeatures.Select(x => x.Name)); + + foreach (var feature in diff) + { + existingFeatures.Add(new FeatureEnablement + { + Name = feature + }); + } + return existingFeatures; + } + + private IEnumerable GetSupportedFeatures() + { + FieldInfo[] fieldInfos = typeof(FeatureNames).GetFields(BindingFlags.Public | + BindingFlags.Static | BindingFlags.FlattenHierarchy); + + return fieldInfos.Where(fi => fi.IsLiteral && !fi.IsInitOnly && fi.FieldType == typeof(string)).Select(x => (string)x.GetValue(x)); + } + } +}