diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md deleted file mode 100644 index c063ac22..00000000 --- a/docs/CHANGELOG.md +++ /dev/null @@ -1,3 +0,0 @@ -4/10/21 -- Updated transmission **internal** download path to `/downloads/`. Previously it was `/downloads/completed`. This won't affect existing users unless you update `transmission-settings.json` and/or `docker-compose-base.yml` from GitHub. -- Subtitles are now (optionally) downloaded via celery which will be running as `root` if you haven't updated your `docker-compose.base.yml` to use the new entrypoint which will otherwise run it as a non-privileged user. diff --git a/src/frontend/.gitignore b/src/frontend/.gitignore index 84c8d4f0..b54b45a1 100644 --- a/src/frontend/.gitignore +++ b/src/frontend/.gitignore @@ -38,3 +38,5 @@ testem.log # System Files .DS_Store Thumbs.db + +.angular diff --git a/src/frontend/src/app/api.service.ts b/src/frontend/src/app/api.service.ts index 4a16c86e..06e17022 100644 --- a/src/frontend/src/app/api.service.ts +++ b/src/frontend/src/app/api.service.ts @@ -39,7 +39,8 @@ export class ApiService { API_URL_GENRES_MOVIE = '/api/genres/movie/'; API_URL_GENRES_TV = '/api/genres/tv/'; API_URL_MEDIA_CATEGORIES = '/api/media-categories/'; - API_URL_QUALITY_PROFILES = '/api/quality-profiles/'; + API_URL_QUALITIES = '/api/qualities/'; + API_URL_QUALITY_PROFILES = '/api/quality-profile/'; API_URL_GIT_COMMIT = '/api/git-commit/'; API_URL_IMPORT_MEDIA_TV = '/api/import/media/tv/'; API_URL_IMPORT_MEDIA_MOVIE = '/api/import/media/movie/'; @@ -55,7 +56,8 @@ export class ApiService { public userToken: string; public users: any; // staff-only list of all users public settings: any; - public qualityProfiles: string[]; + public qualities: string[] = []; + public qualityProfiles: any[] = []; public mediaCategories: string[]; public watchTVSeasons: any[] = []; public watchTVSeasonRequests: any[] = []; @@ -180,6 +182,7 @@ export class ApiService { } }) ), + this.fetchQualities(), this.fetchQualityProfiles(), this.fetchMediaCategories(), ]).pipe( @@ -211,7 +214,7 @@ export class ApiService { ); } - public fetchSettings() { + public fetchSettings(): Observable { return this.http.get(this.API_URL_SETTINGS, {headers: this._requestHeaders()}).pipe( map((data: any) => { if (data.length) { @@ -224,7 +227,7 @@ export class ApiService { ); } - public fetchMediaCategories() { + public fetchMediaCategories(): Observable { return this.http.get(this.API_URL_MEDIA_CATEGORIES, {headers: this._requestHeaders()}).pipe( map((data: any) => { if (data.mediaCategories) { @@ -237,11 +240,24 @@ export class ApiService { ); } - public fetchQualityProfiles() { + public fetchQualities(): Observable { + return this.http.get(this.API_URL_QUALITIES, {headers: this._requestHeaders()}).pipe( + map((data: any) => { + if (data.length) { + this.qualities = data; + } else { + console.error('no qualities'); + } + return this.qualities; + }), + ); + } + + public fetchQualityProfiles(): Observable { return this.http.get(this.API_URL_QUALITY_PROFILES, {headers: this._requestHeaders()}).pipe( map((data: any) => { - if (data.profiles) { - this.qualityProfiles = data.profiles; + if (data.length) { + this.qualityProfiles = data; } else { console.error('no quality profiles'); } @@ -303,7 +319,7 @@ export class ApiService { ); } - public login(user: string, pass: string) { + public login(user: string, pass: string): Observable { const params = { username: user, password: pass, @@ -313,8 +329,8 @@ export class ApiService { console.log('token auth', data); this.userToken = data.token; this.localStorage.set(this.STORAGE_KEY_API_TOKEN, this.userToken).subscribe( - (wasSet) => { - console.log('local storage set', wasSet); + () => { + console.log('local storage set'); }, (error) => { console.error('local storage error', error); @@ -334,7 +350,45 @@ export class ApiService { ); } - public searchTorrents(query: string, mediaType: string) { + public updateQualityProfile(id: number, params: any): Observable { + return this.http.patch(`${this.API_URL_QUALITY_PROFILES}${id}/`, params, {headers: this._requestHeaders()}).pipe( + map((data: any) => { + this.qualityProfiles.forEach((profile, index) => { + if (profile.id === id) { + this.qualityProfiles[index] = params; + } + }) + }), + ); + } + + public deleteQualityProfile(id: number): Observable { + return this.http.delete(`${this.API_URL_QUALITY_PROFILES}${id}/`, {headers: this._requestHeaders()}).pipe( + map((data: any) => { + // remove this quality profile + this.qualityProfiles = this.qualityProfiles.filter(profile => profile.id !== id); + // unset quality profile from Movie/TVShow/TVSeasonRequest media records + [this.watchMovies, this.watchTVShows, this.watchTVSeasonRequests].forEach((watchMediaList) => { + watchMediaList.forEach((watchMedia) => { + if (watchMedia.quality_profile === id) { + watchMedia.quality_profile = null; + } + }) + }) + }), + ); + } + + public createQualityProfile(data: any): Observable { + return this.http.post(this.API_URL_QUALITY_PROFILES, data, {headers: this._requestHeaders()}).pipe( + map((data: any) => { + // append this new quality profile + this.qualityProfiles.push(data); + }), + ); + } + + public searchTorrents(query: string, mediaType: string): Observable { return this.http.get(`${this.API_URL_SEARCH_TORRENTS}?q=${query}&media_type=${mediaType}`, {headers: this._requestHeaders()}).pipe( map((data: any) => { return data; @@ -342,7 +396,7 @@ export class ApiService { ); } - public download(torrentResult: any, mediaType: string, tmdbMedia: any, params?: any) { + public download(torrentResult: any, mediaType: string, tmdbMedia: any, params?: any): Observable { // add extra params Object.assign(params || {}, { torrent: torrentResult, @@ -377,7 +431,7 @@ export class ApiService { ); } - public searchMedia(query: string, mediaType: string, page = 1) { + public searchMedia(query: string, mediaType: string, page = 1): Observable { let params = { q: query, media_type: mediaType, @@ -392,7 +446,7 @@ export class ApiService { ); } - public searchSimilarMedia(tmdbMediaId: string, mediaType: string) { + public searchSimilarMedia(tmdbMediaId: string, mediaType: string): Observable { let params = { tmdb_media_id: tmdbMediaId, media_type: mediaType, @@ -407,7 +461,7 @@ export class ApiService { ); } - public searchRecommendedMedia(tmdbMediaId: string, mediaType: string) { + public searchRecommendedMedia(tmdbMediaId: string, mediaType: string): Observable { let params = { tmdb_media_id: tmdbMediaId, media_type: mediaType, @@ -422,7 +476,7 @@ export class ApiService { ); } - public searchMediaDetail(mediaType: string, id: string) { + public searchMediaDetail(mediaType: string, id: string): Observable { const options = {headers: this._requestHeaders(), params: this._defaultParams()}; return this.http.get(`${this.API_URL_SEARCH_MEDIA}${mediaType}/${id}/`, options).pipe( map((data: any) => { @@ -431,7 +485,7 @@ export class ApiService { ); } - public fetchMediaVideos(mediaType: string, id: string) { + public fetchMediaVideos(mediaType: string, id: string): Observable { const options = {headers: this._requestHeaders()}; return this.http.get(`${this.API_URL_SEARCH_MEDIA}${mediaType}/${id}/videos/`, options).pipe( map((data: any) => { @@ -440,7 +494,7 @@ export class ApiService { ); } - public fetchWatchTVShows(params?: any) { + public fetchWatchTVShows(params?: any): Observable { params = params || {}; const httpParams = new HttpParams({fromObject: params}); return this.http.get(this.API_URL_WATCH_TV_SHOW, {params: httpParams, headers: this._requestHeaders()}).pipe( @@ -451,7 +505,7 @@ export class ApiService { ); } - public fetchWatchTVSeasons(params?: any) { + public fetchWatchTVSeasons(params?: any): Observable { params = params || {}; const httpParams = new HttpParams({fromObject: params}); return this.http.get(this.API_URL_WATCH_TV_SEASON, {params: httpParams, headers: this._requestHeaders()}).pipe( @@ -467,7 +521,7 @@ export class ApiService { ); } - public fetchWatchTVSeasonRequests(params?: any) { + public fetchWatchTVSeasonRequests(params?: any): Observable { params = params || {}; const httpParams = new HttpParams({fromObject: params}); return this.http.get(this.API_URL_WATCH_TV_SEASON_REQUEST, {params: httpParams, headers: this._requestHeaders()}).pipe( @@ -483,7 +537,7 @@ export class ApiService { ); } - public fetchWatchMovies(params?: any) { + public fetchWatchMovies(params?: any): Observable { params = params || {}; const httpParams = new HttpParams({fromObject: params}); @@ -500,7 +554,7 @@ export class ApiService { ); } - public fetchWatchTVEpisodes(params: any) { + public fetchWatchTVEpisodes(params: any): Observable { const httpParams = new HttpParams({fromObject: params}); return this.http.get(this.API_URL_WATCH_TV_EPISODE, {headers: this._requestHeaders(), params: httpParams}).pipe( map((records: any) => { @@ -515,7 +569,7 @@ export class ApiService { ); } - public fetchCurrentTorrents(params: any) { + public fetchCurrentTorrents(params: any): Observable { const httpParams = new HttpParams({fromObject: params}); return this.http.get(this.API_URL_CURRENT_TORRENTS, {headers: this._requestHeaders(), params: httpParams}).pipe( map((data: any) => { @@ -526,14 +580,14 @@ export class ApiService { public watchTVShow( showId: number, name: string, posterImageUrl: string, - releaseDate: string, autoWatchNewSeasons?: boolean, qualityProfile?: string) { + releaseDate: string, autoWatchNewSeasons?: boolean, qualityProfile?: number) { const params = { tmdb_show_id: showId, name: name, poster_image_url: posterImageUrl, release_date: releaseDate || null, auto_watch: !!autoWatchNewSeasons, - quality_profile_custom: qualityProfile, + quality_profile: qualityProfile, }; return this.http.post(this.API_URL_WATCH_TV_SHOW, params, {headers: this._requestHeaders()}).pipe( map((data: any) => { @@ -653,12 +707,12 @@ export class ApiService { ); } - public watchMovie(movieId: number, name: string, posterImageUrl: string, releaseDate: string, qualityProfileCustom?: string) { + public watchMovie(movieId: number, name: string, posterImageUrl: string, releaseDate: string, qualityProfile?: number) { const params = { tmdb_movie_id: movieId, name: name, poster_image_url: posterImageUrl, - quality_profile_custom: qualityProfileCustom, + quality_profile: qualityProfile, release_date: releaseDate || null, }; @@ -734,7 +788,7 @@ export class ApiService { ); } - public verifySettings() { + public verifySettings(): Observable { return this.http.get(`${this.API_URL_SETTINGS}${this.settings.id}/verify/`, {headers: this._requestHeaders()}).pipe( map((data: any) => { return data; @@ -801,15 +855,15 @@ export class ApiService { return this._discoverMedia(this.SEARCH_MEDIA_TYPE_TV, params); } - public fetchMovieGenres() { + public fetchMovieGenres(): Observable { return this._fetchGenres(this.SEARCH_MEDIA_TYPE_MOVIE); } - public fetchTVGenres() { + public fetchTVGenres(): Observable { return this._fetchGenres(this.SEARCH_MEDIA_TYPE_TV); } - public verifyJackettIndexers() { + public verifyJackettIndexers(): Observable { return this.http.get(`${this.API_URL_SETTINGS}${this.settings.id}/verify-jackett-indexers/`, {headers: this._requestHeaders()}); } @@ -837,7 +891,7 @@ export class ApiService { return this.http.get(url, {params: httpParams, headers: this._requestHeaders()}); } - public openSubtitlesAuth() { + public openSubtitlesAuth(): Observable { const url = this.API_URL_OPEN_SUBTITLES_AUTH; return this.http.post(url, null, {headers: this._requestHeaders()}); } @@ -961,13 +1015,13 @@ export class ApiService { this._updateStorage().subscribe(); } - protected _fetchGenres(mediaType: string) { + protected _fetchGenres(mediaType: string): Observable { const url = mediaType === this.SEARCH_MEDIA_TYPE_MOVIE ? this.API_URL_GENRES_MOVIE : this.API_URL_GENRES_TV; const params = this._defaultParams(); return this.http.get(url, {headers: this._requestHeaders(), params: params}); } - protected _discoverMedia(mediaType: string, params: any) { + protected _discoverMedia(mediaType: string, params: any): Observable { params = Object.assign(params, this._defaultParams()); const httpParams = new HttpParams({fromObject: params}); const url = mediaType === this.SEARCH_MEDIA_TYPE_MOVIE ? this.API_URL_DISCOVER_MOVIES : this.API_URL_DISCOVER_TV; diff --git a/src/frontend/src/app/app.module.ts b/src/frontend/src/app/app.module.ts index cbf35323..fca4df82 100644 --- a/src/frontend/src/app/app.module.ts +++ b/src/frontend/src/app/app.module.ts @@ -1,67 +1,68 @@ -import { BrowserModule } from '@angular/platform-browser'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { APP_INITIALIZER, NgModule } from '@angular/core'; -import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; -import { HttpClientModule } from '@angular/common/http'; -import { RouterModule, Routes} from '@angular/router'; -import { FormsModule, ReactiveFormsModule } from '@angular/forms'; -import { CommonModule } from '@angular/common'; -import { NgxFilesizeModule } from 'ngx-filesize'; -import { ToastrModule } from 'ngx-toastr'; -import { NgxLoadingModule } from 'ngx-loading'; -import { MomentModule } from 'ngx-moment'; -import { NgSelectModule } from '@ng-select/ng-select'; -import { AngularPageVisibilityModule } from 'angular-page-visibility-v2'; +import {BrowserModule} from '@angular/platform-browser'; +import {BrowserAnimationsModule} from '@angular/platform-browser/animations'; +import {APP_INITIALIZER, NgModule} from '@angular/core'; +import {NgbModule} from '@ng-bootstrap/ng-bootstrap'; +import {HttpClientModule} from '@angular/common/http'; +import {RouterModule, Routes} from '@angular/router'; +import {FormsModule, ReactiveFormsModule} from '@angular/forms'; +import {CommonModule} from '@angular/common'; +import {NgxFilesizeModule} from 'ngx-filesize'; +import {ToastrModule} from 'ngx-toastr'; +import {NgxLoadingModule} from 'ngx-loading'; +import {MomentModule} from 'ngx-moment'; +import {NgSelectModule} from '@ng-select/ng-select'; +import {AngularPageVisibilityModule} from 'angular-page-visibility-v2'; -import { AppComponent } from './app.component'; -import { PageNotFoundComponent } from './page-not-found/page-not-found.component'; -import { SearchManualComponent } from './search/search-manual.component'; -import { SearchInputComponent } from './search/search-input.component'; -import { SearchAutoComponent } from './search/search-auto.component'; -import { SettingsComponent } from './settings/settings.component'; -import { MediaTVComponent } from './media/media-t-v.component'; -import { MediaMovieComponent } from './media/media-movie.component'; -import { SettingsGuard } from './settings.guard'; -import { LoginGuard } from './login.guard'; -import { StaffGuard } from './staff.guard'; -import { LoginComponent } from './login/login.component'; -import { ApiService } from './api.service'; -import { WatchingComponent } from './watching/watching.component'; -import { TorrentDetailsComponent } from './torrent-details/torrent-details.component'; -import { DiscoverComponent } from './discover/discover.component'; -import { MediaResultsComponent } from './media-results/media-results.component'; -import { MediaFilterPipe } from './filter.pipe'; -import { WantedComponent } from './wanted/wanted.component'; -import { RottenTomatoesComponent } from './rotten-tomatoes/rotten-tomatoes.component'; -import { TmdbComponent } from './tmdb/tmdb.component'; -import { NgxDatatableModule } from '@swimlane/ngx-datatable'; -import { RottenTomatoesRedirectComponent } from './rotten-tomatoes-redirect/rotten-tomatoes-redirect.component'; +import {AppComponent} from './app.component'; +import {PageNotFoundComponent} from './page-not-found/page-not-found.component'; +import {SearchManualComponent} from './search/search-manual.component'; +import {SearchInputComponent} from './search/search-input.component'; +import {SearchAutoComponent} from './search/search-auto.component'; +import {SettingsComponent} from './settings/settings.component'; +import {MediaTVComponent} from './media/media-t-v.component'; +import {MediaMovieComponent} from './media/media-movie.component'; +import {SettingsGuard} from './settings.guard'; +import {LoginGuard} from './login.guard'; +import {StaffGuard} from './staff.guard'; +import {LoginComponent} from './login/login.component'; +import {ApiService} from './api.service'; +import {WatchingComponent} from './watching/watching.component'; +import {TorrentDetailsComponent} from './torrent-details/torrent-details.component'; +import {DiscoverComponent} from './discover/discover.component'; +import {MediaResultsComponent} from './media-results/media-results.component'; +import {MediaFilterPipe} from './filter.pipe'; +import {WantedComponent} from './wanted/wanted.component'; +import {RottenTomatoesComponent} from './rotten-tomatoes/rotten-tomatoes.component'; +import {TmdbComponent} from './tmdb/tmdb.component'; +import {NgxDatatableModule} from '@swimlane/ngx-datatable'; +import {RottenTomatoesRedirectComponent} from './rotten-tomatoes-redirect/rotten-tomatoes-redirect.component'; +import {QualityProfilesComponent} from "./settings/quality-profiles.component"; const appRoutes: Routes = [ - { path: '', redirectTo: 'search/auto', pathMatch: 'full' }, // redirects - { path: 'search', redirectTo: 'search/auto', pathMatch: 'full' }, - { path: 'login', component: LoginComponent }, - { path: 'search/auto', component: SearchAutoComponent, canActivate: [LoginGuard, SettingsGuard] }, - { path: 'search/manual', component: SearchManualComponent, canActivate: [LoginGuard, SettingsGuard] }, - { path: 'media/tv/:id', component: MediaTVComponent, canActivate: [LoginGuard, SettingsGuard] }, - { path: 'media/movie/:id', component: MediaMovieComponent, canActivate: [LoginGuard, SettingsGuard] }, - { path: 'watching/:type', component: WatchingComponent, canActivate: [LoginGuard, SettingsGuard] }, - { path: 'wanted/:type', component: WantedComponent, canActivate: [LoginGuard, SettingsGuard] }, - { path: 'settings', component: SettingsComponent, canActivate: [LoginGuard, StaffGuard] }, + {path: '', redirectTo: 'search/auto', pathMatch: 'full'}, // redirects + {path: 'search', redirectTo: 'search/auto', pathMatch: 'full'}, + {path: 'login', component: LoginComponent}, + {path: 'search/auto', component: SearchAutoComponent, canActivate: [LoginGuard, SettingsGuard]}, + {path: 'search/manual', component: SearchManualComponent, canActivate: [LoginGuard, SettingsGuard]}, + {path: 'media/tv/:id', component: MediaTVComponent, canActivate: [LoginGuard, SettingsGuard]}, + {path: 'media/movie/:id', component: MediaMovieComponent, canActivate: [LoginGuard, SettingsGuard]}, + {path: 'watching/:type', component: WatchingComponent, canActivate: [LoginGuard, SettingsGuard]}, + {path: 'wanted/:type', component: WantedComponent, canActivate: [LoginGuard, SettingsGuard]}, + {path: 'settings', component: SettingsComponent, canActivate: [LoginGuard, StaffGuard]}, { path: 'discover', component: DiscoverComponent, canActivate: [LoginGuard, SettingsGuard], children: [ - { path: '', redirectTo: '/discover/tmdb', pathMatch: 'full' }, - { path: 'tmdb', component: TmdbComponent }, - { path: 'rt', component: RottenTomatoesComponent }, + {path: '', redirectTo: '/discover/tmdb', pathMatch: 'full'}, + {path: 'tmdb', component: TmdbComponent}, + {path: 'rt', component: RottenTomatoesComponent}, ], }, - { path: 'rt-redirect/:mediaType/:title', component: RottenTomatoesRedirectComponent }, - { path: 'page-not-found', component: PageNotFoundComponent }, - { path: '**', component: PageNotFoundComponent } + {path: 'rt-redirect/:mediaType/:title', component: RottenTomatoesRedirectComponent}, + {path: 'page-not-found', component: PageNotFoundComponent}, + {path: '**', component: PageNotFoundComponent} ]; export function init(apiService: ApiService) { @@ -69,33 +70,34 @@ export function init(apiService: ApiService) { () => { console.log('app init success'); }, () => { - console.error('app init failed'); - } + console.error('app init failed'); + } ); } @NgModule({ - declarations: [ - AppComponent, - PageNotFoundComponent, - SearchManualComponent, - SearchInputComponent, - SearchAutoComponent, - SettingsComponent, - LoginComponent, - MediaTVComponent, - MediaMovieComponent, - WatchingComponent, - TorrentDetailsComponent, - DiscoverComponent, - MediaResultsComponent, - MediaFilterPipe, - WantedComponent, - RottenTomatoesComponent, - TmdbComponent, - RottenTomatoesRedirectComponent, - ], + declarations: [ + AppComponent, + PageNotFoundComponent, + SearchManualComponent, + SearchInputComponent, + SearchAutoComponent, + SettingsComponent, + LoginComponent, + MediaTVComponent, + MediaMovieComponent, + WatchingComponent, + TorrentDetailsComponent, + DiscoverComponent, + MediaResultsComponent, + MediaFilterPipe, + WantedComponent, + RottenTomatoesComponent, + TmdbComponent, + RottenTomatoesRedirectComponent, + QualityProfilesComponent, + ], imports: [ RouterModule.forRoot(appRoutes, { useHash: true @@ -115,11 +117,11 @@ export function init(apiService: ApiService) { AngularPageVisibilityModule, NgxDatatableModule, ], - providers: [ - { provide: APP_INITIALIZER, useFactory: init, deps: [ApiService], multi: true }, - MediaFilterPipe, - ], - bootstrap: [AppComponent] + providers: [ + {provide: APP_INITIALIZER, useFactory: init, deps: [ApiService], multi: true}, + MediaFilterPipe, + ], + bootstrap: [AppComponent] }) export class AppModule { constructor() { diff --git a/src/frontend/src/app/media/media-movie.component.html b/src/frontend/src/app/media/media-movie.component.html index 397123e6..c0a6e729 100644 --- a/src/frontend/src/app/media/media-movie.component.html +++ b/src/frontend/src/app/media/media-movie.component.html @@ -43,9 +43,9 @@
{{ result.release_date | date:'y' }}
- - +
diff --git a/src/frontend/src/app/media/media-movie.component.ts b/src/frontend/src/app/media/media-movie.component.ts index fd2da7a3..1624d523 100644 --- a/src/frontend/src/app/media/media-movie.component.ts +++ b/src/frontend/src/app/media/media-movie.component.ts @@ -15,7 +15,7 @@ import { map, share } from 'rxjs/operators'; export class MediaMovieComponent implements OnInit, OnDestroy { public result: any; public watchMovie: any; - public qualityProfileCustom: string; + public qualityProfile: number; public isLoading = true; public isSaving = false; public trailerUrls$: Observable; @@ -39,8 +39,8 @@ export class MediaMovieComponent implements OnInit, OnDestroy { this.result = data; this.isLoading = false; this.watchMovie = this.getWatchMovie(); - this.qualityProfileCustom = this.watchMovie ? - this.watchMovie.quality_profile_custom : + this.qualityProfile = this.watchMovie ? + this.watchMovie.quality_profile : this.apiService.settings.quality_profile_movies; }, (error) => { @@ -89,7 +89,7 @@ export class MediaMovieComponent implements OnInit, OnDestroy { if (isWatchingMovie) { endpoint = this.apiService.watchMovie( - this.result.id, this.result.title, this.mediaPosterURL(this.result), this.result.release_date, this.qualityProfileCustom); + this.result.id, this.result.title, this.mediaPosterURL(this.result), this.result.release_date, this.qualityProfile); } else if (!isWatchingMovie && watchMovie) { endpoint = this.apiService.unWatchMovie(watchMovie.id); } else { @@ -126,7 +126,7 @@ export class MediaMovieComponent implements OnInit, OnDestroy { return this.apiService.userIsStaff(); } - public qualityProfiles(): string[] { + public qualityProfiles(): any[] { return this.apiService.qualityProfiles; } @@ -144,4 +144,8 @@ export class MediaMovieComponent implements OnInit, OnDestroy { // update the nav back to the main details this.activeNav = 'details'; } + + public trackByProfile(index: number, item: any) { + return item.id; + } } diff --git a/src/frontend/src/app/media/media-t-v.component.html b/src/frontend/src/app/media/media-t-v.component.html index d5f58bf9..7e3aad2d 100644 --- a/src/frontend/src/app/media/media-t-v.component.html +++ b/src/frontend/src/app/media/media-t-v.component.html @@ -43,7 +43,7 @@

{{ tmdbShow.name }}

diff --git a/src/frontend/src/app/media/media-t-v.component.ts b/src/frontend/src/app/media/media-t-v.component.ts index 849869e9..2f5f1547 100644 --- a/src/frontend/src/app/media/media-t-v.component.ts +++ b/src/frontend/src/app/media/media-t-v.component.ts @@ -53,14 +53,14 @@ export class MediaTVComponent implements OnInit, OnDestroy { // define quality profile form control and watch for changes this.qualityProfileControl = new UntypedFormControl( - (watchShow && watchShow.quality_profile_custom) ? - watchShow.quality_profile_custom : + (watchShow && watchShow.quality_profile) ? + watchShow.quality_profile : this.apiService.settings.quality_profile_tv ); this.qualityProfileControl.valueChanges.subscribe((qualityProfile) => { watchShow = this._getWatchShow(); if (watchShow) { - this.apiService.updateWatchTVShow(watchShow.id, {quality_profile_custom: qualityProfile}) + this.apiService.updateWatchTVShow(watchShow.id, {quality_profile: qualityProfile}) .subscribe(() => { this.toastr.success('Updated quality profile'); }, (error) => { @@ -352,10 +352,14 @@ export class MediaTVComponent implements OnInit, OnDestroy { } } - public qualityProfiles(): string[] { + public qualityProfiles(): any[] { return this.apiService.qualityProfiles; } + public trackByProfile(index: number, item: any) { + return item.id; + } + protected _watchShow(autoWatchNewSeasons?: boolean): Observable { return this.apiService.watchTVShow( this.tmdbShow.id, this.tmdbShow.name, this.mediaPosterURL(this.tmdbShow), this.tmdbShow.first_air_date, diff --git a/src/frontend/src/app/settings/quality-profiles.component.css b/src/frontend/src/app/settings/quality-profiles.component.css new file mode 100644 index 00000000..e69de29b diff --git a/src/frontend/src/app/settings/quality-profiles.component.html b/src/frontend/src/app/settings/quality-profiles.component.html new file mode 100644 index 00000000..7ce66ac8 --- /dev/null +++ b/src/frontend/src/app/settings/quality-profiles.component.html @@ -0,0 +1,61 @@ + + diff --git a/src/frontend/src/app/settings/quality-profiles.component.spec.ts b/src/frontend/src/app/settings/quality-profiles.component.spec.ts new file mode 100644 index 00000000..56c26c21 --- /dev/null +++ b/src/frontend/src/app/settings/quality-profiles.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { QualityProfilesComponent } from './quality-profiles.component'; + +describe('QualityProfilesComponent', () => { + let component: QualityProfilesComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [QualityProfilesComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(QualityProfilesComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/src/app/settings/quality-profiles.component.ts b/src/frontend/src/app/settings/quality-profiles.component.ts new file mode 100644 index 00000000..eddb236f --- /dev/null +++ b/src/frontend/src/app/settings/quality-profiles.component.ts @@ -0,0 +1,119 @@ +import {Component, OnInit} from '@angular/core'; +import {NgbActiveModal} from "@ng-bootstrap/ng-bootstrap"; +import {ApiService} from "../api.service"; +import {FormArray, FormBuilder, FormGroup, Validators} from "@angular/forms"; +import {ToastrService} from 'ngx-toastr'; +import {Observable} from "rxjs"; + + +@Component({ + selector: 'app-quality-profiles', + templateUrl: './quality-profiles.component.html', + styleUrl: './quality-profiles.component.css' +}) +export class QualityProfilesComponent implements OnInit { + public isLoading = false; + public form: FormGroup<{ profiles: FormArray }>; + + constructor( + public apiService: ApiService, + public activeModal: NgbActiveModal, + public fb: FormBuilder, + public toastr: ToastrService, + ) { + } + + ngOnInit() { + this.form = this.fb.group({ + profiles: this.fb.array(this.apiService.qualityProfiles.map(p => this._getNewFormGroup(p))), + }); + } + + public add() { + this.form.controls.profiles.insert(0, this._getNewFormGroup()); + } + + public save(profileFormGroup: FormGroup) { + this.isLoading = true; + let update$: Observable; + let updateVerb: string; + const data = profileFormGroup.value; + // update + if (data.id) { + update$ = this.apiService.updateQualityProfile(data.id, data); + updateVerb = 'updated'; + } + // create + else { + update$ = this.apiService.createQualityProfile(data); + updateVerb = 'created'; + } + update$.subscribe({ + next: () => { + this.toastr.success(`Successfully ${updateVerb} quality profile`); + this.isLoading = false; + }, + error: (error) => { + console.error(error); + let msg: string; + // display specific error + if (error.error) { + msg = Object.entries(error.error).map(([key,value]: [k: string, v: string[]]) => { + return `${key}: ${value.join(', ')}`; + }).join(', '); + } + // display generic error + else { + msg = 'An unknown error occurred updating the quality profile' + } + this.toastr.error(msg); + this.isLoading = false; + } + }) + } + + public delete(formArrayIndex: number) { + const profileFormGroup = this.form.controls.profiles.controls[formArrayIndex]; + const data = profileFormGroup.value; + + // remove unsaved form control + if (!data.id) { + this.form.controls.profiles.removeAt(formArrayIndex); + return; + } + + this.isLoading = true; + + // delete existing record + this.apiService.deleteQualityProfile(data.id).subscribe({ + next: () => { + // remove form group + this.form.controls.profiles.removeAt(formArrayIndex); + this.toastr.success('Successfully deleted quality profile'); + this.isLoading = false; + }, + error: (error) => { + // display specific error message if it exists + if (error?.error?.message) { + this.toastr.error(error.error.message); + } else { + this.toastr.error('An unknown error occurred deleting the quality profile'); + } + console.error(error); + this.isLoading = false; + } + }) + } + + protected _getNewFormGroup(data?: any): FormGroup { + return this.fb.group({ + id: data?.id, + name: this.fb.control(data?.name, [Validators.required, Validators.minLength(2)]), + quality: this.fb.control(data?.quality, [Validators.required]), + min_size_gb: this.fb.control(data?.min_size_gb, [Validators.min(0)]), + max_size_gb: this.fb.control(data?.max_size_gb, [Validators.min(0)]), + require_hdr: data?.require_hdr, + require_five_point_one: data?.require_five_point_one, + }) + } +} diff --git a/src/frontend/src/app/settings/settings.component.html b/src/frontend/src/app/settings/settings.component.html index 8ff9f9b9..4ffaea0d 100644 --- a/src/frontend/src/app/settings/settings.component.html +++ b/src/frontend/src/app/settings/settings.component.html @@ -37,7 +37,7 @@
@@ -95,15 +95,19 @@
+ +
@@ -145,31 +149,31 @@
-
-
- -
-
- - - Leave blank to keep existing password - - - - -
-
- -
-
- -
+ +
+ +
+ +
+ + + Leave blank to keep existing password + + + + +
+ +
+ +

+
- +
diff --git a/src/frontend/src/app/settings/settings.component.ts b/src/frontend/src/app/settings/settings.component.ts index 31f8ffd5..41b2448f 100644 --- a/src/frontend/src/app/settings/settings.component.ts +++ b/src/frontend/src/app/settings/settings.component.ts @@ -1,37 +1,35 @@ -import { EMPTY } from 'rxjs'; -import { ChangeDetectorRef } from '@angular/core'; -import { ToastrService } from 'ngx-toastr'; -import { ApiService } from '../api.service'; -import { UntypedFormArray, UntypedFormBuilder, UntypedFormControl, Validators } from '@angular/forms'; -import { Component, OnInit, AfterContentChecked } from '@angular/core'; -import { concat, Observable, Subscription } from 'rxjs'; -import { tap } from 'rxjs/operators'; +import {EMPTY} from 'rxjs'; +import {ToastrService} from 'ngx-toastr'; +import {ApiService} from '../api.service'; +import {FormArray, FormBuilder, FormControl, Validators, FormRecord} from '@angular/forms'; +import {Component, OnInit} from '@angular/core'; +import {concat, Observable, Subscription} from 'rxjs'; +import {tap} from 'rxjs/operators'; +import {NgbModal} from "@ng-bootstrap/ng-bootstrap"; +import {QualityProfilesComponent} from "./quality-profiles.component"; + @Component({ selector: 'app-settings', templateUrl: './settings.component.html', styleUrls: ['./settings.component.css'] }) -export class SettingsComponent implements OnInit, AfterContentChecked { +export class SettingsComponent implements OnInit { public users: any[]; - public form; + public form: FormRecord; public isSaving = false; public isLoading = false; - public isVeryingJackettIndexers = false; + public isVerifyingJackettIndexers = false; public isLoadingUsers = false; public gitCommit = ''; public authenticateOpenSubtitles$: Subscription; constructor( public apiService: ApiService, - private toastr: ToastrService, - private fb: UntypedFormBuilder, - private changeDectorRef: ChangeDetectorRef - ) { } - - ngAfterContentChecked() { - // handles form "required" dynamically changing after lifecycle check - this.changeDectorRef.detectChanges(); + public toastr: ToastrService, + public fb: FormBuilder, + public modalService: NgbModal, + ) { } ngOnInit() { @@ -56,7 +54,7 @@ export class SettingsComponent implements OnInit, AfterContentChecked { 'exclusions': [settings['keyword_search_filters'] ? Object.keys(settings['keyword_search_filters']) : []], 'enable_video_detection': [settings['enable_video_detection'], Validators.required], 'language': [settings['language'], Validators.required], - 'users': new UntypedFormArray([]), + 'users': new FormArray([]), 'apprise_notification_url': [settings['apprise_notification_url']], 'preferred_media_category': [settings['preferred_media_category'], Validators.required], 'stuck_download_handling_enabled': [settings['stuck_download_handling_enabled'], Validators.required], @@ -72,9 +70,9 @@ export class SettingsComponent implements OnInit, AfterContentChecked { password: '', }; Object.keys(user).forEach((key) => { - controls[key] = new UntypedFormControl(user[key]); + controls[key] = new FormControl(user[key]); }); - this.form.get('users').insert(0, this.fb.group(controls)); + this.form.controls.users.insert(0, this.fb.group(controls)); }); }, (error) => { this.toastr.error('An unknown error occurred loading users'); @@ -93,7 +91,7 @@ export class SettingsComponent implements OnInit, AfterContentChecked { } public hasExclusions(): boolean { - const exclusions = this.form.get('exclusions').value; + const exclusions = this.form.controls.exclusions.value; return exclusions && exclusions.length; } @@ -113,7 +111,7 @@ export class SettingsComponent implements OnInit, AfterContentChecked { ).subscribe(); } - public qualityProfiles(): string[] { + public qualityProfiles(): any[] { return this.apiService.qualityProfiles; } @@ -122,7 +120,7 @@ export class SettingsComponent implements OnInit, AfterContentChecked { } public addUser() { - this.form.get('users').push(this.fb.group({ + this.form.controls.users.push(this.fb.group({ username: ['', Validators.required], password: ['', Validators.required], can_immediately_watch_movies: [false], @@ -132,7 +130,7 @@ export class SettingsComponent implements OnInit, AfterContentChecked { public saveUser(index: number) { - const userControl = this.form.get('users').controls[index]; + const userControl = this.form.controls.users.controls[index]; if (!userControl.valid) { this.toastr.error('Please supply all required fields for this user'); return; @@ -153,7 +151,7 @@ export class SettingsComponent implements OnInit, AfterContentChecked { this.apiService.createUser(userControl.value.username, userControl.value.password).subscribe( (data) => { this.toastr.success(`Added ${userControl.value.username}`); - this.form.get('users').at(index).addControl('id', new UntypedFormControl(data.id)); + this.form.controls.users.at(index).addControl('id', new FormControl(data.id)); }, (error) => { this.toastr.error(`An unknown error occurred adding user ${userControl.value.username}`); @@ -164,11 +162,11 @@ export class SettingsComponent implements OnInit, AfterContentChecked { } public removeUser(index: number) { - const userControl = this.form.get('users').controls[index]; + const userControl = this.form.controls.users.controls[index]; this.apiService.deleteUser(userControl.value.id).subscribe( (data) => { this.toastr.success(`Successfully deleted ${userControl.value.username}`); - this.form.get('users').removeAt(index); + this.form.controls.users.removeAt(index); }, (error) => { this.toastr.error(`An unknown error occurred deleting user ${userControl.value.username}`); @@ -178,7 +176,7 @@ export class SettingsComponent implements OnInit, AfterContentChecked { } public canDeleteUser(index: number) { - const userControl = this.form.get('users').controls[index]; + const userControl = this.form.controls.users.controls[index]; return userControl.get('id') && this.apiService.user.id !== userControl.get('id').value; } @@ -215,8 +213,7 @@ export class SettingsComponent implements OnInit, AfterContentChecked { 'open_subtitles_auto', 'open_subtitles_username', 'open_subtitles_password', - ]. - forEach((key) => { + ].forEach((key) => { params[key] = this.form.value[key]; }); @@ -234,12 +231,12 @@ export class SettingsComponent implements OnInit, AfterContentChecked { ), this.apiService.fetchSettings(), ).subscribe( - (data: any) => { - }, (error) => { - console.error(error); - this.toastr.error(error_message); - } - ); + (data: any) => { + }, (error) => { + console.error(error); + this.toastr.error(error_message); + } + ); } public queueTask(task: string): void { @@ -290,6 +287,17 @@ export class SettingsComponent implements OnInit, AfterContentChecked { }) } + public manageQualityProfiles() { + this.modalService.open(QualityProfilesComponent, { + size: "lg", + scrollable: true, + }); + } + + public trackByProfile(index: number, item: any) { + return item.id; + } + protected _saveSettings(): Observable { this.isSaving = true; @@ -365,7 +373,7 @@ export class SettingsComponent implements OnInit, AfterContentChecked { } protected _verifyJackettIndexers() { - this.isVeryingJackettIndexers = true; + this.isVerifyingJackettIndexers = true; this.apiService.verifyJackettIndexers().subscribe( (data: any[]) => { const failedIndexers = data.filter((indexer: any) => { @@ -378,11 +386,11 @@ export class SettingsComponent implements OnInit, AfterContentChecked { } else { this.toastr.success('All indexers were successful'); } - this.isVeryingJackettIndexers = false; + this.isVerifyingJackettIndexers = false; }, (error) => { this.toastr.error('An unknown error occurred verifying jackett indexers'); - this.isVeryingJackettIndexers = false; + this.isVerifyingJackettIndexers = false; }, ); } diff --git a/src/frontend/src/app/wanted/wanted.component.html b/src/frontend/src/app/wanted/wanted.component.html index 25022f9a..3d764ff5 100644 --- a/src/frontend/src/app/wanted/wanted.component.html +++ b/src/frontend/src/app/wanted/wanted.component.html @@ -17,6 +17,7 @@ /', views.ImportMediaLibraryView.as_view()), path('genres//', views.GenresView.as_view()), path('media-categories/', views.MediaCategoriesView.as_view()), - path('quality-profiles/', views.QualityProfilesView.as_view()), + path('qualities/', views.QualitiesView.as_view()), path('auth/', views.ObtainAuthTokenView.as_view()), # authenticates user and returns token path('git-commit/', views.GitCommitView.as_view()), # returns this app's git commit path('open-subtitles/auth/', views.OpenSubtitlesAuthView.as_view()), # auths against open subtitles diff --git a/src/nefarious/api/views.py b/src/nefarious/api/views.py index 3802e6ff..699ac0c2 100644 --- a/src/nefarious/api/views.py +++ b/src/nefarious/api/views.py @@ -507,13 +507,6 @@ def get(self, request, media_type: str): }) -@method_decorator(gzip_page, name='dispatch') -class QualityProfilesView(views.APIView): - - def get(self, request): - return Response({'profiles': [p.name for p in PROFILES]}) - - @method_decorator(gzip_page, name='dispatch') class MediaCategoriesView(views.APIView): @@ -577,3 +570,10 @@ def post(self, request): assert 'message' in request.data, 'missing notification message' return Response({'success': send_message(request.data['message'])}) + +@method_decorator(gzip_page, name='dispatch') +class QualitiesView(views.APIView): + + def get(self, request): + return Response([p.name for p in PROFILES]) + diff --git a/src/nefarious/api/viewsets.py b/src/nefarious/api/viewsets.py index 26e5283d..5526110b 100644 --- a/src/nefarious/api/viewsets.py +++ b/src/nefarious/api/viewsets.py @@ -1,5 +1,4 @@ from datetime import datetime - from django.contrib.auth.models import User from django.utils.decorators import method_decorator from django.views.decorators.gzip import gzip_page @@ -18,9 +17,9 @@ from nefarious.api.serializers import ( NefariousSettingsSerializer, WatchTVEpisodeSerializer, WatchTVShowSerializer, UserSerializer, WatchMovieSerializer, NefariousPartialSettingsSerializer, - WatchTVSeasonSerializer, WatchTVSeasonRequestSerializer, TorrentBlacklistSerializer, + WatchTVSeasonSerializer, WatchTVSeasonRequestSerializer, TorrentBlacklistSerializer, QualityProfileSerializer, ) -from nefarious.models import NefariousSettings, WatchTVEpisode, WatchTVShow, WatchMovie, WatchTVSeason, WatchTVSeasonRequest, TorrentBlacklist, WatchMediaBase +from nefarious.models import NefariousSettings, WatchTVEpisode, WatchTVShow, WatchMovie, WatchTVSeason, WatchTVSeasonRequest, TorrentBlacklist, QualityProfile from nefarious.tasks import watch_tv_episode_task, watch_tv_show_season_task, watch_movie_task, send_websocket_message_task from nefarious.utils import ( verify_settings_jackett, verify_settings_transmission, verify_settings_tmdb, @@ -216,6 +215,27 @@ def get_queryset(self): @method_decorator(gzip_page, name='dispatch') +class QualityProfileViewSet(viewsets.ModelViewSet): + permission_classes = (IsAdminUser,) + queryset = QualityProfile.objects.all() + serializer_class = QualityProfileSerializer + + def destroy(self, request, *args, **kwargs): + # prevent the deletion of the default tv/movies profiles in NefariousSettings + nefarious_settings = NefariousSettings.get() + if self.get_object() in [nefarious_settings.quality_profile_tv, nefarious_settings.quality_profile_movies]: + media_type = '' + if self.get_object() == nefarious_settings.quality_profile_tv: + media_type = 'tv' + elif self.get_object() == nefarious_settings.quality_profile_movies: + media_type = 'movies' + raise ValidationError({ + 'success': False, + 'message': f"Cannot delete profile '{self.get_object()}' since it's used as a system-wide default for {media_type}", + }) + return super().destroy(request, *args, **kwargs) + + class TorrentBlacklistViewSet(viewsets.ModelViewSet): queryset = TorrentBlacklist.objects.all() serializer_class = TorrentBlacklistSerializer diff --git a/src/nefarious/management/commands/nefarious-init.py b/src/nefarious/management/commands/nefarious-init.py index a6cac27c..e4d087f4 100644 --- a/src/nefarious/management/commands/nefarious-init.py +++ b/src/nefarious/management/commands/nefarious-init.py @@ -1,7 +1,8 @@ from django.contrib.auth.models import User from django.core.management.base import BaseCommand -from nefarious.models import NefariousSettings +from nefarious.models import NefariousSettings, QualityProfile from nefarious.tmdb import get_tmdb_client +from nefarious.quality import PROFILE_HD_1080p, PROFILE_ANY class Command(BaseCommand): @@ -22,7 +23,13 @@ def handle(self, *args, **options): options['username'], options['password'], options['email']))) # create settings if they don't already exist - nefarious_settings, _ = NefariousSettings.objects.get_or_create() + nefarious_settings = NefariousSettings.objects.all().first() + if not nefarious_settings: + nefarious_settings = NefariousSettings.objects.create( + ## define default quality profiles + quality_profile_tv=QualityProfile.objects.get(quality=PROFILE_ANY), + quality_profile_movies=QualityProfile.objects.get(quality=PROFILE_HD_1080p), + ) # populate tmdb configuration if necessary if not nefarious_settings.tmdb_configuration or not nefarious_settings.tmdb_languages: @@ -30,4 +37,5 @@ def handle(self, *args, **options): configuration = tmdb_client.Configuration() nefarious_settings.tmdb_configuration = configuration.info() nefarious_settings.tmdb_languages = configuration.languages() - nefarious_settings.save() + + nefarious_settings.save() diff --git a/src/nefarious/migrations/0079_auto_20240728_1312.py b/src/nefarious/migrations/0079_auto_20240728_1312.py new file mode 100644 index 00000000..83b86679 --- /dev/null +++ b/src/nefarious/migrations/0079_auto_20240728_1312.py @@ -0,0 +1,54 @@ +# Generated by Django 3.0.2 on 2024-07-28 13:12 + +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('nefarious', '0078_nefarioussettings_jackett_filter_index'), + ] + + operations = [ + migrations.CreateModel( + name='QualityProfile', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=500, unique=True)), + ('quality', models.CharField(choices=[('any', 'any'), ('sd', 'sd'), ('hd-720p', 'hd-720p'), ('hd-720p-1080p', 'hd-720p-1080p'), ('hd-1080p', 'hd-1080p'), ('ultra-hd', 'ultra-hd')], max_length=500)), + ('min_size_gb', models.DecimalField(blank=True, decimal_places=2, help_text='minimum size (gb) to download', max_digits=10, null=True, validators=[django.core.validators.MinValueValidator(0)])), + ('max_size_gb', models.DecimalField(blank=True, decimal_places=2, help_text='maximum size (gb) to download', max_digits=10, null=True, validators=[django.core.validators.MinValueValidator(0)])), + ], + ), + migrations.AlterModelOptions( + name='nefarioussettings', + options={'verbose_name_plural': 'Settings'}, + ), + migrations.AddField( + model_name='nefarioussettings', + name='quality_profile_movies_default', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='quality_profile_movies_default', to='nefarious.QualityProfile'), + ), + migrations.AddField( + model_name='nefarioussettings', + name='quality_profile_tv_default', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='quality_profile_tv_default', to='nefarious.QualityProfile'), + ), + migrations.AddField( + model_name='watchmovie', + name='quality_profile', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='nefarious.QualityProfile'), + ), + migrations.AddField( + model_name='watchtvseasonrequest', + name='quality_profile', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='nefarious.QualityProfile'), + ), + migrations.AddField( + model_name='watchtvshow', + name='quality_profile', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='nefarious.QualityProfile'), + ), + ] diff --git a/src/nefarious/migrations/0080_populate_quality_profiles.py b/src/nefarious/migrations/0080_populate_quality_profiles.py new file mode 100644 index 00000000..c4ff7ab3 --- /dev/null +++ b/src/nefarious/migrations/0080_populate_quality_profiles.py @@ -0,0 +1,39 @@ +from django.db import migrations, transaction +from nefarious.quality import PROFILES + + +def populate_quality_profile(apps, schema_editor): + NefariousSettings = apps.get_model('nefarious', 'NefariousSettings') + QualityProfile = apps.get_model('nefarious', 'QualityProfile') + + nefarious_settings = NefariousSettings.objects.all().first() + + # copy values from old field + for profile in PROFILES: + QualityProfile.objects.create( + name=profile, + quality=profile, + ) + + if not nefarious_settings: + return + + quality_profile_tv = QualityProfile.objects.filter(quality=nefarious_settings.quality_profile_tv).first() + quality_profile_movies = QualityProfile.objects.filter(quality=nefarious_settings.quality_profile_movies).first() + + nefarious_settings.quality_profile_tv_default = quality_profile_tv + nefarious_settings.quality_profile_movies_default = quality_profile_movies + + with transaction.atomic(): + nefarious_settings.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ('nefarious', '0079_auto_20240728_1312'), + ] + + operations = [ + migrations.RunPython(populate_quality_profile, reverse_code=lambda a, b: None), + ] diff --git a/src/nefarious/migrations/0081_populate_quality_profile_media.py b/src/nefarious/migrations/0081_populate_quality_profile_media.py new file mode 100644 index 00000000..ab7d53a9 --- /dev/null +++ b/src/nefarious/migrations/0081_populate_quality_profile_media.py @@ -0,0 +1,35 @@ +# Generated by Django 3.0.2 on 2024-07-27 21:07 + +from django.db import migrations, transaction + + +def populate_quality_profile_media(apps, schema_editor): + QualityProfile = apps.get_model('nefarious', 'QualityProfile') + WatchMovie = apps.get_model('nefarious', 'WatchMovie') + WatchTVShow = apps.get_model('nefarious', 'WatchTVShow') + WatchTVSeasonRequest = apps.get_model('nefarious', 'WatchTVSeasonRequest') + + # copy default quality profile into new field + for model in [WatchMovie, WatchTVShow, WatchTVSeasonRequest]: + for media in model.objects.all(): + if media.quality_profile_custom: + # find matching quality profile by the quality & type + quality_profile = QualityProfile.objects.filter( + quality=media.quality_profile_custom, + ).first() + # assign quality profile + media.quality_profile = quality_profile + # must save in transaction + with transaction.atomic(): + media.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ('nefarious', '0080_populate_quality_profiles'), + ] + + operations = [ + migrations.RunPython(populate_quality_profile_media, reverse_code=lambda a, b: None), + ] diff --git a/src/nefarious/migrations/0082_auto_20240728_1417.py b/src/nefarious/migrations/0082_auto_20240728_1417.py new file mode 100644 index 00000000..3665423a --- /dev/null +++ b/src/nefarious/migrations/0082_auto_20240728_1417.py @@ -0,0 +1,21 @@ +# Generated by Django 3.0.2 on 2024-07-28 14:17 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('nefarious', '0081_populate_quality_profile_media'), + ] + + operations = [ + migrations.RemoveField( + model_name='nefarioussettings', + name='quality_profile_movies', + ), + migrations.RemoveField( + model_name='nefarioussettings', + name='quality_profile_tv', + ), + ] diff --git a/src/nefarious/migrations/0083_auto_20240728_1418.py b/src/nefarious/migrations/0083_auto_20240728_1418.py new file mode 100644 index 00000000..ee6421c1 --- /dev/null +++ b/src/nefarious/migrations/0083_auto_20240728_1418.py @@ -0,0 +1,23 @@ +# Generated by Django 3.0.2 on 2024-07-28 14:18 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('nefarious', '0082_auto_20240728_1417'), + ] + + operations = [ + migrations.RenameField( + model_name='nefarioussettings', + old_name='quality_profile_movies_default', + new_name='quality_profile_movies', + ), + migrations.RenameField( + model_name='nefarioussettings', + old_name='quality_profile_tv_default', + new_name='quality_profile_tv', + ), + ] diff --git a/src/nefarious/migrations/0084_auto_20240728_1752.py b/src/nefarious/migrations/0084_auto_20240728_1752.py new file mode 100644 index 00000000..9bd33811 --- /dev/null +++ b/src/nefarious/migrations/0084_auto_20240728_1752.py @@ -0,0 +1,25 @@ +# Generated by Django 3.0.2 on 2024-07-28 17:52 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('nefarious', '0083_auto_20240728_1418'), + ] + + operations = [ + migrations.RemoveField( + model_name='watchmovie', + name='quality_profile_custom', + ), + migrations.RemoveField( + model_name='watchtvseasonrequest', + name='quality_profile_custom', + ), + migrations.RemoveField( + model_name='watchtvshow', + name='quality_profile_custom', + ), + ] diff --git a/src/nefarious/migrations/0085_auto_20240801_1323.py b/src/nefarious/migrations/0085_auto_20240801_1323.py new file mode 100644 index 00000000..166771d7 --- /dev/null +++ b/src/nefarious/migrations/0085_auto_20240801_1323.py @@ -0,0 +1,18 @@ +# Generated by Django 3.0.2 on 2024-08-01 13:23 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('nefarious', '0084_auto_20240728_1752'), + ] + + operations = [ + migrations.RenameField( + model_name='qualityprofile', + old_name='quality', + new_name='profile', + ), + ] diff --git a/src/nefarious/migrations/0086_qualityprofile_is_hdr.py b/src/nefarious/migrations/0086_qualityprofile_is_hdr.py new file mode 100644 index 00000000..2b37a025 --- /dev/null +++ b/src/nefarious/migrations/0086_qualityprofile_is_hdr.py @@ -0,0 +1,18 @@ +# Generated by Django 3.0.2 on 2024-08-02 14:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('nefarious', '0085_auto_20240801_1323'), + ] + + operations = [ + migrations.AddField( + model_name='qualityprofile', + name='is_hdr', + field=models.BooleanField(default=False), + ), + ] diff --git a/src/nefarious/migrations/0087_auto_20240802_1457.py b/src/nefarious/migrations/0087_auto_20240802_1457.py new file mode 100644 index 00000000..6574b93e --- /dev/null +++ b/src/nefarious/migrations/0087_auto_20240802_1457.py @@ -0,0 +1,22 @@ +# Generated by Django 3.0.2 on 2024-08-02 14:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('nefarious', '0086_qualityprofile_is_hdr'), + ] + + operations = [ + migrations.RemoveField( + model_name='qualityprofile', + name='is_hdr', + ), + migrations.AddField( + model_name='qualityprofile', + name='hdr', + field=models.BooleanField(default=False, help_text='media must be in HDR (High Dynamic Range)'), + ), + ] diff --git a/src/nefarious/migrations/0088_auto_20240802_1551.py b/src/nefarious/migrations/0088_auto_20240802_1551.py new file mode 100644 index 00000000..37472f27 --- /dev/null +++ b/src/nefarious/migrations/0088_auto_20240802_1551.py @@ -0,0 +1,18 @@ +# Generated by Django 3.0.2 on 2024-08-02 15:51 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('nefarious', '0087_auto_20240802_1457'), + ] + + operations = [ + migrations.RenameField( + model_name='qualityprofile', + old_name='hdr', + new_name='require_hdr', + ), + ] diff --git a/src/nefarious/migrations/0089_qualityprofile_require_five_point_one.py b/src/nefarious/migrations/0089_qualityprofile_require_five_point_one.py new file mode 100644 index 00000000..c49bfae1 --- /dev/null +++ b/src/nefarious/migrations/0089_qualityprofile_require_five_point_one.py @@ -0,0 +1,18 @@ +# Generated by Django 3.0.2 on 2024-08-04 13:49 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('nefarious', '0088_auto_20240802_1551'), + ] + + operations = [ + migrations.AddField( + model_name='qualityprofile', + name='require_five_point_one', + field=models.BooleanField(default=False, help_text='media must be in 5.1 surround sound (e.g. Dolby 5.1)'), + ), + ] diff --git a/src/nefarious/migrations/0090_auto_20240812_2209.py b/src/nefarious/migrations/0090_auto_20240812_2209.py new file mode 100644 index 00000000..a64475c0 --- /dev/null +++ b/src/nefarious/migrations/0090_auto_20240812_2209.py @@ -0,0 +1,18 @@ +# Generated by Django 3.0.2 on 2024-08-12 22:09 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('nefarious', '0089_qualityprofile_require_five_point_one'), + ] + + operations = [ + migrations.RenameField( + model_name='qualityprofile', + old_name='profile', + new_name='quality', + ), + ] diff --git a/src/nefarious/migrations/0091_auto_20240815_2159.py b/src/nefarious/migrations/0091_auto_20240815_2159.py new file mode 100644 index 00000000..75c7f5dd --- /dev/null +++ b/src/nefarious/migrations/0091_auto_20240815_2159.py @@ -0,0 +1,29 @@ +# Generated by Django 3.0.2 on 2024-08-15 21:59 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('nefarious', '0090_auto_20240812_2209'), + ] + + operations = [ + migrations.AlterField( + model_name='watchmovie', + name='quality_profile', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='nefarious.QualityProfile'), + ), + migrations.AlterField( + model_name='watchtvseasonrequest', + name='quality_profile', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='nefarious.QualityProfile'), + ), + migrations.AlterField( + model_name='watchtvshow', + name='quality_profile', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='nefarious.QualityProfile'), + ), + ] diff --git a/src/nefarious/migrations/0092_auto_20240816_1229.py b/src/nefarious/migrations/0092_auto_20240816_1229.py new file mode 100644 index 00000000..b229fb91 --- /dev/null +++ b/src/nefarious/migrations/0092_auto_20240816_1229.py @@ -0,0 +1,24 @@ +# Generated by Django 3.0.2 on 2024-08-16 12:29 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('nefarious', '0091_auto_20240815_2159'), + ] + + operations = [ + migrations.AlterField( + model_name='nefarioussettings', + name='quality_profile_movies', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='quality_profile_movies_default', to='nefarious.QualityProfile'), + ), + migrations.AlterField( + model_name='nefarioussettings', + name='quality_profile_tv', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='quality_profile_tv_default', to='nefarious.QualityProfile'), + ), + ] diff --git a/src/nefarious/migrations/0093_auto_20240816_1248.py b/src/nefarious/migrations/0093_auto_20240816_1248.py new file mode 100644 index 00000000..2808c3ae --- /dev/null +++ b/src/nefarious/migrations/0093_auto_20240816_1248.py @@ -0,0 +1,41 @@ +# Generated by Django 3.0.2 on 2024-08-16 12:48 + +from django.conf import settings +from django.db import migrations, models +import nefarious.models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('nefarious', '0092_auto_20240816_1229'), + ] + + operations = [ + migrations.AlterField( + model_name='watchmovie', + name='user', + field=models.ForeignKey(on_delete=models.SET(nefarious.models.get_first_admin_user), to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='watchtvepisode', + name='user', + field=models.ForeignKey(on_delete=models.SET(nefarious.models.get_first_admin_user), to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='watchtvseason', + name='user', + field=models.ForeignKey(on_delete=models.SET(nefarious.models.get_first_admin_user), to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='watchtvseasonrequest', + name='user', + field=models.ForeignKey(on_delete=models.SET(nefarious.models.get_first_admin_user), to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='watchtvshow', + name='user', + field=models.ForeignKey(on_delete=models.SET(nefarious.models.get_first_admin_user), to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/src/nefarious/migrations/0094_auto_20240816_1251.py b/src/nefarious/migrations/0094_auto_20240816_1251.py new file mode 100644 index 00000000..c41544eb --- /dev/null +++ b/src/nefarious/migrations/0094_auto_20240816_1251.py @@ -0,0 +1,29 @@ +# Generated by Django 3.0.2 on 2024-08-16 12:51 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('nefarious', '0093_auto_20240816_1248'), + ] + + operations = [ + migrations.AlterField( + model_name='watchmovie', + name='quality_profile', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='nefarious.QualityProfile'), + ), + migrations.AlterField( + model_name='watchtvseasonrequest', + name='quality_profile', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='nefarious.QualityProfile'), + ), + migrations.AlterField( + model_name='watchtvshow', + name='quality_profile', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='nefarious.QualityProfile'), + ), + ] diff --git a/src/nefarious/migrations/0095_auto_20240816_1302.py b/src/nefarious/migrations/0095_auto_20240816_1302.py new file mode 100644 index 00000000..ad3fcc02 --- /dev/null +++ b/src/nefarious/migrations/0095_auto_20240816_1302.py @@ -0,0 +1,24 @@ +# Generated by Django 3.0.2 on 2024-08-16 13:02 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('nefarious', '0094_auto_20240816_1251'), + ] + + operations = [ + migrations.AlterField( + model_name='nefarioussettings', + name='quality_profile_movies', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='quality_profile_movies_default', to='nefarious.QualityProfile'), + ), + migrations.AlterField( + model_name='nefarioussettings', + name='quality_profile_tv', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='quality_profile_tv_default', to='nefarious.QualityProfile'), + ), + ] diff --git a/src/nefarious/migrations/0096_auto_20240816_1352.py b/src/nefarious/migrations/0096_auto_20240816_1352.py new file mode 100644 index 00000000..d31b4556 --- /dev/null +++ b/src/nefarious/migrations/0096_auto_20240816_1352.py @@ -0,0 +1,23 @@ +# Generated by Django 3.0.2 on 2024-08-16 13:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('nefarious', '0095_auto_20240816_1302'), + ] + + operations = [ + migrations.AlterField( + model_name='qualityprofile', + name='require_five_point_one', + field=models.BooleanField(blank=True, default=False, help_text='media must be in 5.1 surround sound (e.g. Dolby 5.1)'), + ), + migrations.AlterField( + model_name='qualityprofile', + name='require_hdr', + field=models.BooleanField(blank=True, default=False, help_text='media must be in HDR (High Dynamic Range)'), + ), + ] diff --git a/src/nefarious/migrations/0097_auto_20240816_1353.py b/src/nefarious/migrations/0097_auto_20240816_1353.py new file mode 100644 index 00000000..ee0fa387 --- /dev/null +++ b/src/nefarious/migrations/0097_auto_20240816_1353.py @@ -0,0 +1,23 @@ +# Generated by Django 3.0.2 on 2024-08-16 13:53 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('nefarious', '0096_auto_20240816_1352'), + ] + + operations = [ + migrations.AlterField( + model_name='qualityprofile', + name='require_five_point_one', + field=models.BooleanField(default=False, help_text='media must be in 5.1 surround sound (e.g. Dolby 5.1)', null=True), + ), + migrations.AlterField( + model_name='qualityprofile', + name='require_hdr', + field=models.BooleanField(default=False, help_text='media must be in HDR (High Dynamic Range)', null=True), + ), + ] diff --git a/src/nefarious/models.py b/src/nefarious/models.py index 0f7a2a5f..055d382c 100644 --- a/src/nefarious/models.py +++ b/src/nefarious/models.py @@ -1,8 +1,10 @@ import os from django.contrib.auth.models import User +from django.core.validators import MinValueValidator from django.conf import settings from jsonfield import JSONField from django.db import models + from nefarious import media_category from nefarious import quality @@ -16,6 +18,26 @@ MEDIA_TYPE_TV_EPISODE = 'TV_EPISODE' +def get_first_admin_user(): + return User.objects.filter(is_staff=True).order_by('id').first() + + +class QualityProfile(models.Model): + name = models.CharField(max_length=500, unique=True) + quality = models.CharField(max_length=500, choices=zip(quality.PROFILE_NAMES, quality.PROFILE_NAMES)) + min_size_gb = models.DecimalField( + null=True, blank=True, max_digits=10, decimal_places=2, validators=[MinValueValidator(0)], help_text='minimum size (gb) to download') + max_size_gb = models.DecimalField( + null=True, blank=True, max_digits=10, decimal_places=2, validators=[MinValueValidator(0)], help_text='maximum size (gb) to download') + require_hdr = models.BooleanField(default=False, null=True, help_text='media must be in HDR (High Dynamic Range)') + require_five_point_one = models.BooleanField(default=False, null=True, help_text='media must be in 5.1 surround sound (e.g. Dolby 5.1)') + + def __str__(self): + if self.name == self.quality: + return self.name + return f'{self.name} ({self.quality})' + + class NefariousSettings(models.Model): JACKETT_TOKEN_DEFAULT = 'COPY_YOUR_JACKETT_TOKEN_HERE' @@ -26,6 +48,7 @@ class NefariousSettings(models.Model): jackett_host = models.CharField(max_length=500, default='jackett') jackett_port = models.IntegerField(default=9117) jackett_token = models.CharField(max_length=500, default=JACKETT_TOKEN_DEFAULT) + jackett_filter_index = models.CharField( # https://github.com/Jackett/Jackett#filter-indexers max_length=500, null=True, blank=True, help_text='Optional Jackett index filter to use for searches') @@ -37,7 +60,7 @@ class NefariousSettings(models.Model): transmission_tv_download_dir = models.CharField(max_length=500, default='tv/', help_text='Relative to download path') transmission_movie_download_dir = models.CharField(max_length=500, default='movies/', help_text='Relative to download path') - # tmbd - the movie database + # tmdb - the movie database tmdb_token = models.CharField(max_length=500, default=settings.TMDB_API_TOKEN) tmdb_configuration = JSONField(blank=True, null=True) tmdb_languages = JSONField(blank=True, null=True) # type: list @@ -49,8 +72,8 @@ class NefariousSettings(models.Model): open_subtitles_user_token = models.CharField(max_length=500, blank=True, null=True, help_text='OpenSubtitles user auth token') # generated in auth flow open_subtitles_auto = models.BooleanField(default=False, help_text='Whether to automatically download subtitles') - quality_profile_tv = models.CharField(max_length=500, default=quality.PROFILE_ANY.name, choices=zip(quality.PROFILE_NAMES, quality.PROFILE_NAMES)) - quality_profile_movies = models.CharField(max_length=500, default=quality.PROFILE_HD_720P_1080P.name, choices=zip(quality.PROFILE_NAMES, quality.PROFILE_NAMES)) + quality_profile_tv = models.ForeignKey(QualityProfile, on_delete=models.PROTECT, related_name='quality_profile_tv_default') + quality_profile_movies = models.ForeignKey(QualityProfile, on_delete=models.PROTECT, related_name='quality_profile_movies_default') # whether to allow hardcoded subtitles allow_hardcoded_subs = models.BooleanField(default=False) @@ -88,18 +111,21 @@ def get_tmdb_poster_url(self, poster_path): poster_path.lstrip('/'), ) - def should_save_subtitles(self): + def should_save_subtitles(self) -> bool: return all([ self.open_subtitles_auto, self.open_subtitles_user_token, ]) + class Meta: + verbose_name_plural = "Settings" + class WatchMediaBase(models.Model): """ Abstract base class for all watchable media classes """ - user = models.ForeignKey(User, on_delete=models.CASCADE) + user = models.ForeignKey(User, on_delete=models.SET(get_first_admin_user)) date_added = models.DateTimeField(auto_now_add=True) date_updated = models.DateTimeField(auto_now=True) collected = models.BooleanField(default=False) @@ -126,7 +152,7 @@ class WatchMovie(WatchMediaBase): tmdb_movie_id = models.IntegerField(unique=True) name = models.CharField(max_length=255) poster_image_url = models.CharField(max_length=1000) - quality_profile_custom = models.CharField(max_length=500, null=True, blank=True, choices=zip(quality.PROFILE_NAMES, quality.PROFILE_NAMES)) + quality_profile = models.ForeignKey(QualityProfile, on_delete=models.SET_NULL, null=True, blank=True) class Meta: ordering = ('name',) @@ -142,14 +168,15 @@ class WatchTVShow(models.Model): """ Shows are unique in that you don't request to "watch" a show. Instead, you watch specific seasons and episodes """ - user = models.ForeignKey(User, on_delete=models.CASCADE) + user = models.ForeignKey(User, on_delete=models.SET(get_first_admin_user)) tmdb_show_id = models.IntegerField(unique=True) name = models.CharField(max_length=255) poster_image_url = models.CharField(max_length=1000) release_date = models.DateField(null=True, blank=True) auto_watch = models.BooleanField(default=False) # whether to automatically watch future seasons auto_watch_date_updated = models.DateField(null=True, blank=True) # date auto watch requested/updated - quality_profile_custom = models.CharField(max_length=500, null=True, blank=True, choices=zip(quality.PROFILE_NAMES, quality.PROFILE_NAMES)) + quality_profile = models.ForeignKey(QualityProfile, on_delete=models.SET_NULL, null=True, blank=True) + class Meta: ordering = ('name',) @@ -169,10 +196,10 @@ class WatchTVSeasonRequest(models.Model): The task queue will routinely scan for new episodes for a season that may not have had it's full episode list at the time of the request to watch the entire season. Essentially, nefarious will re-request a season's episode list to see if it needs to download any new episodes. """ - user = models.ForeignKey(User, on_delete=models.CASCADE) + user = models.ForeignKey(User, on_delete=models.SET(get_first_admin_user)) watch_tv_show = models.ForeignKey(WatchTVShow, on_delete=models.CASCADE) season_number = models.IntegerField() - quality_profile_custom = models.CharField(max_length=500, null=True, blank=True, choices=zip(quality.PROFILE_NAMES, quality.PROFILE_NAMES)) + quality_profile = models.ForeignKey(QualityProfile, on_delete=models.SET_NULL, null=True, blank=True) collected = models.BooleanField(default=False) date_added = models.DateTimeField(auto_now_add=True) date_updated = models.DateTimeField(auto_now=True) diff --git a/src/nefarious/parsers/base.py b/src/nefarious/parsers/base.py index afbe7127..6b0a51d8 100644 --- a/src/nefarious/parsers/base.py +++ b/src/nefarious/parsers/base.py @@ -3,6 +3,8 @@ from nefarious import quality from nefarious.quality import Resolution, Profile +# piracy nomenclature +# https://en.wikipedia.org/wiki/Pirated_movie_release_types # regex parsing taken from: # https://github.com/Sonarr/Sonarr/blob/537e4d7c39e839e75e7a7ad84e95cd582ec1d20e/src/NzbDrone.Core/Parser/QualityParser.cs @@ -57,6 +59,8 @@ class ParserBase: high_def_pdtv_regex = regex.compile(r"hr[-_. ]ws", regex.I) raw_hd_regex = regex.compile(r"\b(?RawHD|1080i[-_. ]HDTV|Raw[-_. ]HD|MPEG[-_. ]?2)\b", regex.I) hardcoded_subs_regex = regex.compile(r"\b(?hc|korsub)\b", regex.I) + hdr_regex = regex.compile(r"\bhdr\b", regex.I) + five_point_one_regex = regex.compile(r'\b(ddp?)?5[. ]1\b', regex.I) # # 5.1 surround sound def __init__(self, title): self.title_query = title @@ -74,13 +78,34 @@ def parse(self): if 'title' in self.match and self.match['title']: self.match['title'] = self.normalize_media_title(self.match['title'][0]) - # quality - title_quality = self.parse_quality(self.title_query) - self.match['quality'] = title_quality.name - self.match['resolution'] = self.parse_resolution(self.title_query) + self.parse_tags() - # hardcoded subs - self.match['hc'] = self.parse_hardcoded_subs() + return self.match + + def parse_tags(self): + + # quality + title_quality = self.parse_quality(self.title_query) + self.match['quality'] = title_quality.name + self.match['resolution'] = self.parse_resolution(self.title_query) + + # hardcoded subs + self.match['hc'] = self.parse_hardcoded_subs() + + # hdr (high dynamic range) + self.match['hdr'] = self.parse_hdr() + + # 5.1 surround sound + self.match['five_point_one'] = self.parse_five_point_one() + + def parse_five_point_one(self): + # 5.1 surround sound + match = self.five_point_one_regex.search(self.title_query) + return True if match else False + + def parse_hdr(self): + match = self.hdr_regex.search(self.title_query) + return True if match else False def parse_hardcoded_subs(self): match = self.hardcoded_subs_regex.search(self.title_query) @@ -267,6 +292,13 @@ def is_match(self, title, *args, **kwargs) -> bool: return False return self._is_match(title, *args, **kwargs) + def is_five_point_one_match(self, needs_five_point_one = False): + # 5.1 surround sound + return self.match['five_point_one'] if needs_five_point_one else True + + def is_hdr_match(self, needs_hdr = False): + return self.match['hdr'] if needs_hdr else True + def is_quality_match(self, profile: Profile) -> bool: return self.match['quality'] in profile.qualities diff --git a/src/nefarious/parsers/tv.py b/src/nefarious/parsers/tv.py index 39db682d..03cdbeb4 100644 --- a/src/nefarious/parsers/tv.py +++ b/src/nefarious/parsers/tv.py @@ -271,13 +271,7 @@ def parse(self): for i, episode in enumerate(self.match['episode']): self.match['episode'][i] = self.normalize_season_episode(episode) - # quality - title_quality = self.parse_quality(self.title_query) - self.match['quality'] = title_quality.name - self.match['resolution'] = self.parse_resolution(self.title_query) - - # hardcoded subs - self.match['hc'] = self.parse_hardcoded_subs() + self.parse_tags() return self.match diff --git a/src/nefarious/processors.py b/src/nefarious/processors.py index e37fb6d9..9141e5c2 100644 --- a/src/nefarious/processors.py +++ b/src/nefarious/processors.py @@ -6,7 +6,8 @@ from django.utils import dateparse, timezone from transmissionrpc import Torrent -from nefarious.models import WatchMovie, NefariousSettings, TorrentBlacklist, WatchTVEpisode, WatchTVSeason +from nefarious.models import WatchMovie, NefariousSettings, TorrentBlacklist, WatchTVEpisode, WatchTVSeason, QualityProfile +from nefarious.parsers.base import ParserBase from nefarious.parsers.movie import MovieParser from nefarious.parsers.tv import TVParser from nefarious.quality import Profile @@ -41,10 +42,8 @@ def fetch(self): if search.ok: for result in search.results: - if self.is_match(result['Title']): + if self.is_match(result['Title'], result['Size']): valid_search_results.append(result) - else: - logger_background.info('Not matched: {}'.format(result['Title'])) if valid_search_results: @@ -61,7 +60,7 @@ def fetch(self): logger_background.info('Valid Search Results: {}'.format(len(valid_search_results))) - # find the torrent result with the highest weight (i.e seeds) + # find the torrent result with the highest weight (e.g. seeds) best_result = self._get_best_torrent_result(valid_search_results) transmission_client = get_transmission_client(self.nefarious_settings) @@ -99,7 +98,7 @@ def fetch(self): continue else: logger_background.info('No valid search results for {}'.format(self._sanitize_title(str(self.watch_media)))) - # try again without possessive apostrophes (ie. The Handmaids Tale vs The Handmaid's Tale) + # try again without possessive apostrophes (e.g. The Handmaids Tale vs The Handmaid's Tale) if not self._reprocess_without_possessive_apostrophes and self._possessive_apostrophes_regex.search(str(self.watch_media)): self._reprocess_without_possessive_apostrophes = True logger_background.warning('Retrying without possessive apostrophes: "{}"'.format(self._sanitize_title(str(self.watch_media)))) @@ -114,19 +113,46 @@ def fetch(self): return False - def is_match(self, title: str) -> bool: + def is_match(self, title: str, size_bytes: int) -> bool: parser = self._get_parser(title) quality_profile = self._get_quality_profile() - profile = Profile.get_from_name(quality_profile) - - return ( - self._is_match(parser) and - parser.is_quality_match(profile) and - parser.is_hardcoded_subs_match(self.nefarious_settings.allow_hardcoded_subs) and - parser.is_keyword_search_filter_match( + profile = Profile.get_from_name(quality_profile.quality) + size_gb = size_bytes / (1024**3) + mismatch = None + + # title + if not self._is_match(parser): + mismatch = 'title' + # quality + elif not parser.is_quality_match(profile): + mismatch = 'quality' + # size + elif quality_profile.min_size_gb is not None and size_gb < quality_profile.min_size_gb: + mismatch = f'size min: {size_gb} < {quality_profile.min_size_gb}' + elif quality_profile.max_size_gb is not None and size_gb > quality_profile.max_size_gb: + mismatch = f'size max: {size_gb} > {quality_profile.max_size_gb}' + # subs + elif not parser.is_hardcoded_subs_match(self.nefarious_settings.allow_hardcoded_subs): + mismatch = f'hardcoded subs' + # hdr + elif not parser.is_hdr_match(quality_profile.require_hdr): + mismatch = 'hdr' + # 5.1 surround sound + elif not parser.is_five_point_one_match(quality_profile.require_five_point_one): + mismatch = 'five_point_one' + # keyword filters + elif not parser.is_keyword_search_filter_match( self.nefarious_settings.keyword_search_filters.keys() if self.nefarious_settings.keyword_search_filters else [] - ) - ) + ): + mismatch = f'keyword search filters' + + # failed + if mismatch: + logger_background.info('[SEARCH: {}][NOT MATCHED: {}][PROFILE: {}][REASON: {}]'.format(self.watch_media, title, quality_profile, mismatch)) + return False + + # success + return True def _set_last_attempt_date(self): self.watch_media.last_attempt_date = timezone.utc.localize(datetime.utcnow()) @@ -144,7 +170,7 @@ def _results_with_valid_urls(self, results: list): def _get_best_torrent_result(self, results: list): return get_best_torrent_result(results) - def _get_quality_profile(self): + def _get_quality_profile(self) -> QualityProfile: raise NotImplementedError def _get_watch_media(self, watch_media_id: int): @@ -156,7 +182,7 @@ def _get_download_dir(self, transmission_session): def _get_tmdb_media(self): raise NotImplementedError - def _get_parser(self, title: str): + def _get_parser(self, title: str) -> ParserBase: raise NotImplementedError def _get_tmdb_title_key(self): @@ -179,11 +205,11 @@ def _get_search_results(self): class WatchMovieProcessor(WatchProcessorBase): - def _get_quality_profile(self): + def _get_quality_profile(self) -> QualityProfile: # try custom quality profile then fallback to global setting - return self.watch_media.quality_profile_custom or self.nefarious_settings.quality_profile_movies + return self.watch_media.quality_profile or self.nefarious_settings.quality_profile_movies - def _get_parser(self, title: str): + def _get_parser(self, title: str) -> MovieParser: return MovieParser(title) def _is_match(self, parser): @@ -222,12 +248,12 @@ def _get_search_results(self): class WatchTVProcessorBase(WatchProcessorBase): - def _get_quality_profile(self): + def _get_quality_profile(self) -> QualityProfile: # try custom quality profile then fallback to global setting watch_media = self.watch_media # type: WatchTVEpisode|WatchTVSeason - return watch_media.watch_tv_show.quality_profile_custom or self.nefarious_settings.quality_profile_tv + return watch_media.watch_tv_show.quality_profile or self.nefarious_settings.quality_profile_tv - def _get_parser(self, title: str): + def _get_parser(self, title: str) -> TVParser: return TVParser(title) def _get_media_type(self) -> str: