forked from Kometa-Team/Kometa
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
277cd01
commit 23d5914
Showing
22 changed files
with
5,626 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
**/dist | ||
**/build | ||
*.spec | ||
**/__pycache__ | ||
/.vscode | ||
**/log | ||
README.md | ||
LICENSE | ||
.gitignore | ||
.git |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,21 @@ | ||
FROM python:3-slim | ||
VOLUME /config | ||
COPY . / | ||
RUN \ | ||
echo "**** install system packages ****" && \ | ||
apt-get update && \ | ||
apt-get upgrade -y --no-install-recommends && \ | ||
apt-get install -y tzdata --no-install-recommends && \ | ||
echo "**** install python packages ****" && \ | ||
pip3 install --no-cache-dir --upgrade --requirement /requirements.txt && \ | ||
echo "**** install Plex-Auto-Collections ****" && \ | ||
chmod +x /plex_meta_manager.py && \ | ||
echo "**** cleanup ****" && \ | ||
apt-get autoremove -y && \ | ||
apt-get clean && \ | ||
rm -rf \ | ||
/requirements.txt \ | ||
/tmp/* \ | ||
/var/tmp/* \ | ||
/var/lib/apt/lists/* | ||
ENTRYPOINT ["python3", "plex_meta_manager.py"] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,2 +1,20 @@ | ||
# Plex-Meta-Manager | ||
Python script to update metadata information for movies, shows, and collections | ||
# Plex Meta Manager | ||
|
||
The original concept for Plex Meta Manager is [Plex Auto Collections](https://github.com/mza921/Plex-Auto-Collections), but this is rewritten from the ground up to be able to include a scheduler, metadata edits, multiple libraries, and logging. Plex Meta Manager is a Python 3 script that can be continuously run using YMAL configuration files to update on a schedule the metadata of the movies, shows, and collections in your libraries as well as automatically build collections based on various methods all detailed in the wiki. Some collection examples that the script can automatically build and update daily include Plex Based Searches like actor, genre, or studio collections or Collections based on TMDb, IMDb, Trakt, TVDb, AniDB, or MyAnimeList lists and various other services. | ||
|
||
The script can update many metadata fields for movies, shows, collections, seasons, and episodes and can act as a backup if your plex DB goes down. It can even update metadata the plex UI can't like Season Names. If the time is put into the metadata configuration file you can have a way to recreate your library and all its metadata changes with the click of a button. | ||
|
||
The script is designed to work with most Metadata agents including the new Plex Movie Agent, [Hama Anime Agent](https://github.com/ZeroQI/Hama.bundle), and [MyAnimeList Anime Agent](https://github.com/Fribb/MyAnimeList.bundle). | ||
|
||
## Getting Started | ||
|
||
* [Wiki](https://github.com/meisnate12/Plex-Meta-Manager/wiki) | ||
* [Local Installation](https://github.com/meisnate12/Plex-Meta-Manager/wiki/Local-Installation) | ||
* [Docker Installation](https://github.com/meisnate12/Plex-Meta-Manager/wiki/Docker) | ||
|
||
## Support | ||
|
||
* If you're getting an Error or have an Enhancement post in the [Issues](https://github.com/meisnate12/Plex-Meta-Manager/issues) | ||
* If you have a configuration question or want to see some example and user shared configurations visit the [Discussions](https://github.com/meisnate12/Plex-Meta-Manager/discussions) | ||
* Pull Request are welcome | ||
* [Buy Me a Pizza](https://www.buymeacoffee.com/meisnate12) |
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,57 @@ | ||
libraries: | ||
Movies: | ||
library_type: movie | ||
TV Shows: | ||
library_type: show | ||
Anime: | ||
library_type: show | ||
cache: | ||
cache: true | ||
cache_expiration: 60 | ||
plex: # Can be individually specified per library as well | ||
url: http://192.168.1.12:32400 | ||
token: #################### | ||
sync_mode: append | ||
asset_directory: config/assets | ||
radarr: # Can be individually specified per library as well | ||
url: http://192.168.1.12:7878 | ||
token: ################################ | ||
version: v2 | ||
quality_profile: HD-1080p | ||
root_folder_path: S:/Movies | ||
add: false | ||
search: false | ||
sonarr: # Can be individually specified per library as well | ||
url: http://192.168.1.12:8989 | ||
token: ################################ | ||
version: v2 | ||
quality_profile: HD-1080p | ||
root_folder_path: "S:/TV Shows" | ||
add: false | ||
search: false | ||
tautulli: # Can be individually specified per library as well | ||
url: http://192.168.1.12:8181 | ||
apikey: ################################ | ||
tmdb: | ||
apikey: ################################ | ||
language: en | ||
trakt: | ||
client_id: ################################################################ | ||
client_secret: ################################################################ | ||
authorization: | ||
# everything below is autofilled by the script | ||
access_token: | ||
token_type: | ||
expires_in: | ||
refresh_token: | ||
scope: public | ||
created_at: | ||
mal: | ||
client_id: ################################ | ||
client_secret: ################################################################ | ||
authorization: | ||
# everything below is autofilled by the script | ||
access_token: | ||
token_type: | ||
expires_in: | ||
refresh_token: |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,116 @@ | ||
import logging, requests | ||
from lxml import html | ||
from modules import util | ||
from modules.util import Failed | ||
from retrying import retry | ||
|
||
logger = logging.getLogger("Plex Meta Manager") | ||
|
||
class AniDBAPI: | ||
def __init__(self, Cache=None, TMDb=None, Trakt=None): | ||
self.Cache = Cache | ||
self.TMDb = TMDb | ||
self.Trakt = Trakt | ||
self.urls = { | ||
"anime": "https://anidb.net/anime", | ||
"popular": "https://anidb.net/latest/anime/popular/?h=1", | ||
"relation": "/relation/graph" | ||
} | ||
self.id_list = html.fromstring(requests.get("https://raw.githubusercontent.com/Anime-Lists/anime-lists/master/anime-list-master.xml").content) | ||
|
||
def convert_anidb_to_tvdb(self, anidb_id): return self.convert_anidb(anidb_id, "anidbid", "tvdbid") | ||
def convert_anidb_to_imdb(self, anidb_id): return self.convert_anidb(anidb_id, "anidbid", "imdbid") | ||
def convert_tvdb_to_anidb(self, tvdb_id): return self.convert_anidb(tvdb_id, "tvdbid", "anidbid") | ||
def convert_imdb_to_anidb(self, imdb_id): return self.convert_anidb(imdb_id, "imdbid", "anidbid") | ||
def convert_anidb(self, input_id, from_id, to_id): | ||
ids = self.id_list.xpath("//anime[@{}='{}']/@{}".format(from_id, input_id, to_id)) | ||
if len(ids) > 0: | ||
if len(ids[0]) > 0: return ids[0] if to_id == "imdbid" else int(ids[0]) | ||
else: raise Failed("AniDB Error: No {} ID found for {} ID: {}".format(util.pretty_ids[to_id], util.pretty_ids[from_id], input_id)) | ||
else: raise Failed("AniDB Error: {} ID: {} not found".format(util.pretty_ids[from_id], input_id)) | ||
|
||
@retry(stop_max_attempt_number=6, wait_fixed=10000) | ||
def send_request(self, url, language): | ||
return requests.get(url, headers={"Accept-Language": language, "User-Agent": "Mozilla/5.0 x64"}).content | ||
|
||
def get_popular(self, language): | ||
response = html.fromstring(self.send_request(self.urls["popular"], language)) | ||
return util.get_int_list(response.xpath("//td[@class='name anime']/a/@href"), "AniDB ID") | ||
|
||
def validate_anidb_id(self, anidb_id, language): | ||
response = html.fromstring(self.send_request("{}/{}".format(self.urls["anime"], anidb_id), language)) | ||
ids = response.xpath("//*[text()='a{}']/text()".format(anidb_id)) | ||
if len(ids) > 0: | ||
return util.regex_first_int(ids[0], "AniDB ID") | ||
raise Failed("AniDB Error: AniDB ID: {} not found".format(anidb_id)) | ||
|
||
def get_anidb_relations(self, anidb_id, language): | ||
response = html.fromstring(self.send_request("{}/{}{}".format(self.urls["anime"], anidb_id, self.urls["relation"]), language)) | ||
return util.get_int_list(response.xpath("//area/@href"), "AniDB ID") | ||
|
||
def validate_anidb_list(self, anidb_list, language): | ||
anidb_values = [] | ||
for anidb_id in anidb_list: | ||
try: | ||
anidb_values.append(self.validate_anidb_id(anidb_id, language)) | ||
except Failed as e: | ||
logger.error(e) | ||
if len(anidb_values) > 0: | ||
return anidb_values | ||
raise Failed("AniDB Error: No valid AniDB IDs in {}".format(anidb_list)) | ||
|
||
def get_items(self, method, data, language, status_message=True): | ||
pretty = util.pretty_names[method] if method in util.pretty_names else method | ||
if status_message: | ||
logger.debug("Data: {}".format(data)) | ||
anime_ids = [] | ||
if method == "anidb_popular": | ||
if status_message: | ||
logger.info("Processing {}: {} Anime".format(pretty, data)) | ||
anime_ids.extend(self.get_popular(language)[:data]) | ||
else: | ||
if status_message: logger.info("Processing {}: {}".format(pretty, data)) | ||
if method == "anidb_id": anime_ids.append(data) | ||
elif method == "anidb_relation": anime_ids.extend(self.get_anidb_relations(data, language)) | ||
else: raise Failed("AniDB Error: Method {} not supported".format(method)) | ||
show_ids = [] | ||
movie_ids = [] | ||
for anidb_id in anime_ids: | ||
try: | ||
tmdb_id, tvdb_id = self.convert_from_imdb(self.convert_anidb_to_imdb(anidb_id), language) | ||
if tmdb_id: movie_ids.append(tmdb_id) | ||
else: raise Failed | ||
except Failed: | ||
try: show_ids.append(self.convert_anidb_to_tvdb(anidb_id)) | ||
except Failed: logger.error("AniDB Error: No TVDb ID or IMDb ID found for AniDB ID: {}".format(anidb_id)) | ||
if status_message: | ||
logger.debug("AniDB IDs Found: {}".format(anime_ids)) | ||
logger.debug("TMDb IDs Found: {}".format(movie_ids)) | ||
logger.debug("TVDb IDs Found: {}".format(show_ids)) | ||
return movie_ids, show_ids | ||
|
||
def convert_from_imdb(self, imdb_id, language): | ||
if self.Cache: | ||
tmdb_id, tvdb_id = self.Cache.get_ids_from_imdb(imdb_id) | ||
expired = False | ||
if not tmdb_id: | ||
tmdb_id, expired = self.Cache.get_tmdb_from_imdb(imdb_id) | ||
if expired: | ||
tmdb_id = None | ||
else: | ||
tmdb_id = None | ||
from_cache = tmdb_id is not None | ||
|
||
if not tmdb_id and self.TMDb: | ||
try: tmdb_id = self.TMDb.convert_imdb_to_tmdb(imdb_id) | ||
except Failed: pass | ||
if not tmdb_id and self.Trakt: | ||
try: tmdb_id = self.Trakt.convert_imdb_to_tmdb(imdb_id) | ||
except Failed: pass | ||
try: | ||
if tmdb_id and not from_cache: self.TMDb.get_movie(tmdb_id) | ||
except Failed: tmdb_id = None | ||
if not tmdb_id: raise Failed("TVDb Error: No TMDb ID found for IMDb: {}".format(imdb_id)) | ||
if self.Cache and tmdb_id and expired is not False: | ||
self.Cache.update_imdb("movie", expired, imdb_id, tmdb_id) | ||
return tmdb_id |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,128 @@ | ||
import logging, os, random, sqlite3 | ||
from contextlib import closing | ||
from datetime import datetime, timedelta | ||
|
||
logger = logging.getLogger("Plex Meta Manager") | ||
|
||
class Cache: | ||
def __init__(self, config_path, expiration): | ||
cache = "{}.cache".format(os.path.splitext(config_path)[0]) | ||
with sqlite3.connect(cache) as connection: | ||
connection.row_factory = sqlite3.Row | ||
with closing(connection.cursor()) as cursor: | ||
cursor.execute("SELECT count(name) FROM sqlite_master WHERE type='table' AND name='guids'") | ||
if cursor.fetchone()[0] == 0: | ||
logger.info("Initializing cache database at {}".format(cache)) | ||
cursor.execute( | ||
"""CREATE TABLE IF NOT EXISTS guids ( | ||
INTEGER PRIMARY KEY, | ||
plex_guid TEXT, | ||
tmdb_id TEXT, | ||
imdb_id TEXT, | ||
tvdb_id TEXT, | ||
anidb_id TEXT, | ||
mal_id TEXT, | ||
expiration_date TEXT, | ||
media_type TEXT)""" | ||
) | ||
cursor.execute( | ||
"""CREATE TABLE IF NOT EXISTS imdb_map ( | ||
INTEGER PRIMARY KEY, | ||
imdb_id TEXT, | ||
t_id TEXT, | ||
expiration_date TEXT, | ||
media_type TEXT)""" | ||
) | ||
else: | ||
logger.info("Using cache database at {}".format(cache)) | ||
self.expiration = expiration | ||
self.cache_path = cache | ||
|
||
def get_ids_from_imdb(self, imdb_id): | ||
tmdb_id, tmdb_expired = self.get_tmdb_id("movie", imdb_id=imdb_id) | ||
tvdb_id, tvdb_expired = self.get_tvdb_id("show", imdb_id=imdb_id) | ||
return tmdb_id, tvdb_id | ||
|
||
def get_tmdb_id(self, media_type, plex_guid=None, imdb_id=None, tvdb_id=None, anidb_id=None, mal_id=None): | ||
return self.get_id_from(media_type, "tmdb_id", plex_guid=plex_guid, imdb_id=imdb_id, tvdb_id=tvdb_id, anidb_id=anidb_id, mal_id=mal_id) | ||
|
||
def get_imdb_id(self, media_type, plex_guid=None, tmdb_id=None, tvdb_id=None, anidb_id=None, mal_id=None): | ||
return self.get_id_from(media_type, "imdb_id", plex_guid=plex_guid, tmdb_id=tmdb_id, tvdb_id=tvdb_id, anidb_id=anidb_id, mal_id=mal_id) | ||
|
||
def get_tvdb_id(self, media_type, plex_guid=None, tmdb_id=None, imdb_id=None, anidb_id=None, mal_id=None): | ||
return self.get_id_from(media_type, "tvdb_id", plex_guid=plex_guid, tmdb_id=tmdb_id, imdb_id=imdb_id, anidb_id=anidb_id, mal_id=mal_id) | ||
|
||
def get_anidb_id(self, media_type, plex_guid=None, tmdb_id=None, imdb_id=None, tvdb_id=None, mal_id=None): | ||
return self.get_id_from(media_type, "anidb_id", plex_guid=plex_guid, tmdb_id=tmdb_id, imdb_id=imdb_id, tvdb_id=tvdb_id, mal_id=mal_id) | ||
|
||
def get_mal_id(self, media_type, plex_guid=None, tmdb_id=None, imdb_id=None, tvdb_id=None, anidb_id=None): | ||
return self.get_id_from(media_type, "anidb_id", plex_guid=plex_guid, tmdb_id=tmdb_id, imdb_id=imdb_id, tvdb_id=tvdb_id, anidb_id=anidb_id) | ||
|
||
def get_id_from(self, media_type, id_from, plex_guid=None, tmdb_id=None, imdb_id=None, tvdb_id=None, anidb_id=None, mal_id=None): | ||
if plex_guid: return self.get_id(media_type, "plex_guid", id_from, plex_guid) | ||
elif tmdb_id: return self.get_id(media_type, "tmdb_id", id_from, tmdb_id) | ||
elif imdb_id: return self.get_id(media_type, "imdb_id", id_from, imdb_id) | ||
elif tvdb_id: return self.get_id(media_type, "tvdb_id", id_from, tvdb_id) | ||
elif anidb_id: return self.get_id(media_type, "anidb_id", id_from, anidb_id) | ||
elif mal_id: return self.get_id(media_type, "mal_id", id_from, mal_id) | ||
else: return None, None | ||
|
||
def get_id(self, media_type, from_id, to_id, key): | ||
id_to_return = None | ||
expired = None | ||
with sqlite3.connect(self.cache_path) as connection: | ||
connection.row_factory = sqlite3.Row | ||
with closing(connection.cursor()) as cursor: | ||
cursor.execute("SELECT * FROM guids WHERE {} = ? AND media_type = ?".format(from_id), (key, media_type)) | ||
row = cursor.fetchone() | ||
if row and row[to_id]: | ||
datetime_object = datetime.strptime(row["expiration_date"], "%Y-%m-%d") | ||
time_between_insertion = datetime.now() - datetime_object | ||
id_to_return = int(row[to_id]) | ||
expired = time_between_insertion.days > self.expiration | ||
return id_to_return, expired | ||
|
||
def update_guid(self, media_type, plex_guid, tmdb_id, imdb_id, tvdb_id, anidb_id, mal_id, expired): | ||
expiration_date = datetime.now() if expired is True else (datetime.now() - timedelta(days=random.randint(1, self.expiration))) | ||
with sqlite3.connect(self.cache_path) as connection: | ||
connection.row_factory = sqlite3.Row | ||
with closing(connection.cursor()) as cursor: | ||
cursor.execute("INSERT OR IGNORE INTO guids(plex_guid) VALUES(?)", (plex_guid,)) | ||
cursor.execute( | ||
"""UPDATE guids SET | ||
tmdb_id = ?, | ||
imdb_id = ?, | ||
tvdb_id = ?, | ||
anidb_id = ?, | ||
mal_id = ?, | ||
expiration_date = ?, | ||
media_type = ? | ||
WHERE plex_guid = ?""", (tmdb_id, imdb_id, tvdb_id, anidb_id, mal_id, expiration_date.strftime("%Y-%m-%d"), media_type, plex_guid)) | ||
if imdb_id and (tmdb_id or tvdb_id): | ||
cursor.execute("INSERT OR IGNORE INTO imdb_map(imdb_id) VALUES(?)", (imdb_id,)) | ||
cursor.execute("UPDATE imdb_map SET t_id = ?, expiration_date = ?, media_type = ? WHERE imdb_id = ?", (tmdb_id if media_type == "movie" else tvdb_id, expiration_date.strftime("%Y-%m-%d"), media_type, imdb_id)) | ||
|
||
def get_tmdb_from_imdb(self, imdb_id): return self.query_imdb_map("movie", imdb_id) | ||
def get_tvdb_from_imdb(self, imdb_id): return self.query_imdb_map("show", imdb_id) | ||
def query_imdb_map(self, media_type, imdb_id): | ||
id_to_return = None | ||
expired = None | ||
with sqlite3.connect(self.cache_path) as connection: | ||
connection.row_factory = sqlite3.Row | ||
with closing(connection.cursor()) as cursor: | ||
cursor.execute("SELECT * FROM imdb_map WHERE imdb_id = ? AND media_type = ?", (imdb_id, media_type)) | ||
row = cursor.fetchone() | ||
if row and row["t_id"]: | ||
datetime_object = datetime.strptime(row["expiration_date"], "%Y-%m-%d") | ||
time_between_insertion = datetime.now() - datetime_object | ||
id_to_return = int(row["t_id"]) | ||
expired = time_between_insertion.days > self.expiration | ||
return id_to_return, expired | ||
|
||
def update_imdb(self, media_type, expired, imdb_id, t_id): | ||
expiration_date = datetime.now() if expired is True else (datetime.now() - timedelta(days=random.randint(1, self.expiration))) | ||
with sqlite3.connect(self.cache_path) as connection: | ||
connection.row_factory = sqlite3.Row | ||
with closing(connection.cursor()) as cursor: | ||
cursor.execute("INSERT OR IGNORE INTO imdb_map(imdb_id) VALUES(?)", (imdb_id,)) | ||
cursor.execute("UPDATE imdb_map SET t_id = ?, expiration_date = ?, media_type = ? WHERE imdb_id = ?", (t_id, expiration_date.strftime("%Y-%m-%d"), media_type, imdb_id)) |
Oops, something went wrong.