Making the show details page use the new api for shows, seasons & episodes

This commit is contained in:
Zoe Roux 2020-08-03 01:56:48 +02:00
parent b8d5265316
commit 09f8328900
14 changed files with 234 additions and 119 deletions

View File

@ -1,20 +1,14 @@
import {NgModule} from '@angular/core';
import {RouterModule, Routes} from '@angular/router';
import {LibraryItemGridComponent} from './components/library-item-grid/library-item-grid.component';
import {CollectionComponent} from "./collection/collection.component";
import {NotFoundComponent} from './not-found/not-found.component';
import {PlayerComponent} from "./pages/player/player.component";
import {SearchComponent} from "./pages/search/search.component";
import {CollectionResolverService} from "./services/resolvers/collection-resolver.service";
import {PageResolver} from './services/resolvers/page-resolver.service';
import {PeopleResolverService} from "./services/resolvers/people-resolver.service";
import {SearchResolverService} from "./services/resolvers/search-resolver.service";
import {ShowResolverService} from './services/resolvers/show-resolver.service';
import {StreamResolverService} from "./services/resolvers/stream-resolver.service";
import {ShowDetailsComponent} from './pages/show-details/show-details.component';
import {AuthGuard} from "./auth/misc/authenticated-guard.service";
import {LibraryItem} from "../models/library-item";
import {LibraryItemService, LibraryService} from "./services/api.service";
import {Show} from "../models/show";
import {ItemResolver} from "./services/resolvers/item-resolver.service";
const routes: Routes = [
{path: "browse", component: LibraryItemGridComponent, pathMatch: "full",
@ -28,11 +22,16 @@ const routes: Routes = [
canActivate: [AuthGuard.forPermissions("read")]
},
{path: "show/:show-slug", component: ShowDetailsComponent, resolve: { show: ShowResolverService }, canLoad: [AuthGuard.forPermissions("read")], canActivate: [AuthGuard.forPermissions("read")]},
{path: "collection/:collection-slug", component: CollectionComponent, resolve: { collection: CollectionResolverService }, canLoad: [AuthGuard.forPermissions("read")], canActivate: [AuthGuard.forPermissions("read")]},
{path: "people/:people-slug", component: CollectionComponent, resolve: { collection: PeopleResolverService }, canLoad: [AuthGuard.forPermissions("read")], canActivate: [AuthGuard.forPermissions("read")]},
{path: "watch/:item", component: PlayerComponent, resolve: { item: StreamResolverService }, canLoad: [AuthGuard.forPermissions("play")], canActivate: [AuthGuard.forPermissions("play")]},
{path: "search/:query", component: SearchComponent, resolve: { items: SearchResolverService }, canLoad: [AuthGuard.forPermissions("read")], canActivate: [AuthGuard.forPermissions("read")]},
{path: "show/:slug", component: ShowDetailsComponent,
resolve: { show: ItemResolver.forResource<Show>("shows/:slug") },
canLoad: [AuthGuard.forPermissions("read")],
canActivate: [AuthGuard.forPermissions("read")]
},
// {path: "collection/:collection-slug", component: CollectionComponent, resolve: { collection: CollectionResolverService }, canLoad: [AuthGuard.forPermissions("read")], canActivate: [AuthGuard.forPermissions("read")]},
//
// {path: "people/:people-slug", component: CollectionComponent, resolve: { collection: PeopleResolverService }, canLoad: [AuthGuard.forPermissions("read")], canActivate: [AuthGuard.forPermissions("read")]},
// {path: "watch/:item", component: PlayerComponent, resolve: { item: StreamResolverService }, canLoad: [AuthGuard.forPermissions("play")], canActivate: [AuthGuard.forPermissions("play")]},
// {path: "search/:query", component: SearchComponent, resolve: { items: SearchResolverService }, canLoad: [AuthGuard.forPermissions("read")], canActivate: [AuthGuard.forPermissions("read")]},
{path: "**", component: NotFoundComponent}
];
@ -46,11 +45,7 @@ const routes: Routes = [
LibraryService,
LibraryItemService,
PageResolver.resolvers,
ShowResolverService,
CollectionResolverService,
PeopleResolverService,
StreamResolverService,
SearchResolverService
ItemResolver.resolvers,
]
})
export class AppRoutingModule { }

View File

