[2] Overlay Overhaul!
5
.gitignore
vendored
|
@ -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/
|
||||
|
|
2
VERSION
|
@ -1 +1 @@
|
|||
1.16.5-develop1
|
||||
1.16.5-develop2
|
||||
|
|
Before Width: | Height: | Size: 34 KiB |
Before Width: | Height: | Size: 42 KiB |
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
Before Width: | Height: | Size: 7.4 KiB After Width: | Height: | Size: 7.4 KiB |
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
@ -127,7 +127,9 @@ html_theme_options = {
|
|||
("Run Commands & Environment Variables", "home/environmental"),
|
||||
("_divider", ),
|
||||
("Configuration File", "config/configuration"),
|
||||
("Metadata File", "metadata/metadata"),
|
||||
("Metadata Files", "metadata/metadata"),
|
||||
("Overlay Files", "metadata/overlay"),
|
||||
("Playlist Files", "metadata/playlist"),
|
||||
("_divider", ),
|
||||
("Scheduling Guide", "home/guides/scheduling"),
|
||||
("Image Asset Directory Guide", "home/guides/assets"),
|
||||
|
@ -160,7 +162,9 @@ html_theme_options = {
|
|||
("Notifiarr", "config/notifiarr"),
|
||||
]),
|
||||
("_menu", "Metadata", [
|
||||
("Metadata and Playlist Files", "metadata/metadata"),
|
||||
("Metadata Files", "metadata/metadata"),
|
||||
("Overlay Files", "metadata/overlay"),
|
||||
("Playlist Files", "metadata/playlist"),
|
||||
("_divider", ),
|
||||
("Templates", "metadata/templates"),
|
||||
("Filters", "metadata/filters"),
|
||||
|
@ -191,7 +195,6 @@ html_theme_options = {
|
|||
("_menu", "Details", [
|
||||
("Setting Details", "metadata/details/setting"),
|
||||
("Schedule Details", "metadata/details/schedule"),
|
||||
("Image Overlay Details", "metadata/details/overlay"),
|
||||
("Metadata Details", "metadata/details/metadata"),
|
||||
("Radarr/Sonarr Details", "metadata/details/arr"),
|
||||
])
|
||||
|
|
|
@ -9,9 +9,9 @@ A template Configuration File can be found in the [GitHub Repo](https://github.c
|
|||
This table outlines the third-party services that Plex Meta Manager can make use of. Each service has specific requirements for setup that can be found by clicking the links within the table.
|
||||
|
||||
| Attribute | Required |
|
||||
|:-----------------------------|:---------------------------------------:|
|
||||
|:----------------------------------------------------------|:---------------------------------------:|
|
||||
| [`libraries`](libraries) | ✅ |
|
||||
| [`playlist_files`](playlist) | ❌ |
|
||||
| [`playlist_files`](libraries.md#playlist-files-attribute) | ❌ |
|
||||
| [`settings`](settings) | ❌ |
|
||||
| [`webhooks`](webhooks) | ❌ |
|
||||
| [`plex`](plex) | ✅ <br/>Either here or per library |
|
||||
|
|
|
@ -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/<<MAPPING_NAME>>.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/<<MAPPING_NAME>>_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.
|
||||
|
|
|
@ -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<br>**Values:** `default`: Library default<br>`hide`: Hide Collection<br>`hide_items`: Hide Items in this Collection<br>`show_items`: Show this Collection and its Items<table class="clearTable"><tr><td>`default`</td><td>Library default</td></tr><tr><td>`hide`</td><td>Hide Collection</td></tr><tr><td>`hide_items`</td><td>Hide Items in this Collection</td></tr><tr><td>`show_items`</td><td>Show this Collection and its Items</td></tr></table> |
|
||||
| `update_blank_track_titles` | Search though every track in a music library and replace any blank track titles with the tracks sort title<br>**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.<br>**Values:** `true` or `false` |
|
||||
| `remove_overlays` | Search through every title and removes all overlays.<br>**Values:** `true` or `false` |
|
||||
| `split_duplicates` | Splits all duplicate movies/shows found in this library<br>**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.<br>**Values:** `true` or `false` |
|
||||
| `radarr_remove_by_tag` | Removes every item from Radarr with the Tags given<br>**Values:** List or comma separated string of tags |
|
||||
|
|
|
@ -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
|
|||
|
||||
</details>
|
||||
|
||||
### Libraries Only
|
||||
### Operations
|
||||
|
||||
Only run library operations, skip collections.
|
||||
Only run library operations skipping collections and overlays.
|
||||
|
||||
<table class="dualTable colwidths-auto align-default table">
|
||||
<tr>
|
||||
|
@ -259,13 +260,13 @@ Only run library operations, skip collections.
|
|||
</tr>
|
||||
<tr>
|
||||
<th>Flags</th>
|
||||
<td><code>-lo</code> or <code>--libraries-only</code></td>
|
||||
<td><code>PMM_LIBRARIES_ONLY</code></td>
|
||||
<td><code>-op</code> or <code>--operations</code></td>
|
||||
<td><code>PMM_OPERATIONS</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Example</th>
|
||||
<td><code>--libraries-only</code></td>
|
||||
<td><code>PMM_LIBRARIES_ONLY=true</code></td>
|
||||
<td><code>--operations</code></td>
|
||||
<td><code>PMM_OPERATIONS=true</code></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
@ -273,7 +274,7 @@ Only run library operations, skip collections.
|
|||
<summary>Local Environment</summary>
|
||||
|
||||
```shell
|
||||
python plex_meta_manager.py --libraries-only
|
||||
python plex_meta_manager.py --operations
|
||||
```
|
||||
|
||||
</details>
|
||||
|
@ -281,7 +282,46 @@ python plex_meta_manager.py --libraries-only
|
|||
<summary>Docker Environment</summary>
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### Overlays
|
||||
|
||||
Only run library overlays skipping operations and collections.
|
||||
|
||||
<table class="dualTable colwidths-auto align-default table">
|
||||
<tr>
|
||||
<th style="background-color: #222;"></th>
|
||||
<th>Shell</th>
|
||||
<th>Environment</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Flags</th>
|
||||
<td><code>-ov</code> or <code>--overlays</code></td>
|
||||
<td><code>PMM_OVERLAYS</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Example</th>
|
||||
<td><code>--overlays</code></td>
|
||||
<td><code>PMM_OVERLAYS=true</code></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<details>
|
||||
<summary>Local Environment</summary>
|
||||
|
||||
```shell
|
||||
python plex_meta_manager.py --overlays
|
||||
```
|
||||
|
||||
</details>
|
||||
<details>
|
||||
<summary>Docker Environment</summary>
|
||||
|
||||
```shell
|
||||
docker run -it -v "X:\Media\Plex Meta Manager\config:/config:rw" meisnate12/plex-meta-manager --overlays
|
||||
```
|
||||
|
||||
</details>
|
||||
|
|
|
@ -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,59 +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
|
||||
|
||||
```
|
||||
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: true` with nesting
|
||||
### `asset_folders: true`
|
||||
|
||||
```
|
||||
config
|
||||
|
|
|
@ -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<br>**Default:** `None`<br>**Values:**<table class="clearTable"><tr><td>`true`</td><td>Lock</td></tr><tr><td>`false`</td><td>Unlock</td></tr></table> |
|
||||
| `item_lock_background` | **Description:** Locks/Unlocks the background of every movie/show in the collection<br>**Default:** `None`<br>**Values:**<table class="clearTable"><tr><td>`true`</td><td>Lock</td></tr><tr><td>`false`</td><td>Unlock</td></tr></table> |
|
||||
| `item_lock_title` | **Description:** Locks/Unlocks the title of every movie/show in the collection<br>**Default:** `None`<br>**Values:**<table class="clearTable"><tr><td>`true`</td><td>Lock</td></tr><tr><td>`false`</td><td>Unlock</td></tr></table> |
|
||||
| `item_overlay` | **Description:** Adds and overlay image to the poster of every movie/show in the collection see [Overlay Details](overlay) for more information.<br>**Values:** Name of overlay to be applied |
|
||||
| `item_assets` | **Description:** Checks your assets folders for assets of every movie/show in the collection<br>**Default:** `false`<br>**Values:** `true` or `false` |
|
||||
| `item_refresh` | **Description:** Refreshes the metadata of every movie/show in the collection<br>**Default:** `false`<br>**Values:** `true` or `false` |
|
||||
| `item_refresh_delay` | **Description:** Amount of time to wait between each `item_refresh` of every movie/show in the collection<br>**Default:** `0`<br>**Values:** Number greater then `0` |
|
||||
| `item_tmdb_season_titles` | **Description:** Changes the season titles of every show in the collection to match TMDb<br>**Default:** `false`<br>**Values:** `true` or `false` |
|
||||
|
|
|
@ -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.
|
||||
|
||||
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
|
||||
|
||||
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).
|
||||
Metadata files are used to create and maintain collections and metadata within the Plex libraries on the server.
|
||||
|
||||
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.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)
|
||||
* Example Metadata Files can be found in the [Plex Meta Manager Configs Repository](https://github.com/meisnate12/Plex-Meta-Manager-Configs/tree/master/PMM)
|
||||
|
||||
## Playlist Files
|
||||
## Collection Attributes
|
||||
|
||||
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)
|
||||
|
||||
## Collections and Playlists Mappings
|
||||
|
||||
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
|
||||
|
||||
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).
|
||||
|
||||
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.
|
||||
|
||||
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:
|
||||
### Example
|
||||
|
||||
```yaml
|
||||
playlists:
|
||||
Marvel Cinematic Universe:
|
||||
collections:
|
||||
Trending:
|
||||
trakt_trending: 10
|
||||
tmdb_trending_daily: 10
|
||||
tmdb_trending_weekly: 10
|
||||
sort_title: +1_Trending
|
||||
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
|
||||
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
|
||||
```
|
||||
* 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.
|
||||
|
||||
## 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:
|
||||
|
||||
```yaml
|
||||
dynamic_collections:
|
||||
Decades:
|
||||
type: decade
|
||||
```
|
||||
|
||||
## Metadata Attributes
|
||||
|
||||
Plex Meta Manager can automatically update items in Plex based on what's defined within the `metadata` attribute.
|
||||
|
||||
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
|
||||
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
|
||||
```
|
||||
|
||||
### 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
|
||||
```
|
||||
|
||||
|
||||
|
||||
|
|
|
@ -87,34 +87,12 @@ 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.
|
||||
|:-------------|:--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `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
|
||||
|
||||
|
|
|
@ -80,10 +80,10 @@ 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 | ✅ | ❌ | ❌ |
|
||||
|:---------------|:-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:--------:|:--------:|:--------:|
|
||||
| `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. | ✅ | ❌ | ❌ |
|
||||
|
@ -92,27 +92,6 @@ The available attributes for editing shows, seasons, and episodes are as follows
|
|||
| `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.
|
||||
|
||||
### General Attributes
|
||||
|
||||
| Attribute | Values | Shows | Seasons | Episodes |
|
||||
|
|
114
docs/metadata/overlay.md
Normal file
|
@ -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
|
||||
```
|
94
docs/metadata/playlist.md
Normal file
|
@ -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
|
||||
```
|
|
@ -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
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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] = {}
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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("")
|
||||
|
|
203
modules/overlays.py
Normal file
|
@ -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()
|
248
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):
|
||||
def upload_poster(self, item, image, url=False):
|
||||
if url:
|
||||
item.uploadPoster(url=image)
|
||||
else:
|
||||
item.uploadPoster(filepath=image)
|
||||
self.reload(item)
|
||||
|
||||
@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,54 +875,27 @@ 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)
|
||||
|
||||
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")):
|
||||
|
@ -939,10 +914,8 @@ class Plex(Library):
|
|||
break
|
||||
|
||||
if poster or background:
|
||||
if upload:
|
||||
self.upload_images(item, poster=poster, background=background, overlay=overlay)
|
||||
else:
|
||||
return poster, background, item_dir
|
||||
|
||||
if isinstance(item, Show):
|
||||
missing_seasons = ""
|
||||
missing_episodes = ""
|
||||
|
@ -950,36 +923,29 @@ class Plex(Library):
|
|||
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)
|
||||
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"
|
||||
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)
|
||||
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)
|
||||
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"
|
||||
|
||||
|
@ -994,23 +960,16 @@ class Plex(Library):
|
|||
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)
|
||||
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"
|
||||
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:
|
||||
|
@ -1018,16 +977,127 @@ class Plex(Library):
|
|||
|
||||
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
|
||||
|
|
|
@ -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}")
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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:
|
||||
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:
|
||||
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("")
|
||||
|
|