[2] Overlay Overhaul!

pull/858/head
meisnate12 3 years ago
parent 4d014c9512
commit adf2ad2073

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/

@ -1 +1 @@
1.16.5-develop1
1.16.5-develop2

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

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) | &#9989; <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 | &#10060; |
| [`metadata_path`](#metadata-path) | Location of Metadata YAML files | `/config/<<MAPPING_NAME>>.yml` | &#10060; |
| [`overlay_path`](#overlay-path) | Location of Overlay YAML files | None | &#10060; |
| [`missing_path`](#missing-path) | Location to create the YAML file listing missing items for this library | `/config/<<MAPPING_NAME>>_missing.yml` | &#10060; |
| [`schedule`](../metadata/details/schedule) | Use any [schedule option](../metadata/details/schedule) to control when this library is run. | daily | &#10060; |
| [`operations`](operations) | Library Operations to run | N/A | &#10060; |
@ -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.
## Metadata Files
If utilized to their fullest, these files can be used to maintain the entire server's collections and playlists, and can be used as a backup for these in the event of a restore requirement.
Metadata files are used to create and maintain collections and metadata within the Plex libraries on the server.
## Metadata Files
If utilized to their fullest, these files can be used to maintain the entire server's collections and metadata, and can be used as a backup for these in the event of a restore requirement.
Collections, templates, metadata, and dynamic collections are defined within one or more Metadata files, which are linked to libraries in the [Libraries Attribute](../config/libraries) within the [Configuration File](../config/configuration.md).
Collections, templates, metadata, and dynamic collections are defined within one or more Metadata files, which are linked to libraries in the [Libraries Attribute](../config/libraries.md#metadata-path) within the [Configuration File](../config/configuration.md).
These are the attributes which can be used within the Metadata File:
| Attribute | Description |
|:--------------------------------------------------------|:----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `metadata` | contains definitions of metadata changes to [movie](metadata/movie), [show](metadata/show), or [music](metadata/music) library's items [movie titles, episode descriptions, etc.] |
| [`templates`](templates) | contains definitions of templates that can be leveraged by multiple collections |
| [`external_templates`](templates.md#external-templates) | contains [path types](../config/paths) that point to external templates that can be leveraged by multiple collections |
| [`collections`](#collections-and-playlists-mappings) | contains definitions of collections you wish to add to one or more libraries |
| [`dynamic_collections`](dynamic) | contains definitions of dynamic collections you wish to create in one or more libraries |
| [`collections`](#collection-attributes) | contains definitions of collections you wish to add to one or more libraries |
| [`dynamic_collections`](#dynamic-collection-attributes) | contains definitions of [dynamic collections](dynamic) you wish to create |
| [`metadata`](#metadata-attributes) | contains definitions of metadata changes to [movie](metadata/movie), [show](metadata/show), or [music](metadata/music) library's items [movie titles, episode descriptions, etc.] |
* One of `metadata`, `collections` or `dynamic_collections` must be present for the Metadata File to execute.
* Example Metadata Files can be found in the [Plex Meta Manager Configs Repository](https://github.com/meisnate12/Plex-Meta-Manager-Configs)
## Playlist Files
Playlists are defined in one or more Playlist files that are mapped in the [Playlist Files Attribute](../config/playlist) within the Configuration File.
These are the attributes which can be utilized within the Playlist File:
| Attribute | Description |
|:--------------------------------------------------------|:--------------------------------------------------------------------------------------------------------------------|
| [`templates`](templates) | contains definitions of templates that can be leveraged by multiple playlists |
| [`external_templates`](templates.md#external-templates) | contains [path types](../config/paths) that point to external templates that can be leveraged by multiple playlists |
| [`playlists`](#additional-playlist-attributes) | contains definitions of playlists you wish to add to the server |
* Example Metadata Files can be found in the [Plex Meta Manager Configs Repository](https://github.com/meisnate12/Plex-Meta-Manager-Configs/tree/master/PMM)
* `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)
## Collection Attributes
## 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)
## 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.)
* Show and Hide collections at set intervals (i.e. show Christmas collections in December only)
Below is an example dynamic collection which will create a collection for each of the decades represented within the library:
Each collection requires its own section within the `collections` attribute and unlike playlists, collections can be built using as many Builders as needed.
```yaml
dynamic_collections:
Decades:
type: decade
collections:
Trending Movies:
# ... builders, details, and filters for this collection
Popular Movies:
# ... builders, details, and filters for this collection
etc:
# ... builders, details, and filters for this collection
```
## Collection and Playlist Attributes
There are three types of attributes that can be utilized within a collection/playlist:
There are three types of attributes that can be utilized within a collection:
### Builders
Builders use third-party services to source items to be added to the collection/playlist. Multiple builders can be used in the same collection/playlist from a variety of sources listed below.
Builders use third-party services to source items to be added to the collection. Multiple builders can be used in the same collection from a variety of sources listed below.
* [Plex Builders](builders/plex)
* [Smart Builders](builders/smart)
@ -86,37 +61,134 @@ Builders use third-party services to source items to be added to the collection/
* [AniList Builders](builders/anilist)
* [MyAnimeList Builders](builders/myanimelist)
## Details
### Details
These can alter any aspect of the collection/playlist or the media items within them.
These can alter any aspect of the collection or the media items within them.
* [Setting Details](details/setting)
* [Schedule Detail](details/schedule)
* [Image Overlay Detail](details/overlay)
* [Metadata Details](details/metadata)
* [Arr Details](details/arr)
## Filters
### Filters
These filter media items added to the collection by any of the Builders.
* [Filters](filters)
## Additional Playlist Attributes
### Example
Playlist operations requires the `libraries` attribute, which instructs the operation to look in the specified libraries. This allows media to be combined from multiple libraries into one playlist. The mappings that you define in the `libraries` attribute must match the library names in your [Configuration File](../config/configuration).
```yaml
collections:
Trending:
trakt_trending: 10
tmdb_trending_daily: 10
tmdb_trending_weekly: 10
sort_title: +1_Trending
sync_mode: sync
smart_label: random
summary: Movies Trending across the internet
Popular:
tmdb_popular: 40
imdb_list:
url: https://www.imdb.com/search/title/?title_type=feature,tv_movie,documentary,short
limit: 40
sort_title: +2_Popular
sync_mode: sync
smart_label: random
summary: Popular Movies across the internet
```
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.
## Dynamic Collection Attributes
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:
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
playlists:
Marvel Cinematic Universe:
sync_mode: sync
libraries: Movies, TV Shows
sync_to_users: User1, someone@somewhere.com, User3
trakt_list: https://trakt.tv/users/donxy/lists/marvel-cinematic-universe?sort=rank,asc
summary: Marvel Cinematic Universe In Chronological Order
dynamic_collections:
Decades:
type: decade
```
* 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.
## 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 | &#9989; | &#9989; | &#9989; |
| `alt_title` | Alternative title to look for | &#9989; | &#10060; | &#10060; |
| `year` | Year of show for better identification | &#9989; | &#10060; | &#10060; |
|:---------------|:-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:--------:|:--------:|:--------:|
| `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. | &#9989; | &#9989; | &#9989; |
| `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. | &#9989; | &#10060; | &#10060; |
| `year` | Year of show for better identification. See the [Metadata Page](../metadata.md#metadata-attributes) for how searching for files works. | &#9989; | &#10060; | &#10060; |
| `tmdb_show` | TMDb Show ID to use for metadata useful for miniseries that have been compiled into a movie | &#9989; | &#10060; | &#10060; |
| `tmdb_movie` | TMDb Movie ID to use for metadata useful for movies that have been split into segments | &#9989; | &#10060; | &#10060; |
| `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. | &#9989; | &#10060; | &#10060; |
@ -92,27 +92,6 @@ The available attributes for editing shows, seasons, and episodes are as follows
| `seasons` | Mapping to define Seasons | &#9989; | &#10060; | &#10060; |
| `episodes` | Mapping to define Episodes | &#10060; | &#9989; | &#10060; |
* 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 |

@ -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. | &#9989; |
| `url` | URL of Overlay Image Online | &#10060; |
| `git` | Location in the [Configs Repo](https://github.com/meisnate12/Plex-Meta-Manager-Configs) of the Overlay Image | &#10060; |
| `repo` | Location in the [Custom Repo](../config/settings.md#custom-repo) of the Overlay Image | &#10060; |
* 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
```

@ -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("")

@ -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()

@ -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("")

Loading…
Cancel
Save