@ -1,6 +1,6 @@
<div class="root">
<div class="episodes" #scrollView (scroll)="onScroll()">
<div class="episode" *ngFor="let episode of this.episodes" #episodeDom >
<div class="episode" *ngFor="let episode of this.episodes?.items" #itemsDom >
<button mat-icon-button class="moreBtn" [matMenuTriggerFor]="more" [matMenuTriggerData]="{episode: episode}"><i class="material-icons">more_vert</i></button>
<a routerLink="/watch/{{episode.slug}}" href="/watch/{{episode.slug}}">
<div matRipple class="img" [style.background-image]="sanitize(episode.thumb)">

View File

@ -1,56 +1,22 @@
import { Component, ElementRef, Input, ViewChild } from '@angular/core';
import { MatButton } from "@angular/material/button";
import { Component, Input} from '@angular/core';
import { DomSanitizer } from "@angular/platform-browser";
import { Episode } from "../../../models/episode";
import {HorizontalScroller} from "../../misc/horizontal-scroller";
import {Page} from "../../../models/page";
@Component({
selector: 'app-episodes-list',
templateUrl: './episodes-list.component.html',
styleUrls: ['./episodes-list.component.scss']
})
export class EpisodesListComponent
export class EpisodesListComponent extends HorizontalScroller
{
@Input() displayShowTitle: boolean = false;
@Input() episodes: Episode[];
@ViewChild("scrollView", { static: true }) private scrollView: ElementRef;
@ViewChild("leftBtn", { static: false }) private leftBtn: MatButton;
@ViewChild("rightBtn", { static: false }) private rightBtn: MatButton;
@ViewChild("episodeDom", { static: false }) private episode: ElementRef;
@Input() episodes: Page<Episode>;
constructor(private sanitizer: DomSanitizer) { }
scrollLeft()
constructor(private sanitizer: DomSanitizer)
{
let scroll: number = this.roundScroll(this.scrollView.nativeElement.offsetWidth * 0.80);
this.scrollView.nativeElement.scrollBy({ top: 0, left: -scroll, behavior: "smooth" });
}
scrollRight()
{
let scroll: number = this.roundScroll(this.scrollView.nativeElement.offsetWidth * 0.80);
this.scrollView.nativeElement.scrollBy({ top: 0, left: scroll, behavior: "smooth" });
}
roundScroll(offset: number): number
{
let episodeSize: number = this.episode.nativeElement.scrollWidth;
offset = Math.round(offset / episodeSize) * episodeSize;
if (offset == 0)
offset = episodeSize;
return offset;
}
onScroll()
{
if (this.scrollView.nativeElement.scrollLeft <= 0)
this.leftBtn._elementRef.nativeElement.classList.add("d-none");
else
this.leftBtn._elementRef.nativeElement.classList.remove("d-none");
if (this.scrollView.nativeElement.scrollLeft >= this.scrollView.nativeElement.scrollWidth - this.scrollView.nativeElement.clientWidth)
this.rightBtn._elementRef.nativeElement.classList.add("d-none");
else
this.rightBtn._elementRef.nativeElement.classList.remove("d-none");
super();
}
sanitize(url: string)

View File

@ -16,7 +16,7 @@ export class LibraryItemGridComponent
@Input() page: Page<LibraryItem>;
@Input() sortEnabled: boolean = true;
sortType: string = "title";
sortKeys: string[] = ["title", "start year", "end year", "status", "type"]
sortKeys: string[] = ["title", "start year", "end year"]
sortUp: boolean = true;
constructor(private route: ActivatedRoute,
@ -48,7 +48,7 @@ export class LibraryItemGridComponent
this.sortType = type;
this.sortUp = order;
this.items.getAll({sort: `${this.sortType.replace(/\s/g, "")}:${this.sortUp ? "asc" : "desc"}`})
this.items.getAll({sortBy: `${this.sortType.replace(/\s/g, "")}:${this.sortUp ? "asc" : "desc"}`})
.subscribe(x => this.page = x);
}
}

View File

@ -0,0 +1,44 @@
import {ElementRef, ViewChild} from "@angular/core";
import {MatButton} from "@angular/material/button";
export class HorizontalScroller
{
@ViewChild("scrollView", { static: true }) private scrollView: ElementRef;
@ViewChild("leftBtn", { static: false }) private leftBtn: MatButton;
@ViewChild("rightBtn", { static: false }) private rightBtn: MatButton;
@ViewChild("itemsDom", { static: false }) private items: ElementRef;
scrollLeft()
{
let scroll: number = this.roundScroll(this.scrollView.nativeElement.offsetWidth * 0.80);
this.scrollView.nativeElement.scrollBy({ top: 0, left: -scroll, behavior: "smooth" });
}
scrollRight()
{
let scroll: number = this.roundScroll(this.scrollView.nativeElement.offsetWidth * 0.80);
this.scrollView.nativeElement.scrollBy({ top: 0, left: scroll, behavior: "smooth" });
}
roundScroll(offset: number): number
{
let itemSize: number = this.items.nativeElement.scrollWidth;
offset = Math.round(offset / itemSize) * itemSize;
if (offset == 0)
offset = itemSize;
return offset;
}
onScroll()
{
if (this.scrollView.nativeElement.scrollLeft <= 0)
this.leftBtn._elementRef.nativeElement.classList.add("d-none");
else
this.leftBtn._elementRef.nativeElement.classList.remove("d-none");
if (this.scrollView.nativeElement.scrollLeft >= this.scrollView.nativeElement.scrollWidth - this.scrollView.nativeElement.clientWidth)
this.rightBtn._elementRef.nativeElement.classList.add("d-none");
else
this.rightBtn._elementRef.nativeElement.classList.remove("d-none");
}
}

View File

@ -78,12 +78,12 @@
<div class="container-fluid mt-3">
<mat-form-field>
<mat-label>Season</mat-label>
<mat-select [(value)]="season" (selectionChange)="getEpisodes()">
<mat-option *ngFor="let season of this.show.seasons" [value]="season.seasonNumber">{{season.title}}</mat-option>
<mat-select [(value)]="season" (selectionChange)="getEpisodes(season.seasonNumber)">
<mat-option *ngFor="let season of this.seasons" [value]="season.seasonNumber">{{season.title}}</mat-option>
</mat-select>
</mat-form-field>
</div>
<app-episodes-list [episodes]="episodes"></app-episodes-list>
<app-episodes-list [episodes]="episodes[season]"></app-episodes-list>
</div>
<div class="container-fluid mt-5">

View File

