From adf2ad207356fb40d26ee182c723992f081a42b9 Mon Sep 17 00:00:00 2001 From: meisnate12 Date: Mon, 18 Apr 2022 14:16:39 -0400 Subject: [PATCH] [2] Overlay Overhaul! --- .gitignore | 5 +- VERSION | 2 +- config/overlays/4K-Dolby/overlay.png | Bin 34656 -> 0 bytes config/overlays/4K-HDR/overlay.png | Bin 43356 -> 0 bytes config/overlays/{4K/overlay.png => 4K.png} | Bin .../overlays/{Dolby/overlay.png => Dolby.png} | Bin config/overlays/{HDR/overlay.png => HDR.png} | Bin docs/conf.py | 9 +- docs/config/configuration.md | 32 +- docs/config/libraries.md | 16 + docs/config/operations.md | 1 + docs/home/environmental.md | 58 ++- docs/home/guides/assets.md | 61 +-- docs/metadata/details/metadata.md | 2 - docs/metadata/metadata.md | 196 +++++++--- docs/metadata/metadata/movie.md | 36 +- docs/metadata/metadata/show.md | 45 +-- docs/metadata/overlay.md | 114 ++++++ docs/metadata/playlist.md | 94 +++++ modules/builder.py | 125 ++++-- modules/cache.py | 12 +- modules/config.py | 20 +- modules/library.py | 73 +++- modules/meta.py | 19 +- modules/operations.py | 4 +- modules/overlays.py | 203 ++++++++++ modules/plex.py | 358 +++++++++++------- modules/tmdb.py | 8 + modules/util.py | 38 +- plex_meta_manager.py | 49 ++- 30 files changed, 1128 insertions(+), 452 deletions(-) delete mode 100644 config/overlays/4K-Dolby/overlay.png delete mode 100644 config/overlays/4K-HDR/overlay.png rename config/overlays/{4K/overlay.png => 4K.png} (100%) rename config/overlays/{Dolby/overlay.png => Dolby.png} (100%) rename config/overlays/{HDR/overlay.png => HDR.png} (100%) create mode 100644 docs/metadata/overlay.md create mode 100644 docs/metadata/playlist.md create mode 100644 modules/overlays.py diff --git a/.gitignore b/.gitignore index 86bd113e..97ca9ad1 100644 --- a/.gitignore +++ b/.gitignore @@ -13,9 +13,10 @@ __pycache__/ /test* logs/ config/* -!config/overlays/*/overlay.png +!config/overlays/ +config/overlays/*/ +config/overlays/temp.png !config/*.template -!overlay.png build/ develop-eggs/ dist/ diff --git a/VERSION b/VERSION index dc022bd8..5eb0e463 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.16.5-develop1 +1.16.5-develop2 diff --git a/config/overlays/4K-Dolby/overlay.png b/config/overlays/4K-Dolby/overlay.png deleted file mode 100644 index 35f1d8254d60a6b1fcaf14f02f4ccced8252bf6f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 34656 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 z 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 here or per library | -| [`tmdb`](tmdb) | ✅ | -| [`tautulli`](tautulli) | ❌ | -| [`omdb`](omdb) | ❌ | -| [`notifiarr`](notifiarr) | ❌ | -| [`anidb`](anidb) | ❌ | -| [`radarr`](radarr) | ❌ | -| [`sonarr`](sonarr) | ❌ | -| [`trakt`](trakt) | ❌ | -| [`mal`](myanimelist) | ❌ | \ No newline at end of file +| Attribute | Required | +|:----------------------------------------------------------|:---------------------------------------:| +| [`libraries`](libraries) | ✅ | +| [`playlist_files`](libraries.md#playlist-files-attribute) | ❌ | +| [`settings`](settings) | ❌ | +| [`webhooks`](webhooks) | ❌ | +| [`plex`](plex) | ✅
Either here or per library | +| [`tmdb`](tmdb) | ✅ | +| [`tautulli`](tautulli) | ❌ | +| [`omdb`](omdb) | ❌ | +| [`notifiarr`](notifiarr) | ❌ | +| [`anidb`](anidb) | ❌ | +| [`radarr`](radarr) | ❌ | +| [`sonarr`](sonarr) | ❌ | +| [`trakt`](trakt) | ❌ | +| [`mal`](myanimelist) | ❌ | \ No newline at end of file diff --git a/docs/config/libraries.md b/docs/config/libraries.md index 5720df9d..19352b8e 100644 --- a/docs/config/libraries.md +++ b/docs/config/libraries.md @@ -35,6 +35,8 @@ libraries: - file: config/TV Shows.yml - git: meisnate12/ShowCharts - git: meisnate12/Networks + overlay_path: + - file: config/Overlays.yml TV Shows On Second Plex: library_name: TV Shows plex: @@ -79,6 +81,7 @@ The available attributes for each library are as follows: |:-------------------------------------------|:---------------------------------------------------------------------------------------------|:--------------------------------------:|:-------------------------------:| | [`library_name`](#library-name) | Library name (required only when trying to use multiple libraries with the same name) | Base Attribute Name | ❌ | | [`metadata_path`](#metadata-path) | Location of Metadata YAML files | `/config/<>.yml` | ❌ | +| [`overlay_path`](#overlay-path) | Location of Overlay YAML files | None | ❌ | | [`missing_path`](#missing-path) | Location to create the YAML file listing missing items for this library | `/config/<>_missing.yml` | ❌ | | [`schedule`](../metadata/details/schedule) | Use any [schedule option](../metadata/details/schedule) to control when this library is run. | daily | ❌ | | [`operations`](operations) | Library Operations to run | N/A | ❌ | @@ -125,6 +128,19 @@ libraries: TV Shows: ``` +### Overlay Path + +The `overlay_path` attribute is used to define [Overlay Files](../metadata/metadata) by specifying the path type and path of the files that will be executed against the parent library. See [Path Types](paths) for how to define them. + +```yaml +libraries: + TV Shows: + metadata_path: + - file: config/TV Shows.yml + overlay_path: + - file: config/Overlays.yml +``` + ### Missing Path The `missing_path` attribute is used to define where to save the "missing items" YAML file. This file is used to store information about media which is missing from the Plex library compared to what is expected from the Metadata file. diff --git a/docs/config/operations.md b/docs/config/operations.md index 04317d61..344beb17 100644 --- a/docs/config/operations.md +++ b/docs/config/operations.md @@ -30,6 +30,7 @@ The available attributes for the operations attribute are as follows | `mass_collection_mode` | Updates every Collection in your library to the specified Collection Mode
**Values:** `default`: Library default
`hide`: Hide Collection
`hide_items`: Hide Items in this Collection
`show_items`: Show this Collection and its Items
`default`Library default
`hide`Hide Collection
`hide_items`Hide Items in this Collection
`show_items`Show this Collection and its Items
| | `update_blank_track_titles` | Search though every track in a music library and replace any blank track titles with the tracks sort title
**Values:** `true` or `false` | | `remove_title_parentheses` | Search through every title and remove all ending parentheses in an items title if the title isn not locked.
**Values:** `true` or `false` | +| `remove_overlays` | Search through every title and removes all overlays.
**Values:** `true` or `false` | | `split_duplicates` | Splits all duplicate movies/shows found in this library
**Values:** `true` or `false` | | `radarr_add_all` | Adds every item in the library to Radarr. The existing paths in plex will be used as the root folder of each item, if the paths in Plex are not the same as your Radarr paths you can use the `plex_path` and `radarr_path` [Radarr](radarr) details to convert the paths.
**Values:** `true` or `false` | | `radarr_remove_by_tag` | Removes every item from Radarr with the Tags given
**Values:** List or comma separated string of tags | diff --git a/docs/home/environmental.md b/docs/home/environmental.md index 446dd1fe..41ea987e 100644 --- a/docs/home/environmental.md +++ b/docs/home/environmental.md @@ -13,7 +13,8 @@ These docs are assuming you have a basic understanding of Docker concepts. One | [Run](#run) | `-r` or `--run` | `PMM_RUN` | | [Run Tests](#run-tests) | `-rt`, `--tests`, or `--run-tests` | `PMM_TEST` | | [Collections Only](#collections-only) | `-co` or `--collections-only` | `PMM_COLLECTIONS_ONLY` | -| [Libraries Only](#libraries-only) | `-lo` or `--libraries-only` | `PMM_LIBRARIES_ONLY` | +| [Operations](#operations) | `-op` or `--operations` | `PMM_OPERATIONS` | +| [Overlays](#overlays) | `-ov` or `--overlays` | `PMM_OVERLAYS` | | [Run Collections](#run-collections) | `-rc` or `--run-collections` | `PMM_COLLECTIONS` | | [Run Libraries](#run-libraries) | `-rl` or `--run-libraries` | `PMM_LIBRARIES` | | [Run Metadata Files](#run-metadata-files) | `-rm` or `--run-metadata-files` | `PMM_METADATA_FILES` | @@ -247,9 +248,9 @@ docker run -it -v "X:\Media\Plex Meta Manager\config:/config:rw" meisnate12/plex -### Libraries Only +### Operations -Only run library operations, skip collections. +Only run library operations skipping collections and overlays. @@ -259,13 +260,13 @@ Only run library operations, skip collections. - - + + - - + +
Flags-lo or --libraries-onlyPMM_LIBRARIES_ONLY-op or --operationsPMM_OPERATIONS
Example--libraries-onlyPMM_LIBRARIES_ONLY=true--operationsPMM_OPERATIONS=true
@@ -273,7 +274,7 @@ Only run library operations, skip collections. Local Environment ```shell -python plex_meta_manager.py --libraries-only +python plex_meta_manager.py --operations ``` @@ -281,7 +282,46 @@ python plex_meta_manager.py --libraries-only Docker Environment ```shell -docker run -it -v "X:\Media\Plex Meta Manager\config:/config:rw" meisnate12/plex-meta-manager --libraries-only +docker run -it -v "X:\Media\Plex Meta Manager\config:/config:rw" meisnate12/plex-meta-manager --operations +``` + + + +### Overlays + +Only run library overlays skipping operations and collections. + + + + + + + + + + + + + + + + + +
ShellEnvironment
Flags-ov or --overlaysPMM_OVERLAYS
Example--overlaysPMM_OVERLAYS=true
+ +
+ Local Environment + +```shell +python plex_meta_manager.py --overlays +``` + +
+
+ Docker Environment + +```shell +docker run -it -v "X:\Media\Plex Meta Manager\config:/config:rw" meisnate12/plex-meta-manager --overlays ```
diff --git a/docs/home/guides/assets.md b/docs/home/guides/assets.md index e1aeb9bc..0938b8b3 100644 --- a/docs/home/guides/assets.md +++ b/docs/home/guides/assets.md @@ -25,10 +25,8 @@ By default [if no `asset_directory` is specified], the program will look in the Assets are searched for only at specific times. -1. Collection assets are searched for whenever that collection is run. -2. Item assets for items in a collection are searched for whenever that collection is run and has `item_assets: true` as a Collection Detail. -3. Item assets and Unmanaged Collections assets are searched for whenever the `assets_for_all` Library Operation is active. -4. Item assets will be searched for any item that has an overlay applied to it. +1. Collection and Playlist assets are searched for whenever that collection/playlist is run. +2. Item assets and Unmanaged Collections assets are searched for whenever the `assets_for_all` Library Operation is active. * If you want to silence the `Asset Warning: No poster or background found in an assets folder for 'TITLE'` you can use the [`show_missing_assets` Setting Attribute](../../config/settings.md#show-missing-assets): ```yaml @@ -48,6 +46,7 @@ The table below shows the asset folder path structures that will be searched for | Season poster | `assets/ASSET_NAME/Season##.ext` | `assets/ASSET_NAME_Season##.ext` | | Season background | `assets/ASSET_NAME/Season##_background.ext` | `assets/ASSET_NAME_Season##_background.ext` | | Episode poster | `assets/ASSET_NAME/S##E##.ext` | `assets/ASSET_NAME_S##E##.ext` | +| Episode background | `assets/ASSET_NAME/S##E##_background.ext` | `assets/ASSET_NAME_S##E##_background.ext` | * For **Collections** replace `ASSET_NAME` with the mapping name used with the collection unless `system_name` is specified, which you would then use what's specified in `system_name`. @@ -67,7 +66,7 @@ The table below shows the asset folder path structures that will be searched for Here's an example config folder structure with an assets directory with `asset_folders` set to true and false. -### `asset_folders: true` without nesting +### `asset_folders: true` ``` config @@ -119,58 +118,6 @@ config │ ├── Season04_background.png ``` -### `asset_folders: true` with nesting - -``` -config -├── config.yml -├── Movies.yml -├── TV Shows.yml -├── assets -│ ├── The Lord of the Rings -│ ├── poster.png -│ ├── background.png -│ ├── The Lord of the Rings The Fellowship of the Ring (2001) -│ ├── poster.png -│ ├── background.png -│ ├── The Lord of the Rings The Two Towers (2002) -│ ├── poster.png -│ ├── background.png -│ ├── The Lord of the Rings The Return of the King (2003) -│ ├── poster.png -│ ├── background.png -│ ├── Star Wars (Animated) -│ ├── poster.png -│ ├── background.png -│ ├── Star Wars The Clone Wars -│ ├── poster.png -│ ├── background.png -│ ├── Season00.png -│ ├── Season01.png -│ ├── Season02.png -│ ├── Season03.png -│ ├── Season04.png -│ ├── Season05.png -│ ├── Season06.png -│ ├── Season07.png -│ ├── S07E01.png -│ ├── S07E02.png -│ ├── S07E03.png -│ ├── S07E04.png -│ ├── S07E05.png -│ ├── Star Wars Rebels -│ ├── poster.png -│ ├── background.png -│ ├── Season01.png -│ ├── Season01_background.png -│ ├── Season02.png -│ ├── Season02_background.png -│ ├── Season03.png -│ ├── Season03_background.png -│ ├── Season04.png -│ ├── Season04_background.png -``` - ### `asset_folders: false` ``` diff --git a/docs/metadata/details/metadata.md b/docs/metadata/details/metadata.md index 92a407c2..f1e6cb13 100644 --- a/docs/metadata/details/metadata.md +++ b/docs/metadata/details/metadata.md @@ -45,8 +45,6 @@ None of these details work with Playlists. | `item_lock_poster` | **Description:** Locks/Unlocks the poster of every movie/show in the collection
**Default:** `None`
**Values:**
`true`Lock
`false`Unlock
| | `item_lock_background` | **Description:** Locks/Unlocks the background of every movie/show in the collection
**Default:** `None`
**Values:**
`true`Lock
`false`Unlock
| | `item_lock_title` | **Description:** Locks/Unlocks the title of every movie/show in the collection
**Default:** `None`
**Values:**
`true`Lock
`false`Unlock
| -| `item_overlay` | **Description:** Adds and overlay image to the poster of every movie/show in the collection see [Overlay Details](overlay) for more information.
**Values:** Name of overlay to be applied | -| `item_assets` | **Description:** Checks your assets folders for assets of every movie/show in the collection
**Default:** `false`
**Values:** `true` or `false` | | `item_refresh` | **Description:** Refreshes the metadata of every movie/show in the collection
**Default:** `false`
**Values:** `true` or `false` | | `item_refresh_delay` | **Description:** Amount of time to wait between each `item_refresh` of every movie/show in the collection
**Default:** `0`
**Values:** Number greater then `0` | | `item_tmdb_season_titles` | **Description:** Changes the season titles of every show in the collection to match TMDb
**Default:** `false`
**Values:** `true` or `false` | diff --git a/docs/metadata/metadata.md b/docs/metadata/metadata.md index 1a41c575..8d05c4fd 100644 --- a/docs/metadata/metadata.md +++ b/docs/metadata/metadata.md @@ -1,75 +1,50 @@ -# Metadata and Playlist Files - -Metadata and Playlist files are used to create and maintain collections within the Plex libraries and playlists on the server. +## Metadata Files -If utilized to their fullest, these files can be used to maintain the entire server's collections and playlists, and can be used as a backup for these in the event of a restore requirement. +Metadata files are used to create and maintain collections and metadata within the Plex libraries on the server. -## Metadata Files +If utilized to their fullest, these files can be used to maintain the entire server's collections and metadata, and can be used as a backup for these in the event of a restore requirement. -Collections, templates, metadata, and dynamic collections are defined within one or more Metadata files, which are linked to libraries in the [Libraries Attribute](../config/libraries) within the [Configuration File](../config/configuration.md). +Collections, templates, metadata, and dynamic collections are defined within one or more Metadata files, which are linked to libraries in the [Libraries Attribute](../config/libraries.md#metadata-path) within the [Configuration File](../config/configuration.md). These are the attributes which can be used within the Metadata File: | Attribute | Description | |:--------------------------------------------------------|:----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `metadata` | contains definitions of metadata changes to [movie](metadata/movie), [show](metadata/show), or [music](metadata/music) library's items [movie titles, episode descriptions, etc.] | | [`templates`](templates) | contains definitions of templates that can be leveraged by multiple collections | | [`external_templates`](templates.md#external-templates) | contains [path types](../config/paths) that point to external templates that can be leveraged by multiple collections | -| [`collections`](#collections-and-playlists-mappings) | contains definitions of collections you wish to add to one or more libraries | -| [`dynamic_collections`](dynamic) | contains definitions of dynamic collections you wish to create in one or more libraries | +| [`collections`](#collection-attributes) | contains definitions of collections you wish to add to one or more libraries | +| [`dynamic_collections`](#dynamic-collection-attributes) | contains definitions of [dynamic collections](dynamic) you wish to create | +| [`metadata`](#metadata-attributes) | contains definitions of metadata changes to [movie](metadata/movie), [show](metadata/show), or [music](metadata/music) library's items [movie titles, episode descriptions, etc.] | * One of `metadata`, `collections` or `dynamic_collections` must be present for the Metadata File to execute. -* Example Metadata Files can be found in the [Plex Meta Manager Configs Repository](https://github.com/meisnate12/Plex-Meta-Manager-Configs) - -## Playlist Files - -Playlists are defined in one or more Playlist files that are mapped in the [Playlist Files Attribute](../config/playlist) within the Configuration File. - -These are the attributes which can be utilized within the Playlist File: - -| Attribute | Description | -|:--------------------------------------------------------|:--------------------------------------------------------------------------------------------------------------------| -| [`templates`](templates) | contains definitions of templates that can be leveraged by multiple playlists | -| [`external_templates`](templates.md#external-templates) | contains [path types](../config/paths) that point to external templates that can be leveraged by multiple playlists | -| [`playlists`](#additional-playlist-attributes) | contains definitions of playlists you wish to add to the server | - -* `playlists` is required in order to run the Playlist File. -* You can find example Playlist Files in the [Plex Meta Manager Configs Repository](https://github.com/meisnate12/Plex-Meta-Manager-Configs) -* Plex does not support the "Continue Watching" feature for playlists, you can [vote for the feature here](https://forums.plex.tv/t/playlists-remember-position-for-subsequent-resume/84866/39) +* Example Metadata Files can be found in the [Plex Meta Manager Configs Repository](https://github.com/meisnate12/Plex-Meta-Manager-Configs/tree/master/PMM) -## Collections and Playlists Mappings +## Collection Attributes -Plex Meta Manager can run a number of different operations within `collections:` and `playlists:` such as: +Plex Meta Manager can run a number of different operations within `collections` and such as: * Automatically build and update collections and playlists * Sync the collection with the source list if one is used * Send missing media to Sonarr/Radarr (Lidarr not supported at this time) -* Show and Hide collections and playlists at set intervals (i.e. show Christmas collections in December only) +* Show and Hide collections at set intervals (i.e. show Christmas collections in December only) - -## Dynamic Collection Mappings - -Plex Meta Manager can automatically create dynamic collections based on different criteria, such as - -* Collections for the top `X` popular people on TMDb (Bruce Willis, Tom Hanks etc.) -* Collections for each decade represented in the library (Best of 1990s, Best of 2000s etc.) -* Collections for each of the moods/styles within a Music library (A Cappella, Pop Rock etc.) - -Below is an example dynamic collection which will create a collection for each of the decades represented within the library: +Each collection requires its own section within the `collections` attribute and unlike playlists, collections can be built using as many Builders as needed. ```yaml -dynamic_collections: - Decades: - type: decade +collections: + Trending Movies: + # ... builders, details, and filters for this collection + Popular Movies: + # ... builders, details, and filters for this collection + etc: + # ... builders, details, and filters for this collection ``` -## Collection and Playlist Attributes - -There are three types of attributes that can be utilized within a collection/playlist: +There are three types of attributes that can be utilized within a collection: ### Builders -Builders use third-party services to source items to be added to the collection/playlist. Multiple builders can be used in the same collection/playlist from a variety of sources listed below. +Builders use third-party services to source items to be added to the collection. Multiple builders can be used in the same collection from a variety of sources listed below. * [Plex Builders](builders/plex) * [Smart Builders](builders/smart) @@ -86,37 +61,134 @@ Builders use third-party services to source items to be added to the collection/ * [AniList Builders](builders/anilist) * [MyAnimeList Builders](builders/myanimelist) -## Details +### Details -These can alter any aspect of the collection/playlist or the media items within them. +These can alter any aspect of the collection or the media items within them. * [Setting Details](details/setting) * [Schedule Detail](details/schedule) -* [Image Overlay Detail](details/overlay) * [Metadata Details](details/metadata) * [Arr Details](details/arr) -## Filters +### Filters These filter media items added to the collection by any of the Builders. * [Filters](filters) -## Additional Playlist Attributes +### Example + +```yaml +collections: + Trending: + trakt_trending: 10 + tmdb_trending_daily: 10 + tmdb_trending_weekly: 10 + sort_title: +1_Trending + sync_mode: sync + smart_label: random + summary: Movies Trending across the internet + Popular: + tmdb_popular: 40 + imdb_list: + url: https://www.imdb.com/search/title/?title_type=feature,tv_movie,documentary,short + limit: 40 + sort_title: +2_Popular + sync_mode: sync + smart_label: random + summary: Popular Movies across the internet +``` + +## Dynamic Collection Attributes + +Plex Meta Manager can dynamically create collections based on a verity of different criteria, such as + +* Collections for the top `X` popular people on TMDb (Bruce Willis, Tom Hanks etc.) +* Collections for each decade represented in the library (Best of 1990s, Best of 2000s etc.) +* Collections for each of the moods/styles within a Music library (A Cappella, Pop Rock etc.) +* Collections for each of a Trakt Users Lists. + +Below is an example dynamic collection which will create a collection for each of the decades represented within the library: -Playlist operations requires the `libraries` attribute, which instructs the operation to look in the specified libraries. This allows media to be combined from multiple libraries into one playlist. The mappings that you define in the `libraries` attribute must match the library names in your [Configuration File](../config/configuration). +```yaml +dynamic_collections: + Decades: + type: decade +``` + +## Metadata Attributes -The playlist can also use the `sync_to_users` attributes to control who has visibility of the playlist. This will override the global [`playlist_sync_to_users` Setting](../config/settings.md#playlist-sync-to-users). `sync_to_users` can be set to `all` to sync to all users who have access to the Plex Media Server, or a list/comma-separated string of users. The Plex Media Server owner will always have visibility of the Playlists, so does not need to be defined within the attribute. Leaving `sync_to_users` empty will make the playlist visible to the Plex Media Server owner only. +Plex Meta Manager can automatically update items in Plex based on what's defined within the `metadata` attribute. -In the following example, media is pulled from the `Movies` and `TV Shows` libraries into the one Playlist, and the playlist is shared with a specific set of users: +Each metadata requires its own section within the `metadata` attribute. Each item is defined by the mapping name which must be the same as the item name in the library unless an `alt_title` is specified. ```yaml -playlists: - Marvel Cinematic Universe: - sync_mode: sync - libraries: Movies, TV Shows - sync_to_users: User1, someone@somewhere.com, User3 - trakt_list: https://trakt.tv/users/donxy/lists/marvel-cinematic-universe?sort=rank,asc - summary: Marvel Cinematic Universe In Chronological Order +metadata: + Godzilla vs. Mechagodzilla II: + # ... details to change for this itwm + Godzilla vs. Megaguirus: + # ... details to change for this itwm + Godzilla vs. Megalon: + # ... details to change for this itwm + Halloween (Rob Zombie): + # ... details to change for this itwm + etc: + # ... details to change for this itwm ``` -* Unlike collections, playlists can only be built using one Builder as their ordering is inherited from the builder; it is not possible to combine builders. \ No newline at end of file + +### Title & Year + +YAML files cannot have two items with the same mapping name so if you have two movies with the same name you define each one with a name of your choosing. Then use the `title` attribute to specify the real title and the `year` attribute to specify which of the multiple movies is for this mapping. + +```yaml +metadata: + Godzilla1: + title: Godzilla + year: 1954 + content_rating: R + Godzilla2: + title: Godzilla + year: 1998 + content_rating: PG-13 +``` + +### Alt Title + +To define an alternative title that the item may be called when searching use `alt_title`. When a title is found matching `alt_title` then the name of the itme will be changed to match the mapping name or `title` if specified. + +For Example, the 2007 movie Halloween shares a name with another movie in the Halloween franchise so this changes the title to `Halloween (Rob Zombie)` if the title is currently Halloween. + +```yaml +metadata: + Halloween (Rob Zombie): + alt_title: Halloween + year: 2007 +``` + +### Example + +```yaml +metadata: + Godzilla1: + title: Godzilla + year: 1954 + content_rating: R + Godzilla2: + title: Godzilla + year: 1998 + content_rating: PG-13 + Godzilla vs. Mechagodzilla II: + content_rating: PG + Godzilla vs. Megaguirus: + content_rating: PG + originally_available: 2000-08-31 + Godzilla vs. Megalon: + content_rating: G + originally_available: 1973-03-17 + Halloween (Rob Zombie): + alt_title: Halloween + year: 2007 +``` + + + diff --git a/docs/metadata/metadata/movie.md b/docs/metadata/metadata/movie.md index 961dfd5a..f430f915 100644 --- a/docs/metadata/metadata/movie.md +++ b/docs/metadata/metadata/movie.md @@ -86,35 +86,13 @@ The available attributes for editing movies are as follows ### Special Attributes -| Attribute | Allowed Values | -|:-------------|:--------------------------------------------------------------------------------------------------| -| `title` | Title if different from the mapping value useful when you have multiple movies with the same name | -| `alt_title` | Alternative title to look for | -| `year` | Year of movie for better identification | -| `tmdb_show` | TMDb Show ID to use for metadata useful for miniseries that have been compiled into a movie | -| `tmdb_movie` | TMDb Movie ID to use for metadata useful for movies that have been split into segments | - - -* YAML files cannot have two items with the same mapping name so if you have two movies with the same name you would change the mapping values to whatever you want. Then use the `title` attribute to specify the real title and use the `year` attribute to specify which of the multiple movies to choose. - ```yaml - metadata: - Godzilla1: - title: Godzilla - year: 1954 - content_rating: R - Godzilla2: - title: Godzilla - year: 1998 - content_rating: PG-13 - ``` - -* If you know of another Title your movie might exist under, but you want it titled differently you can use `alt_title` to specify another title to look under and then be changed to the mapping name. For Example TMDb uses the name `The Legend of Korra`, but I want it as `Avatar: The Legend of Korra` (Which must be surrounded by quotes since it uses the character `:`): - ```yaml - metadata: - "Avatar: The Legend of Korra": - alt_title: The Legend of Korra - ``` - This would change the name of the TMDb default `The Legend of Korra` to `Avatar: The Legend of Korra` and would not mess up any subsequent runs. +| Attribute | Allowed Values | +|:-------------|:--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `title` | Title if different from the mapping value useful when you have multiple movies with the same name. See the [Metadata Page](../metadata.md#metadata-attributes) for how searching for files works. | +| `alt_title` | Alternative title to look for and then change to the mapping name. See the [Metadata Page](../metadata.md#metadata-attributes) for how searching for files works. | +| `year` | Year of movie for better identification. See the [Metadata Page](../metadata.md#metadata-attributes) for how searching for files works. | +| `tmdb_show` | TMDb Show ID to use for metadata useful for miniseries that have been compiled into a movie. **This is not used to say this show is the given ID.** | +| `tmdb_movie` | TMDb Movie ID to use for metadata useful for movies that have been split into segments **This is not used to say this show is the given ID.** | ### General Attributes diff --git a/docs/metadata/metadata/show.md b/docs/metadata/metadata/show.md index 14b7e8ea..2c5d7889 100644 --- a/docs/metadata/metadata/show.md +++ b/docs/metadata/metadata/show.md @@ -79,39 +79,18 @@ The available attributes for editing shows, seasons, and episodes are as follows ### Special Attributes -| Attribute | Values | Shows | Seasons | Episodes | -|:---------------|:--------------------------------------------------------------------------------------------------------------------------------------------------|:--------:|:--------:|:--------:| -| `title` | Title if different from the mapping value useful when you have multiple shows with the same name | ✅ | ✅ | ✅ | -| `alt_title` | Alternative title to look for | ✅ | ❌ | ❌ | -| `year` | Year of show for better identification | ✅ | ❌ | ❌ | -| `tmdb_show` | TMDb Show ID to use for metadata useful for miniseries that have been compiled into a movie | ✅ | ❌ | ❌ | -| `tmdb_movie` | TMDb Movie ID to use for metadata useful for movies that have been split into segments | ✅ | ❌ | ❌ | -| `f1_season` | F1 Season Year to make the Show represent a Season of F1 Races. See [Formula 1 Metadata Guide](../../home/guides/formula) for more information. | ✅ | ❌ | ❌ | -| `round_prefix` | Used only with `f1_season` to add the round as a prefix to the Season (Race) Titles i.e. `Australian Grand Prix` --> `01 - Australian Grand Prix` | ✅ | ❌ | ❌ | -| `shorten_gp` | Used only with `f1_season` to shorten `Grand Prix` to `GP` in the Season (Race) Titles i.e. `Australian Grand Prix` --> `Australian GP` | ✅ | ❌ | ❌ | -| `seasons` | Mapping to define Seasons | ✅ | ❌ | ❌ | -| `episodes` | Mapping to define Episodes | ❌ | ✅ | ❌ | - -* YAML files cannot have two items with the same mapping name so if you have two shows with the same name you would change the mapping values to whatever you want. Then use the `title` attribute to specify the real title and use the `year` attribute to specify which of the multiple shows to choose. - ```yaml - metadata: - Godzilla1: - title: Godzilla - year: 1954 - content_rating: R - Godzilla2: - title: Godzilla - year: 1998 - content_rating: PG-13 - ``` - -* If you know of another Title your show might exist under, but you want it titled differently you can use `alt_title` to specify another title to look under and then be changed to the mapping name. For Example TMDb uses the name `The Legend of Korra`, but I want it as `Avatar: The Legend of Korra` (Which must be surrounded by quotes since it uses the character `:`): - ```yaml - metadata: - "Avatar: The Legend of Korra": - alt_title: The Legend of Korra - ``` - This would change the name of the TMDb default `The Legend of Korra` to `Avatar: The Legend of Korra` and would not mess up any subsequent runs. +| Attribute | Values | Shows | Seasons | Episodes | +|:---------------|:-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:--------:|:--------:|:--------:| +| `title` | Title if different from the mapping value useful when you have multiple shows with the same name. See the [Metadata Page](../metadata.md#metadata-attributes) for how searching for files works. | ✅ | ✅ | ✅ | +| `alt_title` | Alternative title to look for and then change to the mapping name. See the [Metadata Page](../metadata.md#metadata-attributes) for how searching for files works. | ✅ | ❌ | ❌ | +| `year` | Year of show for better identification. See the [Metadata Page](../metadata.md#metadata-attributes) for how searching for files works. | ✅ | ❌ | ❌ | +| `tmdb_show` | TMDb Show ID to use for metadata useful for miniseries that have been compiled into a movie | ✅ | ❌ | ❌ | +| `tmdb_movie` | TMDb Movie ID to use for metadata useful for movies that have been split into segments | ✅ | ❌ | ❌ | +| `f1_season` | F1 Season Year to make the Show represent a Season of F1 Races. See [Formula 1 Metadata Guide](../../home/guides/formula) for more information. | ✅ | ❌ | ❌ | +| `round_prefix` | Used only with `f1_season` to add the round as a prefix to the Season (Race) Titles i.e. `Australian Grand Prix` --> `01 - Australian Grand Prix` | ✅ | ❌ | ❌ | +| `shorten_gp` | Used only with `f1_season` to shorten `Grand Prix` to `GP` in the Season (Race) Titles i.e. `Australian Grand Prix` --> `Australian GP` | ✅ | ❌ | ❌ | +| `seasons` | Mapping to define Seasons | ✅ | ❌ | ❌ | +| `episodes` | Mapping to define Episodes | ❌ | ✅ | ❌ | ### General Attributes diff --git a/docs/metadata/overlay.md b/docs/metadata/overlay.md new file mode 100644 index 00000000..af229328 --- /dev/null +++ b/docs/metadata/overlay.md @@ -0,0 +1,114 @@ +# Overlay Files + +Overlay files are used to create and maintain overlays within the Plex libraries on the server. + +Overlays and templates are defined within one or more Overlay files, which are linked to libraries in the [Libraries Attribute](../config/libraries.md#overlay-path) within the [Configuration File](../config/configuration.md). + +**To remove all overlays use the `remove_overlays` library operation.** + +**To change a single overlay original Image either replace the image in the assets folder or remove the `Overlay` shared label and then PMM will overlay the new image** + +These are the attributes which can be used within the Overlay File: + +| Attribute | Description | +|:--------------------------------------------------------|:-------------------------------------------------------------------------------------------------------------------| +| [`templates`](templates) | contains definitions of templates that can be leveraged by multiple overlays | +| [`external_templates`](templates.md#external-templates) | contains [path types](../config/paths) that point to external templates that can be leveraged by multiple overlays | +| [`overlays`](#overlay-attributes) | contains definitions of overlays you wish to add | + +* `overlays` is required in order to run the Overlay File. +* Example Overlay Files can be found in the [Plex Meta Manager Configs Repository](https://github.com/meisnate12/Plex-Meta-Manager-Configs/tree/master/PMM) + +## Overlay Attributes + +Each overlay requires its own section within the `overalys` attribute. + +```yaml +overlays: + IMDb Top 250: + # ... builders, details, and filters for this overlay + 4K: + # ... builders, details, and filters for this overlay + etc: + # ... builders, details, and filters for this overlay +``` + +Each section must have the only required attribute, `overlay`. + + +| Attribute | Description | Required | +|:----------|:-------------------------------------------------------------------------------------------------------------|:--------:| +| `name` | Name of the overlay. Each overlay name should be unique. | ✅ | +| `url` | URL of Overlay Image Online | ❌ | +| `git` | Location in the [Configs Repo](https://github.com/meisnate12/Plex-Meta-Manager-Configs) of the Overlay Image | ❌ | +| `repo` | Location in the [Custom Repo](../config/settings.md#custom-repo) of the Overlay Image | ❌ | + +* If `url`, `git`, and `repo` are all not defined then PMM will look in your `config/overlays` folder for a `.png` file named the same as the `name` attribute. + +```yaml +overlays: + IMDb Top 250: + overlay: + name: IMDb Top 250 + imdb_chart: top_movies +``` + +There are three types of attributes that can be utilized within an overlay: + +### Builders + +Builders use third-party services to source items for overlays. Multiple builders can be used in the same overlay from a variety of sources listed below. + +* [Plex Builders](builders/plex) +* [Smart Builders](builders/smart) +* [TMDb Builders](builders/tmdb) +* [TVDb Builders](builders/tvdb) +* [IMDb Builders](builders/imdb) +* [Trakt Builders](builders/trakt) +* [Tautulli Builders](builders/tautulli) +* [Letterboxd Builders](builders/letterboxd) +* [ICheckMovies Builders](builders/icheckmovies) +* [FlixPatrol Builders](builders/flixpatrol) +* [StevenLu Builders](builders/stevenlu) +* [AniDB Builders](builders/anidb) +* [AniList Builders](builders/anilist) +* [MyAnimeList Builders](builders/myanimelist) + +## Details + +Only a few details can be used with overlays: `limit`, `show_missing`, `save_missing`, `missing_only_released`, `minimum_items`, `cache_builders`, `tmdb_region` + +* [Setting Details](details/setting) +* [Metadata Details](details/metadata) + +## Filters + +These filter media items added to the collection by any of the Builders. + +* [Filters](filters) + +## Examples + +```yaml +overlays: + 4K: + overlay: + name: 4K # This will look for a local overlays/4K.png in your configs folder + plex_search: + all: + resolution: 4K + HDR: + overlay: + name: HDR + git: PMM/overlays/HDR + plex_search: + all: + hdr: true + Dolby: + overlay: + name: Dolby + url: https://somewebsite.com/dobly_overlay.png + plex_all: true + filters: + has_dolby_vision: true +``` \ No newline at end of file diff --git a/docs/metadata/playlist.md b/docs/metadata/playlist.md new file mode 100644 index 00000000..a7f45083 --- /dev/null +++ b/docs/metadata/playlist.md @@ -0,0 +1,94 @@ +# Playlist Files + +Playlist files are used to create and maintain playlists on the Plex Server. + +If utilized to their fullest, these files can be used to maintain the entire server's collections and playlists, and can be used as a backup for these in the event of a restore requirement. + +Playlists are defined in one or more Playlist files that are mapped in the [Playlist Files Attribute](../config/libraries.md#playlist-files-attribute) within the Configuration File. + +These are the attributes which can be utilized within the Playlist File: + +| Attribute | Description | +|:--------------------------------------------------------|:--------------------------------------------------------------------------------------------------------------------| +| [`templates`](templates) | contains definitions of templates that can be leveraged by multiple playlists | +| [`external_templates`](templates.md#external-templates) | contains [path types](../config/paths) that point to external templates that can be leveraged by multiple playlists | +| [`playlists`](#playlist-attributes) | contains definitions of playlists you wish to add to the server | + +* `playlists` is required in order to run the Playlist File. +* You can find example Playlist Files in the [Plex Meta Manager Configs Repository](https://github.com/meisnate12/Plex-Meta-Manager-Configs/tree/master/PMM) +* Plex does not support the "Continue Watching" feature for playlists, you can [vote for the feature here](https://forums.plex.tv/t/playlists-remember-position-for-subsequent-resume/84866/39) + +## Playlist Attributes + +Plex Meta Manager can automatically build and update playlists defined within the `playlists` attribute. + +Each playlist requires its own section within the `playlists` attribute and unlike collections, playlists can only be built using one Builder as their ordering is inherited from the builder; it is not possible to combine builders. + +```yaml +playlists: + Marvel Cinematic Universe Chronological Order: + # ... builder, details, and filters for this playlist + Star Wars Clone Wars Chronological Order: + # ... builder, details, and filters for this playlist + etc: + # ... builder, details, and filters for this playlist +``` + +Playlists require the `libraries` attribute, which instructs the operation to look in the specified libraries. This allows media to be combined from multiple libraries into one playlist. The mappings that you define in the `libraries` attribute must match the library names in your [Configuration File](../config/configuration). + +The playlist can also use the `sync_to_users` attributes to control who has visibility of the playlist. This will override the global [`playlist_sync_to_users` Setting](../config/settings.md#playlist-sync-to-users). `sync_to_users` can be set to `all` to sync to all users who have access to the Plex Media Server, or a list/comma-separated string of users. The Plex Media Server owner will always have visibility of the Playlists, so does not need to be defined within the attribute. Leaving `sync_to_users` empty will make the playlist visible to the Plex Media Server owner only. + +There are three types of attributes that can be utilized within a playlist: + +### Builders + +Builders use third-party services to source items to be added to the playlist. Multiple builders can be used in the same playlist from a variety of sources listed below. + +* [Plex Builders](builders/plex) +* [Smart Builders](builders/smart) +* [TMDb Builders](builders/tmdb) +* [TVDb Builders](builders/tvdb) +* [IMDb Builders](builders/imdb) +* [Trakt Builders](builders/trakt) +* [Tautulli Builders](builders/tautulli) +* [Letterboxd Builders](builders/letterboxd) +* [ICheckMovies Builders](builders/icheckmovies) +* [FlixPatrol Builders](builders/flixpatrol) +* [StevenLu Builders](builders/stevenlu) +* [AniDB Builders](builders/anidb) +* [AniList Builders](builders/anilist) +* [MyAnimeList Builders](builders/myanimelist) + +### Details + +These can alter any aspect of the playlist or the media items within them. + +* [Setting Details](details/setting) +* [Schedule Detail](details/schedule) +* [Metadata Details](details/metadata) +* [Arr Details](details/arr) + +### Filters + +These filter media items added to the playlist by any of the Builders. + +* [Filters](filters) + +## Example + +In the following example, media is pulled from the `Movies` and `TV Shows` libraries into the one Playlist, and the playlist is shared with a specific set of users: + +```yaml +playlists: + Marvel Cinematic Universe Chronological Order: + sync_mode: sync + libraries: Movies, TV Shows + sync_to_users: User1, someone@somewhere.com, User3 + trakt_list: https://trakt.tv/users/donxy/lists/marvel-cinematic-universe?sort=rank,asc + summary: Marvel Cinematic Universe In Chronological Order + Star Wars Clone Wars Chronological Order: + sync_to_users: all + sync_mode: sync + libraries: Movies, TV Shows + trakt_list: https://trakt.tv/users/tomfin46/lists/star-wars-the-clone-wars-chronological-episode-order +``` diff --git a/modules/builder.py b/modules/builder.py index a64fb04b..c6caf1e5 100644 --- a/modules/builder.py +++ b/modules/builder.py @@ -98,7 +98,7 @@ scheduled_boolean = ["visible_library", "visible_home", "visible_shared"] string_details = ["sort_title", "content_rating", "name_mapping"] ignored_details = [ "smart_filter", "smart_label", "smart_url", "run_again", "schedule", "sync_mode", "template", "test", - "delete_not_scheduled", "tmdb_person", "build_collection", "collection_order", "collection_level", + "delete_not_scheduled", "tmdb_person", "build_collection", "collection_order", "collection_level", "overlay", "validate_builders", "libraries", "sync_to_users", "collection_name", "playlist_name", "name", "blank_collection" ] details = [ @@ -108,7 +108,7 @@ details = [ collectionless_details = ["collection_order", "plex_collectionless", "label", "label_sync_mode", "test"] + \ poster_details + background_details + summary_details + string_details item_false_details = ["item_lock_background", "item_lock_poster", "item_lock_title"] -item_bool_details = ["item_tmdb_season_titles", "item_assets", "revert_overlay", "item_refresh"] + item_false_details +item_bool_details = ["item_tmdb_season_titles", "revert_overlay", "item_refresh"] + item_false_details item_details = ["non_item_remove_label", "item_label", "item_radarr_tag", "item_sonarr_tag", "item_overlay", "item_refresh_delay"] + item_bool_details + list(plex.item_advance_keys.keys()) none_details = ["label.sync", "item_label.sync"] radarr_details = ["radarr_add_missing", "radarr_add_existing", "radarr_folder", "radarr_monitor", "radarr_search", "radarr_availability", "radarr_quality", "radarr_tag"] @@ -190,6 +190,10 @@ custom_sort_builders = [ "mal_popular", "mal_favorite", "mal_suggested", "mal_userlist", "mal_season", "mal_genre", "mal_studio" ] episode_parts_only = ["plex_pilots"] +overlay_only = ["overlay"] +overlay_attributes = [ + "filters", "limit", "show_missing", "save_missing", "missing_only_released", "minimum_items", "cache_builders", "tmdb_region" +] + all_builders + overlay_only parts_collection_valid = [ "filters", "plex_all", "plex_search", "trakt_list", "trakt_list_details", "collection_filtering", "collection_mode", "label", "visible_library", "limit", "visible_home", "visible_shared", "show_missing", "save_missing", "missing_only_released", "server_preroll", "changes_webhooks", @@ -202,7 +206,7 @@ playlist_attributes = [ "server_preroll", "changes_webhooks", "minimum_items", "cache_builders" ] + custom_sort_builders + summary_details + poster_details + radarr_details + sonarr_details music_attributes = [ - "non_item_remove_label", "item_label", "item_assets", "collection_filtering", "item_lock_background", "item_lock_poster", "item_lock_title", + "non_item_remove_label", "item_label", "collection_filtering", "item_lock_background", "item_lock_poster", "item_lock_title", "item_refresh", "item_refresh_delay", "plex_search", "plex_all", "filters" ] + details + summary_details + poster_details + background_details @@ -215,8 +219,14 @@ class CollectionBuilder: self.library = library self.libraries = [] self.playlist = library is None + self.overlay = overlay methods = {m.lower(): m for m in self.data} - self.type = "playlist" if self.playlist else "collection" + if self.playlist: + self.type = "playlist" + elif self.overlay: + self.type = "overlay" + else: + self.type = "collection" self.Type = self.type.capitalize() if "name" in methods: @@ -246,6 +256,48 @@ class CollectionBuilder: self.data[attr] = new_attributes[attr] methods[attr.lower()] = attr + if self.overlay: + if "overlay" in methods: + logger.debug("") + logger.debug("Validating Method: overlay") + logger.debug(f"Value: {data[methods['overlay']]}") + if isinstance(data[methods["overlay"]], dict): + if "name" not in data[methods["overlay"]] or not data[methods["overlay"]]["name"]: + raise Failed(f"{self.Type} Error: overlay must have the name attribute") + self.overlay = data[methods["overlay"]]["name"] + if "git" in data[methods["overlay"]] and data[methods["overlay"]]["git"]: + url = f"{util.github_base}{data[methods['overlay']]['git']}.png" + elif "repo" in data[methods["overlay"]] and data[methods["overlay"]]["repo"]: + url = f"{self.config.custom_repo}{data[methods['overlay']]['git']}.png" + elif "url" in data[methods["overlay"]] and data[methods["overlay"]]["url"]: + url = data[methods["overlay"]]["url"] + else: + url = None + if url: + response = self.config.get(url) + if response.status_code >= 400: + raise Failed(f"{self.Type} Error: Overlay Image not found at: {url}") + if "Content-Type" not in response.headers or response.headers["Content-Type"] != "image/png": + raise Failed(f"{self.Type} Error: Overlay Image not a png: {url}") + if not os.path.exists(library.overlay_folder) or not os.path.isdir(library.overlay_folder): + os.makedirs(library.overlay_folder, exist_ok=False) + logger.info(f"Creating Overlay Folder found at: {library.overlay_folder}") + clean_name, _ = util.validate_filename(self.overlay) + overlay_path = os.path.join(library.overlay_folder, f"{clean_name}.png") + if os.path.exists(overlay_path): + os.remove(overlay_path) + with open(overlay_path, "wb") as handler: + handler.write(response.content) + while util.is_locked(overlay_path): + time.sleep(1) + else: + self.overlay = data[methods["overlay"]] + else: + self.overlay = self.mapping_name + overlay_path = os.path.join(library.overlay_folder, f"{self.overlay}.png") + if not os.path.exists(overlay_path): + raise Failed(f"{self.Type} Error: Overlay Image not found at: {overlay_path}") + if self.playlist: if "libraries" in methods: logger.debug("") @@ -355,7 +407,7 @@ class CollectionBuilder: else: raise Failed(f"Playlist Error: User: {user} not found in plex\nOptions: {plex_users}") - if "delete_not_scheduled" in methods: + if "delete_not_scheduled" in methods and not self.overlay: logger.debug("") logger.debug("Validating Method: delete_not_scheduled") logger.debug(f"Value: {data[methods['delete_not_scheduled']]}") @@ -388,38 +440,38 @@ class CollectionBuilder: suffix = f" and could not be found to delete" raise NotScheduled(f"{err}\n\n{self.Type} {self.name} not scheduled to run{suffix}") - self.collectionless = "plex_collectionless" in methods and not self.playlist + self.collectionless = "plex_collectionless" in methods and not self.playlist and not self.overlay self.validate_builders = True - if "validate_builders" in methods: + if "validate_builders" in methods and not self.overlay: logger.debug("") logger.debug("Validating Method: validate_builders") logger.debug(f"Value: {data[methods['validate_builders']]}") self.validate_builders = util.parse(self.Type, "validate_builders", self.data, datatype="bool", methods=methods, default=True) self.run_again = False - if "run_again" in methods: + if "run_again" in methods and not self.overlay: logger.debug("") logger.debug("Validating Method: run_again") logger.debug(f"Value: {data[methods['run_again']]}") self.run_again = util.parse(self.Type, "run_again", self.data, datatype="bool", methods=methods, default=False) - self.build_collection = True - if "build_collection" in methods and not self.playlist: + self.build_collection = False if self.overlay else True + if "build_collection" in methods and not self.playlist and not self.overlay: logger.debug("") logger.debug("Validating Method: build_collection") logger.debug(f"Value: {data[methods['build_collection']]}") self.build_collection = util.parse(self.Type, "build_collection", self.data, datatype="bool", methods=methods, default=True) self.blank_collection = False - if "blank_collection" in methods and not self.playlist: + if "blank_collection" in methods and not self.playlist and not self.overlay: logger.debug("") logger.debug("Validating Method: blank_collection") logger.debug(f"Value: {data[methods['blank_collection']]}") self.blank_collection = util.parse(self.Type, "blank_collection", self.data, datatype="bool", methods=methods, default=False) self.sync = self.library.sync_mode == "sync" - if "sync_mode" in methods: + if "sync_mode" in methods and not self.overlay: logger.debug("") logger.debug("Validating Method: sync_mode") if not self.data[methods["sync_mode"]]: @@ -493,7 +545,7 @@ class CollectionBuilder: self.smart_filter_details = "" self.smart_label = {"sort_by": "random", "all": {"label": [self.name]}} self.smart_label_collection = False - if "smart_label" in methods and not self.playlist and not self.library.is_music: + if "smart_label" in methods and not self.playlist and not self.overlay and not self.library.is_music: logger.debug("") logger.debug("Validating Method: smart_label") self.smart_label_collection = True @@ -516,7 +568,7 @@ class CollectionBuilder: self.smart_url = None self.smart_type_key = None - if "smart_url" in methods and not self.playlist: + if "smart_url" in methods and not self.playlist and not self.overlay: logger.debug("") logger.debug("Validating Method: smart_url") if not self.data[methods["smart_url"]]: @@ -528,7 +580,7 @@ class CollectionBuilder: except ValueError: raise Failed(f"{self.Type} Error: smart_url is incorrectly formatted") - if "smart_filter" in methods and not self.playlist: + if "smart_filter" in methods and not self.playlist and not self.overlay: self.smart_type_key, self.smart_filter_details, self.smart_url = self.build_filter("smart_filter", self.data[methods["smart_filter"]], display=True, default_sort="random") if self.collectionless: @@ -634,6 +686,10 @@ class CollectionBuilder: raise Failed(f"{self.Type} Error: {method_final} attribute not allowed for Collectionless collection") elif self.smart_url and method_name in all_builders + smart_url_invalid: raise Failed(f"{self.Type} Error: {method_final} builder not allowed when using smart_filter") + elif not self.overlay and method_name in overlay_only: + raise Failed(f"{self.Type} Error: {method_final} attribute only allowed in an overlay file") + elif self.overlay and method_name not in overlay_attributes: + raise Failed(f"{self.Type} Error: {method_final} attribute not allowed in an overlay file") elif method_name in summary_details: self._summary(method_name, method_data) elif method_name in poster_details: @@ -722,7 +778,6 @@ class CollectionBuilder: self.do_missing = not self.config.no_missing and (self.details["show_missing"] or self.details["save_missing"] or (self.library.Radarr and self.radarr_details["add_missing"]) or (self.library.Sonarr and self.sonarr_details["add_missing"])) - if self.build_collection: try: self.obj = self.library.get_playlist(self.name) if self.playlist else self.library.get_collection(self.name) @@ -909,9 +964,9 @@ class CollectionBuilder: name = method_data if not os.path.exists(overlay): raise Failed(f"{self.Type} Error: {name} overlay image not found at {overlay}") - if name in self.library.overlays: + if name in self.library.overlays_old: raise Failed("Each Overlay can only be used once per Library") - self.library.overlays.append(name) + self.library.overlays_old.append(name) self.item_details[method_name] = name elif method_name == "item_refresh_delay": self.item_details[method_name] = util.parse(self.Type, method_name, method_data, datatype="int", default=0, minimum=0) @@ -2089,7 +2144,7 @@ class CollectionBuilder: filter_check = len(item.collections) > 0 elif filter_attr == "has_overlay": for label in item.labels: - if label.tag.lower().endswith(" overlay"): + if label.tag.lower().endswith(" overlay") or label.tag.lower() == "overlay": filter_check = True break elif filter_attr == "has_dolby_vision": @@ -2275,19 +2330,7 @@ class CollectionBuilder: rating_keys = [] if "item_overlay" in self.item_details: overlay_name = self.item_details["item_overlay"] - if self.config.Cache: - cache_keys = self.config.Cache.query_image_map_overlay(self.library.image_table_name, overlay_name) - if cache_keys: - for rating_key in cache_keys: - try: - item = self.fetch_item(rating_key) - except Failed as e: - logger.error(e) - continue - if isinstance(item, (Movie, Show)): - self.library.edit_tags("label", item, add_tags=[f"{overlay_name} Overlay"]) - self.config.Cache.update_remove_overlay(self.library.image_table_name, overlay_name) - rating_keys = [int(item.ratingKey) for item in self.library.get_labeled_items(f"{overlay_name} Overlay")] + rating_keys = [int(item.ratingKey) for item in self.library.search(label=f"{overlay_name} Overlay")] overlay_folder = os.path.join(self.config.default_dir, "overlays", overlay_name) overlay_image = Image.open(os.path.join(overlay_folder, "overlay.png")).convert("RGBA") overlay = (overlay_name, overlay_folder, overlay_image) @@ -2303,7 +2346,7 @@ class CollectionBuilder: if "non_item_remove_label" in self.item_details: rk_compare = [item.ratingKey for item in self.items] for remove_label in self.item_details["non_item_remove_label"]: - for non_item in self.library.get_labeled_items(remove_label): + for non_item in self.library.search(label=remove_label, libtype=self.collection_level): if non_item.ratingKey not in rk_compare: self.library.edit_tags("label", non_item, remove_tags=[remove_label]) @@ -2312,9 +2355,9 @@ class CollectionBuilder: for item in self.items: if int(item.ratingKey) in rating_keys and not revert: rating_keys.remove(int(item.ratingKey)) - if "item_assets" in self.item_details or overlay is not None: + if overlay is not None: try: - self.library.find_assets(item, overlay=overlay, folders=self.details["asset_folders"], create=self.details["create_asset_folders"]) + self.library.update_asset(item, overlay=overlay, folders=self.details["asset_folders"], create=self.details["create_asset_folders"]) except Failed as e: logger.error(e) self.library.edit_tags("label", item, add_tags=add_tags, remove_tags=remove_tags, sync_tags=sync_tags) @@ -2384,14 +2427,14 @@ class CollectionBuilder: self.library.edit_tags("label", item, remove_tags=[f"{overlay_name} Overlay"]) og_image = os.path.join(overlay_folder, f"{rating_key}.png") if os.path.exists(og_image): - self.library.upload_file_poster(item, og_image) + self.library.upload_poster(item, og_image) os.remove(og_image) self.config.Cache.update_image_map(item.ratingKey, self.library.image_table_name, "", "") def load_collection(self): - if not self.obj and self.smart_url: + if self.obj is None and self.smart_url: self.library.create_smart_collection(self.name, self.smart_type_key, self.smart_url) - elif not self.obj and self.blank_collection: + elif self.obj is None and self.blank_collection: self.library.create_blank_collection(self.name) elif self.smart_label_collection: try: @@ -2518,9 +2561,11 @@ class CollectionBuilder: if "name_mapping" in self.details: if self.details["name_mapping"]: name_mapping = self.details["name_mapping"] else: logger.error(f"{self.Type} Error: name_mapping attribute is blank") + final_name, _ = util.validate_filename(name_mapping) poster_image, background_image, asset_location = self.library.find_assets( - self.obj, name=name_mapping, upload=False, - folders=self.details["asset_folders"], create=self.details["create_asset_folders"] + name="poster" if self.details["asset_folders"] else final_name, + folder_name=final_name if self.details["asset_folders"] else None, + prefix=f"{name_mapping}'s " ) if poster_image: self.posters["asset_directory"] = poster_image diff --git a/modules/cache.py b/modules/cache.py index 173fca08..631fd055 100644 --- a/modules/cache.py +++ b/modules/cache.py @@ -599,6 +599,14 @@ class Cache: compare TEXT, location TEXT)""" ) + cursor.execute( + f"""CREATE TABLE IF NOT EXISTS {table_name}_overlays ( + key INTEGER PRIMARY KEY, + rating_key TEXT UNIQUE, + overlay TEXT, + compare TEXT, + location TEXT)""" + ) return table_name def query_image_map_overlay(self, table_name, overlay): @@ -625,8 +633,8 @@ class Cache: cursor.execute(f"SELECT * FROM {table_name} WHERE rating_key = ?", (rating_key,)) row = cursor.fetchone() if row and row["location"]: - return row["location"], row["compare"] - return None, None + return row["location"], row["compare"], row["overlay"] + return None, None, None def update_image_map(self, rating_key, table_name, location, compare, overlay=""): with sqlite3.connect(self.cache_path) as connection: diff --git a/modules/config.py b/modules/config.py index b2608026..2b2e50c9 100644 --- a/modules/config.py +++ b/modules/config.py @@ -16,6 +16,7 @@ from modules.mal import MyAnimeList from modules.meta import PlaylistFile from modules.notifiarr import Notifiarr from modules.omdb import OMDb +from modules.overlays import Overlays from modules.plex import Plex from modules.radarr import Radarr from modules.sonarr import Sonarr @@ -483,7 +484,7 @@ class ConfigFile: default_playlist_file = os.path.abspath(os.path.join(self.default_dir, "playlists.yml")) logger.warning(f"Config Warning: playlist_files attribute is blank using default: {default_playlist_file}") paths_to_check = [default_playlist_file] - files = util.load_yaml_files(paths_to_check) + files = util.load_files(paths_to_check, "playlist_files") if not files: raise Failed("Config Error: No Paths Found for playlist_files") for file_type, playlist_file, temp_vars in files: @@ -575,7 +576,8 @@ class ConfigFile: "mass_content_rating_update": None, "mass_originally_available_update": None, "mass_imdb_parental_labels": None, - "remove_title_parentheses": None + "remove_title_parentheses": None, + "remove_overlays": None } display_name = f"{params['name']} ({params['mapping_name']})" if lib and "library_name" in lib and lib["library_name"] else params["mapping_name"] @@ -669,6 +671,8 @@ class ConfigFile: params["update_blank_track_titles"] = check_for_attribute(lib["operations"], "update_blank_track_titles", var_type="bool", default=False, save=False) if "remove_title_parentheses" in lib["operations"]: params["remove_title_parentheses"] = check_for_attribute(lib["operations"], "remove_title_parentheses", var_type="bool", default=False, save=False) + if "remove_overlays" in lib["operations"]: + params["remove_overlays"] = check_for_attribute(lib["operations"], "remove_overlays", var_type="bool", default=False, save=False) if "mass_collection_mode" in lib["operations"]: try: params["mass_collection_mode"] = util.check_collection_mode(lib["operations"]["mass_collection_mode"]) @@ -729,7 +733,7 @@ class ConfigFile: if lib and "metadata_path" in lib: if not lib["metadata_path"]: raise Failed("Config Error: metadata_path attribute is blank") - files = util.load_yaml_files(lib["metadata_path"]) + files = util.load_files(lib["metadata_path"], "metadata_path") if not files: raise Failed("Config Error: No Paths Found for metadata_path") params["metadata_path"] = files @@ -748,6 +752,15 @@ class ConfigFile: except NotScheduled: params["skip_library"] = True + params["overlay_path"] = [] + if lib and "overlay_path" in lib: + if not lib["overlay_path"]: + raise Failed("Config Error: overlay_path attribute is blank") + files = util.load_files(lib["overlay_path"], "overlay_path") + if not files: + raise Failed("Config Error: No Paths Found for overlay_path") + params["overlay_path"] = files + logger.info("") logger.separator("Plex Configuration", space=False, border=False) params["plex"] = { @@ -850,6 +863,7 @@ class ConfigFile: logger.info(f"{display_name} library's Tautulli Connection {'Failed' if library.Tautulli is None else 'Successful'}") library.Webhooks = Webhooks(self, {"error_webhooks": library.error_webhooks}, library=library, notifiarr=self.NotifiarrFactory) + library.Overlays = Overlays(self, library) logger.info("") self.libraries.append(library) diff --git a/modules/library.py b/modules/library.py index aafa9c9b..eceda90d 100644 --- a/modules/library.py +++ b/modules/library.py @@ -1,9 +1,9 @@ import os, shutil, time from abc import ABC, abstractmethod from modules import util -from modules.meta import MetadataFile -from modules.util import Failed +from modules.meta import MetadataFile, OverlayFile from modules.operations import Operations +from modules.util import Failed, ImageData from PIL import Image from plexapi.exceptions import BadRequest from ruamel import yaml @@ -17,10 +17,13 @@ class Library(ABC): self.Tautulli = None self.Webhooks = None self.Operations = Operations(config, self) + self.Overlays = None self.Notifiarr = None self.collections = [] self.metadatas = [] + self.overlays = [] self.metadata_files = [] + self.overlay_files = [] self.missing = {} self.movie_map = {} self.show_map = {} @@ -30,18 +33,21 @@ class Library(ABC): self.movie_rating_key_map = {} self.show_rating_key_map = {} self.run_again = [] - self.overlays = [] + self.overlays_old = [] self.type = "" self.config = config self.name = params["name"] self.original_mapping_name = params["mapping_name"] self.metadata_path = params["metadata_path"] + self.overlay_path = params["overlay_path"] self.skip_library = params["skip_library"] self.asset_depth = params["asset_depth"] self.asset_directory = params["asset_directory"] if params["asset_directory"] else [] self.default_dir = params["default_dir"] self.mapping_name, output = util.validate_filename(self.original_mapping_name) self.image_table_name = self.config.Cache.get_image_table_name(self.original_mapping_name) if self.config.Cache else None + self.overlay_folder = os.path.join(self.config.default_dir, "overlays") + self.overlay_backup = os.path.join(self.overlay_folder, f"{self.mapping_name} Original Posters") self.missing_path = params["missing_path"] if params["missing_path"] else os.path.join(self.default_dir, f"{self.mapping_name}_missing.yml") self.asset_folders = params["asset_folders"] self.create_asset_folders = params["create_asset_folders"] @@ -82,6 +88,7 @@ class Library(ABC): self.sonarr_remove_by_tag = params["sonarr_remove_by_tag"] self.update_blank_track_titles = params["update_blank_track_titles"] self.remove_title_parentheses = params["remove_title_parentheses"] + self.remove_overlays = params["remove_overlays"] self.mass_collection_mode = params["mass_collection_mode"] self.metadata_backup = params["metadata_backup"] self.genre_mapper = params["genre_mapper"] @@ -123,13 +130,20 @@ class Library(ABC): self.metadata_files.append(meta_obj) except Failed as e: logger.error(e) + for file_type, overlay_file, temp_vars in self.overlay_path: + try: + over_obj = OverlayFile(self.config, self, file_type, overlay_file, temp_vars) + self.overlays.extend([o.lower() for o in over_obj.overlays]) + self.overlay_files.append(over_obj) + except Failed as e: + logger.error(e) def upload_images(self, item, poster=None, background=None, overlay=None): image = None image_compare = None poster_uploaded = False if self.config.Cache: - image, image_compare = self.config.Cache.query_image_map(item.ratingKey, self.image_table_name) + image, image_compare, _ = self.config.Cache.query_image_map(item.ratingKey, self.image_table_name) if poster is not None: try: @@ -158,11 +172,10 @@ class Library(ABC): response = self.config.get(item.posterUrl) if response.status_code >= 400: raise Failed(f"Overlay Error: Overlay Failed for {item.title}") - og_image = response.content ext = "jpg" if response.headers["Content-Type"] == "image/jpegss" else "png" temp_image = os.path.join(overlay_folder, f"temp.{ext}") with open(temp_image, "wb") as handler: - handler.write(og_image) + handler.write(response.content) shutil.copyfile(temp_image, os.path.join(overlay_folder, f"{item.ratingKey}.{ext}")) while util.is_locked(temp_image): time.sleep(1) @@ -171,8 +184,9 @@ class Library(ABC): new_poster = new_poster.resize(overlay_image.size, Image.ANTIALIAS) new_poster.paste(overlay_image, (0, 0), overlay_image) new_poster.save(temp_image) - self.upload_file_poster(item, temp_image) + self.upload_poster(item, temp_image) self.edit_tags("label", item, add_tags=[f"{overlay_name} Overlay"]) + self.reload(item) poster_uploaded = True logger.info(f"Detail: Overlay: {overlay_name} applied to {item.title}") except (OSError, BadRequest) as e: @@ -184,7 +198,7 @@ class Library(ABC): try: image = None if self.config.Cache: - image, image_compare = self.config.Cache.query_image_map(item.ratingKey, f"{self.image_table_name}_backgrounds") + image, image_compare, _ = self.config.Cache.query_image_map(item.ratingKey, f"{self.image_table_name}_backgrounds") if str(background.compare) != str(image_compare): image = None if image is None or image != item.art: @@ -212,7 +226,7 @@ class Library(ABC): pass @abstractmethod - def upload_file_poster(self, item, image): + def upload_poster(self, item, image, url=False): pass @abstractmethod @@ -227,6 +241,47 @@ class Library(ABC): def get_all(self, collection_level=None, load=False): pass + def find_assets(self, name="poster", folder_name=None, item_directory=None, prefix=""): + poster = None + background = None + item_dir = None + search_dir = item_directory if item_directory else None + for ad in self.asset_directory: + item_dir = None + if not search_dir: + search_dir = ad + if folder_name: + if os.path.isdir(os.path.join(ad, folder_name)): + item_dir = os.path.join(ad, folder_name) + else: + for n in range(1, self.asset_depth + 1): + new_path = ad + for i in range(1, n + 1): + new_path = os.path.join(new_path, "*") + matches = util.glob_filter(os.path.join(new_path, folder_name)) + if len(matches) > 0: + item_dir = os.path.abspath(matches[0]) + break + if item_dir is None: + continue + search_dir = item_dir + if item_directory: + item_dir = item_directory + file_name = name if item_dir else f"{folder_name}_{name}" + poster_filter = os.path.join(search_dir, f"{file_name}.*") + background_filter = os.path.join(search_dir, "background.*" if file_name == "poster" else f"{file_name}_background.*") + + poster_matches = util.glob_filter(poster_filter) + if len(poster_matches) > 0: + poster = ImageData("asset_directory", os.path.abspath(poster_matches[0]), prefix=prefix, is_url=False) + + background_matches = util.glob_filter(background_filter) + if len(background_matches) > 0: + background = ImageData("asset_directory", os.path.abspath(background_matches[0]), prefix=prefix, is_poster=False, is_url=False) + + break + return poster, background, item_dir + def add_missing(self, collection, items, is_movie): if collection not in self.missing: self.missing[collection] = {} diff --git a/modules/meta.py b/modules/meta.py index a1a1f0a2..bad9912f 100644 --- a/modules/meta.py +++ b/modules/meta.py @@ -239,10 +239,10 @@ class DataFile: def external_templates(self, data): if "external_templates" in data and data["external_templates"]: - files = util.load_yaml_files(data["external_templates"]) + files = util.load_files(data["external_templates"], "external_templates") if not files: logger.error("Config Error: No Paths Found for external_templates") - for file_type, template_file, temp_vars in util.load_yaml_files(data["external_templates"]): + for file_type, template_file, temp_vars in util.load_files(data["external_templates"], "external_templates"): temp_data = self.load_file(file_type, template_file) if temp_data and isinstance(temp_data, dict) and "templates" in temp_data and temp_data["templates"] and isinstance(temp_data["templates"], dict): for temp_key, temp_value in temp_data["templates"].items(): @@ -1083,3 +1083,18 @@ class PlaylistFile(DataFile): if not self.playlists: raise Failed("YAML Error: playlists attribute is required") logger.info(f"Playlist File Loaded Successfully") + +class OverlayFile(DataFile): + def __init__(self, config, library, file_type, path, temp_vars): + super().__init__(config, file_type, path, temp_vars) + self.library = library + self.data_type = "Overlay" + logger.info("") + logger.info(f"Loading Overlay File {file_type}: {path}") + data = self.load_file(self.type, self.path) + self.overlays = get_dict("overlays", data, self.library.overlays) + self.templates = get_dict("templates", data) + self.external_templates(data) + if not self.overlays: + raise Failed("YAML Error: overlays attribute is required") + logger.info(f"Overlay File Loaded Successfully") diff --git a/modules/operations.py b/modules/operations.py index 7be3a741..4a1c81f8 100644 --- a/modules/operations.py +++ b/modules/operations.py @@ -74,7 +74,7 @@ class Operations: continue logger.ghost(f"Processing: {i}/{len(items)} {item.title}") if self.library.assets_for_all: - self.library.find_assets(item) + self.library.update_asset2(item) tmdb_id, tvdb_id, imdb_id = self.library.get_ids(item) item.batchEdits() @@ -381,7 +381,7 @@ class Operations: logger.separator(f"Unmanaged Collection Assets Check for {self.library.name} Library", space=False, border=False) logger.info("") for col in unmanaged_collections: - self.library.find_assets(col) + self.library.update_asset2(col) if self.library.metadata_backup: logger.info("") diff --git a/modules/overlays.py b/modules/overlays.py new file mode 100644 index 00000000..08a9a803 --- /dev/null +++ b/modules/overlays.py @@ -0,0 +1,203 @@ +import os, time +from modules import util +from modules.builder import CollectionBuilder +from modules.util import Failed +from plexapi.exceptions import BadRequest +from plexapi.video import Show, Season, Episode +from PIL import Image + +logger = util.logger + +class Overlays: + def __init__(self, config, library): + self.config = config + self.library = library + self.overlays = [] + + def run_overlays(self): + logger.info("") + logger.separator(f"{self.library.name} Library Overlays") + logger.info("") + overlay_rating_keys = {} + item_keys = {} + os.makedirs(self.library.overlay_backup, exist_ok=True) + overlay_updated = {} + overlay_images = {} + item_overlays = {} + if not self.library.remove_overlays: + for overlay_file in self.library.overlay_files: + for k, v in overlay_file.overlays.items(): + builder = CollectionBuilder(self.config, overlay_file, k, v, library=self.library, overlay=True) + logger.info("") + + logger.separator(f"Running {k} Overlay", space=False, border=False) + + if builder.filters or builder.tmdb_filters: + logger.info("") + for filter_key, filter_value in builder.filters: + logger.info(f"Collection Filter {filter_key}: {filter_value}") + for filter_key, filter_value in builder.tmdb_filters: + logger.info(f"Collection Filter {filter_key}: {filter_value}") + + for method, value in builder.builders: + logger.debug("") + logger.debug(f"Builder: {method}: {value}") + logger.info("") + builder.filter_and_save_items(builder.gather_ids(method, value)) + if builder.added_items: + if builder.overlay not in overlay_rating_keys: + overlay_rating_keys[builder.overlay] = [] + for item in builder.added_items: + item_keys[item.ratingKey] = item + if item.ratingKey not in overlay_rating_keys[builder.overlay]: + overlay_rating_keys[builder.overlay].append(item.ratingKey) + + for overlay_name, over_keys in overlay_rating_keys.items(): + clean_name, _ = util.validate_filename(overlay_name) + image_compare = None + if self.config.Cache: + _, image_compare, _ = self.config.Cache.query_image_map(overlay_name, f"{self.library.image_table_name}_overlays") + overlay_file = os.path.join(self.library.overlay_folder, f"{clean_name}.png") + overlay_size = os.stat(overlay_file).st_size + overlay_updated[overlay_name] = not image_compare or str(overlay_size) != str(image_compare) + overlay_images[overlay_name] = Image.open(overlay_file).convert("RGBA") + for over_key in over_keys: + if over_key not in item_overlays: + item_overlays[over_key] = [] + item_overlays[over_key].append(overlay_name) + if self.config.Cache: + self.config.Cache.update_image_map(overlay_name, f"{self.library.image_table_name}_overlays", overlay_name, overlay_size) + + def get_overlay_items(libtype=None): + return [o for o in self.library.search(label="Overlay", libtype=libtype) if o.ratingKey not in item_overlays] + + remove_overlays = get_overlay_items() + if self.library.is_show: + remove_overlays.extend(get_overlay_items(libtype="episode")) + remove_overlays.extend(get_overlay_items(libtype="season")) + elif self.library.is_music: + remove_overlays.extend(get_overlay_items(libtype="album")) + + for i, item in enumerate(remove_overlays, 1): + logger.ghost(f"Restoring: {i}/{len(remove_overlays)} {item.title}") + clean_name, _ = util.validate_filename(item.title) + poster, _, item_dir = self.library.find_assets( + name="poster" if self.library.asset_folders else clean_name, + folder_name=clean_name if self.library.asset_folders else None, + prefix=f"{item.title}'s " + ) + poster_location = None + is_url = False + if poster: + poster_location = poster.location + elif os.path.exists(os.path.join(self.library.overlay_backup, f"{item.ratingKey}.png")): + poster_location = os.path.join(self.library.overlay_backup, f"{item.ratingKey}.png") + elif os.path.exists(os.path.join(self.library.overlay_backup, f"{item.ratingKey}.jpg")): + poster_location = os.path.join(self.library.overlay_backup, f"{item.ratingKey}.jpg") + else: + is_url = True + if self.library.is_movie: + if item.ratingKey in self.library.movie_rating_key_map: + poster_location = self.config.TMDb.get_movie(self.library.movie_rating_key_map[item.ratingKey]).poster_url + elif self.library.is_show: + if item.ratingKey in self.library.show_rating_key_map: + poster_location = self.config.TMDb.get_show(self.config.Convert.tvdb_to_tmdb(self.library.show_rating_key_map[item.ratingKey])).poster_url + if poster_location: + self.library.upload_poster(item, poster_location, url=is_url) + self.library.edit_tags("label", item, remove_tags=["Overlay"]) + else: + logger.error(f"No Poster found to restore for {item.title}") + logger.exorcise() + + for i, (over_key, over_names) in enumerate(item_overlays.items(), 1): + try: + item = item_keys[over_key] + logger.ghost(f"Overlaying: {i}/{len(item_overlays)} {item.title}") + image_compare = None + overlay_compare = None + if self.config.Cache: + image, image_compare, _ = self.config.Cache.query_image_map(item.ratingKey, f"{self.library.image_table_name}_overlays") + overlay_compare = [] if overlay_compare is None else util.get_list(overlay_compare) + has_overlay = any([item_tag.tag.lower() == "overlay" for item_tag in item.labels]) + + overlay_change = False if has_overlay else True + if not overlay_change: + for oc in overlay_compare: + if oc not in over_names: + overlay_change = True + if not overlay_change: + for over_name in over_names: + if over_name not in overlay_compare or overlay_updated[over_name]: + overlay_change = True + + clean_name, _ = util.validate_filename(item.title) + poster, _, item_dir = self.library.find_assets( + name="poster" if self.library.asset_folders else clean_name, + folder_name=clean_name if self.library.asset_folders else None, + prefix=f"{item.title}'s " + ) + has_original = False + changed_image = False + if poster: + if image_compare and str(poster.compare) != str(image_compare): + changed_image = True + else: + if os.path.exists(os.path.join(self.library.overlay_backup, f"{item.ratingKey}.png")): + has_original = os.path.join(self.library.overlay_backup, f"{item.ratingKey}.png") + elif os.path.exists(os.path.join(self.library.overlay_backup, f"{item.ratingKey}.jpg")): + has_original = os.path.join(self.library.overlay_backup, f"{item.ratingKey}.jpg") + else: + changed_image = True + self.library.reload(item) + poster_url = item.posterUrl + if has_overlay: + if self.library.is_movie: + if item.ratingKey in self.library.movie_rating_key_map: + poster_url = self.config.TMDb.get_movie(self.library.movie_rating_key_map[item.ratingKey]).poster_url + elif self.library.is_show: + check_key = item.ratingKey if isinstance(item, Show) else item.show().ratingKey + tmdb_id = self.config.Convert.tvdb_to_tmdb(self.library.show_rating_key_map[check_key]) + if isinstance(item, Show) and item.ratingKey in self.library.show_rating_key_map: + poster_url = self.config.TMDb.get_show(tmdb_id).poster_url + elif isinstance(item, Season): + poster_url = self.config.TMDb.get_season(tmdb_id, item.seasonNumber).poster_url + elif isinstance(item, Episode): + poster_url = self.config.TMDb.get_episode(tmdb_id, item.seasonNumber, item.episodeNumber).still_url + response = self.config.get(poster_url) + if response.status_code >= 400: + raise Failed(f"Overlay Error: Poster Download Failed for {item.title}") + ext = "jpg" if response.headers["Content-Type"] == "image/jpeg" else "png" + backup_image = os.path.join(self.library.overlay_backup, f"{item.ratingKey}.{ext}") + with open(backup_image, "wb") as handler: + handler.write(response.content) + while util.is_locked(backup_image): + time.sleep(1) + has_original = backup_image + + poster_uploaded = False + if changed_image or overlay_change: + new_poster = Image.open(poster.location if poster else has_original).convert("RGBA") + temp = os.path.join(self.library.overlay_folder, f"temp.png") + try: + for over_name in over_names: + new_poster = new_poster.resize(overlay_images[over_name].size, Image.ANTIALIAS) + new_poster.paste(overlay_images[over_name], (0, 0), overlay_images[over_name]) + new_poster.save(temp, "PNG") + self.library.upload_poster(item, temp) + self.library.edit_tags("label", item, add_tags=["Overlay"]) + self.library.reload(item) + poster_uploaded = True + logger.info(f"Detail: Overlays: {', '.join(over_names)} applied to {item.title}") + except (OSError, BadRequest) as e: + logger.stacktrace() + raise Failed(f"Overlay Error: {e}") + + if self.config.Cache: + if poster_uploaded: + self.config.Cache.update_image_map( + item.ratingKey, self.library.image_table_name, item.thumb, + poster.compare if poster else item.thumb, overlay=','.join(over_names) + ) + except Failed as e: + logger.error(e) + logger.exorcise() diff --git a/modules/plex.py b/modules/plex.py index 4343419e..151eb3e2 100644 --- a/modules/plex.py +++ b/modules/plex.py @@ -459,7 +459,7 @@ class Plex(Library): @retry(stop_max_attempt_number=6, wait_fixed=10000, retry_on_exception=util.retry_if_not_plex) def get_labeled_items(self, label): - return self.Plex.search(label=label) + return self.search(label=label) @retry(stop_max_attempt_number=6, wait_fixed=10000, retry_on_exception=util.retry_if_not_plex) def fetchItem(self, data): @@ -566,9 +566,11 @@ class Plex(Library): raise Failed(e) @retry(stop_max_attempt_number=6, wait_fixed=10000, retry_on_exception=util.retry_if_not_plex) - def upload_file_poster(self, item, image): - item.uploadPoster(filepath=image) - self.reload(item) + def upload_poster(self, item, image, url=False): + if url: + item.uploadPoster(url=image) + else: + item.uploadPoster(filepath=image) @retry(stop_max_attempt_number=6, wait_fixed=10000, retry_on_exception=util.retry_if_not_failed) def get_actor_id(self, name): @@ -798,7 +800,7 @@ class Plex(Library): def get_collection_items(self, collection, smart_label_collection): if smart_label_collection: - return self.get_labeled_items(collection.title if isinstance(collection, Collection) else str(collection)) + return self.search(label=collection.title if isinstance(collection, Collection) else str(collection)) elif isinstance(collection, (Collection, Playlist)): if collection.smart: return self.get_filter_items(self.smart_filter(collection)) @@ -873,161 +875,229 @@ class Plex(Library): logger.info(final) return final - def find_assets(self, item, name=None, upload=True, overlay=None, folders=None, create=None): - if isinstance(item, (Movie, Artist, Show)): - path_test = str(item.locations[0]) + def update_asset(self, item, overlay=None, folders=None, create=None): + if isinstance(item, (Movie, Artist, Show, Episode, Season)): + starting = item.show() if isinstance(item, (Episode, Season)) else item + path_test = str(starting.locations[0]) if not os.path.dirname(path_test): path_test = path_test.replace("\\", "/") - name = os.path.basename(os.path.dirname(path_test) if isinstance(item, Movie) else path_test) + name = os.path.basename(os.path.dirname(path_test) if isinstance(starting, Movie) else path_test) elif isinstance(item, (Collection, Playlist)): - name = name if name else item.title + name = item.title else: return None, None, None - if not folders: + if folders is None: folders = self.asset_folders - if not create: + if create is None: create = self.create_asset_folders - found_folder = None - poster = None - background = None - for ad in self.asset_directory: - item_dir = None - if folders: - if os.path.isdir(os.path.join(ad, name)): - item_dir = os.path.join(ad, name) - else: - for n in range(1, self.asset_depth + 1): - new_path = ad - for i in range(1, n + 1): - new_path = os.path.join(new_path, "*") - matches = util.glob_filter(os.path.join(new_path, name)) - if len(matches) > 0: - item_dir = os.path.abspath(matches[0]) - break - if item_dir is None: - continue - found_folder = item_dir - poster_filter = os.path.join(item_dir, "poster.*") - background_filter = os.path.join(item_dir, "background.*") - else: - poster_filter = os.path.join(ad, f"{name}.*") - background_filter = os.path.join(ad, f"{name}_background.*") - - poster_matches = util.glob_filter(poster_filter) - if len(poster_matches) > 0: - poster = ImageData("asset_directory", os.path.abspath(poster_matches[0]), prefix=f"{item.title}'s ", is_url=False) - - background_matches = util.glob_filter(background_filter) - if len(background_matches) > 0: - background = ImageData("asset_directory", os.path.abspath(background_matches[0]), prefix=f"{item.title}'s ", is_poster=False, is_url=False) - - if item_dir and self.dimensional_asset_rename and (not poster or not background): - for file in util.glob_filter(os.path.join(item_dir, "*.*")): - if file.lower().endswith((".jpg", ".png", ".jpeg")): - image = Image.open(file) - _w, _h = image.size - image.close() - if not poster and _h >= _w: - new_path = os.path.join(os.path.dirname(file), f"poster{os.path.splitext(file)[1].lower()}") - os.rename(file, new_path) - poster = ImageData("asset_directory", os.path.abspath(new_path), prefix=f"{item.title}'s ", is_url=False) - elif not background and _w > _h: - new_path = os.path.join(os.path.dirname(file), f"background{os.path.splitext(file)[1].lower()}") - os.rename(file, new_path) - background = ImageData("asset_directory", os.path.abspath(new_path), prefix=f"{item.title}'s ", is_poster=False, is_url=False) - if poster and background: - break - if poster or background: - if upload: - self.upload_images(item, poster=poster, background=background, overlay=overlay) + poster, background, item_dir = self.find_assets( + name="poster" if folders else name, + folder_name=name if folders else None, + prefix=f"{item.title}'s " + ) + if item_dir and self.dimensional_asset_rename and (not poster or not background): + for file in util.glob_filter(os.path.join(item_dir, "*.*")): + if file.lower().endswith((".jpg", ".png", ".jpeg")): + image = Image.open(file) + _w, _h = image.size + image.close() + if not poster and _h >= _w: + new_path = os.path.join(os.path.dirname(file), f"poster{os.path.splitext(file)[1].lower()}") + os.rename(file, new_path) + poster = ImageData("asset_directory", os.path.abspath(new_path), prefix=f"{item.title}'s ", is_url=False) + elif not background and _w > _h: + new_path = os.path.join(os.path.dirname(file), f"background{os.path.splitext(file)[1].lower()}") + os.rename(file, new_path) + background = ImageData("asset_directory", os.path.abspath(new_path), prefix=f"{item.title}'s ", is_poster=False, is_url=False) + if poster and background: + break + + if poster or background: + self.upload_images(item, poster=poster, background=background, overlay=overlay) + + if isinstance(item, Show): + missing_seasons = "" + missing_episodes = "" + found_season = False + found_episode = False + for season in self.query(item.seasons): + season_name = f"Season{'0' if season.seasonNumber < 10 else ''}{season.seasonNumber}" + season_poster, season_background, _ = self.find_assets( + name=season_name, + folder_name=name, + item_directory=item_dir, + prefix=f"{item.title} Season {season.seasonNumber}'s " + ) + if season_poster: + found_season = True + elif self.show_missing_season_assets and season.seasonNumber > 0: + missing_seasons += f"\nMissing Season {season.seasonNumber} Poster" + if season_poster or season_background: + self.upload_images(season, poster=season_poster, background=season_background) + for episode in self.query(season.episodes): + if episode.seasonEpisode: + episode_poster, episode_background, _ = self.find_assets( + name=episode.seasonEpisode.upper(), + folder_name=name, + item_directory=item_dir, + prefix=f"{item.title} {episode.seasonEpisode.upper()}'s " + ) + if episode_poster: + found_episode = True + self.upload_images(episode, poster=episode_poster, background=episode_background) + elif self.show_missing_episode_assets: + missing_episodes += f"\nMissing {episode.seasonEpisode.upper()} Title Card" + + if (found_season and missing_seasons) or (found_episode and missing_episodes): + output = f"Missing Posters for {item.title}" + if found_season: + output += missing_seasons + if found_episode: + output += missing_episodes + logger.info(output) + if isinstance(item, Artist): + missing_assets = "" + found_album = False + for album in self.query(item.albums): + album_poster, album_background, _ = self.find_assets( + name=album.title, + folder_name=name, + item_directory=item_dir, + prefix=f"{item.title} Album {album.title}'s " + ) + if album_poster: + found_album = True else: - return poster, background, item_dir - if isinstance(item, Show): - missing_seasons = "" - missing_episodes = "" - found_season = False - found_episode = False - for season in self.query(item.seasons): - season_name = f"Season{'0' if season.seasonNumber < 10 else ''}{season.seasonNumber}" - if item_dir: - season_poster_filter = os.path.join(item_dir, f"{season_name}.*") - season_background_filter = os.path.join(item_dir, f"{season_name}_background.*") - else: - season_poster_filter = os.path.join(ad, f"{name}_{season_name}.*") - season_background_filter = os.path.join(ad, f"{name}_{season_name}_background.*") - season_poster = None - season_background = None - matches = util.glob_filter(season_poster_filter) - if len(matches) > 0: - season_poster = ImageData("asset_directory", os.path.abspath(matches[0]), prefix=f"{item.title} Season {season.seasonNumber}'s ", is_url=False) - found_season = True - elif self.show_missing_season_assets and season.seasonNumber > 0: - missing_seasons += f"\nMissing Season {season.seasonNumber} Poster" - matches = util.glob_filter(season_background_filter) - if len(matches) > 0: - season_background = ImageData("asset_directory", os.path.abspath(matches[0]), prefix=f"{item.title} Season {season.seasonNumber}'s ", is_poster=False, is_url=False) - if season_poster or season_background: - self.upload_images(season, poster=season_poster, background=season_background) - for episode in self.query(season.episodes): - if episode.seasonEpisode: - if item_dir: - episode_filter = os.path.join(item_dir, f"{episode.seasonEpisode.upper()}.*") - else: - episode_filter = os.path.join(ad, f"{name}_{episode.seasonEpisode.upper()}.*") - matches = util.glob_filter(episode_filter) - if len(matches) > 0: - episode_poster = ImageData("asset_directory", os.path.abspath(matches[0]), prefix=f"{item.title} {episode.seasonEpisode.upper()}'s ", is_url=False) - found_episode = True - self.upload_images(episode, poster=episode_poster) - elif self.show_missing_episode_assets: - missing_episodes += f"\nMissing {episode.seasonEpisode.upper()} Title Card" - - if (found_season and missing_seasons) or (found_episode and missing_episodes): - output = f"Missing Posters for {item.title}" - if found_season: - output += missing_seasons - if found_episode: - output += missing_episodes - logger.info(output) - if isinstance(item, Artist): - missing_assets = "" - found_album = False - for album in self.query(item.albums): - if item_dir: - album_poster_filter = os.path.join(item_dir, f"{album.title}.*") - album_background_filter = os.path.join(item_dir, f"{album.title}_background.*") - else: - album_poster_filter = os.path.join(ad, f"{name}_{album.title}.*") - album_background_filter = os.path.join(ad, f"{name}_{album.title}_background.*") - album_poster = None - album_background = None - matches = util.glob_filter(album_poster_filter) - if len(matches) > 0: - album_poster = ImageData("asset_directory", os.path.abspath(matches[0]), prefix=f"{item.title} Album {album.title}'s ", is_url=False) - found_album = True - else: - missing_assets += f"\nMissing Album {album.title} Poster" - matches = util.glob_filter(album_background_filter) - if len(matches) > 0: - album_background = ImageData("asset_directory", os.path.abspath(matches[0]), prefix=f"{item.title} Album {album.title}'s ", is_poster=False, is_url=False) - if album_poster or album_background: - self.upload_images(album, poster=album_poster, background=album_background) - if self.show_missing_season_assets and found_album and missing_assets: - logger.info(f"Missing Album Posters for {item.title}{missing_assets}") + missing_assets += f"\nMissing Album {album.title} Poster" + if album_poster or album_background: + self.upload_images(album, poster=album_poster, background=album_background) + if self.show_missing_season_assets and found_album and missing_assets: + logger.info(f"Missing Album Posters for {item.title}{missing_assets}") if isinstance(item, (Movie, Show)) and not poster and overlay: self.upload_images(item, overlay=overlay) - if create and folders and not found_folder: + if create and folders and item_dir is None: filename, _ = util.validate_filename(name) - found_folder = os.path.join(self.asset_directory[0], filename) - os.makedirs(found_folder, exist_ok=True) - logger.info(f"Asset Directory Created: {found_folder}") - elif isinstance(item, (Movie, Show)) and not overlay and folders and not found_folder: + item_dir = os.path.join(self.asset_directory[0], filename) + os.makedirs(item_dir, exist_ok=True) + logger.info(f"Asset Directory Created: {item_dir}") + elif isinstance(item, (Movie, Show)) and not overlay and folders and item_dir is None: logger.warning(f"Asset Warning: No asset folder found called '{name}'") elif isinstance(item, (Movie, Show)) and not poster and not background and self.show_missing_assets: logger.warning(f"Asset Warning: No poster or background found in an assets folder for '{name}'") - return None, None, found_folder + return None, None, item_dir + + def update_asset2(self, item, folders=None, create=None): + if isinstance(item, (Movie, Artist, Show)): + starting = item.show() if isinstance(item, (Episode, Season)) else item + path_test = str(starting.locations[0]) + if not os.path.dirname(path_test): + path_test = path_test.replace("\\", "/") + name = os.path.basename(os.path.dirname(path_test) if isinstance(starting, Movie) else path_test) + elif isinstance(item, (Collection, Playlist)): + name, _ = util.validate_filename(item.title) + else: + return None, None, None + if folders is None: + folders = self.asset_folders + if create is None: + create = self.create_asset_folders + + poster, background, item_dir = self.find_assets( + name="poster" if folders else name, + folder_name=name if folders else None, + prefix=f"{item.title}'s " + ) + if item_dir and self.dimensional_asset_rename and (not poster or not background): + for file in util.glob_filter(os.path.join(item_dir, "*.*")): + if file.lower().endswith((".jpg", ".png", ".jpeg")): + image = Image.open(file) + _w, _h = image.size + image.close() + if not poster and _h >= _w: + new_path = os.path.join(os.path.dirname(file), f"poster{os.path.splitext(file)[1].lower()}") + os.rename(file, new_path) + poster = ImageData("asset_directory", os.path.abspath(new_path), prefix=f"{item.title}'s ", is_url=False) + elif not background and _w > _h: + new_path = os.path.join(os.path.dirname(file), f"background{os.path.splitext(file)[1].lower()}") + os.rename(file, new_path) + background = ImageData("asset_directory", os.path.abspath(new_path), prefix=f"{item.title}'s ", is_poster=False, is_url=False) + if poster and background: + break + + if poster or background: + self.upload_images(item, poster=poster, background=background) + + if isinstance(item, Show): + missing_seasons = "" + missing_episodes = "" + found_season = False + found_episode = False + for season in self.query(item.seasons): + season_name = f"Season{'0' if season.seasonNumber < 10 else ''}{season.seasonNumber}" + season_poster, season_background, _ = self.find_assets( + name=season_name, + folder_name=name, + item_directory=item_dir, + prefix=f"{item.title} Season {season.seasonNumber}'s " + ) + if season_poster: + found_season = True + elif self.show_missing_season_assets and season.seasonNumber > 0: + missing_seasons += f"\nMissing Season {season.seasonNumber} Poster" + if season_poster or season_background: + self.upload_images(season, poster=season_poster, background=season_background) + for episode in self.query(season.episodes): + if episode.seasonEpisode: + episode_poster, episode_background, _ = self.find_assets( + name=episode.seasonEpisode.upper(), + folder_name=name, + item_directory=item_dir, + prefix=f"{item.title} {episode.seasonEpisode.upper()}'s " + ) + if episode_poster or episode_background: + found_episode = True + self.upload_images(episode, poster=episode_poster, background=episode_background) + elif self.show_missing_episode_assets: + missing_episodes += f"\nMissing {episode.seasonEpisode.upper()} Title Card" + + if (found_season and missing_seasons) or (found_episode and missing_episodes): + output = f"Missing Posters for {item.title}" + if found_season: + output += missing_seasons + if found_episode: + output += missing_episodes + logger.info(output) + if isinstance(item, Artist): + missing_assets = "" + found_album = False + for album in self.query(item.albums): + album_poster, album_background, _ = self.find_assets( + name=album.title, + folder_name=name, + item_directory=item_dir, + prefix=f"{item.title} Album {album.title}'s " + ) + if album_poster: + found_album = True + else: + missing_assets += f"\nMissing Album {album.title} Poster" + if album_poster or album_background: + self.upload_images(album, poster=album_poster, background=album_background) + if self.show_missing_season_assets and found_album and missing_assets: + logger.info(f"Missing Album Posters for {item.title}{missing_assets}") + + if create and folders and item_dir is None: + filename, _ = util.validate_filename(name) + item_dir = os.path.join(self.asset_directory[0], filename) + os.makedirs(item_dir, exist_ok=True) + logger.info(f"Asset Directory Created: {item_dir}") + elif folders and item_dir is None: + logger.warning(f"Asset Warning: No asset folder found called '{name}'") + elif not poster and not background and self.show_missing_assets: + logger.warning(f"Asset Warning: No poster or background found in an assets folder for '{name}'") + return poster, background, item_dir def get_ids(self, item): tmdb_id = None diff --git a/modules/tmdb.py b/modules/tmdb.py index 24f215b3..bd655cdd 100644 --- a/modules/tmdb.py +++ b/modules/tmdb.py @@ -206,6 +206,14 @@ class TMDb: try: return TMDbShow(self, tmdb_id) except TMDbException as e: raise Failed(f"TMDb Error: No Show found for TMDb ID {tmdb_id}: {e}") + def get_season(self, tmdb_id, season_number, partial=None): + try: return self.TMDb.tv_season(tmdb_id, season_number, partial=partial) + except TMDbException as e: raise Failed(f"TMDb Error: No Season found for TMDb ID {tmdb_id} Season {season_number}: {e}") + + def get_episode(self, tmdb_id, season_number, episode_number, partial=None): + try: return self.TMDb.tv_episode(tmdb_id, season_number, episode_number, partial=partial) + except TMDbException as e: raise Failed(f"TMDb Error: No Episode found for TMDb ID {tmdb_id} Season {season_number} Episode {episode_number}: {e}") + def get_collection(self, tmdb_id, partial=None): try: return self.TMDb.collection(tmdb_id, partial=partial) except TMDbException as e: raise Failed(f"TMDb Error: No Collection found for TMDb ID {tmdb_id}: {e}") diff --git a/modules/util.py b/modules/util.py index d8eec25f..a01409ac 100644 --- a/modules/util.py +++ b/modules/util.py @@ -263,41 +263,41 @@ def time_window(tw): else: return tw -def load_yaml_files(yaml_files): +def load_files(files_to_load, method, file_type="yml"): files = [] - for yaml_file in get_list(yaml_files, split=False): - if isinstance(yaml_file, dict): + for file in get_list(files_to_load, split=False): + if isinstance(file, dict): temp_vars = {} - if "template_variables" in yaml_file and yaml_file["template_variables"] and isinstance(yaml_file["template_variables"], dict): - temp_vars = yaml_file["template_variables"] + if "template_variables" in file and file["template_variables"] and isinstance(file["template_variables"], dict): + temp_vars = file["template_variables"] def check_dict(attr, name): - if attr in yaml_file: - if yaml_file[attr]: - files.append((name, yaml_file[attr], temp_vars)) + if attr in file: + if file[attr]: + files.append((name, file[attr], temp_vars)) else: - logger.error(f"Config Error: metadata_path {attr} is blank") + logger.error(f"Config Error: {method} {attr} is blank") check_dict("url", "URL") check_dict("git", "Git") check_dict("repo", "Repo") check_dict("file", "File") - if "folder" in yaml_file: - if yaml_file["folder"] is None: - logger.error(f"Config Error: metadata_path folder is blank") - elif not os.path.isdir(yaml_file["folder"]): - logger.error(f"Config Error: Folder not found: {yaml_file['folder']}") + if "folder" in file: + if file["folder"] is None: + logger.error(f"Config Error: {method} folder is blank") + elif not os.path.isdir(file["folder"]): + logger.error(f"Config Error: Folder not found: {file['folder']}") else: - yml_files = glob_filter(os.path.join(yaml_file["folder"], "*.yml")) + yml_files = glob_filter(os.path.join(file["folder"], f"*.{file_type}")) if yml_files: files.extend([("File", yml, temp_vars) for yml in yml_files]) else: - logger.error(f"Config Error: No YAML (.yml) files found in {yaml_file['folder']}") + logger.error(f"Config Error: No {file_type.upper()} (.{file_type}) files found in {file['folder']}") else: - if os.path.exists(yaml_file): - files.append(("File", yaml_file, {})) + if os.path.exists(file): + files.append(("File", file, {})) else: - logger.error(f"Config Error: Path not found: {yaml_file}") + logger.error(f"Config Error: Path not found: {file}") return files def check_num(num, is_int=True): diff --git a/plex_meta_manager.py b/plex_meta_manager.py index 21a1bea6..cb14f986 100644 --- a/plex_meta_manager.py +++ b/plex_meta_manager.py @@ -26,7 +26,8 @@ parser.add_argument("-is", "--ignore-schedules", dest="ignore_schedules", help=" parser.add_argument("-ig", "--ignore-ghost", dest="ignore_ghost", help="Run ignoring ghost logging", action="store_true", default=False) parser.add_argument("-rt", "--test", "--tests", "--run-test", "--run-tests", dest="test", help="Run in debug mode with only collections that have test: true", action="store_true", default=False) parser.add_argument("-co", "--collection-only", "--collections-only", dest="collection_only", help="Run only collection operations", action="store_true", default=False) -parser.add_argument("-lo", "--library-only", "--libraries-only", dest="library_only", help="Run only library operations", action="store_true", default=False) +parser.add_argument("-op", "--operation", "--operations", "-lo", "--library-only", "--libraries-only", "--operation-only", "--operations-only", dest="operations", help="Run only operations", action="store_true", default=False) +parser.add_argument("-ov", "--overlay", "--overlays", "--overlay-only", "--overlays-only", dest="overlays", help="Run only overlays", action="store_true", default=False) parser.add_argument("-lf", "--library-first", "--libraries-first", dest="library_first", help="Run library operations before collections", action="store_true", default=False) parser.add_argument("-rc", "-cl", "--collection", "--collections", "--run-collection", "--run-collections", dest="collections", help="Process only specified collections (comma-separated list)", type=str) parser.add_argument("-rl", "-l", "--library", "--libraries", "--run-library", "--run-libraries", dest="libraries", help="Process only specified libraries (comma-separated list)", type=str) @@ -40,19 +41,25 @@ parser.add_argument("-w", "--width", dest="width", help="Screen Width (Default: args = parser.parse_args() def get_arg(env_str, default, arg_bool=False, arg_int=False): - env_var = os.environ.get(env_str) - if env_var: + env_vars = [env_str] if not isinstance(env_str, list) else env_str + final_value = None + for env_var in env_vars: + env_value = os.environ.get(env_var) + if env_value is not None: + final_value = env_value + break + if final_value is not None: if arg_bool: - if env_var is True or env_var is False: - return env_var - elif env_var.lower() in ["t", "true"]: + if final_value is True or final_value is False: + return final_value + elif final_value.lower() in ["t", "true"]: return True else: return False elif arg_int: - return int(env_var) + return int(final_value) else: - return str(env_var) + return str(final_value) else: return default @@ -63,7 +70,8 @@ test = get_arg("PMM_TEST", args.test, arg_bool=True) ignore_schedules = get_arg("PMM_IGNORE_SCHEDULES", args.ignore_schedules, arg_bool=True) ignore_ghost = get_arg("PMM_IGNORE_GHOST", args.ignore_ghost, arg_bool=True) collection_only = get_arg("PMM_COLLECTIONS_ONLY", args.collection_only, arg_bool=True) -library_only = get_arg("PMM_LIBRARIES_ONLY", args.library_only, arg_bool=True) +operations_only = get_arg(["PMM_OPERATIONS", "PMM_LIBRARIES_ONLY"], args.operations, arg_bool=True) +overlays_only = get_arg(["PMM_OVERLAYS", "PMM_OVERLAYS_ONLY"], args.overlays, arg_bool=True) library_first = get_arg("PMM_LIBRARIES_FIRST", args.library_first, arg_bool=True) collections = get_arg("PMM_COLLECTIONS", args.collections) libraries = get_arg("PMM_LIBRARIES", args.libraries) @@ -152,7 +160,8 @@ def start(attrs): logger.debug(f"--run (PMM_RUN): {run}") logger.debug(f"--run-tests (PMM_TEST): {test}") logger.debug(f"--collections-only (PMM_COLLECTIONS_ONLY): {collection_only}") - logger.debug(f"--libraries-only (PMM_LIBRARIES_ONLY): {library_only}") + logger.debug(f"--operations (PMM_OPERATIONS): {operations_only}") + logger.debug(f"--overlays (PMM_OVERLAYS): {overlays_only}") logger.debug(f"--libraries-first (PMM_LIBRARIES_FIRST): {library_first}") logger.debug(f"--run-collections (PMM_COLLECTIONS): {collections}") logger.debug(f"--run-libraries (PMM_LIBRARIES): {libraries}") @@ -211,8 +220,11 @@ def update_libraries(config): logger.info("") logger.separator(f"{library.name} Library") - if config.library_first and library.library_operation and not config.test_mode and not collection_only: - library.Operations.run_operations() + if config.library_first and not config.test_mode and not collection_only: + if not overlays_only and library.library_operation: + library.Operations.run_operations() + if not operations_only and library.overlay_files or library.remove_overlays: + library.Overlays.run_overlays() logger.debug("") logger.debug(f"Mapping Name: {library.original_mapping_name}") @@ -247,7 +259,7 @@ def update_libraries(config): for collection in library.get_all_collections(): logger.info(f"Collection {collection.title} Deleted") library.query(collection.delete) - if not library.is_other and not library.is_music and (library.metadata_files or library.original_mapping_name in config.library_map) and not library_only: + if not library.is_other and not library.is_music and not operations_only and (library.metadata_files or library.overlay_files): logger.info("") logger.separator(f"Mapping {library.name} Library", space=False, border=False) logger.info("") @@ -271,15 +283,18 @@ def update_libraries(config): logger.info("") logger.warning(f"Collection: {config.resume_from} not in Metadata File: {metadata.path}") continue - if collections_to_run and not library_only: + if collections_to_run and not operations_only and not overlays_only: logger.info("") logger.separator(f"{'Test ' if config.test_mode else ''}Collections") logger.remove_library_handler(library.mapping_name) run_collection(config, library, metadata, collections_to_run) logger.re_add_library_handler(library.mapping_name) - if not config.library_first and library.library_operation and not config.test_mode and not collection_only: - library.Operations.run_operations() + if not config.library_first and not config.test_mode and not collection_only: + if not overlays_only and library.library_operation: + library.Operations.run_operations() + if not operations_only and library.overlay_files or library.remove_overlays: + library.Overlays.run_overlays() logger.remove_library_handler(library.mapping_name) except Exception as e: @@ -301,7 +316,7 @@ def update_libraries(config): break amount_added = 0 - if has_run_again and not library_only: + if has_run_again and not operations_only and not overlays_only: logger.info("") logger.separator("Run Again") logger.info("")