@ -1,4 +1,3 @@
import { HttpClient } from "@angular/common/http";
import { Component, OnInit } from '@angular/core';
import { MatSnackBar } from "@angular/material/snack-bar";
import { Title } from '@angular/platform-browser';
@ -8,7 +7,9 @@ import { Show } from "../../../models/show";
import {MatDialog} from "@angular/material/dialog";
import {TrailerDialogComponent} from "../trailer-dialog/trailer-dialog.component";
import {MetadataEditComponent} from "../metadata-edit/metadata-edit.component";
import {Account} from "../../../models/account";
import {Season} from "../../../models/season";
import {EpisodeService, SeasonService} from "../../services/api.service";
import {Page} from "../../../models/page";
@Component({
selector: 'app-show-details',
@ -18,17 +19,24 @@ import {Account} from "../../../models/account";
export class ShowDetailsComponent implements OnInit
{
show: Show;
episodes: Episode[] = null;
season: number;
seasons: Season[];
season: number = 1;
episodes: Page<Episode>[] = [];
private toolbar: HTMLElement;
private backdrop: HTMLElement;
constructor(private route: ActivatedRoute, private http: HttpClient, private snackBar: MatSnackBar, private title: Title, private router: Router, private dialog: MatDialog)
constructor(private route: ActivatedRoute,
private snackBar: MatSnackBar,
private title: Title,
private router: Router,
private dialog: MatDialog,
private seasonService: SeasonService,
private episodeService: EpisodeService)
{
this.route.queryParams.subscribe(params =>
{
this.season = params["season"];
this.season = params["season"] ?? 1;
});
this.route.data.subscribe(data =>
@ -36,10 +44,19 @@ export class ShowDetailsComponent implements OnInit
this.show = data.show;
this.title.setTitle(this.show.title + " - Kyoo");
if (this.season == undefined || this.show.seasons == undefined || this.show.seasons.find(x => x.seasonNumber == this.season) == null)
this.season = 1;
if (this.show.isMovie)
return;
this.getEpisodes();
this.seasonService.getForShow(this.show.slug, {limit: 0}).subscribe(x =>
{
this.seasons = x.items;
if (x.items.find(x => x.seasonNumber == this.season) == null)
{
this.season = 1;
this.getEpisodes(1);
}
});
this.getEpisodes(this.season);
});
}
@ -72,23 +89,17 @@ export class ShowDetailsComponent implements OnInit
this.router.navigate(["/watch/" + this.show.slug + "-s1e1"]);
}
getEpisodes()
getEpisodes(season: number)
{
if (this.show == undefined || this.show.seasons == undefined)
if (season < 0)
return;
if (this.show.seasons.find(x => x.seasonNumber == this.season).episodes != null)
this.episodes = this.show.seasons.find(x => x.seasonNumber == this.season).episodes;
if (this.episodes[season] != undefined)
return;
this.http.get<Episode[]>("api/episodes/" + this.show.slug + "/season/" + this.season).subscribe((episodes: Episode[]) =>
this.episodeService.getFromSeasonNumber(this.show.slug, this.season).subscribe(x =>
{
this.show.seasons.find(x => x.seasonNumber == this.season).episodes = episodes;
this.episodes = episodes;
}, error =>
{
console.log(error.status + " - " + error.message);
this.snackBar.open("An unknow error occured while getting episodes.", null, { horizontalPosition: "left", panelClass: ['snackError'], duration: 2500 });
this.episodes[season] = x;
});
}
@ -108,10 +119,10 @@ export class ShowDetailsComponent implements OnInit
redownloadImages()
{
this.http.post("api/show/download-images/" + this.show.slug, undefined).subscribe(() => { }, error =>
{
console.log(error.status + " - " + error.message);
this.snackBar.open("An unknown error occured while re-downloading images.", null, { horizontalPosition: "left", panelClass: ['snackError'], duration: 2500 });
});
// this.http.post("api/show/download-images/" + this.show.slug, undefined).subscribe(() => { }, error =>
// {
// console.log(error.status + " - " + error.message);
// this.snackBar.open("An unknown error occured while re-downloading images.", null, { horizontalPosition: "left", panelClass: ['snackError'], duration: 2500 });
// });
}
}

View File

@ -6,24 +6,38 @@ import {IResource} from "../../models/resources/resource";
import {Library} from "../../models/library";
import {LibraryItem} from "../../models/library-item";
import {map} from "rxjs/operators";
import {Season} from "../../models/season";
import {Episode} from "../../models/episode";
export interface ApiArgs
{
sortBy?: string;
limit?: number;
afterID?: number;
[key: string]: any;
}
class CrudApi<T extends IResource>
{
constructor(private client: HttpClient, private route: string) {}
constructor(protected client: HttpClient, private route: string) {}
get(id: number | string): Observable<T>
{
return this.client.get<T>(`/api/${this.route}/${id}`);
}
getAll(args: {sort: string} = null): Observable<Page<T>>
protected ArgsAsQuery(args: ApiArgs): string
{
let params: string = "?";
if (args && args.sort)
params += "sortBy=" + args.sort;
if (params == "?")
params = "";
return this.client.get<Page<T>>(`/api/${this.route}${params}`)
if (args == null)
return "";
let params: string = Object.keys(args).map(x => `${x}=${args[x]}`).join("&");
return params ? `?${params}` : "";
}
getAll(args?: ApiArgs): Observable<Page<T>>
{
return this.client.get<Page<T>>(`/api/${this.route}${this.ArgsAsQuery(args)}`)
.pipe(map(x => Object.assign(new Page<T>(), x)));
}
@ -64,3 +78,43 @@ export class LibraryItemService extends CrudApi<LibraryItem>
super(client, "items");
}
}
@Injectable({
providedIn: 'root'
})
export class SeasonService extends CrudApi<Season>
{
constructor(client: HttpClient)
{
super(client, "seasons");
}
getForShow(show: string | number, args?: ApiArgs): Observable<Page<Season>>
{
return this.client.get(`/api/show/${show}/seasons${this.ArgsAsQuery(args)}`)
.pipe(map(x => Object.assign(new Page<Season>(), x)));
}
}
@Injectable({
providedIn: 'root'
})
export class EpisodeService extends CrudApi<Episode>
{
constructor(client: HttpClient)
{
super(client, "episodes");
}
getFromSeason(season: string | number, args?: ApiArgs): Observable<Page<Episode>>
{
return this.client.get(`/api/seasons/${season}/episodes${this.ArgsAsQuery(args)}`)
.pipe(map(x => Object.assign(new Page<Episode>(), x)));
}
getFromSeasonNumber(show: string | number, seasonNumber: number, args?: ApiArgs): Observable<Page<Episode>>
{
return this.client.get(`/api/seasons/${show}-${seasonNumber}/episodes${this.ArgsAsQuery(args)}`)
.pipe(map(x => Object.assign(new Page<Episode>(), x)));
}
}

View File

@ -0,0 +1,44 @@
import {HttpClient, HttpErrorResponse} from '@angular/common/http';
import {Injectable} from '@angular/core';
import {MatSnackBar} from '@angular/material/snack-bar';
import {ActivatedRouteSnapshot, Resolve} from '@angular/router';
import {Observable, EMPTY} from 'rxjs';
import {catchError} from 'rxjs/operators';
import {IResource} from "../../../models/resources/resource";
@Injectable()
export class ItemResolver
{
public static resolvers: any[] = [];
static forResource<T extends IResource>(resource: string)
{
@Injectable()
class Resolver implements Resolve<T>
{
constructor(private http: HttpClient,
private snackBar: MatSnackBar)
{ }
resolve(route: ActivatedRouteSnapshot): T | Observable<T> | Promise<T>
{
let res: string = resource.replace(/:(.*?)(\/|$)/, (x, y) => `${route.paramMap.get(y)}/`);
return this.http.get<T>(`api/${res}`)
.pipe(
catchError((error: HttpErrorResponse) =>
{
console.log(error.status + " - " + error.message);
this.snackBar.open(`An unknown error occurred: ${error.message}.`, null, {
horizontalPosition: "left",
panelClass: ['snackError'],
duration: 2500
});
return EMPTY;
}));
}
}
ItemResolver.resolvers.push(Resolver);
return Resolver;
}
}

View File

@ -23,7 +23,7 @@ export class PageResolver
resolve(route: ActivatedRouteSnapshot): Page<T> | Observable<Page<T>> | Promise<Page<T>>
{
let res: string = resource.replace(/:(.*?)\//, (x, y) => `${route.paramMap.get(y)}/`);
let res: string = resource.replace(/:(.*?)(\/|$)/, (x, y) => `${route.paramMap.get(y)}/`);
return this.http.get<Page<T>>(`api/${res}`)
.pipe(

View File

@ -13,15 +13,15 @@ export class ShowResolverService implements Resolve<Show>
resolve(route: ActivatedRouteSnapshot): Show | Observable<Show> | Promise<Show>
{
let slug: string = route.paramMap.get("show-slug");
return this.http.get<Show>("api/shows/" + slug).pipe(catchError((error: HttpErrorResponse) =>
{
console.log(error.status + " - " + error.message);
if (error.status == 404)
this.snackBar.open("Show \"" + slug + "\" not found.", null, { horizontalPosition: "left", panelClass: ['snackError'], duration: 2500 });
else
this.snackBar.open("An unknown error occured.", null, { horizontalPosition: "left", panelClass: ['snackError'], duration: 2500 });
return EMPTY;
}));
let slug: string = route.paramMap.get("show-slug");
return this.http.get<Show>("api/shows/" + slug).pipe(catchError((error: HttpErrorResponse) =>
{
console.log(error.status + " - " + error.message);
if (error.status == 404)
this.snackBar.open("Show \"" + slug + "\" not found.", null, { horizontalPosition: "left", panelClass: ['snackError'], duration: 2500 });
else
this.snackBar.open("An unknown error occured.", null, { horizontalPosition: "left", panelClass: ['snackError'], duration: 2500 });
return EMPTY;
}));
}
}

View File

@ -1,6 +1,7 @@
import {ExternalID} from "./external-id";
import {IResource} from "./resources/resource";
export interface Episode
export interface Episode extends IResource
{
seasonNumber: number;
episodeNumber: number;

View File

@ -1,7 +1,8 @@
import { Episode } from "./episode";
import {ExternalID} from "./external-id";
import {IResource} from "./resources/resource";
export interface Season
export interface Season extends IResource
{
seasonNumber: number;
title: string;

View File

@ -1,12 +1,12 @@
import { Season } from "./season";
import { Genre } from "./genre";
import { People } from "./people";
import { Studio } from "./studio";
import {Season} from "./season";
import {Genre} from "./genre";
import {People} from "./people";
import {Studio} from "./studio";
import {ExternalID} from "./external-id";
import {IResource} from "./resources/resource";
export interface Show
export interface Show extends IResource
{
slug: string;
title: string;
aliases: string[];
overview: string;
@ -16,7 +16,6 @@ export interface Show
people: People[];
seasons: Season[];
trailerUrl: string;
isCollection: boolean;
isMovie: boolean;
startYear: number;
endYear : number;