diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..689d5bfd --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +**/dist +**/build +*.spec +**/__pycache__ +/.vscode +**/log +README.md +LICENSE +.gitignore +.git diff --git a/.gitignore b/.gitignore index b6e47617..e8f099c1 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,10 @@ __pycache__/ # Distribution / packaging .Python +/modules/test.py +logs/ +config/* +!config/*.template build/ develop-eggs/ dist/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..857f16bb --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md index 496d9727..315e0840 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/config/Movies.yml.template b/config/Movies.yml.template new file mode 100644 index 00000000..22ca3b84 --- /dev/null +++ b/config/Movies.yml.template @@ -0,0 +1,1418 @@ +collections: + +###################################################### +# Chart Collections # +###################################################### + + Plex Popular: + tautulli_popular: + list_days: 30 + list_size: 20 + list_buffer: 20 + tautulli_watched: + list_days: 30 + list_size: 20 + list_buffer: 20 + sort_title: +++_1Plex Popular + sync_mode: sync + summary: Movies Popular on Plex + collection_order: alpha + Trending: + trakt_trending: 40 + tmdb_trending_daily: 40 + tmdb_trending_weekly: 40 + sort_title: +++_3Trending + sync_mode: sync + summary: Movies Trending across the internet + collection_order: alpha + Popular: + tmdb_popular: 40 + imdb_list: + url: https://www.imdb.com/search/title/?title_type=feature,tv_movie,documentary,short + limit: 40 + sort_title: +++_4Popular + sync_mode: sync + summary: Popular Movies across the internet + collection_order: alpha + Top Rated: + imdb_list: https://www.imdb.com/search/title/?groups=top_250&count=250 + tmdb_top_rated: 250 + sort_title: +++_5Top Rated + sync_mode: sync + summary: Top Rated Movies across the internet + collection_order: alpha + Best of 2014: + trakt_list: https://trakt.tv/users/lish408/lists/rotten-tomatoes-best-of-2014 + sort_title: +++_Best of 2014 + sync_mode: sync + summary: Rotten Tomatoes Best Movies of 2014 + collection_order: release + Best of 2015: + trakt_list: https://trakt.tv/users/lish408/lists/rotten-tomatoes-best-of-2015 + sort_title: +++_Best of 2015 + sync_mode: sync + summary: Rotten Tomatoes Best Movies of 2015 + collection_order: release + Best of 2016: + trakt_list: https://trakt.tv/users/lish408/lists/rotten-tomatoes-best-of-2016 + sort_title: +++_Best of 2016 + sync_mode: sync + summary: Rotten Tomatoes Best Movies of 2016 + collection_order: release + Best of 2017: + trakt_list: https://trakt.tv/users/lish408/lists/rotten-tomatoes-best-of-2017 + sort_title: +++_Best of 2017 + sync_mode: sync + summary: Rotten Tomatoes Best Movies of 2017 + collection_order: release + Best of 2018: + trakt_list: https://trakt.tv/users/lish408/lists/rotten-tomatoes-best-of-2018 + sort_title: +++_Best of 2018 + sync_mode: sync + summary: Rotten Tomatoes Best Movies of 2018 + collection_order: release + Best of 2019: + trakt_list: https://trakt.tv/users/lish408/lists/rotten-tomatoes-best-of-2019 + sort_title: +++_Best of 2019 + sync_mode: sync + summary: Rotten Tomatoes Best Movies of 2019 + collection_order: release + Best of 2020: + trakt_list: https://trakt.tv/users/lish408/lists/rotten-tomatoes-best-of-2020 + sort_title: +++_Best of 2020 + sync_mode: sync + summary: Rotten Tomatoes Best Movies of 2020 + collection_order: release + + +###################################################### +# Genre Collections # +###################################################### + + Action: + imdb_list: + - url: https://www.imdb.com/search/title/?title_type=feature&release_date=1990-01-01,&user_rating=5.0,10.0&num_votes=100000,&genres=action + limit: 100 + - url: https://www.imdb.com/search/title/?title_type=feature&release_date=1990-01-01,&user_rating=5.0,10.0&num_votes=100000,&genres=action&sort=user_rating,desc + limit: 100 + sort_title: ++_Action + sync_mode: sync + summary: Action film is a genre wherein physical action takes precedence in the storytelling. The film will often have continuous motion and action including physical stunts, chases, fights, battles, and races. The story usually revolves around a hero that has a goal, but is facing incredible odds to obtain it. + collection_order: alpha + Adventure: + imdb_list: + - url: https://www.imdb.com/search/title/?title_type=feature&release_date=1990-01-01,&user_rating=5.0,10.0&num_votes=100000,&genres=adventure + limit: 100 + - url: https://www.imdb.com/search/title/?title_type=feature&release_date=1990-01-01,&user_rating=5.0,10.0&num_votes=100000,&genres=adventure&sort=user_rating,desc + limit: 100 + sort_title: ++_Adventure + sync_mode: sync + summary: Adventure film is a genre that revolves around the conquests and explorations of a protagonist. The purpose of the conquest can be to retrieve a person or treasure, but often the main focus is simply the pursuit of the unknown. These films generally take place in exotic locations and play on historical myths. Adventure films incorporate suspenseful puzzles and intricate obstacles that the protagonist must overcome in order to achieve the end goal. + collection_order: alpha + Animation: + imdb_list: + - url: https://www.imdb.com/search/title/?title_type=feature&release_date=1990-01-01,&user_rating=5.0,10.0&num_votes=100000,&genres=animation + limit: 100 + - url: https://www.imdb.com/search/title/?title_type=feature&release_date=1990-01-01,&user_rating=5.0,10.0&num_votes=100000,&genres=animation&sort=user_rating,desc + limit: 100 + sort_title: ++_Animation + sync_mode: sync + summary: Animated film is a collection of illustrations that are photographed frame-by-frame and then played in a quick succession. Since its inception, animation has had a creative and imaginative tendency. Being able to bring animals and objects to life, this genre has catered towards fairy tales and children’s stories. However, animation has long been a genre enjoyed by all ages. As of recent, there has even been an influx of animation geared towards adults. Animation is commonly thought of as a technique, thus it’s ability to span over many different genres. + collection_order: alpha + Christmas: + trakt_list: + - https://trakt.tv/users/movistapp/lists/christmas-movies + - https://trakt.tv/users/2borno2b/lists/christmas-movies-extravanganza + imdb_list: + - https://www.imdb.com/list/ls025976544/ + - https://www.imdb.com/list/ls003863000/ + - https://www.imdb.com/list/ls027454200/ + - https://www.imdb.com/list/ls027886673/ + - https://www.imdb.com/list/ls097998599/ + sort_title: ++_Christmas + sync_mode: sync + summary: Christmas film is a genre that revolves around the plot involving Christmas. + collection_order: alpha + Comedy: + imdb_list: + - url: https://www.imdb.com/search/title/?title_type=feature&release_date=1990-01-01,&user_rating=5.0,10.0&num_votes=100000,&genres=comedy + limit: 100 + - url: https://www.imdb.com/search/title/?title_type=feature&release_date=1990-01-01,&user_rating=5.0,10.0&num_votes=100000,&genres=comedy&sort=user_rating,desc + limit: 100 + sort_title: ++_Comedy + sync_mode: sync + summary: Comedy is a genre of film that uses humor as a driving force. The aim of a comedy film is to illicit laughter from the audience through entertaining stories and characters. Although the comedy film may take on some serious material, most have a happy ending. Comedy film has the tendency to become a hybrid sub-genre because humor can be incorporated into many other genres. Comedies are more likely than other films to fall back on the success and popularity of an individual star. + collection_order: alpha + Crime: + imdb_list: + - url: https://www.imdb.com/search/title/?title_type=feature&release_date=1990-01-01,&user_rating=5.0,10.0&num_votes=100000,&genres=crime + limit: 100 + - url: https://www.imdb.com/search/title/?title_type=feature&release_date=1990-01-01,&user_rating=5.0,10.0&num_votes=100000,&genres=crime&sort=user_rating,desc + limit: 100 + sort_title: ++_Crime + sync_mode: sync + summary: Crime film is a genre that revolves around the action of a criminal mastermind. A Crime film will often revolve around the criminal himself, chronicling his rise and fall. Some Crime films will have a storyline that follows the criminal's victim, yet others follow the person in pursuit of the criminal. This genre tends to be fast paced with an air of mystery – this mystery can come from the plot or from the characters themselves. + collection_order: alpha + Documentary: + imdb_list: + - url: https://www.imdb.com/search/title/?title_type=feature&release_date=1990-01-01,&user_rating=5.0,10.0&num_votes=100000,&genres=documentary + limit: 100 + - url: https://www.imdb.com/search/title/?title_type=feature&release_date=1990-01-01,&user_rating=5.0,10.0&num_votes=100000,&genres=documentary&sort=user_rating,desc + limit: 100 + sort_title: ++_Documentary + sync_mode: sync + summary: Documentary film is a non-fiction genre intended to document reality primarily for the purposes of instruction, education, or maintaining a historical record. + collection_order: alpha + Drama: + imdb_list: + - url: https://www.imdb.com/search/title/?title_type=feature&release_date=1990-01-01,&user_rating=5.0,10.0&num_votes=100000,&genres=drama + limit: 100 + - url: https://www.imdb.com/search/title/?title_type=feature&release_date=1990-01-01,&user_rating=5.0,10.0&num_votes=100000,&genres=drama&sort=user_rating,desc + limit: 100 + sort_title: ++_Drama + sync_mode: sync + summary: Drama film is a genre that relies on the emotional and relational development of realistic characters. While Drama film relies heavily on this kind of development, dramatic themes play a large role in the plot as well. Often, these dramatic themes are taken from intense, real life issues. Whether heroes or heroines are facing a conflict from the outside or a conflict within themselves, Drama film aims to tell an honest story of human struggles. + collection_order: alpha + Family: + imdb_list: + - url: https://www.imdb.com/search/title/?title_type=feature&release_date=1990-01-01,&user_rating=5.0,10.0&num_votes=100000,&genres=family + limit: 100 + - url: https://www.imdb.com/search/title/?title_type=feature&release_date=1990-01-01,&user_rating=5.0,10.0&num_votes=100000,&genres=family&sort=user_rating,desc + limit: 100 + sort_title: ++_Family + sync_mode: sync + summary: Family film is a genre that is contains appropriate content for younger viewers. Family film aims to appeal not only to children, but to a wide range of ages. While the storyline may appeal to a younger audience, there are components of the film that are geared towards adults- such as witty jokes and humor. This genre may fall into many other genres, including comedy, adventure, fantasy, and animated film. + collection_order: alpha + Fantasy: + imdb_list: + - url: https://www.imdb.com/search/title/?title_type=feature&release_date=1990-01-01,&user_rating=5.0,10.0&num_votes=100000,&genres=fantasy + limit: 100 + - url: https://www.imdb.com/search/title/?title_type=feature&release_date=1990-01-01,&user_rating=5.0,10.0&num_votes=100000,&genres=fantasy&sort=user_rating,desc + limit: 100 + sort_title: ++_Fantasy + sync_mode: sync + summary: Fantasy film is a genre that incorporates imaginative and fantastic themes. These themes usually involve magic, supernatural events, or fantasy worlds. Although it is its own distinct genre, these films can overlap into the horror and science fiction genres. Unlike science fiction, a fantasy film does not need to be rooted in fact. This element allows the audience to be transported into a new and unique world. Often, these films center on an ordinary hero in an extraordinary situation. + collection_order: alpha + Gangster: + imdb_list: + - https://www.imdb.com/list/ls026270180/ + - https://www.imdb.com/list/ls000093502/ + sort_title: ++_Gangster + sync_mode: sync + summary: Gangster film is a sub-genre of crime films that center on organized crime or the mafia. Often the plot revolves around the rise and fall of an organized crime leader. Many Gangster films explore the destructive nature of organized crime, while others attempt to show the humanity of the individual characters. + collection_order: alpha + Halloween.: + trakt_list: + - https://trakt.tv/users/kairbear08/lists/halloween + - https://trakt.tv/users/mybicycle/lists/halloween + - https://trakt.tv/users/jayinftl/lists/halloween + - https://trakt.tv/users/roswellgeek/lists/halloween + sort_title: ++_Halloween + sync_mode: sync + name_mapping: Halloween (Season) + summary: Halloween film is a genre that revolves around the plot involving Halloween. + collection_order: alpha + History: + imdb_list: + - url: https://www.imdb.com/search/title/?title_type=feature&release_date=1990-01-01,&user_rating=5.0,10.0&num_votes=100000,&genres=history + limit: 100 + - url: https://www.imdb.com/search/title/?title_type=feature&release_date=1990-01-01,&user_rating=5.0,10.0&num_votes=100000,&genres=history&sort=user_rating,desc + limit: 100 + sort_title: ++_History + sync_mode: sync + summary: History film is a genre that takes historical events and people and interprets them in a larger scale. Historical accuracy is not the main focus, but rather the telling of a grandiose story. The drama of an History film is often accentuated by a sweeping musical score, lavish costumes, and high production value. + collection_order: alpha + Horror: + imdb_list: + - url: https://www.imdb.com/search/title/?title_type=feature&release_date=1990-01-01,&user_rating=5.0,10.0&num_votes=100000,&genres=horror + limit: 100 + - url: https://www.imdb.com/search/title/?title_type=feature&release_date=1990-01-01,&user_rating=5.0,10.0&num_votes=100000,&genres=horror&sort=user_rating,desc + limit: 100 + sort_title: ++_Horror + sync_mode: sync + summary: Horror film is a genre that aims to create a sense of fear, panic, alarm, and dread for the audience. These films are often unsettling and rely on scaring the audience through a portrayal of their worst fears and nightmares. Horror films usually center on the arrival of an evil force, person, or event. Many Horror films include mythical creatures such as ghosts, vampires, and zombies. Traditionally, Horror films incorporate a large amount of violence and gore into the plot. Though it has its own style, Horror film often overlaps into Fantasy, Thriller, and Science-Fiction genres. + collection_order: alpha + LGBTQ+: + imdb_list: https://www.imdb.com/list/ls062688328/ + sort_title: ++_LGBTQ+ + sync_mode: sync + summary: LGBTQ+ film is a genre of films where the characters decpict lesbian, gay, bisexual, transgender, queer and intersex people. + collection_order: alpha + Martial Arts: + imdb_list: + - https://www.imdb.com/list/ls000099643/ + - https://www.imdb.com/list/ls068611186/ + - https://www.imdb.com/list/ls068378513/ + - https://www.imdb.com/list/ls090404120/ + sort_title: ++_Martial Arts + sync_mode: sync + summary: Martial Arts film is a sub-genre of action films that feature numerous martial arts combat between characters. These combats are usually the films' primary appeal and entertainment value, and often are a method of storytelling and character expression and development. Martial Arts are frequently featured in training scenes and other sequences in addition to fights. Martial Arts films commonly include other types of action, such as hand-to-hand combat, stuntwork, chases, and gunfights. + collection_order: alpha + Music: + imdb_list: + - url: https://www.imdb.com/search/title/?title_type=feature&release_date=1990-01-01,&user_rating=5.0,10.0&num_votes=100000,&genres=music + limit: 100 + - url: https://www.imdb.com/search/title/?title_type=feature&release_date=1990-01-01,&user_rating=5.0,10.0&num_votes=100000,&genres=music&sort=user_rating,desc + limit: 100 + sort_title: ++_Music + sync_mode: sync + summary: Music film is genre that revolves around music being an integral part of the characters lives. + collection_order: alpha + Musical: + imdb_list: + - url: https://www.imdb.com/search/title/?title_type=feature&release_date=1990-01-01,&user_rating=5.0,10.0&num_votes=100000,&genres=musical + limit: 100 + - url: https://www.imdb.com/search/title/?title_type=feature&release_date=1990-01-01,&user_rating=5.0,10.0&num_votes=100000,&genres=musical&sort=user_rating,desc + limit: 100 + sort_title: ++_Musical + sync_mode: sync + summary: A Musical interweaves vocal and dance performances into the narrative of the film. The songs of a film can either be used to further the story or simply enhance the experience of the audience. These films are often done on a grand scale and incorporate lavish costumes and sets. Traditional musicals center on a well-known star, famous for their dancing or singing skills (i.e. Fred Astaire, Gene Kelly, Judy Garland). These films explore concepts such are love and success, allowing the audience to escape from reality. + collection_order: alpha + Mystery: + imdb_list: + - url: https://www.imdb.com/search/title/?title_type=feature&release_date=1990-01-01,&user_rating=5.0,10.0&num_votes=100000,&genres=mystery + limit: 100 + - url: https://www.imdb.com/search/title/?title_type=feature&release_date=1990-01-01,&user_rating=5.0,10.0&num_votes=100000,&genres=mystery&sort=user_rating,desc + limit: 100 + sort_title: ++_Mystery + sync_mode: sync + summary: A Mystery film centers on a person of authority, usually a detective, that is trying to solve a mysterious crime. The main protagonist uses clues, investigation, and logical reasoning. The biggest element in these films is a sense of “whodunit” suspense, usually created through visual cues and unusual plot twists. + collection_order: alpha + Pandemic: + imdb_list: https://www.imdb.com/list/ls092321048/ + sort_title: ++_Pandemic + sync_mode: sync + summary: A Pandemic film resolves around widespread viruses, plagues, and diseases. + collection_order: alpha + Romance: + imdb_list: + - url: https://www.imdb.com/search/title/?title_type=feature&release_date=1990-01-01,&user_rating=5.0,10.0&num_votes=100000,&genres=romance + limit: 100 + - url: https://www.imdb.com/search/title/?title_type=feature&release_date=1990-01-01,&user_rating=5.0,10.0&num_votes=100000,&genres=romance&sort=user_rating,desc + limit: 100 + sort_title: ++_Romance + sync_mode: sync + summary: "Romance film can be defined as a genre wherein the plot revolves around the love between two protagonists. This genre usually has a theme that explores an issue within love, including but not limited to: love at first sight, forbidden love, love triangles, and sacrificial love. The tone of Romance film can vary greatly. Whether the end is happy or tragic, Romance film aims to evoke strong emotions in the audience." + collection_order: alpha + Romantic Comedy: + imdb_list: + - url: https://www.imdb.com/search/title/?title_type=feature&release_date=1990-01-01,&user_rating=5.0,10.0&num_votes=100000,&genres=romance,comedy + limit: 100 + - url: https://www.imdb.com/search/title/?title_type=feature&release_date=1990-01-01,&user_rating=5.0,10.0&num_votes=100000,&genres=romance,comedy&sort=user_rating,desc + limit: 100 + sort_title: ++_Romantic Comedy + sync_mode: sync + summary: Romantic Comedy is a genre that attempts to catch the viewer’s heart with the combination of love and humor. This sub-genre is light-hearted and usually places the two protagonists in humorus situation. Romantic-Comedy film revolves around a romantic ideal, such as true love. In the end, the ideal triumphs over the situation or obstacle, thus creating a happy ending. + collection_order: alpha + filters: + genre: Comedy + Romantic Drama: + imdb_list: + - url: https://www.imdb.com/search/title/?title_type=feature&release_date=1990-01-01,&user_rating=5.0,10.0&num_votes=100000,&genres=romance,drama + limit: 100 + - url: https://www.imdb.com/search/title/?title_type=feature&release_date=1990-01-01,&user_rating=5.0,10.0&num_votes=100000,&genres=romance,drama&sort=user_rating,desc + limit: 100 + sort_title: ++_Romantic Drama + sync_mode: sync + summary: Romantic Drama film is a genre that explores the complex side of love. The plot usually centers around an obstacle that is preventing love between two people. The obstacles in Romantic Drama film can range from a family's disapproval, to forbidden love, to one's own psychological restraints. Many Romantic Dramas end with the lovers separating because of the enormity of the obstacle, the realization of incompatibility, or simply because of fate. + collection_order: alpha + filters: + genre: Drama + Sci-Fi: + imdb_list: + - url: https://www.imdb.com/search/title/?title_type=feature&release_date=1990-01-01,&user_rating=5.0,10.0&num_votes=100000,&genres=sci-fi + limit: 100 + - url: https://www.imdb.com/search/title/?title_type=feature&release_date=1990-01-01,&user_rating=5.0,10.0&num_votes=100000,&genres=sci-fi&sort=user_rating,desc + limit: 100 + sort_title: ++_Sci-Fi + sync_mode: sync + summary: Science Fiction (Sci-Fi) film is a genre that incorporates hypothetical, science-based themes into the plot of the film. Often, this genre incorporates futuristic elements and technologies to explore social, political, and philosophical issues. The film itself is usually set in the future, either on earth or in space. Traditionally, a Science Fiction film will incorporate heroes, villains, unexplored locations, fantastical quests, and advanced technology. + collection_order: alpha + Sports: + imdb_list: + - url: https://www.imdb.com/search/title/?title_type=feature&release_date=1990-01-01,&user_rating=5.0,10.0&num_votes=100000,&genres=sport + limit: 100 + - url: https://www.imdb.com/search/title/?title_type=feature&release_date=1990-01-01,&user_rating=5.0,10.0&num_votes=100000,&genres=sport&sort=user_rating,desc + limit: 100 + sort_title: ++_Sport + sync_mode: sync + summary: A Sport Film revolves around a sport setting, event, or an athlete. Often, these films will center on a single sporting event that carries significant importance. Sports films traditionally have a simple plot that builds up to the significant sporting event. This genre is known for incorporating film techniques to build anticipation and intensity. Sport films have a large range of sub-genres, from comedies to dramas, and are more likely than other genres to be based true-life events. + collection_order: alpha + Stand Up Comedy: + imdb_list: + - https://www.imdb.com/list/ls070221411/ + - https://www.imdb.com/list/ls086584751/ + - https://www.imdb.com/list/ls086022668/ + - https://www.imdb.com/list/ls049792208/ + sort_title: ++_Stand Up Comedy + sync_mode: sync + summary: Stand-up comedy is a comedic style in which a comedian performs in front of a live audience, speaking directly to them through a microphone. Comedians give the illusion that they are dialoguing, but in actuality, they are monologuing a grouping of humorous stories, jokes and one-liners, typically called a shtick, routine, act, or set. Some stand-up comedians use props, music or magic tricks to enhance their acts. Stand-up comedians perform quasi-autobiographical and fictionalized extensions of their offstage selves. + collection_order: alpha + Sword & Sorcery: + imdb_list: https://www.imdb.com/list/ls022909805 + sort_title: ++_Sword & Sorcery + sync_mode: sync + summary: Sword and Sorcery film is a sub-genre of Fantasy that tend to be more plot-driven. These films rely on heavy action and battle scenes. Common themes in Sword and Sorcery films include a rescue mission, saving a princess, and battling a fantastical monster. The worlds and characters in these films are often much less developed than in other fantasy sub-genres. Of all the Fantasy sub-genres, Sword and Sorcery is most likely to be geared towards a younger audience, as many of these films are animated. + collection_order: alpha + Thriller: + imdb_list: + - url: https://www.imdb.com/search/title/?title_type=feature&release_date=1990-01-01,&user_rating=5.0,10.0&num_votes=100000,&genres=thriller + limit: 100 + - url: https://www.imdb.com/search/title/?title_type=feature&release_date=1990-01-01,&user_rating=5.0,10.0&num_votes=100000,&genres=thriller&sort=user_rating,desc + limit: 100 + sort_title: ++_Thriller + sync_mode: sync + summary: Thriller Film is a genre that revolves around anticipation and suspense. The aim for Thrillers is to keep the audience alert and on the edge of their seats. The protagonist in these films is set against a problem – an escape, a mission, or a mystery. No matter what sub-genre a Thriller film falls into, it will emphasize the danger that the protagonist faces. The tension with the main problem is built on throughout the film and leads to a highly stressful climax. + collection_order: alpha + War: + imdb_list: + - url: https://www.imdb.com/search/title/?title_type=feature&release_date=1990-01-01,&user_rating=5.0,10.0&num_votes=100000,&genres=war + limit: 100 + - url: https://www.imdb.com/search/title/?title_type=feature&release_date=1990-01-01,&user_rating=5.0,10.0&num_votes=100000,&genres=war&sort=user_rating,desc + limit: 100 + sort_title: ++_War + sync_mode: sync + summary: War Film is a genre that looks at the reality of war on a grand scale. They often focus on landmark battles as well as political issues within war. This genre usually focuses on a main character and his team of support, giving the audience an inside look into the gritty reality of war. + collection_order: alpha + Western: + imdb_list: + - url: https://www.imdb.com/search/title/?title_type=feature&release_date=1990-01-01,&user_rating=5.0,10.0&num_votes=100000,&genres=western + limit: 100 + - url: https://www.imdb.com/search/title/?title_type=feature&release_date=1990-01-01,&user_rating=5.0,10.0&num_votes=100000,&genres=western&sort=user_rating,desc + limit: 100 + sort_title: ++_Western + sync_mode: sync + summary: "Western Film is a genre that revolves around stories primarily set in the late 19th century in the American Old West. Most Westerns are set between the American Civil War (1865) and the early 1900s. Common themes within Western Film include: the conquest of the wild west, the cultural separation of the East and the West, the West’s resistance to modern change, the conflict between Cowboys and Indians, outlaws, and treasure/gold hunting. American Western Film usually revolves around a stoic hero and emphasizes the importance of honor and sacrifice." + collection_order: alpha + +###################################################### +# TMDB Collections # +###################################################### + 101 Dalmatians: + tmdb_collection_details: 100693 + schedule: monthly(2) + 101 Dalmatians (Live-Action): + tmdb_collection_details: 124916 + schedule: monthly(2) + 28 Days/Weeks Later: + tmdb_collection_details: 1565 + name_mapping: 28 Days-Weeks Later + schedule: monthly(2) + 3 Ninja: + tmdb_collection_details: 71458 + schedule: monthly(2) + "300": + tmdb_collection_details: 125570 + schedule: monthly(19) + Addams Family: + tmdb_collection_details: 11716 + schedule: monthly(2) + Air Bud: + tmdb_collection_details: 97445 + schedule: monthly(2) + Aladdin: + tmdb_collection_details: 86027 + schedule: monthly(2) + Alice in Wonderland: + tmdb_collection_details: 261307 + schedule: monthly(2) + Alien: + tmdb_collection_details: 8091, 135416 + schedule: monthly(2) + All Dogs Go to Heaven: + tmdb_collection_details: 140910 + schedule: monthly(2) + Almighty: + tmdb_collection_details: 124949 + schedule: monthly(2) + Alvin and the Chipmunks: + tmdb_collection_details: 167613 + schedule: monthly(2) + The Amazing Spider-Man: + tmdb_collection_details: 125574 + schedule: monthly(2) + American Pie: + tmdb_collection_details: 2806, 298820 + schedule: monthly(2) + American Psycho: + tmdb_collection_details: 86105 + schedule: monthly(2) + An American Tail: + tmdb_collection_details: 8783 + schedule: monthly(2) + Anaconda: + tmdb_collection_details: 105995 + tmdb_movie: 336560 + schedule: monthly(2) + Anchorman: + tmdb_collection_details: 93791 + schedule: monthly(2) + Angels in the ...: + tmdb_collection_details: 508334 + name_mapping: Angels in the + schedule: monthly(2) + The Angry Birds: + tmdb_collection_details: 531315 + schedule: monthly(8) + add_to_arr: true + Annabelle: + tmdb_collection_details: 402074 + schedule: monthly(2) + Ant-Man: + tmdb_collection_details: 422834 + schedule: monthly(2) + Appleseed: + tmdb_collection_details: 87800, 371526 + schedule: monthly(2) + Atlantis: + tmdb_collection_details: 100965 + schedule: monthly(2) + Attack on Titan: + tmdb_collection_details: 370411 + schedule: monthly(2) + Austin Powers: + tmdb_collection_details: 1006 + schedule: monthly(2) + The Avengers: + tmdb_collection_details: 86311 + schedule: monthly(2) + AVP: + tmdb_collection_details: 115762 + schedule: monthly(2) + Babe: + tmdb_collection_details: 9435 + schedule: monthly(2) + Back to the Future: + tmdb_collection_details: 264 + schedule: monthly(2) + Bad Boys: + tmdb_collection_details: 14890 + schedule: monthly(2) + Bad Moms: + tmdb_collection_details: 487376 + schedule: monthly(2) + Bad Santa: + tmdb_collection_details: 423173 + schedule: monthly(2) + Balto: + tmdb_collection_details: 117693 + schedule: monthly(2) + Bambi: + tmdb_collection_details: 87250 + schedule: monthly(2) + Barbershop: + tmdb_collection_details: 176097 + tmdb_movie: 14177 + schedule: monthly(2) + Batman: + tmdb_collection_details: 120794 + schedule: monthly(2) + Batman (Adam West) Animation: + tmdb_collection_details: 626517 + schedule: monthly(2) + Beauty and the Beast: + tmdb_collection_details: 153010 + schedule: monthly(2) + Bill & Ted's Most Excellent: + tmdb_collection_details: 91746 + schedule: monthly(2) + Black Water: + tmdb_collection_details: 730166 + schedule: monthly(2) + Blade: + tmdb_collection_details: 735 + schedule: monthly(2) + Blade Runner: + tmdb_collection_details: 422837 + schedule: monthly(2) + The Blues Brothers: + tmdb_collection_details: 112636 + schedule: monthly(2) + The Boondock Saints: + tmdb_collection_details: 87186 + schedule: monthly(2) + Borat Moviefilms: + tmdb_collection_details: 747168 + schedule: monthly(2) + The Bourne: + tmdb_collection_details: 31562 + schedule: monthly(2) + The Boy: + tmdb_collection_details: 666337 + schedule: monthly(2) + Bring It On: + tmdb_collection_details: 430186 + schedule: monthly(2) + Brother Bear: + tmdb_collection_details: 96472 + schedule: monthly(2) + The Buddies: + tmdb_collection_details: 91657 + schedule: monthly(2) + Captain America: + tmdb_collection_details: 131295 + schedule: monthly(2) + Carrie: + tmdb_collection_details: 257053 + schedule: monthly(2) + Cars: + tmdb_collection_details: 87118 + schedule: monthly(2) + Charlie Brown: + imdb_list: https://www.imdb.com/list/ls054850259/ + summary: Collection of Movies and TV Specials with the beloved Peanuts characters. + schedule: monthly(2) + Charlie's Angels: + tmdb_collection_details: 86029 + schedule: monthly(2) + Cheaper by the Dozen: + tmdb_collection_details: 114783 + schedule: monthly(2) + The Chronicles of Narnia: + tmdb_collection_details: 420 + schedule: monthly(2) + The Chronicles of Riddick: + tmdb_collection_details: 2794 + schedule: monthly(2) + Cinderella: + tmdb_collection_details: 55419 + schedule: monthly(2) + Cinderella Story: + tmdb_collection_details: 437451 + schedule: monthly(2) + City Slickers: + tmdb_collection_details: 150156 + schedule: monthly(2) + Clash of the Titans: + tmdb_collection_details: 86780 + schedule: monthly(2) + Clerks: + tmdb_collection_details: 182813 + schedule: monthly(2) + Cloudy with a Chance of Meatballs: + tmdb_collection_details: 177467 + schedule: monthly(2) + Cloverfield: + imdb_list: https://www.imdb.com/list/ls021933730/ + summary: Cloverfield is an American science fiction anthology film series and media franchise created and produced by J. J. Abrams consisting of three films, viral marketing websites linking the films together, and a tie-in manga to the first film titled Cloverfield/Kishin (2008), all set in a shared fictional universe referred to as the "Cloververse". The franchise as a whole deals with creatures from other dimensions attacking Earth throughout various decades, all as a repercussion of an experiment by an astronaut team aboard the Cloverfield Station in outer-space. Each film depicts the reality-altering effects of their study, which was meant to find a new energy source replacing the planet's depleted resources, only to open portals for assault from various beasts from deep space. + schedule: monthly(17) + The Conjuring: + tmdb_collection_details: 313086 + schedule: monthly(2) + Cornetto Trilogy: + imdb_list: https://www.imdb.com/list/ls068623110/ + summary: An anthology series of British comedic genre films directed by Edgar Wright, written by Wright and Simon Pegg, produced by Nira Park, and starring Pegg and Nick Frost. The trilogy consists of Shaun of the Dead (2004), Hot Fuzz (2007), and The World's End (2013). + schedule: monthly(2) + Creed: + tmdb_collection_details: 553717 + schedule: monthly(2) + Crocodile Dundee: + tmdb_collection_details: 9332 + schedule: monthly(2) + The Croods: + tmdb_collection_details: 464577 + schedule: monthly(2) + Crouching Tiger, Hidden Dragon: + tmdb_collection_details: 290973 + schedule: monthly(2) + Daddy's Home: + tmdb_collection_details: 473971 + schedule: monthly(2) + The Dark Knight: + tmdb_collection_details: 263 + schedule: monthly(2) + DC Super Hero Girls: + tmdb_collection_details: 477208, 557495 + schedule: monthly(2) + Deadpool: + tmdb_collection_details: 448150 + tmdb_movie: 567604 + schedule: monthly(2) + Death Note: + tmdb_collection_details: 102019 + schedule: monthly(2) + Death Race: + tmdb_collection_details: 86116 + schedule: monthly(2) + The Debt Collector: + tmdb_collection_details: 709271 + schedule: monthly(2) + Despicable Me: + tmdb_collection_details: 86066, 544669 + schedule: monthly(2) + Die Hard: + tmdb_collection_details: 1570 + schedule: monthly(2) + Dirty Harry: + tmdb_collection_details: 10456 + schedule: monthly(2) + Divergent: + tmdb_collection_details: 283579 + schedule: monthly(2) + A Dog's Purpose: + tmdb_collection_details: 591028 + schedule: monthly(2) + DragonHeart: + tmdb_collection_details: 169452 + schedule: monthly(2) + Dumb and Dumber: + tmdb_collection_details: 96665 + schedule: monthly(2) + Dungeons & Dragons: + tmdb_collection_details: 106498 + add_to_arr: true + schedule: monthly(7) + The Emperor's New Groove: + tmdb_collection_details: 178117 + schedule: monthly(2) + The Equalizer: + tmdb_collection_details: 523855 + schedule: monthly(2) + Escape From ...: + tmdb_collection_details: 115838 + name_mapping: Escape From + schedule: monthly(2) + Escape Plan: + tmdb_collection_details: 525891 + schedule: monthly(2) + Evangelion: + tmdb_collection_details: 210303 + summary: A Japanese animated film series and a retelling of the original Neon Genesis Evangelion anime television series, produced by Studio Khara. Hideaki Anno served as the writer and general manager of the project, with Kazuya Tsurumaki and Masayuki directing the films themselves. Yoshiyuki Sadamoto, Ikuto Yamashita and Shiro Sagisu returned to provide character designs, mechanical designs and music respectively. + schedule: monthly(2) + The Expendables: + tmdb_collection_details: 126125 + schedule: monthly(2) + Fantasia: + tmdb_collection_details: 55427 + schedule: monthly(2) + Fantastic Beasts: + tmdb_collection_details: 435259 + schedule: monthly(2) + Fantastic Four: + tmdb_collection_details: 9744 + schedule: monthly(2) + The Fast and the Furious: + tmdb_collection_details: 9485, 688042 + schedule: monthly(2) + Fifty Shades: + tmdb_collection_details: 344830 + schedule: monthly(2) + Final Destination: + tmdb_collection_details: 8864 + schedule: monthly(2) + Final Fantasy: + imdb_list: https://www.imdb.com/list/ls022264056/ + summary: A collection of films based off or closely associated with the Final Fantasy video games. + schedule: monthly(2) + Finding Nemo: + tmdb_collection_details: 137697 + schedule: monthly(2) + The Flintstones: + tmdb_collection_details: 351684 + schedule: monthly(2) + The Fox and the Hound: + tmdb_collection_details: 100970 + schedule: monthly(2) + Free Willy: + tmdb_collection_details: 9328 + schedule: monthly(2) + Friday: + tmdb_collection_details: 43563 + schedule: monthly(2) + Friday the 13th: + tmdb_collection_details: 9735 + tmdb_movie: 6466, 222724 + schedule: monthly(2) + Frozen: + tmdb_collection_details: 386382 + tmdb_movie: 326359, 460793 + schedule: monthly(2) + G.I. Joe: + tmdb_collection_details: 135468 + schedule: monthly(2) + Garfield: + tmdb_collection_details: 86115, 373918 + schedule: monthly(2) + George Carlin Stand Up: + imdb_list: https://www.imdb.com/list/ls070221411/ + summary: Collection of George Carlin's Stand Up Comedy HBO Specials + schedule: monthly(2) + George Lopez Stand Up: + imdb_list: https://www.imdb.com/list/ls086584751/ + summary: Collection of George Lopez's Stand Up Comedy Specials + schedule: monthly(2) + George of the Jungle: + tmdb_collection_details: 126221 + schedule: monthly(2) + Ghost in the Shell: + tmdb_collection_details: 23026 + schedule: monthly(2) + Ghost Rider: + tmdb_collection_details: 90306 + schedule: monthly(2) + Ghostbusters: + tmdb_collection_details: 2980 + tmdb_movie: 43074 + schedule: monthly(2) + The Girl - Millennium: + tmdb_collection_details: 575987 + schedule: monthly(2) + The Godfather: + tmdb_collection_details: 230 + schedule: monthly(2) + Godzilla (Showa): + tmdb_collection_details: 374509 + tmdb_movie: 18983 + schedule: monthly(2) + Godzilla (Heisei): + tmdb_collection_details: 374511 + tmdb_movie: 39256 + schedule: monthly(2) + Godzilla (Millennium): + tmdb_collection_details: 374512 + schedule: monthly(2) + Godzilla (MonsterVerse): + tmdb_collection_details: 535313 + tmdb_movie: 293167 + schedule: monthly(2) + Godzilla (Anime): + tmdb_collection_details: 535790 + schedule: monthly(2) + A Goofy Movie: + tmdb_collection_details: 410261 + schedule: monthly(2) + Goosebumps: + tmdb_collection_details: 508783 + schedule: monthly(19) + Guardians of the Galaxy: + tmdb_collection_details: 284433 + schedule: monthly(2) + Green Street Hooligans: + tmdb_collection_details: 152544 + schedule: monthly(2) + Grown Ups: + tmdb_collection_details: 180546 + schedule: monthly(2) + Halloween: + tmdb_collection_details: 91361, 126209 + schedule: monthly(2) + Halo: + tmdb_list_details: 7070832 + schedule: monthly(2) + The Hangover: + tmdb_collection_details: 86119 + schedule: monthly(2) + Hannibal Lecter: + tmdb_collection_details: 9743 + tmdb_movie: 11454 + schedule: monthly(2) + Happy Death Day: + tmdb_collection_details: 526380 + schedule: monthly(19) + Happy Feet: + tmdb_collection_details: 92012 + schedule: monthly(2) + Harold & Kumar: + tmdb_collection_details: 30663 + schedule: monthly(2) + Harry Potter: + tmdb_collection_details: 1241 + schedule: monthly(2) + ... Has Fallen: + tmdb_collection_details: 508783 + name_mapping: Has Fallen + schedule: monthly(2) + Hellboy: + tmdb_collection_details: 508783 + schedule: monthly(2) + Hellboy (Animated): + tmdb_collection_details: 123203 + schedule: monthly(2) + High School Musical: + tmdb_collection_details: 87253 + schedule: monthly(2) + Highlander: + tmdb_collection_details: 8050 + schedule: monthly(2) + The Hobbit: + tmdb_collection_details: 121938 + schedule: monthly(2) + Home Alone: + tmdb_collection_details: 9888 + schedule: monthly(2) + Honey, I Shrunk the Kids: + tmdb_collection_details: 72119 + schedule: monthly(2) + Horrible Bosses: + tmdb_collection_details: 280588 + schedule: monthly(2) + Hot Tub Time Machine: + tmdb_collection_details: 313576 + schedule: monthly(2) + Hotel Transylvania: + tmdb_collection_details: 185103 + schedule: monthly(2) + House of 1000 Corpses: + tmdb_collection_details: 105625 + schedule: monthly(2) + How to Train Your Dragon: + tmdb_collection_details: 89137 + schedule: monthly(2) + The Human Centipede: + tmdb_collection_details: 96671 + schedule: monthly(2) + The Hunchback of Notre Dame: + tmdb_collection_details: 97456 + schedule: monthly(2) + The Hunger Games: + tmdb_collection_details: 131635 + schedule: monthly(2) + The Huntsman: + tmdb_collection_details: 393379 + schedule: monthly(2) + Ice Age: + tmdb_collection_details: 8354 + tmdb_movie: 79218, 717095, 387893 + schedule: monthly(2) + The Incredibles: + tmdb_collection_details: 468222 + schedule: monthly(2) + Independence Day: + tmdb_collection_details: 304378 + schedule: monthly(2) + Indiana Jones: + tmdb_collection_details: 84 + schedule: monthly(2) + Ip Man: + tmdb_collection_details: 70068 + tmdb_movie: 658009, 643413, 450001, 751391, 44249, 182127, 44865 + collection_order: alpha + schedule: monthly(2) + Iron Man: + tmdb_collection_details: 131292 + schedule: monthly(2) + It: + tmdb_collection_details: 477962 + schedule: monthly(2) + James Bond: + tmdb_collection_details: 645 + schedule: monthly(2) + Jaws: + tmdb_collection_details: 2366 + schedule: monthly(2) + Jay and Silent Bob: + tmdb_collection_details: 726870 + schedule: monthly(2) + Jeff Dunham Stand Up: + imdb_list: https://www.imdb.com/list/ls086022668/ + summary: Collection of Jeff Dunham's Stand Up Comedy Specials + schedule: monthly(2) + John Wick: + tmdb_collection_details: 404609 + schedule: monthly(2) + Johnny Tsunami: + tmdb_collection_details: 394316 + schedule: monthly(2) + Jumanji: + tmdb_collection_details: 495527 + schedule: monthly(2) + Jump Street: + tmdb_collection_details: 212562 + schedule: monthly(2) + The Jungle Book: + tmdb_collection_details: 97459 + schedule: monthly(2) + Jurassic Park: + tmdb_collection_details: 328 + tmdb_movie: 630322 + schedule: monthly(2) + The Karate Kid: + tmdb_collection_details: 8580 + tmdb_movie: 38575 + schedule: monthly(2) + Kevin Hart Stand Up: + imdb_list: https://www.imdb.com/list/ls049792208/ + summary: Collection of Kevin Hart's Stand Up Comedy Specials + schedule: monthly(2) + Kick-Ass: + tmdb_collection_details: 179892 + schedule: monthly(2) + Kill Bill: + tmdb_collection_details: 2883 + schedule: monthly(2) + Kingsman: + tmdb_collection_details: 391860 + schedule: monthly(2) + Kung Fu Panda: + tmdb_collection_details: 77816 + schedule: monthly(2) + Lady and the Tramp: + tmdb_collection_details: 97460 + schedule: monthly(2) + Lake Placid: + tmdb_collection_details: 97768 + schedule: monthly(2) + The Land Before Time: + tmdb_collection_details: 19163 + schedule: monthly(2) + Legally Blonde: + tmdb_collection_details: 86024 + schedule: monthly(2) + LEGO DC Comics Super Heroes: + tmdb_collection_details: 386162 + schedule: monthly(2) + The Lego Movie: + tmdb_collection_details: 325470 + schedule: monthly(2) + Lego Star Wars: + tmdb_collection_details: 302331 + schedule: monthly(2) + Lethal Weapon: + tmdb_collection_details: 945 + schedule: monthly(2) + Lilo & Stitch: + tmdb_collection_details: 97461 + schedule: monthly(2) + The Lion King: + tmdb_collection_details: 94032 + schedule: monthly(2) + The Little Mermaid: + tmdb_collection_details: 33085 + schedule: monthly(2) + The Lord of the Rings: + tmdb_collection_details: 119 + schedule: monthly(2) + The Lord of the Rings (Animated): + tmdb_collection_details: 141290 + schedule: monthly(2) + Mad Max: + tmdb_collection_details: 8945 + schedule: monthly(2) + Madagascar: + tmdb_collection_details: 14740 + tmdb_movie: 161143, 25472, 270946 + schedule: monthly(2) + Maleficent: + tmdb_collection_details: 531331 + schedule: monthly(2) + Mall Cop: + tmdb_collection_details: 328372 + schedule: monthly(2) + The Man with No Name: + imdb_list: https://www.imdb.com/list/ls023916334/ + summary: An Italian film series consisting of three Spaghetti Western films directed by Sergio Leone. The films are titled A Fistful of Dollars (1964), For a Few Dollars More (1965) and The Good, the Bad and the Ugly (1966). The series has become known for establishing the Spaghetti Western genre, and inspiring the creation of many more Spaghetti Western films. The three films are consistently listed among the best rated Western films in history. + schedule: monthly(2) + Marvel Rising: + tmdb_collection_details: 627234 + schedule: monthly(2) + Marx Brothers: + imdb_list: https://www.imdb.com/list/ls068486735/ + summary: The Marx Brothers were an American family comedy act that was successful in vaudeville, on Broadway, and in motion pictures from 1905 to 1949. Five of the Marx Brothers' thirteen feature films were selected by the American Film Institute (AFI) as among the top 100 comedy films, with two of them, Duck Soup (1933) and A Night at the Opera (1935), in the top fifteen. They are widely considered by critics, scholars, and fans to be among the greatest and most influential comedians of the 20th century. + schedule: monthly(2) + Mary Poppins: + tmdb_collection_details: 527439 + schedule: monthly(2) + The Mask: + tmdb_collection_details: 43072 + schedule: monthly(2) + The Matrix: + tmdb_collection_details: 2344 + schedule: monthly(2) + Maya the Bee: + tmdb_collection_details: 522250 + schedule: monthly(2) + The Maze Runner: + tmdb_collection_details: 295130 + schedule: monthly(2) + Mean Girls: + tmdb_collection_details: 99606 + schedule: monthly(2) + Meet the Parents: + tmdb_collection_details: 51509 + schedule: monthly(2) + Men In Black: + tmdb_collection_details: 86055 + schedule: monthly(2) + The Mighty Ducks: + tmdb_collection_details: 10709 + schedule: monthly(2) + "Mission: Impossible": + tmdb_collection_details: 87359 + name_mapping: Mission Impossible + schedule: monthly(2) + Monsters, Inc.: + tmdb_collection_details: 137696 + schedule: monthly(2) + Monty Python: + imdb_list: https://www.imdb.com/list/ls072012494/ + summary: Monty Python is a British surreal comedy troupe who created sketch comedy television shows and movies + schedule: monthly(2) + add_to_arr: false + Mortal Kombat: + tmdb_collection_details: 9818 + tmdb_movie: 664767 + schedule: monthly(2) + Mothra: + tmdb_collection_details: 171732 + tmdb_movie: 39410 + schedule: monthly(2) + Mulan: + tmdb_collection_details: 87236 + schedule: monthly(2) + The Mummy: + tmdb_collection_details: 1733 + schedule: monthly(2) + The Muppet: + tmdb_collection_details: 256377 + schedule: monthly(2) + National Treasure: + tmdb_collection_details: 52984 + schedule: monthly(2) + Neighbors: + tmdb_collection_details: 400700 + schedule: monthly(2) + The Neverending Story: + tmdb_collection_details: 91430 + schedule: monthly(2) + Night at the Museum: + tmdb_collection_details: 85943 + schedule: monthly(2) + A Nightmare on Elm Street: + tmdb_collection_details: 8581 + tmdb_movie: 6466, 23437 + schedule: monthly(2) + Now You See Me: + tmdb_collection_details: 382685 + schedule: monthly(2) + Ocean's: + tmdb_collection_details: 304 + schedule: monthly(2) + Ong Bak: + tmdb_collection_details: 94589 + schedule: monthly(2) + Oz: + tmdb_collection_details: 627517 + tmdb_movie: 13155, 68728 + schedule: monthly(2) + Pacific Rim: + tmdb_collection_details: 363369 + schedule: monthly(2) + Paddington: + tmdb_collection_details: 488924 + schedule: monthly(2) + Parasyte: + tmdb_collection_details: 385386 + schedule: monthly(2) + Percy Jackson: + tmdb_collection_details: 179919 + schedule: monthly(2) + Pet Sematary: + tmdb_collection_details: 10789 + tmdb_movie: 157433 + schedule: monthly(2) + Peter Pan: + tmdb_collection_details: 55422 + schedule: monthly(2) + Pirates of the Caribbean: + tmdb_collection_details: 295 + schedule: monthly(2) + Pitch Perfect: + tmdb_collection_details: 306031 + schedule: monthly(2) + Planes: + tmdb_collection_details: 270252 + schedule: monthly(2) + Planet of the Apes: + tmdb_collection_details: 173710 + schedule: monthly(2) + Pocahontas: + tmdb_collection_details: 136214 + schedule: monthly(2) + Pokémon: + imdb_list: https://www.imdb.com/list/ls062687939/ + summary: Pokémon is a media franchise created by video game designer Satoshi Tajiri that centers on fictional creatures called Pokémon. As of 2020, there have been 23 animated films and one live action film. The first nineteen animated films are based on the anime television series of the same name, with the original film being remade into the 22nd. The 20th, 21st and 23rd animated films are set in an alternate continuity to the anime. + schedule: monthly(2) + Police Story: + tmdb_collection_details: 269098 + schedule: monthly(2) + Power Rangers: + tmdb_collection_details: 708816 + tmdb_movie: 305470, 306264 + schedule: monthly(2) + Predator: + tmdb_collection_details: 399 + schedule: monthly(2) + The Princess Diaries: + tmdb_collection_details: 107674 + schedule: monthly(2) + The Purge: + tmdb_collection_details: 256322 + schedule: monthly(2) + Quarantine: + tmdb_collection_details: 123932 + schedule: monthly(2) + Rambo: + tmdb_collection_details: 5039 + schedule: monthly(2) + Red Cliff: + tmdb_collection_details: 96677 + schedule: monthly(2) + Rent: + tmdb_list_details: 7072241 + schedule: monthly(2) + The Rescuers: + tmdb_collection_details: 57971 + schedule: monthly(2) + Resident Evil: + tmdb_collection_details: 17255 + schedule: monthly(2) + "Resident Evil: Biohazard": + tmdb_collection_details: 133352 + name_mapping: Resident Evil Biohazard + schedule: monthly(2) + Ride Along: + tmdb_collection_details: 376650 + schedule: monthly(2) + Rio: + tmdb_collection_details: 229932 + schedule: monthly(2) + Robert Langdon: + tmdb_collection_details: 115776 + schedule: monthly(2) + RoboCop: + tmdb_collection_details: 5547 + schedule: monthly(2) + Rocky: + tmdb_collection_details: 1575 + schedule: monthly(2) + Rugrats: + tmdb_collection_details: 57129 + schedule: monthly(2) + Rurouni Kenshin: + tmdb_collection_details: 247028 + schedule: monthly(2) + Rush Hour: + tmdb_collection_details: 90863 + schedule: monthly(2) + The Sandlot: + tmdb_collection_details: 87214 + schedule: monthly(2) + The Santa Clause: + tmdb_collection_details: 53159 + schedule: monthly(2) + Santa Paws: + tmdb_collection_details: 469648 + schedule: monthly(2) + Saw: + tmdb_collection_details: 656 + schedule: monthly(2) + Scary Movie: + tmdb_collection_details: 4246 + schedule: monthly(2) + The Scorpion King: + tmdb_collection_details: 116669 + schedule: monthly(2) + Scream: + tmdb_collection_details: 2602 + schedule: monthly(2) + The Secret Life of Pets: + tmdb_collection_details: 427084 + schedule: monthly(2) + Shaft: + tmdb_collection_details: 495, 608103 + schedule: monthly(2) + Shanghai Noon: + tmdb_collection_details: 59567 + schedule: monthly(2) + Sharknado: + tmdb_collection_details: 286023 + schedule: monthly(2) + Sherlock Holmes: + tmdb_collection_details: 102322 + schedule: monthly(2) + The Shining: + tmdb_collection_details: 530064 + schedule: monthly(2) + Shrek: + tmdb_collection_details: 2150 + schedule: monthly(2) + Silent Hill: + tmdb_collection_details: 64748 + schedule: monthly(2) + Slap Shot: + tmdb_collection_details: 261526 + schedule: monthly(2) + The Smurfs: + tmdb_collection_details: 134897 + schedule: monthly(2) + Spider-Man (Avengers): + tmdb_collection_details: 531241 + schedule: monthly(2) + Spider-Man (Original): + tmdb_collection_details: 556 + schedule: monthly(2) + Spy Kids: + tmdb_collection_details: 86486 + schedule: monthly(2) + "Star Trek: Alternate Reality": + tmdb_collection_details: 115575 + name_mapping: Star Trek Alternate Reality + schedule: monthly(2) + "Star Trek: The Next Generation": + tmdb_collection_details: 115570 + name_mapping: Star Trek The Next Generation + schedule: monthly(2) + "Star Trek: The Original Series": + tmdb_collection_details: 151 + name_mapping: Star Trek The Original Series + schedule: monthly(2) + "Star Wars: Skywalker Saga": + tmdb_collection_details: 10 + name_mapping: Star Wars Skywalker Saga + schedule: monthly(2) + "Star Wars: Legends": + tmdb_movie: 348350, 330459 + summary: "Star Wars Anthology Films and other Star Wars Movies" + name_mapping: Star Wars Legends + schedule: monthly(2) + Step Up: + tmdb_collection_details: 86092 + schedule: monthly(2) + Street Fighter: + tmdb_collection_details: 190435 + tmdb_movie: 687354, 11667 + schedule: monthly(2) + Stuart Little: + tmdb_collection_details: 99727 + schedule: monthly(2) + Super Troopers: + tmdb_collection_details: 449462 + schedule: monthly(2) + Superman (Original): + tmdb_collection_details: 8537 + schedule: monthly(2) + Surf's Up: + tmdb_collection_details: 436295 + schedule: monthly(2) + Taken: + tmdb_collection_details: 135483 + schedule: monthly(2) + Tarzan: + tmdb_collection_details: 106768 + schedule: monthly(2) + Ted: + tmdb_collection_details: 266672 + schedule: monthly(2) + Teenage Mutant Ninja Turtles: + tmdb_collection_details: 1582, 401562 + tmdb_movie: 1273 + schedule: monthly(2) + Tekken: + tmdb_collection_details: 294172 + schedule: monthly(2) + The Terminator: + tmdb_collection_details: 528 + schedule: monthly(2) + Texas Chainsaw Massacre: + tmdb_collection_details: 111751, 425175 + schedule: monthly(2) + Thor: + tmdb_collection_details: 131296 + schedule: monthly(2) + The Three Stooges: + imdb_list: https://www.imdb.com/list/ls075972675/ + tmdb_movie: 76489 + summary: The Three Stooges were an American vaudeville and comedy team active from 1922 until 1970, best known for their 190 short subject films by Columbia Pictures that have been regularly airing on television since 1958. + schedule: monthly(2) + Tinker Bell: + tmdb_collection_details: 315595 + schedule: monthly(2) + Tokyo Ghoul: + tmdb_collection_details: 551278 + schedule: monthly(2) + Tom and Jerry: + imdb_list: https://www.imdb.com/list/ls022966050/ + summary: Tom and Jerry's animated feature-length films based on the series. + schedule: monthly(2) + Tomb Raider: + tmdb_collection_details: 2467, 621142 + schedule: monthly(2) + Tomie: + tmdb_collection_details: 139394 + schedule: monthly(2) + Toy Story: + tmdb_collection_details: 10194 + tmdb_movie: 130925 + schedule: monthly(2) + Trainspotting: + tmdb_collection_details: 424202 + schedule: monthly(2) + Transformers: + tmdb_collection_details: 8650 + schedule: monthly(2) + The Transporter: + tmdb_collection_details: 9518 + schedule: monthly(2) + Tremors: + tmdb_collection_details: 91799 + schedule: monthly(2) + Trolls: + tmdb_collection_details: 489724 + schedule: monthly(19) + TRON: + tmdb_collection_details: 63043 + tmdb_movie: 73362 + schedule: monthly(2) + Unbreakable: + imdb_list: https://www.imdb.com/list/ls022101006/ + summary: The Unbreakable trilogy, officially known as the Eastrail 177 Trilogy, is an American superhero thriller and psychological horror film series. The films were written, produced, and directed by M. Night Shyamalan. The trilogy consists of Unbreakable (2000), Split (2016), and Glass (2019). + schedule: monthly(2) + Underworld: + tmdb_collection_details: 2326 + schedule: monthly(2) + Viy: + tmdb_collection_details: 428046 + schedule: monthly(2) + Wall Street: + tmdb_collection_details: 52783 + schedule: monthly(2) + Wayne's World: + tmdb_collection_details: 8979 + schedule: monthly(2) + Wonder Woman: + tmdb_collection_details: 468552 + schedule: monthly(2) + Wreck-It Ralph: + tmdb_collection_details: 404825 + schedule: monthly(2) + X-Men: + tmdb_collection_details: 748, 453993, 448150 + tmdb_movie: 567604 + schedule: monthly(2) + xXx: + tmdb_collection_details: 52785 + schedule: monthly(2) + Zenon: + tmdb_collection_details: 321148 + schedule: monthly(2) + Zombieland: + tmdb_collection_details: 537982 + schedule: monthly(2) + Zoolander: + tmdb_collection_details: 352789 + schedule: monthly(2) + Zorro: + tmdb_collection_details: 1657 + schedule: monthly(2) + +###################################################### +# Collectionless Collection # +###################################################### + + Collectionless: + plex_collectionless: + exclude_prefix: + - + + - "~" + sort_title: ~_Collectionless + collection_order: alpha diff --git a/config/config.yml.template b/config/config.yml.template new file mode 100644 index 00000000..021603ac --- /dev/null +++ b/config/config.yml.template @@ -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: diff --git a/modules/anidb.py b/modules/anidb.py new file mode 100644 index 00000000..30bde9b1 --- /dev/null +++ b/modules/anidb.py @@ -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 diff --git a/modules/cache.py b/modules/cache.py new file mode 100644 index 00000000..de2eaa89 --- /dev/null +++ b/modules/cache.py @@ -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)) diff --git a/modules/config.py b/modules/config.py new file mode 100644 index 00000000..59a95c39 --- /dev/null +++ b/modules/config.py @@ -0,0 +1,1185 @@ +import glob, json, logging, os, re +from datetime import datetime, timedelta +from modules import util +from modules.anidb import AniDBAPI +from modules.cache import Cache +from modules.imdb import IMDbAPI +from modules.plex import PlexAPI +from modules.mal import MyAnimeListAPI +from modules.mal import MyAnimeListIDList +from modules.tmdb import TMDbAPI +from modules.trakt import TraktAPI +from modules.tvdb import TVDbAPI +from modules.util import Failed +from ruamel import yaml +from urllib.parse import urlparse + +logger = logging.getLogger("Plex Meta Manager") + +class Config: + def __init__(self, default_dir, config_path=None): + logger.info("Locating config...") + if config_path and os.path.exists(config_path): self.config_path = os.path.abspath(config_path) + elif config_path and not os.path.exists(config_path): raise Failed("Config Error: config not found at {}".format(os.path.abspath(config_path))) + elif os.path.exists(os.path.join(default_dir, "config.yml")): self.config_path = os.path.abspath(os.path.join(default_dir, "config.yml")) + else: raise Failed("Config Error: config not found at {}".format(os.path.abspath(default_dir))) + logger.info("Using {} as config".format(self.config_path)) + + yaml.YAML().allow_duplicate_keys = True + try: self.data, ind, bsi = yaml.util.load_yaml_guess_indent(open(self.config_path)) + except yaml.scanner.ScannerError as e: raise Failed("YAML Error: {}".format(str(e).replace("\n", "\n|\t "))) + + def check_for_attribute(data, attribute, parent=None, test_list=None, options="", default=None, do_print=True, default_is_none=False, var_type="str", throw=False, save=True): + message = "" + endline = "" + data = data if parent is None else data[parent] + text = "{} attribute".format(attribute) if parent is None else "{} sub-attribute {}".format(parent, attribute) + if data is None or attribute not in data: + message = "Config Error: {} not found".format(text) + if parent and save is True: + new_config, ind, bsi = yaml.util.load_yaml_guess_indent(open(self.config_path)) + endline = "\n| {} sub-attribute {} added to config".format(parent, attribute) + if parent not in new_config: new_config = {parent: {attribute: default}} + elif not new_config[parent]: new_config[parent] = {attribute: default} + elif attribute not in new_config[parent]: new_config[parent][attribute] = default + else: endLine = "" + yaml.round_trip_dump(new_config, open(self.config_path, "w"), indent=ind, block_seq_indent=bsi) + elif not data[attribute] and data[attribute] != False: message = "Config Error: {} is blank".format(text) + elif var_type == "bool": + if isinstance(data[attribute], bool): return data[attribute] + else: message = "Config Error: {} must be either true or false".format(text) + elif var_type == "int": + if isinstance(data[attribute], int) and data[attribute] > 0: return data[attribute] + else: message = "Config Error: {} must an integer > 0".format(text) + elif var_type == "path": + if os.path.exists(os.path.abspath(data[attribute])): return data[attribute] + else: message = "Config Error: {} could not be found".format(text) + if default and os.path.exists(os.path.abspath(default)): + return default + elif default: + default = None + default_is_none = True + message = "Config Error: neither {} or the default path {} could be found".format(data[attribute], default) + elif test_list is None or data[attribute] in test_list: return data[attribute] + else: message = "Config Error: {}: {} is an invalid input".format(text, data[attribute]) + if default is not None or default_is_none: + message = message + " using {} as default".format(default) + message = message + endline + if (default is None and not default_is_none) or throw: + if len(options) > 0: + message = message + "\n" + options + raise Failed(message) + if do_print: + util.print_multiline(message) + if attribute in data and data[attribute] and test_list is not None and data[attribute] not in test_list: + util.print_multiline(options) + return default + + self.general = {} + self.general["cache"] = check_for_attribute(self.data, "cache", parent="cache", options="| \ttrue (Create a cache to store ids)\n| \tfalse (Do not create a cache to store ids)", var_type="bool", default=True) if "cache" in self.data else True + self.general["cache_expiration"] = check_for_attribute(self.data, "cache_expiration", parent="cache", var_type="int", default=60) if "cache" in self.data else 60 + if self.general["cache"]: + util.seperator() + self.Cache = Cache(self.config_path, self.general["cache_expiration"]) + else: + self.Cache = None + + util.seperator() + + self.TMDb = None + if "tmdb" in self.data: + logger.info("Connecting to TMDb...") + self.tmdb = {} + self.tmdb["apikey"] = check_for_attribute(self.data, "apikey", parent="tmdb", throw=True) + self.tmdb["language"] = check_for_attribute(self.data, "language", parent="tmdb", default="en") + self.TMDb = TMDbAPI(self.tmdb) + logger.info("TMDb Connection {}".format("Failed" if self.TMDb is None else "Successful")) + else: + raise Failed("Config Error: tmdb attribute not found") + + util.seperator() + + self.Trakt = None + if "trakt" in self.data: + logger.info("Connecting to Trakt...") + self.trakt = {} + try: + self.trakt["client_id"] = check_for_attribute(self.data, "client_id", parent="trakt", throw=True) + self.trakt["client_secret"] = check_for_attribute(self.data, "client_secret", parent="trakt", throw=True) + self.trakt["config_path"] = self.config_path + authorization = self.data["trakt"]["authorization"] if "authorization" in self.data["trakt"] and self.data["trakt"]["authorization"] else None + self.Trakt = TraktAPI(self.trakt, authorization) + except Failed as e: + logger.error(e) + logger.info("Trakt Connection {}".format("Failed" if self.Trakt is None else "Successful")) + else: + logger.warning("trakt attribute not found") + + util.seperator() + + self.MyAnimeList = None + self.MyAnimeListIDList = MyAnimeListIDList() + if "mal" in self.data: + logger.info("Connecting to My Anime List...") + self.mal = {} + try: + self.mal["client_id"] = check_for_attribute(self.data, "client_id", parent="mal", throw=True) + self.mal["client_secret"] = check_for_attribute(self.data, "client_secret", parent="mal", throw=True) + self.mal["config_path"] = self.config_path + authorization = self.data["mal"]["authorization"] if "authorization" in self.data["mal"] and self.data["mal"]["authorization"] else None + self.MyAnimeList = MyAnimeListAPI(self.mal, self.MyAnimeListIDList, authorization) + except Failed as e: + logger.error(e) + logger.info("My Anime List Connection {}".format("Failed" if self.Trakt is None else "Successful")) + else: + logger.warning("mal attribute not found") + + self.TVDb = TVDbAPI(Cache=self.Cache, TMDb=self.TMDb, Trakt=self.Trakt) + self.IMDb = IMDbAPI(Cache=self.Cache, TMDb=self.TMDb, Trakt=self.Trakt, TVDb=self.TVDb) if self.TMDb or self.Trakt else None + self.AniDB = AniDBAPI(Cache=self.Cache, TMDb=self.TMDb, Trakt=self.Trakt) + + self.general["plex"] = {} + self.general["plex"]["url"] = check_for_attribute(self.data, "url", parent="plex", default_is_none=True) if "plex" in self.data else None + self.general["plex"]["token"] = check_for_attribute(self.data, "token", parent="plex", default_is_none=True) if "plex" in self.data else None + self.general["plex"]["asset_directory"] = check_for_attribute(self.data, "asset_directory", parent="plex", var_type="path", default=os.path.join(default_dir, "assets")) if "plex" in self.data else os.path.join(default_dir, "assets") + self.general["plex"]["sync_mode"] = check_for_attribute(self.data, "sync_mode", parent="plex", default="append", test_list=["append", "sync"], options="| \tappend (Only Add Items to the Collection)\n| \tsync (Add & Remove Items from the Collection)") if "plex" in self.data else "append" + + self.general["radarr"] = {} + self.general["radarr"]["url"] = check_for_attribute(self.data, "url", parent="radarr", default_is_none=True) if "radarr" in self.data else None + self.general["radarr"]["version"] = check_for_attribute(self.data, "version", parent="radarr", test_list=["v2", "v3"], options="| \tv2 (For Radarr 0.2)\n| \tv3 (For Radarr 3.0)", default="v2") if "radarr" in self.data else "v2" + self.general["radarr"]["token"] = check_for_attribute(self.data, "token", parent="radarr", default_is_none=True) if "radarr" in self.data else None + self.general["radarr"]["quality_profile"] = check_for_attribute(self.data, "quality_profile", parent="radarr", default_is_none=True) if "radarr" in self.data else None + self.general["radarr"]["root_folder_path"] = check_for_attribute(self.data, "root_folder_path", parent="radarr", default_is_none=True) if "radarr" in self.data else None + self.general["radarr"]["add"] = check_for_attribute(self.data, "add", parent="radarr", var_type="bool", default=False) if "radarr" in self.data else False + self.general["radarr"]["search"] = check_for_attribute(self.data, "search", parent="radarr", var_type="bool", default=False) if "radarr" in self.data else False + + self.general["sonarr"] = {} + self.general["sonarr"]["url"] = check_for_attribute(self.data, "url", parent="sonarr", default_is_none=True) if "sonarr" in self.data else None + self.general["sonarr"]["token"] = check_for_attribute(self.data, "token", parent="sonarr", default_is_none=True) if "sonarr" in self.data else None + self.general["sonarr"]["version"] = check_for_attribute(self.data, "version", parent="sonarr", test_list=["v2", "v3"], options="| \tv2 (For Sonarr 0.2)\n| \tv3 (For Sonarr 3.0)", default="v2") if "sonarr" in self.data else "v2" + self.general["sonarr"]["quality_profile"] = check_for_attribute(self.data, "quality_profile", parent="sonarr", default_is_none=True) if "sonarr" in self.data else None + self.general["sonarr"]["root_folder_path"] = check_for_attribute(self.data, "root_folder_path", parent="sonarr", default_is_none=True) if "sonarr" in self.data else None + self.general["sonarr"]["add"] = check_for_attribute(self.data, "add", parent="sonarr", var_type="bool", default=False) if "sonarr" in self.data else False + self.general["sonarr"]["search"] = check_for_attribute(self.data, "search", parent="sonarr", var_type="bool", default=False) if "sonarr" in self.data else False + + self.general["tautulli"] = {} + self.general["tautulli"]["url"] = check_for_attribute(self.data, "url", parent="tautulli", default_is_none=True) if "tautulli" in self.data else None + self.general["tautulli"]["apikey"] = check_for_attribute(self.data, "apikey", parent="tautulli", default_is_none=True) if "tautulli" in self.data else None + + util.seperator() + + logger.info("Connecting to Plex Libraries...") + + self.libraries = [] + libs = check_for_attribute(self.data, "libraries", throw=True) + for lib in libs: + util.seperator() + params = {} + if "library_name" in libs[lib] and libs[lib]["library_name"]: + params["name"] = str(libs[lib]["library_name"]) + logger.info("Connecting to {} ({}) Library...".format(params["name"], lib)) + else: + params["name"] = str(lib) + logger.info("Connecting to {} Library...".format(params["name"])) + default_lib = os.path.join(default_dir, "{}.yml".format(lib)) + try: + if "metadata_path" in libs[lib]: + if libs[lib]["metadata_path"]: + if os.path.exists(libs[lib]["metadata_path"]): params["metadata_path"] = libs[lib]["metadata_path"] + else: raise Failed("metadata_path not found at {}".format(libs[lib]["metadata_path"])) + else: raise Failed("metadata_path attribute is blank") + else: + if os.path.exists(default_lib): params["metadata_path"] = os.path.abspath(default_lib) + else: raise Failed("default metadata_path not found at {}".format(os.path.abspath(os.path.join(default_dir, "{}.yml".format(params["name"]))))) + + if "library_type" in libs[lib]: + if libs[lib]["library_type"]: + if libs[lib]["library_type"] in ["movie", "show"]: params["library_type"] = libs[lib]["library_type"] + else: raise Failed("library_type attribute must be either 'movie' or 'show'") + else: raise Failed("library_type attribute is blank") + else: raise Failed("library_type attribute is required") + + params["plex"] = {} + if "plex" in libs[lib] and libs[lib]["plex"] and "url" in libs[lib]["plex"]: + if libs[lib]["plex"]["url"]: params["plex"]["url"] = libs[lib]["plex"]["url"] + else: raise Failed("url library attribute is blank") + elif self.general["plex"]["url"]: params["plex"]["url"] = self.general["plex"]["url"] + else: raise Failed("url attribute must be set under plex or under this specific Library") + + if "plex" in libs[lib] and libs[lib]["plex"] and "token" in libs[lib]["plex"]: + if libs[lib]["plex"]["token"]: params["plex"]["token"] = libs[lib]["plex"]["token"] + else: raise Failed("token library attribute is blank") + elif self.general["plex"]["token"]: params["plex"]["token"] = self.general["plex"]["token"] + else: raise Failed("token attribute must be set under plex or under this specific Library") + except Failed as e: + logger.error("Config Error: Skipping {} Library {}".format(str(lib), e)) + continue + + params["asset_directory"] = None + + if "plex" in libs[lib] and "asset_directory" in libs[lib]["plex"]: + if libs[lib]["plex"]["asset_directory"]: + if os.path.exists(libs[lib]["plex"]["asset_directory"]): + params["asset_directory"] = libs[lib]["plex"]["asset_directory"] + else: + logger.warning("Config Warning: Assets will not be used asset_directory not found at {}".format(libs[lib]["plex"]["asset_directory"])) + else: + logger.warning("Config Warning: Assets will not be used asset_directory library attribute is blank") + elif self.general["plex"]["asset_directory"]: + params["asset_directory"] = self.general["plex"]["asset_directory"] + else: + logger.warning("Config Warning: Assets will not be used asset_directory attribute must be set under config or under this specific Library") + + params["sync_mode"] = self.general["plex"]["sync_mode"] + if "plex" in libs[lib] and "sync_mode" in libs[lib]["plex"]: + if libs[lib]["plex"]["sync_mode"]: + if libs[lib]["plex"]["sync_mode"] in ["append", "sync"]: + params["sync_mode"] = libs[lib]["plex"]["sync_mode"] + else: + logger.warning("Config Warning: sync_mode attribute must be either 'append' or 'sync' using general value: {}".format(self.general["plex"]["sync_mode"])) + else: + logger.warning("Config Warning: sync_mode attribute is blank using general value: {}".format(self.general["plex"]["sync_mode"])) + + params["tmdb"] = self.TMDb + params["tvdb"] = self.TVDb + + params["radarr"] = self.general["radarr"].copy() + if "radarr" in libs[lib] and libs[lib]["radarr"]: + if "url" in libs[lib]["radarr"]: + if libs[lib]["radarr"]["url"]: + params["radarr"]["url"] = libs[lib]["radarr"]["url"] + else: + logger.warning("Config Warning: radarr sub-attribute url is blank using general value: {}".format(self.general["radarr"]["url"])) + + if "token" in libs[lib]["radarr"]: + if libs[lib]["radarr"]["token"]: + params["radarr"]["token"] = libs[lib]["radarr"]["token"] + else: + logger.warning("Config Warning: radarr sub-attribute token is blank using general value: {}".format(self.general["radarr"]["token"])) + + if "version" in libs[lib]["radarr"]: + if libs[lib]["radarr"]["version"]: + if libs[lib]["radarr"]["version"] in ["v2", "v3"]: + params["radarr"]["version"] = libs[lib]["radarr"]["version"] + else: + logger.warning("Config Warning: radarr sub-attribute version must be either 'v2' or 'v3' using general value: {}".format(self.general["radarr"]["version"])) + else: + logger.warning("Config Warning: radarr sub-attribute version is blank using general value: {}".format(self.general["radarr"]["version"])) + + if "quality_profile" in libs[lib]["radarr"]: + if libs[lib]["radarr"]["quality_profile"]: + params["radarr"]["quality_profile"] = libs[lib]["radarr"]["quality_profile"] + else: + logger.warning("Config Warning: radarr sub-attribute quality_profile is blank using general value: {}".format(self.general["radarr"]["quality_profile"])) + + if "root_folder_path" in libs[lib]["radarr"]: + if libs[lib]["radarr"]["root_folder_path"]: + params["radarr"]["root_folder_path"] = libs[lib]["radarr"]["root_folder_path"] + else: + logger.warning("Config Warning: radarr sub-attribute root_folder_path is blank using general value: {}".format(self.general["radarr"]["root_folder_path"])) + + if "add" in libs[lib]["radarr"]: + if libs[lib]["radarr"]["add"]: + if isinstance(libs[lib]["radarr"]["add"], bool): + params["radarr"]["add"] = libs[lib]["radarr"]["add"] + else: + logger.warning("Config Warning: radarr sub-attribute add must be either true or false using general value: {}".format(self.general["radarr"]["add"])) + else: + logger.warning("Config Warning: radarr sub-attribute add is blank using general value: {}".format(self.general["radarr"]["add"])) + + if "search" in libs[lib]["radarr"]: + if libs[lib]["radarr"]["search"]: + if isinstance(libs[lib]["radarr"]["search"], bool): + params["radarr"]["search"] = libs[lib]["radarr"]["search"] + else: + logger.warning("Config Warning: radarr sub-attribute search must be either true or false using general value: {}".format(self.general["radarr"]["search"])) + else: + logger.warning("Config Warning: radarr sub-attribute search is blank using general value: {}".format(self.general["radarr"]["search"])) + + if not params["radarr"]["url"] or not params["radarr"]["token"] or not params["radarr"]["quality_profile"] or not params["radarr"]["root_folder_path"]: + params["radarr"] = None + + params["sonarr"] = self.general["sonarr"].copy() + if "sonarr" in libs[lib] and libs[lib]["sonarr"]: + if "url" in libs[lib]["sonarr"]: + if libs[lib]["sonarr"]["url"]: + params["sonarr"]["url"] = libs[lib]["sonarr"]["url"] + else: + logger.warning("Config Warning: sonarr sub-attribute url is blank using general value: {}".format(self.general["sonarr"]["url"])) + + if "token" in libs[lib]["sonarr"]: + if libs[lib]["sonarr"]["token"]: + params["sonarr"]["token"] = libs[lib]["sonarr"]["token"] + else: + logger.warning("Config Warning: sonarr sub-attribute token is blank using general value: {}".format(self.general["sonarr"]["token"])) + + if "version" in libs[lib]["sonarr"]: + if libs[lib]["sonarr"]["version"]: + if libs[lib]["sonarr"]["version"] in ["v2", "v3"]: + params["sonarr"]["version"] = libs[lib]["sonarr"]["version"] + else: + logger.warning("Config Warning: sonarr sub-attribute version must be either 'v2' or 'v3' using general value: {}".format(self.general["sonarr"]["version"])) + else: + logger.warning("Config Warning: sonarr sub-attribute version is blank using general value: {}".format(self.general["sonarr"]["version"])) + + if "quality_profile" in libs[lib]["sonarr"]: + if libs[lib]["sonarr"]["quality_profile"]: + params["sonarr"]["quality_profile"] = libs[lib]["sonarr"]["quality_profile"] + else: + logger.warning("Config Warning: sonarr sub-attribute quality_profile is blank using general value: {}".format(self.general["sonarr"]["quality_profile"])) + + if "root_folder_path" in libs[lib]["sonarr"]: + if libs[lib]["sonarr"]["root_folder_path"]: + params["sonarr"]["root_folder_path"] = libs[lib]["sonarr"]["root_folder_path"] + else: + logger.warning("Config Warning: sonarr sub-attribute root_folder_path is blank using general value: {}".format(self.general["sonarr"]["root_folder_path"])) + + if "add" in libs[lib]["sonarr"]: + if libs[lib]["sonarr"]["add"]: + if isinstance(libs[lib]["sonarr"]["add"], bool): + params["sonarr"]["add"] = libs[lib]["sonarr"]["add"] + else: + logger.warning("Config Warning: sonarr sub-attribute add must be either true or false using general value: {}".format(self.general["sonarr"]["add"])) + else: + logger.warning("Config Warning: sonarr sub-attribute add is blank using general value: {}".format(self.general["sonarr"]["add"])) + + if "search" in libs[lib]["sonarr"]: + if libs[lib]["sonarr"]["search"]: + if isinstance(libs[lib]["sonarr"]["search"], bool): + params["sonarr"]["search"] = libs[lib]["sonarr"]["search"] + else: + logger.warning("Config Warning: sonarr sub-attribute search must be either true or false using general value: {}".format(self.general["sonarr"]["search"])) + else: + logger.warning("Config Warning: sonarr sub-attribute search is blank using general value: {}".format(self.general["sonarr"]["search"])) + + if not params["sonarr"]["url"] or not params["sonarr"]["token"] or not params["sonarr"]["quality_profile"] or not params["sonarr"]["root_folder_path"] or params["library_type"] == "movie": + params["sonarr"] = None + + + params["tautulli"] = self.general["tautulli"].copy() + if "tautulli" in libs[lib] and libs[lib]["tautulli"]: + if "url" in libs[lib]["tautulli"]: + if libs[lib]["tautulli"]["url"]: + params["tautulli"]["url"] = libs[lib]["tautulli"]["url"] + else: + logger.warning("Config Warning: tautulli sub-attribute url is blank using general value: {}".format(self.general["tautulli"]["url"])) + + if "apikey" in libs[lib]["tautulli"]: + if libs[lib]["tautulli"]["apikey"]: + params["tautulli"]["apikey"] = libs[lib]["tautulli"]["apikey"] + else: + logger.warning("Config Warning: tautulli sub-attribute apikey is blank using general value: {}".format(self.general["tautulli"]["apikey"])) + + if not params["tautulli"]["url"] or not params["tautulli"]["apikey"] : + params["tautulli"] = None + + try: + self.libraries.append(PlexAPI(params)) + logger.info("{} Library Connection Successful".format(params["name"])) + except Failed as e: + logger.error(e) + logger.info("{} Library Connection Failed".format(params["name"])) + continue + + util.seperator() + + if len(self.libraries) > 0: + logger.info("{} Plex Library Connection{} Successful".format(len(self.libraries), "s" if len(self.libraries) > 1 else "")) + else: + raise Failed("Plex Error: No Plex libraries were found") + + util.seperator() + + def update_libraries(self): + for library in self.libraries: + logger.info("") + util.seperator("{} Library".format(library.name)) + try: library.update_metadata() + except Failed as e: logger.error(e) + logger.info("") + util.seperator("{} Library Collections".format(library.name)) + collections = library.collections + if collections: + logger.info("") + util.seperator("Mapping {} Library".format(library.name)) + logger.info("") + movie_map, show_map = self.map_guids(library) + + unmanaged_collections = [col.title for col in library.get_all_collections() if col.title not in collections] + if len(unmanaged_collections) > 0: + for col in unmanaged_collections: + logger.info(col) + logger.info("{} Unmanaged Collections".format(len(unmanaged_collections))) + else: + logger.info("No Unmanaged Collections") + + for c in collections: + try: + logger.info("") + util.seperator("{} Collection".format(c)) + logger.info("") + collectionless = "plex_collectionless" in collections[c] + skip_collection = True + if "schedule" not in collections[c]: + skip_collection = False + elif not collections[c]["schedule"]: + logger.error("Collection Error: schedule attribute is blank. Running daily") + skip_collection = False + else: + schedule_list = util.get_list(collections[c]["schedule"]) + current_time = datetime.now() + next_month = current_time.replace(day=28) + timedelta(days=4) + last_day = next_month - timedelta(days=next_month.day) + for schedule in schedule_list: + run_time = str(schedule).lower() + if run_time.startswith("week") or run_time.startswith("month") or run_time.startswith("year"): + match = re.search("\\(([^)]+)\\)", run_time) + if match: + param = match.group(1) + if run_time.startswith("week"): + if param.lower() in util.days_alias: + weekday = util.days_alias[param.lower()] + logger.info("Scheduled weekly on {}".format(util.pretty_days[weekday])) + if weekday == current_time.weekday(): + skip_collection = False + break + else: + logger.error("Collection Error: weekly schedule attribute {} invalid must be a day of the weeek i.e. weekly(Monday)".format(schedule)) + elif run_time.startswith("month"): + try: + if 1 <= int(param) <= 31: + logger.info("Scheduled monthly on the {}".format(util.make_ordinal(param))) + if current_time.day == int(param) or (current_time.day == last_day.day and int(param) > last_day.day): + skip_collection = False + break + else: + logger.error("Collection Error: monthly schedule attribute {} invalid must be between 1 and 31".format(schedule)) + except ValueError: + logger.error("Collection Error: monthly schedule attribute {} invalid must be an integer".format(schedule)) + elif run_time.startswith("year"): + match = re.match("^(1[0-2]|0?[1-9])/(3[01]|[12][0-9]|0?[1-9])$", param) + if match: + month = int(match.group(1)) + day = int(match.group(2)) + logger.info("Scheduled yearly on {} {}".format(util.pretty_months[month], util.make_ordinal(day))) + if current_time.month == month and (current_time.day == day or (current_time.day == last_day.day and day > last_day.day)): + skip_collection = False + break + else: + logger.error("Collection Error: yearly schedule attribute {} invalid must be in the MM/DD format i.e. yearly(11/22)".format(schedule)) + else: + logger.error("Collection Error: failed to parse schedule: {}".format(schedule)) + else: + logger.error("Collection Error: schedule attribute {} invalid".format(schedule)) + + if skip_collection: + logger.info("Skipping Collection {}".format(c)) + continue + + map = {} + details = {} + methods = [] + filters = [] + posters_found = [] + backgrounds_found = [] + + try: + collection_obj = library.get_collection(c) + collection_name = collection_obj.title + except Failed as e: + collection_obj = None + collection_name = c + + + sync_collection = library.sync_mode == "sync" + if "sync_mode" in collections[c]: + if not collections[c]["sync_mode"]: logger.warning("Collection Warning: sync_mode attribute is blank using general: {}".format(library.sync_mode)) + elif collections[c]["sync_mode"] not in ["append", "sync"]: logger.warning("Collection Warning: {} sync_mode invalid using general: {}".format(library.sync_mode, collections[c]["sync_mode"])) + else: sync_collection = collections[c]["sync_mode"] == "sync" + if sync_collection or collectionless: + logger.info("Sync Mode: sync") + if collection_obj: + for item in collection_obj.children: + map[item.ratingKey] = item + else: + logger.info("Sync Mode: append") + + for m in collections[c]: + try: + if "tmdb" in m and not self.TMDb: + logger.info("Collection Error: {} skipped. TMDb must be configured".format(m)) + map = {} + elif "trakt" in m and not self.Trakt: + logger.info("Collection Error: {} skipped. Trakt must be configured".format(m)) + map = {} + elif "imdb" in m and not self.IMDb: + logger.info("Collection Error: {} skipped. TMDb or Trakt must be configured".format(m)) + map = {} + elif m == "tautulli" and not library.Tautulli: + logger.info("Collection Error: {} skipped. Tautulli must be configured".format(m)) + map = {} + elif "mal" in m and not self.MyAnimeList: + logger.info("Collection Error: {} skipped. MyAnimeList must be configured".format(m)) + map = {} + elif collections[c][m] is not None: + if m in util.method_alias: + method_name = util.method_alias[m] + logger.warning("Collection Warning: {} attribute will run as {}".format(m, method_name)) + else: + method_name = m + if method_name in util.show_only_lists and library.is_movie: raise Failed("Collection Error: {} attribute only works for show libraries".format(method_name)) + elif method_name in util.movie_only_lists and library.is_show: raise Failed("Collection Error: {} attribute only works for movie libraries".format(method_name)) + elif method_name in util.movie_only_searches and library.is_show: raise Failed("Collection Error: {} plex search only works for movie libraries".format(method_name)) + elif method_name not in util.collectionless_lists and collectionless: raise Failed("Collection Error: {} attribute does not work for Collectionless collection".format(method_name)) + elif method_name == "tmdb_summary": details["summary"] = self.TMDb.get_movie_show_or_collection(util.regex_first_int(collections[c][m], "TMDb ID"), library.is_movie).overview + elif method_name == "tmdb_description": details["summary"] = self.TMDb.get_list(util.regex_first_int(collections[c][m], "TMDb List ID")).description + elif method_name == "tmdb_biography": details["summary"] = self.TMDb.get_person(util.regex_first_int(collections[c][m], "TMDb Person ID")).biography + elif method_name == "collection_mode": + if collections[c][m] in ["default", "hide", "hide_items", "show_items", "hideItems", "showItems"]: + if collections[c][m] == "hide_items": details[method_name] = "hideItems" + elif collections[c][m] == "show_items": details[method_name] = "showItems" + else: details[method_name] = collections[c][m] + else: raise Failed("Collection Error: {} collection_mode Invalid\n| \tdefault (Library default)\n| \thide (Hide Collection)\n| \thide_items (Hide Items in this Collection)\n| \tshow_items (Show this Collection and its Items)".format(collections[c][m])) + elif method_name == "collection_order": + if collections[c][m] in ["release", "alpha"]: details[method_name] = collections[c][m] + else: raise Failed("Collection Error: {} collection_order Invalid\n| \trelease (Order Collection by release dates)\n| \talpha (Order Collection Alphabetically)".format(collections[c][m])) + elif method_name == "url_poster": posters_found.append(("url", collections[c][m], method_name)) + elif method_name == "tmdb_poster": posters_found.append(("url", "{}{}".format(self.TMDb.image_url, self.get_movie_show_or_collection(util.regex_first_int(collections[c][m], "TMDb ID"), library.is_movie).poster_path), method_name)) + elif method_name == "tmdb_profile": posters_found.append(("url", "{}{}".format(self.TMDb.image_url, self.get_person(util.regex_first_int(collections[c][m], "TMDb Person ID")).profile_path), method_name)) + elif method_name == "file_poster": + if os.path.exists(collections[c][m]): posters_found.append(("file", os.path.abspath(collections[c][m]), method_name)) + else: raise Failed("Collection Error: Poster Path Does Not Exist: {}".format(os.path.abspath(collections[c][m]))) + elif method_name == "url_background": backgrounds_found.append(("url", collections[c][m], method_name)) + elif method_name == "tmdb_background": backgrounds_found.append(("url", "{}{}".format(self.TMDb.image_url, self.get_movie_show_or_collection(util.regex_first_int(collections[c][m], "TMDb ID"), library.is_movie).poster_path), method_name)) + elif method_name == "file_background": + if os.path.exists(collections[c][m]): backgrounds_found.append(("file", os.path.abspath(collections[c][m]), method_name)) + else: raise Failed("Collection Error: Background Path Does Not Exist: {}".format(os.path.abspath(collections[c][m]))) + elif method_name == "add_to_arr": + if isinstance(collections[c][m], bool): details[method_name] = collections[c][m] + else: raise Failed("Collection Error: add_to_arr must be either true or false") + elif method_name in util.all_details: details[method_name] = collections[c][m] + elif method_name in ["year", "year.not"]: methods.append(("plex_search", [[(method_name, util.get_year_list(collections[c][m], method_name))]])) + elif method_name in ["decade", "decade.not"]: methods.append(("plex_search", [[(method_name, util.get_int_list(collections[c][m], util.remove_not(method_name)))]])) + elif method_name in ["actor_details_tmdb", "director_details_tmdb", "writer_details_tmdb"]: + tmdb_ids = get_int_list(collections[c][m], "TMDb Person ID") + valid_ids = [] + for valid_id in valid_ids: + try: + person = self.TMDb.get_person(valid_id) + valid_ids.append(library.get_actor_rating_key(person.name) if method_name == "actor_details_tmdb" else person.name) + if "summary" not in details and hasattr(person, "biography") and person.biography: + details["summary"] = person.biography + if "poster" not in details and hasattr(person, "profile_path") and person.profile_path: + details["poster"] = ("url", "{}{}".format(self.TMDb.image_url, person.profile_path), method_name) + except Failed as e: + logger.error(e) + if len(valid_ids) == 0: + raise Failed("Collection Error: No valid TMDb Person IDs in {}".format(collections[c][m])) + methods.append(("plex_search", [[(method_name[:-13], valid_ids)]])) + elif method_name in util.plex_searches: methods.append(("plex_search", [[(method_name, util.get_list(collections[c][m]))]])) + elif method_name == "plex_collection": methods.append((method_name, library.validate_collections(collections[c][m] if isinstance(collections[c][m], list) else [collections[c][m]]))) + elif method_name == "anidb_popular": + list_count = util.regex_first_int(collections[c][m], "List Size", default=40) + if 1 <= list_count <= 30: + methods.append((method_name, [list_count])) + else: + logger.error("Collection Error: anidb_popular must be an integer between 1 and 30 defaulting to 30") + methods.append((method_name, [30])) + elif method_name in util.count_lists: + list_count = util.regex_first_int(collections[c][m], "List Size", default=20) + if list_count > 0: + methods.append((method_name, [list_count])) + else: + logger.error("Collection Error: {} must be an integer greater then 0 defaulting to 20".format(method_name)) + methods.append((method_name, [20])) + elif method_name in util.tmdb_lists: + values = self.TMDb.validate_tmdb_list(util.get_int_list(collections[c][m], "TMDb {} ID".format(util.tmdb_type[method_name])), util.tmdb_type[method_name]) + if method_name[-8:] == "_details": + if method_name in ["tmdb_collection_details", "tmdb_movie_details", "tmdb_show_details"]: + item = self.TMDb.get_movie_show_or_collection(values[0], library.is_movie) + if "summary" not in details and hasattr(item, "overview") and item.overview: + details["summary"] = item.overview + if "background" not in details and hasattr(item, "backdrop_path") and item.backdrop_path: + details["background"] = ("url", "{}{}".format(self.TMDb.image_url, item.backdrop_path), method_name[:-8]) + if "poster" not in details and hasattr(item, "poster_path") and item.poster_path: + details["poster"] = ("url", "{}{}".format(self.TMDb.image_url, item.poster_path), method_name[:-8]) + else: + item = self.TMDb.get_list(values[0]) + if "summary" not in details and hasattr(item, "description") and item.description: + details["summary"] = item.description + methods.append((method_name[:-8], values)) + else: + methods.append((method_name, values)) + elif method_name == "mal_id": methods.append((method_name, util.get_int_list(collections[c][m], "MyAnimeList ID"))) + elif method_name in ["anidb_id", "anidb_relation"]: methods.append((method_name, self.AniDB.validate_anidb_list(util.get_int_list(collections[c][m], "AniDB ID"), library.Plex.language))) + elif method_name == "trakt_list": methods.append((method_name, self.Trakt.validate_trakt_list(util.get_list(collections[c][m])))) + elif method_name == "trakt_watchlist": methods.append((method_name, self.Trakt.validate_trakt_watchlist(util.get_list(collections[c][m]), library.is_movie))) + elif method_name == "imdb_list": + new_list = [] + for imdb_list in util.get_list(collections[c][m]): + new_dictionary = {} + if isinstance(imdb_list, dict): + if "url" in imdb_list and imdb_list["url"]: imdb_url = imdb_list["url"] + else: raise Failed("Collection Error: No I") + if "limit" in imdb_list and imdb_list["limit"]: list_count = util.regex_first_int(imdb_list["limit"], "List Limit", default=0) + else: list_count = 0 + else: + imdb_url = str(imdb_list) + list_count = 0 + new_list.append({"url": imdb_url, "limit": list_count}) + methods.append((method_name, new_list)) + elif method_name in util.dictionary_lists: + if isinstance(collections[c][m], dict): + def get_int(parent, method, data, default, min=1, max=None): + if method not in data: logger.warning("Collection Warning: {} {} attribute not found using {} as default".format(parent, method, default)) + elif not data[method]: logger.warning("Collection Warning: {} {} attribute is blank using {} as default".format(parent, method, default)) + elif isinstance(data[method], int) and data[method] >= min: + if max is None or data[method] <= max: return data[method] + else: logger.warning("Collection Warning: {} {} attribute {} invalid must an integer <= {} using {} as default".format(parent, method, data[method], max, default)) + else: logger.warning("Collection Warning: {} {} attribute {} invalid must an integer >= {} using {} as default".format(parent, method, data[method], min, default)) + return default + if method_name == "filters": + for filter in collections[c][m]: + if filter in util.method_alias or (filter.endswith(".not") and filter[:-4] in util.method_alias): + final_filter = (util.method_alias[filter[:-4]] + filter[-4:]) if filter.endswith(".not") else util.method_alias[filter] + logger.warning("Collection Warning: {} filter will run as {}".format(filter, final_filter)) + else: + final_filter = filter + if final_filter in util.movie_only_filters and library.is_show: + logger.error("Collection Error: {} filter only works for movie libraries".format(final_filter)) + elif final_filter in util.all_filters: + filters.append((final_filter, collections[c][m][filter])) + else: + logger.error("Collection Error: {} filter not supported".format(filter)) + elif method_name == "plex_collectionless": + new_dictionary = {} + + prefix_list = [] + if "exclude_prefix" in collections[c][m] and collections[c][m]["exclude_prefix"]: + if isinstance(collections[c][m]["exclude_prefix"], list): + prefix_list.extend(collections[c][m]["exclude_prefix"]) + else: + prefix_list.append("{}".format(collections[c][m]["exclude_prefix"])) + + exact_list = [] + if "exclude" in collections[c][m] and collections[c][m]["exclude"]: + if isinstance(collections[c][m]["exclude"], list): + exact_list.extend(collections[c][m]["exclude"]) + else: + exact_list.append("{}".format(collections[c][m]["exclude"])) + + if len(prefix_list) == 0 and len(exact_list) == 0: + raise Failed("Collection Error: you must have at least one exclusion") + details["add_to_arr"] = False + details["collection_mode"] = "showItems" + new_dictionary["exclude_prefix"] = prefix_list + new_dictionary["exclude"] = exact_list + methods.append((method_name, [new_dictionary])) + elif method_name == "plex_search": + search = [] + searches_used = [] + for search_attr in collections[c][m]: + if search_attr in util.method_alias or (search_attr.endswith(".not") and search_attr[:-4] in util.method_alias): + final_attr = (util.method_alias[search_attr[:-4]] + search_attr[-4:]) if search_attr.endswith(".not") else util.method_alias[search_attr] + logger.warning("Collection Warning: {} plex search attribute will run as {}".format(search_attr, final_attr)) + else: + final_attr = search_attr + if final_attr in util.movie_only_searches and library.is_show: + logger.error("Collection Error: {} plex search attribute only works for movie libraries".format(final_attr)) + elif util.remove_not(final_attr) in searches_used: + logger.error("Collection Error: Only one instance of {} can be used try using it as a filter instead".format(final_attr)) + elif final_attr in ["year", "year.not"]: + years = util.get_year_list(collections[c][m][search_attr], final_attr) + if len(years) > 0: + searches_used.append(util.remove_not(final_attr)) + search.append((final_attr, util.get_int_list(collections[c][m][search_attr], util.remove_not(final_attr)))) + elif final_attr in util.plex_searches: + if final_attr.startswith("tmdb_"): + final_attr = final_attr[5:] + searches_used.append(util.remove_not(final_attr)) + search.append((final_attr, util.get_list(collections[c][m][search_attr]))) + else: + logger.error("Collection Error: {} plex search attribute not supported".format(search_attr)) + methods.append((method_name, [search])) + elif method_name == "tmdb_discover": + new_dictionary = {"limit": 100} + for attr in collections[c][m]: + if collections[c][m][attr]: + attr_data = collections[c][m][attr] + if (library.is_movie and attr in util.discover_movie) or (library.is_show and attr in util.discover_tv): + if attr == "language": + if re.compile("([a-z]{2})-([A-Z]{2})").match(str(attr_data)): + new_dictionary[attr] = str(attr_data) + else: + logger.error("Collection Error: Skipping {} attribute {}: {} must match pattern ([a-z]{2})-([A-Z]{2}) e.g. en-US".format(m, attr, attr_data)) + elif attr == "region": + if re.compile("^[A-Z]{2}$").match(str(attr_data)): + new_dictionary[attr] = str(attr_data) + else: + logger.error("Collection Error: Skipping {} attribute {}: {} must match pattern ^[A-Z]{2}$ e.g. US".format(m, attr, attr_data)) + elif attr == "sort_by": + if (library.is_movie and attr_data in util.discover_movie_sort) or (library.is_show and attr_data in util.discover_tv_sort): + new_dictionary[attr] = attr_data + else: + logger.error("Collection Error: Skipping {} attribute {}: {} is invalid".format(m, attr, attr_data)) + elif attr == "certification_country": + if "certification" in collections[c][m] or "certification.lte" in collections[c][m] or "certification.gte" in collections[c][m]: + new_dictionary[attr] = attr_data + else: + logger.error("Collection Error: Skipping {} attribute {}: must be used with either certification, certification.lte, or certification.gte".format(m, attr)) + elif attr in ["certification", "certification.lte", "certification.gte"]: + if "certification_country" in collections[c][m]: + new_dictionary[attr] = attr_data + else: + logger.error("Collection Error: Skipping {} attribute {}: must be used with certification_country".format(m, attr)) + elif attr in ["include_adult", "include_null_first_air_dates", "screened_theatrically"]: + if attr_data is True: + new_dictionary[attr] = attr_data + elif attr in ["primary_release_date.gte", "primary_release_date.lte", "release_date.gte", "release_date.lte", "air_date.gte", "air_date.lte", "first_air_date.gte", "first_air_date.lte"]: + if re.compile("[0-1]?[0-9][/-][0-3]?[0-9][/-][1-2][890][0-9][0-9]").match(str(attr_data)): + the_date = str(attr_data).split("/") if "/" in str(attr_data) else str(attr_data).split("-") + new_dictionary[attr] = "{}-{}-{}".format(the_date[2], the_date[0], the_date[1]) + elif re.compile("[1-2][890][0-9][0-9][/-][0-1]?[0-9][/-][0-3]?[0-9]").match(str(attr_data)): + the_date = str(attr_data).split("/") if "/" in str(attr_data) else str(attr_data).split("-") + new_dictionary[attr] = "{}-{}-{}".format(the_date[0], the_date[1], the_date[2]) + else: + logger.error("Collection Error: Skipping {} attribute {}: {} must match pattern MM/DD/YYYY e.g. 12/25/2020".format(m, attr, attr_data)) + elif attr in ["primary_release_year", "year", "first_air_date_year"]: + if isinstance(attr_data, int) and 1800 < attr_data and attr_data < 2200: + new_dictionary[attr] = attr_data + else: + logger.error("Collection Error: Skipping {} attribute {}: must be a valid year e.g. 1990".format(m, attr)) + elif attr in ["vote_count.gte", "vote_count.lte", "vote_average.gte", "vote_average.lte", "with_runtime.gte", "with_runtime.lte"]: + if (isinstance(attr_data, int) or isinstance(attr_data, float)) and 0 < attr_data: + new_dictionary[attr] = attr_data + else: + logger.error("Collection Error: Skipping {} attribute {}: must be a valid number greater then 0".format(m, attr)) + elif attr in ["with_cast", "with_crew", "with_people", "with_companies", "with_networks", "with_genres", "without_genres", "with_keywords", "without_keywords", "with_original_language", "timezone"]: + new_dictionary[attr] = attr_data + else: + logger.error("Collection Error: {} attribute {} not supported".format(m, attr)) + elif attr == "limit": + if isinstance(attr_data, int) and attr_data > 0: + new_dictionary[attr] = attr_data + else: + logger.error("Collection Error: Skipping {} attribute {}: must be a valid number greater then 0".format(m, attr)) + else: + logger.error("Collection Error: {} attribute {} not supported".format(m, attr)) + else: + logger.error("Collection Error: {} parameter {} is blank".format(m, attr)) + if len(new_dictionary) > 1: + methods.append((method_name, [new_dictionary])) + else: + logger.error("Collection Error: {} had no valid fields".format(m)) + elif "tautulli" in method_name: + new_dictionary = {} + if method_name == "tautulli_popular": new_dictionary["list_type"] = "popular" + elif method_name == "tautulli_watched": new_dictionary["list_type"] = "watched" + else: raise Failed("Collection Error: {} attribute not supported".format(method_name)) + new_dictionary["list_days"] = get_int(method_name, "list_days", collections[c][m], 30) + new_dictionary["list_size"] = get_int(method_name, "list_size", collections[c][m], 10) + new_dictionary["list_buffer"] = get_int(method_name, "list_buffer", collections[c][m], 20) + methods.append((method_name, [new_dictionary])) + elif method_name == "mal_season": + new_dictionary = {"sort_by": "anime_num_list_users"} + current_time = datetime.now() + if current_time.month in [1, 2, 3]: new_dictionary["season"] = "winter" + elif current_time.month in [4, 5, 6]: new_dictionary["season"] = "spring" + elif current_time.month in [7, 8, 9]: new_dictionary["season"] = "summer" + elif current_time.month in [10, 11, 12]: new_dictionary["season"] = "fall" + new_dictionary["year"] = get_int(method_name, "year", collections[c][m], current_time.year, min=1917, max=current_time.year + 1) + new_dictionary["limit"] = get_int(method_name, "limit", collections[c][m], 100, max=500) + if "sort_by" not in collections[c][m]: logger.warning("Collection Error: mal_season sort_by attribute not found using members as default") + elif not collections[c][m]["sort_by"]: logger.warning("Collection Error: mal_season sort_by attribute is blank using members as default") + elif collections[c][m]["sort_by"] not in util.mal_season_sort: logger.warning("Collection Error: mal_season sort_by attribute {} invalid must be either 'members' or 'score' using members as default".format(collections[c][m]["sort_by"])) + else: new_dictionary["sort_by"] = util.mal_season_sort[collections[c][m]["sort_by"]] + if "season" not in collections[c][m]: logger.warning("Collection Error: mal_season season attribute not found using the current season: {} as default".format(new_dictionary["season"])) + elif not collections[c][m]["season"]: logger.warning("Collection Error: mal_season season attribute is blank using the current season: {} as default".format(new_dictionary["season"])) + elif collections[c][m]["season"] not in util.pretty_seasons: logger.warning("Collection Error: mal_season season attribute {} invalid must be either 'winter', 'spring', 'summer' or 'fall' using the current season: {} as default".format(collections[c][m]["season"], new_dictionary["season"])) + else: new_dictionary["season"] = collections[c][m]["season"] + methods.append((method_name, [new_dictionary])) + elif method_name == "mal_userlist": + new_dictionary = {"status": "all", "sort_by": "list_score"} + if "username" not in collections[c][m]: raise Failed("Collection Error: mal_userlist username attribute is required") + elif not collections[c][m]["username"]: raise Failed("Collection Error: mal_userlist username attribute is blank") + else: new_dictionary["username"] = collections[c][m]["username"] + if "status" not in collections[c][m]: logger.warning("Collection Error: mal_season status attribute not found using all as default") + elif not collections[c][m]["status"]: logger.warning("Collection Error: mal_season status attribute is blank using all as default") + elif collections[c][m]["status"] not in util.mal_userlist_status: logger.warning("Collection Error: mal_season status attribute {} invalid must be either 'all', 'watching', 'completed', 'on_hold', 'dropped' or 'plan_to_watch' using all as default".format(collections[c][m]["status"])) + else: new_dictionary["status"] = util.mal_userlist_status[collections[c][m]["status"]] + if "sort_by" not in collections[c][m]: logger.warning("Collection Error: mal_season sort_by attribute not found using score as default") + elif not collections[c][m]["sort_by"]: logger.warning("Collection Error: mal_season sort_by attribute is blank using score as default") + elif collections[c][m]["sort_by"] not in util.mal_userlist_sort: logger.warning("Collection Error: mal_season sort_by attribute {} invalid must be either 'score', 'last_updated', 'title' or 'start_date' using score as default".format(collections[c][m]["sort_by"])) + else: new_dictionary["sort_by"] = util.mal_userlist_sort[collections[c][m]["sort_by"]] + new_dictionary["limit"] = get_int(method_name, "limit", collections[c][m], 100, max=1000) + methods.append((method_name, [new_dictionary])) + else: + logger.error("Collection Error: {} attribute is not a dictionary: {}".format(m, collections[c][m])) + elif method_name == "plex_all": methods.append((method_name, [""])) + elif method_name in util.all_lists: methods.append((method_name, util.get_list(collections[c][m]))) + elif method_name not in ["sync_mode", "schedule"]: logger.error("Collection Error: {} attribute not supported".format(method_name)) + else: + logger.error("Collection Error: {} attribute is blank".format(m)) + except Failed as e: + logger.error(e) + + for i, f in enumerate(filters): + if i == 0: + logger.info("") + logger.info("Collection Filter {}: {}".format(f[0], f[1])) + + do_arr = False + if library.Radarr: + do_arr = details["add_to_arr"] if "add_to_arr" in details else library.Radarr.add + if library.Sonarr: + do_arr = details["add_to_arr"] if "add_to_arr" in details else library.Sonarr.add + + + items_found = 0 + library.clear_collection_missing(collection_name) + + for method, values in methods: + pretty = util.pretty_names[method] if method in util.pretty_names else method + for value in values: + items = [] + missing_movies = [] + missing_shows = [] + def check_map(input_ids): + movie_ids, show_ids = input_ids + items_found_inside = 0 + if len(movie_ids) > 0: + items_found_inside += len(movie_ids) + for movie_id in movie_ids: + if movie_id in movie_map: items.append(movie_map[movie_id]) + else: missing_movies.append(movie_id) + if len(show_ids) > 0: + items_found_inside += len(show_ids) + for show_id in show_ids: + if show_id in show_map: items.append(show_map[show_id]) + else: missing_shows.append(show_id) + return items_found_inside + logger.info("") + if method == "plex_all": + logger.info("Processing {} {}".format(pretty, "Movies" if library.is_movie else "Shows")) + items = library.Plex.all() + items_found += len(items) + elif method == "plex_collection": + items = value.children + items_found += len(items) + elif method == "plex_search": + search_terms = {} + output = "" + for i, attr_pair in enumerate(value): + if attr_pair[0] == "actor": + search_list = [] + for actor in attr_pair[1]: + search_list.append(library.get_actor_rating_key(actor)) + else: + search_list = attr_pair[1] + final_method = attr_pair[0][:-4] + "!" if attr_pair[0][-4:] == ".not" else attr_pair[0] + if library.is_show: + final_method = "show." + final_method + search_terms[final_method] = search_list + ors = "" + for o, param in enumerate(attr_pair[1]): + ors += "{}{}".format(" OR " if o > 0 else "{}(".format(attr_pair[0]), param) + logger.info("\t\t AND {})".format(ors) if i > 0 else "Processing {}: {})".format(pretty, ors)) + items = library.Plex.search(**search_terms) + items_found += len(items) + elif method == "plex_collectionless": + good_collections = [] + for col in library.get_all_collections(): + keep_collection = True + for pre in value["exclude_prefix"]: + if col.title.startswith(pre) or (col.titleSort and col.titleSort.startswith(pre)): + keep_collection = False + break + for ext in value["exclude"]: + if col.title == ext or (col.titleSort and col.titleSort == ext): + keep_collection = False + break + if keep_collection: + good_collections.append(col.title.lower()) + + all_items = library.Plex.all() + length = 0 + for i, item in enumerate(all_items, 1): + length = util.print_return(length, "Processing: {}/{} {}".format(i, len(all_items), item.title)) + add_item = True + for collection in item.collections: + if collection.tag.lower() in good_collections: + add_item = False + break + if add_item: + items.append(item) + items_found += len(items) + util.print_end(length, "Processed {} {}".format(len(all_items), "Movies" if library.is_movie else "Shows")) + elif "tautulli" in method: + items = library.Tautulli.get_items(library, time_range=value["list_days"], stats_count=value["list_size"], list_type=value["list_type"], stats_count_buffer=value["list_buffer"]) + items_found += len(items) + elif "anidb" in method: items_found += check_map(self.AniDB.get_items(method, value, library.Plex.language)) + elif "mal" in method: items_found += check_map(self.MyAnimeList.get_items(method, value)) + elif "tvdb" in method: items_found += check_map(self.TVDb.get_items(method, value, library.Plex.language)) + elif "imdb" in method: items_found += check_map(self.IMDb.get_items(method, value, library.Plex.language)) + elif "tmdb" in method: items_found += check_map(self.TMDb.get_items(method, value, library.is_movie)) + elif "trakt" in method: items_found += check_map(self.Trakt.get_items(method, value, library.is_movie)) + else: logger.error("Collection Error: {} method not supported".format(method)) + + if len(items) > 0: map = library.add_to_collection(collection_obj if collection_obj else collection_name, items, filters, map=map) + else: logger.error("No items found to add to this collection ") + + if len(missing_movies) > 0 or len(missing_shows) > 0: + logger.info("") + if len(missing_movies) > 0: + missing_movies_with_names = [] + for missing_id in missing_movies: + try: + title = str(self.TMDb.get_movie(missing_id).title) + missing_movies_with_names.append((title, missing_id)) + logger.info("{} Collection | ? | {} (TMDB: {})".format(collection_name, title, missing_id)) + except Failed as e: + logger.error(e) + logger.info("{} Movie{} Missing".format(len(missing_movies), "s" if len(missing_movies) > 1 else "")) + library.save_missing(collection_name, missing_movies_with_names, True) + if do_arr and library.Radarr: + library.Radarr.add_tmdb(missing_movies) + if len(missing_shows) > 0 and library.is_show: + missing_shows_with_names = [] + for missing_id in missing_shows: + try: + title = str(self.TVDb.get_series(library.Plex.language, tvdb_id=missing_id).title.encode("ascii", "replace").decode()) + missing_shows_with_names.append((title, missing_id)) + logger.info("{} Collection | ? | {} (TVDB: {})".format(collection_name, title, missing_id)) + except Failed as e: + logger.error(e) + logger.info("{} Show{} Missing".format(len(missing_shows), "s" if len(missing_shows) > 1 else "")) + library.save_missing(c, missing_shows_with_names, False) + if do_arr and library.Sonarr: + library.Sonarr.add_tvdb(missing_shows) + + library.del_collection_if_empty(collection_name) + + if (sync_collection or collectionless) and items_found > 0: + logger.info("") + count_removed = 0 + for ratingKey, item in map.items(): + if item is not None: + logger.info("{} Collection | - | {}".format(collection_name, item.title)) + item.removeCollection(collection_name) + count_removed += 1 + logger.info("{} {}{} Removed".format(count_removed, "Movie" if library.is_movie else "Show", "s" if count_removed == 1 else "")) + logger.info("") + + try: + plex_collection = library.get_collection(collection_name) + except Failed as e: + logger.debug(e) + continue + + edits = {} + if "sort_title" in details: + edits["titleSort.value"] = details["sort_title"] + edits["titleSort.locked"] = 1 + if "content_rating" in details: + edits["contentRating.value"] = details["content_rating"] + edits["contentRating.locked"] = 1 + if "summary" in details: + edits["summary.value"] = details["summary"] + edits["summary.locked"] = 1 + if len(edits) > 0: + plex_collection.edit(**edits) + plex_collection.reload() + logger.info("Details: have been updated") + logger.debug(edits) + if "collection_mode" in details: + plex_collection.modeUpdate(mode=details["collection_mode"]) + if "collection_order" in details: + plex_collection.sortUpdate(sort=details["collection_order"]) + + if library.asset_directory: + name_mapping = c + if "name_mapping" in collections[c]: + if collections[c]["name_mapping"]: name_mapping = collections[c]["name_mapping"] + else: logger.error("Collection Error: name_mapping attribute is blank") + path = os.path.join(library.asset_directory, "{}".format(name_mapping), "poster.*") + matches = glob.glob(path) + if len(matches) > 0: + for match in matches: posters_found.append(("file", os.path.abspath(match), "asset_directory")) + elif len(posters_found) == 0 and "poster" not in details: logger.warning("poster not found at: {}".format(os.path.abspath(path))) + path = os.path.join(library.asset_directory, "{}".format(name_mapping), "background.*") + matches = glob.glob(path) + if len(matches) > 0: + for match in matches: backgrounds_found.append(("file", os.path.abspath(match), "asset_directory")) + elif len(backgrounds_found) == 0 and "background" not in details: logger.warning("background not found at: {}".format(os.path.abspath(path))) + + poster = util.choose_from_list(posters_found, "poster", list_type="tuple") + if not poster and "poster" in details: poster = details["poster"] + if poster: + if poster[0] == "url": plex_collection.uploadPoster(url=poster[1]) + else: plex_collection.uploadPoster(filepath=poster[1]) + logger.info("Detail: {} updated poster to [{}] {}".format(poster[2], poster[0], poster[1])) + + background = util.choose_from_list(backgrounds_found, "background", list_type="tuple") + if not background and "background" in details: background = details["background"] + if background: + if background[0] == "url": plex_collection.uploadArt(url=background[1]) + else: plex_collection.uploadArt(filepath=background[1]) + logger.info("Detail: {} updated background to [{}] {}".format(background[2], background[0], background[1])) + except Exception as e: + util.print_stacktrace() + logger.error("Unknown Error: {}".format(e)) + + logger.info("") + util.seperator("Unmanaged Collections in {} Library".format(library.name)) + logger.info("") + unmanaged_count = 0 + collections_in_plex = [str(pcol) for pcol in collections] + for col in library.get_all_collections(): + if col.title not in collections_in_plex: + logger.info(col.title) + unmanaged_count += 1 + logger.info("{} Unmanaged Collections".format(unmanaged_count)) + else: + logger.error("No collection to update") + + def map_guids(self, library): + movie_map = {} + show_map = {} + length = 0 + count = 0 + logger.info("Mapping {} Library: {}".format("Movie" if library.is_movie else "Show", library.name)) + items = library.Plex.all() + for i, item in enumerate(items, 1): + length = util.print_return(length, "Processing: {}/{} {}".format(i, len(items), item.title)) + id_type, main_id = self.get_id(item, library, length) + if id_type == "movie": + movie_map[main_id] = item.ratingKey + elif id_type == "show": + show_map[main_id] = item.ratingKey + util.print_end(length, "Processed {} {}".format(len(items), "Movies" if library.is_movie else "Shows")) + return movie_map, show_map + + def get_id(self, item, library, length): + expired = None + tmdb_id = None + imdb_id = None + tvdb_id = None + anidb_id = None + mal_id = None + error_message = None + if self.Cache: + if library.is_movie: tmdb_id, expired = self.Cache.get_tmdb_id("movie", plex_guid=item.guid) + else: tvdb_id, expired = self.Cache.get_tvdb_id("show", plex_guid=item.guid) + if not tvdb_id and library.is_show: + tmdb_id, expired = self.Cache.get_tmdb_id("show", plex_guid=item.guid) + anidb_id, expired = self.Cache.get_anidb_id("show", plex_guid=item.guid) + if expired or (not tmdb_id and library.is_movie) or (not tvdb_id and not tmdb_id and library.is_show): + guid = urlparse(item.guid) + item_type = guid.scheme.split(".")[-1] + check_id = guid.netloc + + if item_type == "plex" and library.is_movie: tmdb_id, imdb_id = library.get_ids(item) + elif item_type == "imdb": imdb_id = check_id + elif item_type == "thetvdb": tvdb_id = check_id + elif item_type == "themoviedb": tmdb_id = check_id + elif item_type == "hama": + if check_id.startswith("tvdb"): tvdb_id = re.search("-(.*)", check_id).group(1) + elif check_id.startswith("anidb"): anidb_id = re.search("-(.*)", check_id).group(1) + else: error_message = "Hama Agent ID: {} not supported".format(check_id) + elif item_type == "myanimelist": mal_id = check_id + elif item_type == "local": error_message = "No match in Plex" + else: error_message = "Agent {} not supported".format(item_type) + + if not error_message: + if anidb_id and not tvdb_id: + try: tvdb_id = self.AniDB.convert_anidb_to_tvdb(anidb_id) + except Failed: pass + if anidb_id and not imdb_id: + try: imdb_id = self.AniDB.convert_anidb_to_imdb(anidb_id) + except Failed: pass + if mal_id: + try: + ids = self.MyAnimeListIDList.find_mal_ids(mal_id) + if "thetvdb_id" in ids and int(ids["thetvdb_id"]) > 0: tvdb_id = int(ids["thetvdb_id"]) + elif "themoviedb_id" in ids and int(ids["themoviedb_id"]) > 0: tmdb_id = int(ids["themoviedb_id"]) + else: raise Failed("MyAnimeList Error: MyAnimeList ID: {} has no other IDs associated with it".format(mal_id)) + except Failed: + pass + if mal_id and not tvdb_id: + try: tvdb_id = self.MyAnimeListIDList.convert_mal_to_tvdb(mal_id) + except Failed: pass + if mal_id and not tmdb_id: + try: tmdb_id = self.MyAnimeListIDList.convert_mal_to_tmdb(mal_id) + except Failed: pass + if not tmdb_id and imdb_id and self.TMDb: + try: tmdb_id = self.TMDb.convert_imdb_to_tmdb(imdb_id) + except Failed: pass + if not tmdb_id and imdb_id and self.Trakt: + try: tmdb_id = self.Trakt.convert_imdb_to_tmdb(imdb_id) + except Failed: pass + if not tmdb_id and tvdb_id and self.TMDb: + try: tmdb_id = self.TMDb.convert_tvdb_to_tmdb(tvdb_id) + except Failed: pass + if not tmdb_id and tvdb_id and self.Trakt: + try: tmdb_id = self.Trakt.convert_tvdb_to_tmdb(tvdb_id) + except Failed: pass + if not imdb_id and tmdb_id and self.TMDb: + try: imdb_id = self.TMDb.convert_tmdb_to_imdb(tmdb_id) + except Failed: pass + if not imdb_id and tmdb_id and self.Trakt: + try: imdb_id = self.Trakt.convert_tmdb_to_imdb(tmdb_id) + except Failed: pass + if not imdb_id and tvdb_id and self.Trakt: + try: imdb_id = self.Trakt.convert_tmdb_to_imdb(tmdb_id) + except Failed: pass + if not tvdb_id and tmdb_id and self.TMDb and library.is_show: + try: tvdb_id = self.TMDb.convert_tmdb_to_tvdb(tmdb_id) + except Failed: pass + if not tvdb_id and tmdb_id and self.Trakt and library.is_show: + try: tvdb_id = self.Trakt.convert_tmdb_to_tvdb(tmdb_id) + except Failed: pass + if not tvdb_id and imdb_id and self.Trakt and library.is_show: + try: tvdb_id = self.Trakt.convert_imdb_to_tvdb(imdb_id) + except Failed: pass + if tvdb_id and not anidb_id: + try: anidb_id = self.AniDB.convert_tvdb_to_anidb(tvdb_id) + except Failed: pass + if imdb_id and not anidb_id: + try: anidb_id = self.AniDB.convert_imdb_to_anidb(imdb_id) + except Failed: pass + if tvdb_id and not mal_id: + try: mal_id = self.MyAnimeListIDList.convert_tvdb_to_mal(tvdb_id) + except Failed: pass + if tmdb_id and not mal_id: + try: mal_id = self.MyAnimeListIDList.convert_tmdb_to_mal(tmdb_id) + except Failed: pass + + if (not tmdb_id and library.is_movie) or (not tvdb_id and not ((anidb_id or mal_id) and tmdb_id) and library.is_show): + service_name = "TMDb ID" if library.is_movie else "TVDb ID" + + if self.TMDb and self.Trakt: api_name = "TMDb or Trakt" + elif self.TMDb: api_name = "TMDb" + elif self.Trakt: api_name = "Trakt" + else: api_name = None + + if tmdb_id and imdb_id: id_name = "TMDb ID: {} or IMDb ID: {}".format(tmdb_id, imdb_id) + elif imdb_id and tvdb_id: id_name = "IMDb ID: {} or TVDb ID: {}".format(imdb_id, tvdb_id) + elif tmdb_id: id_name = "TMDb ID: {}".format(tmdb_id) + elif imdb_id: id_name = "IMDb ID: {}".format(imdb_id) + elif tvdb_id: id_name = "TVDb ID: {}".format(tvdb_id) + else: id_name = None + + if anidb_id and not tmdb_id and not tvdb_id: error_message = "Unable to convert AniDb ID: {} to TMDb ID or TVDb ID".format(anidb_id) + elif mal_id and not tmdb_id and not tvdb_id: error_message = "Unable to convert MyAnimeList ID: {} to TMDb ID or TVDb ID".format(mal_id) + elif id_name and api_name: error_message = "Unable to convert {} to {} using {}".format(id_name, service_name, api_name) + elif id_name: error_message = "Configure TMDb or Trakt to covert {} to {}".format(id_name, service_name) + else: error_message = "No ID to convert to {}".format(service_name) + if self.Cache and (tmdb_id and library.is_movie) or ((tvdb_id or ((anidb_id or mal_id) and tmdb_id)) and library.is_show): + util.print_end(length, "Cache | {} | {:<46} | {:<6} | {:<10} | {:<6} | {:<5} | {:<5} | {}".format("^" if expired is True else "+", item.guid, tmdb_id if tmdb_id else "None", imdb_id if imdb_id else "None", tvdb_id if tvdb_id else "None", anidb_id if anidb_id else "None", mal_id if mal_id else "None", item.title)) + self.Cache.update_guid("movie" if library.is_movie else "show", item.guid, tmdb_id, imdb_id, tvdb_id, anidb_id, mal_id, expired) + if tmdb_id and library.is_movie: return "movie", tmdb_id + elif tvdb_id and library.is_show: return "show", tvdb_id + elif (anidb_id or mal_id) and tmdb_id: return "movie", tmdb_id + else: + util.print_end(length, "{} {:<46} | {} for {}".format("Cache | ! |" if self.Cache else "Mapping Error:", item.guid, error_message, item.title)) + return None, None diff --git a/modules/imdb.py b/modules/imdb.py new file mode 100644 index 00000000..9866ba02 --- /dev/null +++ b/modules/imdb.py @@ -0,0 +1,131 @@ +import logging, math, re, requests, time +from lxml import html +from modules import util +from modules.util import Failed +from retrying import retry + +logger = logging.getLogger("Plex Meta Manager") + +class IMDbAPI: + def __init__(self, Cache=None, TMDb=None, Trakt=None, TVDb=None): + if TMDb is None and Trakt is None: + raise Failed("IMDb Error: IMDb requires either TMDb or Trakt") + self.Cache = Cache + self.TMDb = TMDb + self.Trakt = Trakt + self.TVDb = TVDb + + def get_imdb_ids_from_url(self, imdb_url, language, limit): + imdb_url = imdb_url.strip() + if not imdb_url.startswith("https://www.imdb.com/list/ls") and not imdb_url.startswith("https://www.imdb.com/search/title/?"): + raise Failed("IMDb Error: {} must begin with either:\n| https://www.imdb.com/list/ls (For Lists)\n| https://www.imdb.com/search/title/? (For Searches)".format(imdb_url)) + + if imdb_url.startswith("https://www.imdb.com/list/ls"): + try: list_id = re.search("(\\d+)", str(imdb_url)).group(1) + except AttributeError: raise Failed("IMDb Error: Failed to parse List ID from {}".format(imdb_url)) + current_url = "https://www.imdb.com/search/title/?lists=ls{}".format(list_id) + else: + current_url = imdb_url + header = {"Accept-Language": language} + length = 0 + imdb_ids = [] + response = self.send_request(current_url, header) + try: results = html.fromstring(response).xpath("//div[@class='desc']/span/text()")[0].replace(",", "") + except IndexError: raise Failed("IMDb Error: Failed to parse URL: {}".format(imdb_url)) + try: total = int(re.findall("(\\d+) title", results)[0]) + except IndexError: raise Failed("IMDb Error: No Results at URL: {}".format(imdb_url)) + if "&start=" in current_url: current_url = re.sub("&start=\d+", "", current_url) + if "&count=" in current_url: current_url = re.sub("&count=\d+", "", current_url) + if limit < 1 or total < limit: limit = total + remainder = limit % 250 + if remainder == 0: remainder = 250 + num_of_pages = math.ceil(int(limit) / 250) + for i in range(1, num_of_pages + 1): + start_num = (i - 1) * 250 + 1 + length = util.print_return(length, "Parsing Page {}/{} {}-{}".format(i, num_of_pages, start_num, limit if i == num_of_pages else i * 250)) + response = self.send_request("{}&count={}&start={}".format(current_url, remainder if i == num_of_pages else 250, start_num), header) + imdb_ids.extend(html.fromstring(response).xpath("//div[contains(@class, 'lister-item-image')]//a/img//@data-tconst")) + util.print_end(length) + if imdb_ids: return imdb_ids + else: raise Failed("IMDb Error: No Movies Found at {}".format(imdb_url)) + + @retry(stop_max_attempt_number=6, wait_fixed=10000) + def send_request(self, url, header): + return requests.get(url, headers=header).content + + 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)) + show_ids = [] + movie_ids = [] + if method == "imdb_id": + if status_message: + logger.info("Processing {}: {}".format(pretty, data)) + tmdb_id, tvdb_id = self.convert_from_imdb(data, language) + if tmdb_id: movie_ids.append(tmdb_id) + if tvdb_id: show_ids.append(tvdb_id) + elif method == "imdb_list": + if status_message: + logger.info("Processing {}: {}".format(pretty,"{} Items at {}".format(data["limit"], data["url"]) if data["limit"] > 0 else data["url"])) + imdb_ids = self.get_imdb_ids_from_url(data["url"], language, data["limit"]) + total_ids = len(imdb_ids) + length = 0 + for i, imdb_id in enumerate(imdb_ids, 1): + length = util.print_return(length, "Converting IMDb ID {}/{}".format(i, total_ids)) + try: + tmdb_id, tvdb_id = self.convert_from_imdb(imdb_id, language) + if tmdb_id: movie_ids.append(tmdb_id) + if tvdb_id: show_ids.append(tvdb_id) + except Failed as e: logger.warning(e) + util.print_end(length, "Processed {} IMDb IDs".format(total_ids)) + else: + raise Failed("IMDb Error: Method {} not supported".format(method)) + if status_message: + 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) + update_tmdb = False + if not tmdb_id: + tmdb_id, update_tmdb = self.Cache.get_tmdb_from_imdb(imdb_id) + if update_tmdb: + tmdb_id = None + update_tvdb = False + if not tvdb_id: + tvdb_id, update_tvdb = self.Cache.get_tvdb_from_imdb(imdb_id) + if update_tvdb: + tvdb_id = None + else: + tmdb_id = None + tvdb_id = None + from_cache = tmdb_id is not None or tvdb_id is not None + + if not tmdb_id and not tvdb_id and self.TMDb: + try: tmdb_id = self.TMDb.convert_imdb_to_tmdb(imdb_id) + except Failed: pass + if not tmdb_id and not tvdb_id and self.TMDb: + try: tvdb_id = self.TMDb.convert_imdb_to_tvdb(imdb_id) + except Failed: pass + if not tmdb_id and not tvdb_id and self.Trakt: + try: tmdb_id = self.Trakt.convert_imdb_to_tmdb(imdb_id) + except Failed: pass + if not tmdb_id and not tvdb_id and self.Trakt: + try: tvdb_id = self.Trakt.convert_imdb_to_tvdb(imdb_id) + except Failed: pass + try: + if tmdb_id and not from_cache: self.TMDb.get_movie(tmdb_id) + except Failed: tmdb_id = None + try: + if tvdb_id and not from_cache: self.TVDb.get_series(language, tvdb_id=tvdb_id) + except Failed: tvdb_id = None + if not tmdb_id and not tvdb_id : raise Failed("IMDb Error: No TMDb ID or TVDb ID found for IMDb: {}".format(imdb_id)) + if self.Cache: + if tmdb_id and update_tmdb is not False: + self.Cache.update_imdb("movie", update_tmdb, imdb_id, tmdb_id) + if tvdb_id and update_tvdb is not False: + self.Cache.update_imdb("show", update_tvdb, imdb_id, tvdb_id) + return tmdb_id, tvdb_id diff --git a/modules/mal.py b/modules/mal.py new file mode 100644 index 00000000..38654e96 --- /dev/null +++ b/modules/mal.py @@ -0,0 +1,193 @@ +import json, logging, re, requests, secrets, webbrowser +from modules import util +from modules.util import Failed, TimeoutExpired +from retrying import retry +from ruamel import yaml + +logger = logging.getLogger("Plex Meta Manager") + +class MyAnimeListIDList: + def __init__(self): + self.ids = json.loads(requests.get("https://raw.githubusercontent.com/Fribb/anime-lists/master/animeMapping_full.json").content) + + def convert_mal_to_tvdb(self, mal_id): return self.convert_mal(mal_id, "mal_id", "thetvdb_id") + def convert_mal_to_tmdb(self, mal_id): return self.convert_mal(mal_id, "mal_id", "themoviedb_id") + def convert_tvdb_to_mal(self, tvdb_id): return self.convert_mal(tvdb_id, "thetvdb_id", "mal_id") + def convert_tmdb_to_mal(self, tmdb_id): return self.convert_mal(tmdb_id, "themoviedb_id", "mal_id") + def convert_mal(self, input_id, from_id, to_id): + for attrs in self.ids: + if from_id in attrs and int(attrs[from_id]) == int(input_id) and to_id in attrs and int(attrs[to_id]) > 0: + return attrs[to_id] + raise Failed("MyAnimeList Error: {} ID not found for {}: {}".format(util.pretty_ids[to_id], util.pretty_ids[from_id], input_id)) + + def find_mal_ids(self, mal_id): + for mal in self.ids: + if "mal_id" in mal and int(mal["mal_id"]) == int(mal_id): + return mal + raise Failed("MyAnimeList Error: MyAnimeList ID: {} not found".format(mal_id)) + +class MyAnimeListAPI: + def __init__(self, params, MyAnimeListIDList, authorization=None): + self.urls = { + "oauth_token": "https://myanimelist.net/v1/oauth2/token", + "oauth_authorize": "https://myanimelist.net/v1/oauth2/authorize", + "ranking": "https://api.myanimelist.net/v2/anime/ranking", + "season": "https://api.myanimelist.net/v2/anime/season", + "suggestions": "https://api.myanimelist.net/v2/anime/suggestions", + "user": "https://api.myanimelist.net/v2/users" + } + self.client_id = params["client_id"] + self.client_secret = params["client_secret"] + self.config_path = params["config_path"] + self.authorization = authorization + self.MyAnimeListIDList = MyAnimeListIDList + if not self.save_authorization(self.authorization): + if not self.refresh_authorization(): + self.get_authorization() + + def get_authorization(self): + code_verifier = secrets.token_urlsafe(100)[:128] + url = "{}?response_type=code&client_id={}&code_challenge={}".format(self.urls["oauth_authorize"], self.client_id, code_verifier) + logger.info("") + logger.info("Navigate to: {}".format(url)) + logger.info("") + logger.info("Login and click the Allow option. You will then be redirected to a localhost") + logger.info("url that most likely won't load, which is fine. Copy the URL and paste it below") + webbrowser.open(url, new=2) + try: url = util.logger_input("URL").strip() + except TimeoutExpired: raise Failed("Input Timeout: URL required.") + if not url: raise Failed("MyAnimeList Error: No input MyAnimeList code required.") + match = re.search("code=([^&]+)", str(url)) + if not match: + raise Failed("MyAnimeList Error: Invalid URL") + code = match.group(1) + data = { + "client_id": self.client_id, + "client_secret": self.client_secret, + "code": code, + "code_verifier": code_verifier, + "grant_type": "authorization_code" + } + new_authorization = self.oauth_request(data) + if "error" in new_authorization: + raise Failed("MyAnimeList Error: Invalid code") + if not self.save_authorization(new_authorization): + raise Failed("MyAnimeList Error: New Authorization Failed") + + def check_authorization(self, authorization): + try: + self.send_request(self.urls["suggestions"], authorization=authorization) + return True + except Failed as e: + logger.debug(e) + return False + + def refresh_authorization(self): + if self.authorization and "refresh_token" in self.authorization and self.authorization["refresh_token"]: + logger.info("Refreshing Access Token...") + data = { + "client_id": self.client_id, + "client_secret": self.client_secret, + "refresh_token": self.authorization["refresh_token"], + "grant_type": "refresh_token" + } + refreshed_authorization = self.oauth_request(data) + return self.save_authorization(refreshed_authorization) + return False + + def save_authorization(self, authorization): + if authorization is not None and "access_token" in authorization and authorization["access_token"] and self.check_authorization(authorization): + if self.authorization != authorization: + yaml.YAML().allow_duplicate_keys = True + config, ind, bsi = yaml.util.load_yaml_guess_indent(open(self.config_path)) + config["mal"]["authorization"] = { + "access_token": authorization["access_token"], + "token_type": authorization["token_type"], + "expires_in": authorization["expires_in"], + "refresh_token": authorization["refresh_token"] + } + logger.info("Saving authorization information to {}".format(self.config_path)) + yaml.round_trip_dump(config, open(self.config_path, "w"), indent=ind, block_seq_indent=bsi) + self.authorization = authorization + return True + return False + + @retry(stop_max_attempt_number=6, wait_fixed=10000) + def oauth_request(self, data): + return requests.post(self.urls["oauth_token"], data).json() + + @retry(stop_max_attempt_number=6, wait_fixed=10000, retry_on_exception=util.retry_if_not_failed) + def send_request(self, url, authorization=None): + new_authorization = authorization if authorization else self.authorization + response = requests.get(url, headers={"Authorization": "Bearer {}".format(new_authorization["access_token"])}).json() + if "error" in response: raise Failed("MyAnimeList Error: {}".format(response["error"])) + else: return response + + def parse_mal_ids(self, data): + mal_ids = [] + if "data" in data: + for d in data["data"]: + mal_ids.append(d["node"]["id"]) + return mal_ids + + def get_username(self): + return self.send_request("{}/@me".format(self.urls["user"]))["name"] + + def get_ranked(self, ranking_type, limit): + url = "{}?ranking_type={}&limit={}".format(self.urls["ranking"], ranking_type, limit) + return self.parse_mal_ids(self.send_request(url)) + + def get_season(self, season, year, sort_by, limit): + url = "{}/{}/{}?sort={}&limit={}".format(self.urls["season"], year, season, sort_by, limit) + return self.parse_mal_ids(self.send_request(url)) + + def get_suggestions(self, limit): + url = "{}?limit={}".format(self.urls["suggestions"], limit) + return self.parse_mal_ids(self.send_request(url)) + + def get_userlist(self, username, status, sort_by, limit): + url = "{}/{}/animelist?{}sort={}&limit={}".format(self.urls["user"], username, "" if status == "all" else "status={}&".format(status), sort_by, limit) + return self.parse_mal_ids(self.send_request(url)) + + def get_items(self, method, data, status_message=True): + if status_message: + logger.debug("Data: {}".format(data)) + pretty = util.pretty_names[method] if method in util.pretty_names else method + if method == "mal_id": + mal_ids = [data] + if status_message: + logger.info("Processing {}: {}".format(pretty, data)) + elif method in util.mal_ranked_name: + mal_ids = self.get_ranked(util.mal_ranked_name[method], data) + if status_message: + logger.info("Processing {}: {} Anime".format(pretty, data)) + elif method == "mal_season": + mal_ids = self.get_season(data["season"], data["year"], data["sort_by"], data["limit"]) + if status_message: + logger.info("Processing {}: {} Anime from {} {} sorted by {}".format(pretty, data["limit"], util.pretty_seasons[data["season"]], data["year"], util.mal_pretty[data["sort_by"]])) + elif method == "mal_suggested": + mal_ids = self.get_suggestions(data) + if status_message: + logger.info("Processing {}: {} Anime".format(pretty, data)) + elif method == "mal_userlist": + mal_ids = self.get_userlist(data["username"], data["status"], data["sort_by"], data["limit"]) + if status_message: + logger.info("Processing {}: {} Anime from {}'s {} list sorted by {}".format(pretty, data["limit"], self.get_username() if data["username"] == "@me" else data["username"], util.mal_pretty[data["status"]], util.mal_pretty[data["sort_by"]])) + else: + raise Failed("MyAnimeList Error: Method {} not supported".format(method)) + show_ids = [] + movie_ids = [] + for mal_id in mal_ids: + try: + ids = self.MyAnimeListIDList.find_mal_ids(mal_id) + if "thetvdb_id" in ids and int(ids["thetvdb_id"]) > 0: show_ids.append(int(ids["thetvdb_id"])) + elif "themoviedb_id" in ids and int(ids["themoviedb_id"]) > 0: movie_ids.append(int(ids["themoviedb_id"])) + else: raise Failed("MyAnimeList Error: MyAnimeList ID: {} has no other IDs associated with it".format(mal_id)) + except Failed as e: + if status_message: + logger.error(e) + if status_message: + logger.debug("MyAnimeList IDs Found: {}".format(mal_ids)) + logger.debug("Shows Found: {}".format(show_ids)) + logger.debug("Movies Found: {}".format(movie_ids)) + return movie_ids, show_ids diff --git a/modules/plex.py b/modules/plex.py new file mode 100644 index 00000000..e22e7007 --- /dev/null +++ b/modules/plex.py @@ -0,0 +1,430 @@ +import logging, os, requests +from bs4 import BeautifulSoup +from modules import util +from modules.radarr import RadarrAPI +from modules.sonarr import SonarrAPI +from modules.tautulli import TautulliAPI +from modules.util import Failed +from plexapi.exceptions import BadRequest, NotFound, Unauthorized +from plexapi.library import Collections, MovieSection, ShowSection +from plexapi.server import PlexServer +from plexapi.video import Movie, Show +from retrying import retry +from ruamel import yaml +from urllib.parse import urlparse +from urllib.request import Request +from urllib.request import urlopen + +logger = logging.getLogger("Plex Meta Manager") + +class PlexAPI: + def __init__(self, params): + try: self.PlexServer = PlexServer(params["plex"]["url"], params["plex"]["token"], timeout=60) + except Unauthorized: raise Failed("Plex Error: Plex token is invalid") + except ValueError as e: raise Failed("Plex Error: {}".format(e)) + except requests.exceptions.ConnectionError as e: + util.print_stacktrace() + raise Failed("Plex Error: Plex url is invalid") + self.is_movie = params["library_type"] == "movie" + self.is_show = params["library_type"] == "show" + self.Plex = next((s for s in self.PlexServer.library.sections() if s.title == params["name"] and ((self.is_movie and isinstance(s, MovieSection)) or (self.is_show and isinstance(s, ShowSection)))), None) + if not self.Plex: raise Failed("Plex Error: Plex Library {} not found".format(params["name"])) + try: self.data, ind, bsi = yaml.util.load_yaml_guess_indent(open(params["metadata_path"], encoding="utf-8")) + except yaml.scanner.ScannerError as e: raise Failed("YAML Error: {}".format(str(e).replace("\n", "\n|\t "))) + + self.metadata = None + if "metadata" in self.data: + if self.data["metadata"]: self.metadata = self.data["metadata"] + else: logger.warning("Config Warning: metadata attribute is blank") + else: logger.warning("Config Warning: metadata attribute not found") + + self.collections = None + if "collections" in self.data: + if self.data["collections"]: self.collections = self.data["collections"] + else: logger.warning("Config Warning: collections attribute is blank") + else: logger.warning("Config Warning: collections attribute not found") + + if self.metadata is None and self.collections is None: + raise Failed("YAML Error: metadata attributes or collections attribute required") + + if params["asset_directory"]: + logger.info("Using Asset Directory: {}".format(params["asset_directory"])) + + self.Radarr = None + if params["tmdb"] and params["radarr"]: + logger.info("Connecting to {} library's Radarr...".format(params["name"])) + try: self.Radarr = RadarrAPI(params["tmdb"], params["radarr"]) + except Failed as e: logger.error(e) + logger.info("{} library's Radarr Connection {}".format(params["name"], "Failed" if self.Radarr is None else "Successful")) + + self.Sonarr = None + if params["tvdb"] and params["sonarr"]: + logger.info("Connecting to {} library's Sonarr...".format(params["name"])) + try: self.Sonarr = SonarrAPI(params["tvdb"], params["sonarr"], self.Plex.language) + except Failed as e: logger.error(e) + logger.info("{} library's Sonarr Connection {}".format(params["name"], "Failed" if self.Sonarr is None else "Successful")) + + self.Tautulli = None + if params["tautulli"]: + logger.info("Connecting to {} library's Tautulli...".format(params["name"])) + try: self.Tautulli = TautulliAPI(params["tautulli"]) + except Failed as e: logger.error(e) + logger.info("{} library's Tautulli Connection {}".format(params["name"], "Failed" if self.Tautulli is None else "Successful")) + + self.name = params["name"] + + self.missing_path = os.path.join(os.path.dirname(os.path.abspath(params["metadata_path"])), "{}_missing.yml".format(os.path.splitext(os.path.basename(params["metadata_path"]))[0])) + self.metadata_path = params["metadata_path"] + self.asset_directory = params["asset_directory"] + self.sync_mode = params["sync_mode"] + self.plex = params["plex"] + self.radarr = params["radarr"] + self.sonarr = params["sonarr"] + self.tautulli = params["tautulli"] + + @retry(stop_max_attempt_number=6, wait_fixed=10000) + def search(self, title, libtype=None, year=None): + if libtype is not None and year is not None: return self.Plex.search(title=title, year=year, libtype=libtype) + elif libtype is not None: return self.Plex.search(title=title, libtype=libtype) + elif year is not None: return self.Plex.search(title=title, year=year) + else: return self.Plex.search(title=title) + + @retry(stop_max_attempt_number=6, wait_fixed=10000) + def fetchItem(self, data): + return self.PlexServer.fetchItem(data) + + @retry(stop_max_attempt_number=6, wait_fixed=10000) + def server_search(self, data): + return self.PlexServer.search(data) + + def get_all_collections(self): + return self.Plex.search(libtype="collection") + + def get_collection(self, data): + collection = util.choose_from_list(self.search(str(data), libtype="collection"), "collection", str(data), exact=True) + if collection: return collection + else: raise Failed("Plex Error: Collection {} not found".format(data)) + + def get_item(self, data, year=None): + if isinstance(data, (int, Movie, Show)): + try: return self.fetchItem(data.ratingKey if isinstance(data, (Movie, Show)) else data) + except BadRequest: raise Failed("Plex Error: Item {} not found".format(data)) + else: + item_list = self.search(title=data) if year is None else self.search(data, year=year) + item = util.choose_from_list(item_list, "movie" if self.is_movie else "show", data) + if item: return item + else: raise Failed("Plex Error: Item {} not found".format(data)) + + def validate_collections(self, collections): + valid_collections = [] + for collection in collections: + try: valid_collections.append(self.get_collection(collection)) + except Failed as e: logger.error(e) + if len(valid_collections) == 0: + raise Failed("Collection Error: No valid Plex Collections in {}".format(collections[c][m])) + return valid_collections + + def get_actor_rating_key(self, data): + movie_rating_key = None + for result in self.server_search(data): + entry = str(result).split(":") + entry[0] = entry[0][1:] + if entry[0] == "Movie": + movie_rating_key = int(entry[1]) + break + if movie_rating_key: + for role in self.fetchItem(movie_rating_key).roles: + role = str(role).split(":") + if data.upper().replace(" ", "-") == role[2][:-1].upper(): + return int(role[1]) + raise Failed("Plex Error: Actor: {} not found".format(data)) + + def get_ids(self, movie): + req = Request("{}{}".format(self.url, movie.key)) + req.add_header("X-Plex-Token", self.token) + req.add_header("User-Agent", "Mozilla/5.0") + with urlopen(req) as response: + contents = response.read() + tmdb_id = None + imdb_id = None + for guid_tag in BeautifulSoup(contents, "lxml").find_all("guid"): + agent = urlparse(guid_tag["id"]).scheme + guid = urlparse(guid_tag["id"]).netloc + if agent == "tmdb": tmdb_id = guid + elif agent == "imdb": imdb_id = guid + return tmdb_id, imdb_id + + def del_collection_if_empty(self, collection): + missing_data = {} + if not os.path.exists(self.missing_path): + with open(self.missing_path, "w"): pass + try: + missing_data, ind, bsi = yaml.util.load_yaml_guess_indent(open(self.missing_path)) + if not missing_data: + missing_data = {} + if collection in missing_data and len(missing_data[collection]) == 0: + del missing_data[collection] + yaml.round_trip_dump(missing_data, open(self.missing_path, "w"), indent=ind, block_seq_indent=bsi) + except yaml.scanner.ScannerError as e: + logger.error("YAML Error: {}".format(str(e).replace("\n", "\n|\t "))) + + def clear_collection_missing(self, collection): + missing_data = {} + if not os.path.exists(self.missing_path): + with open(self.missing_path, "w"): pass + try: + missing_data, ind, bsi = yaml.util.load_yaml_guess_indent(open(self.missing_path)) + if not missing_data: + missing_data = {} + if collection in missing_data: + missing_data[collection.encode("ascii", "replace").decode()] = {} + yaml.round_trip_dump(missing_data, open(self.missing_path, "w"), indent=ind, block_seq_indent=bsi) + except yaml.scanner.ScannerError as e: + logger.error("YAML Error: {}".format(str(e).replace("\n", "\n|\t "))) + + def save_missing(self, collection, items, is_movie): + missing_data = {} + if not os.path.exists(self.missing_path): + with open(self.missing_path, "w"): pass + try: + missing_data, ind, bsi = yaml.util.load_yaml_guess_indent(open(self.missing_path)) + if not missing_data: + missing_data = {} + col_name = collection.encode("ascii", "replace").decode() + if col_name not in missing_data: + missing_data[col_name] = {} + section = "Movies Missing (TMDb IDs)" if is_movie else "Shows Missing (TVDb IDs)" + if section not in missing_data[col_name]: + missing_data[col_name][section] = {} + for title, item_id in items: + missing_data[col_name][section][int(item_id)] = str(title).encode("ascii", "replace").decode() + yaml.round_trip_dump(missing_data, open(self.missing_path, "w"), indent=ind, block_seq_indent=bsi) + except yaml.scanner.ScannerError as e: + logger.error("YAML Error: {}".format(str(e).replace("\n", "\n|\t "))) + + def add_to_collection(self, collection, items, filters, map={}): + name = collection.title if isinstance(collection, Collections) else collection + collection_items = collection.children if isinstance(collection, Collections) else [] + + total = len(items) + max_length = len(str(total)) + length = 0 + for i, item in enumerate(items, 1): + current = self.get_item(item) + match = True + if filters: + length = util.print_return(length, "Filtering {}/{} {}".format((" " * (max_length - len(str(i)))) + str(i), total, current.title)) + for f in filters: + modifier = f[0][-4:] + method = util.filter_alias[f[0][:-4]] if modifier in [".not", ".lte", ".gte"] else util.filter_alias[f[0]] + if method == "max_age": + threshold_date = datetime.now() - timedelta(days=f[1]) + attr = getattr(current, "originallyAvailableAt") + if attr is None or attr < threshold_date: + match = False + break + elif modifier in [".gte", ".lte"]: + if method == "originallyAvailableAt": + threshold_date = datetime.strptime(f[1], "%m/%d/%y") + attr = getattr(current, "originallyAvailableAt") + if (modifier == ".lte" and attr > threshold_date) or (modifier == ".gte" and attr < threshold_date): + match = False + break + elif method in ["year", "rating"]: + attr = getattr(current, method) + if (modifier == ".lte" and attr > f[1]) or (modifier == ".gte" and attr < f[1]): + match = False + break + else: + terms = f[1] if isinstance(f[1], list) else str(f[1]).split(", ") + if method in ["video_resolution", "audio_language", "subtitle_language"]: + for media in current.media: + if method == "video_resolution": attrs = [media.videoResolution] + for part in media.parts: + if method == "audio_language": attrs = ([a.language for a in part.audioStreams()]) + if method == "subtitle_language": attrs = ([s.language for s in part.subtitleStreams()]) + elif method in ["contentRating", "studio", "year", "rating", "originallyAvailableAt"]: attrs = [str(getattr(current, method))] + elif method in ["actors", "countries", "directors", "genres", "writers", "collections"]: attrs = [getattr(x, "tag") for x in getattr(current, method)] + + if (not list(set(terms) & set(attrs)) and modifier != ".not") or (list(set(terms) & set(attrs)) and modifier == ".not"): + match = False + break + length = util.print_return(length, "Filtering {}/{} {}".format((" " * (max_length - len(str(i)))) + str(i), total, current.title)) + if match: + util.print_end(length, "{} Collection | {} | {}".format(name, "=" if current in collection_items else "+", current.title)) + if current in collection_items: map[current.ratingKey] = None + else: current.addCollection(name) + media_type = "{}{}".format("Movie" if self.is_movie else "Show", "s" if total > 1 else "") + util.print_end(length, "{} {} Processed".format(total, media_type)) + return map + + def update_metadata(self): + logger.info("") + util.seperator("{} Library Metadata".format(self.name)) + logger.info("") + if not self.metadata: + raise Failed("No metadata to edit") + for m in self.metadata: + logger.info("") + util.seperator() + logger.info("") + year = None + if "year" in self.metadata[m]: + now = datetime.datetime.now() + if self.metadata[m]["year"] is None: logger.error("Metadata Error: year attribute is blank") + elif not isinstance(self.metadata[m]["year"], int): logger.error("Metadata Error: year attribute must be an integer") + elif self.metadata[m]["year"] not in range(1800, now.year + 2): logger.error("Metadata Error: year attribute must be between 1800-{}".format(now.year + 1)) + else: year = self.metadata[m]["year"] + + alt_title = None + used_alt = False + if "alt_title" in self.metadata[m]: + if self.metadata[m]["alt_title"] is None: logger.error("Metadata Error: alt_title attribute is blank") + else: alt_title = self.metadata[m]["alt_title"] + + try: + item = self.get_item(m, year=year) + except Failed as e: + if alt_title: + try: + item = self.get_item(alt_title, year=year) + used_alt = True + except Failed as alt_e: + logger.error(alt_e) + logger.error("Skipping {}".format(m)) + continue + else: + logger.error(e) + logger.error("Skipping {}".format(m)) + continue + + logger.info("Updating {}: {}...".format("Movie" if self.is_movie else "Show", alt_title if used_alt else m)) + edits = {} + def add_edit(name, group, key=None, value=None, sub=None): + if value or name in group: + if value or group[name]: + if key is None: key = name + if value is None: value = group[name] + if sub and "sub" in group: + if group["sub"]: + if group["sub"] is True and "(SUB)" not in value: value = "{} (SUB)".format(value) + elif group["sub"] is False and " (SUB)" in value: value = value[:-6] + else: + logger.error("Metadata Error: sub attribute is blank") + edits["{}.value".format(key)] = value + edits["{}.locked".format(key)] = 1 + else: + logger.error("Metadata Error: {} attribute is blank".format(name)) + if used_alt or "sub" in self.metadata[m]: + add_edit("title", self.metadata[m], value=m, sub=True) + add_edit("sort_title", self.metadata[m], key="titleSort") + add_edit("originally_available", self.metadata[m], key="originallyAvailableAt") + add_edit("rating", self.metadata[m]) + add_edit("content_rating", self.metadata[m], key="contentRating") + add_edit("original_title", self.metadata[m], key="originalTitle") + add_edit("studio", self.metadata[m]) + add_edit("tagline", self.metadata[m]) + add_edit("summary", self.metadata[m]) + try: + item.edit(**edits) + item.reload() + logger.info("{}: {} Details Update Successful".format("Movie" if self.is_movie else "Show", m)) + except BadRequest: + logger.error("{}: {} Details Update Failed".format("Movie" if self.is_movie else "Show", m)) + logger.debug("Details Update: {}".format(edits)) + util.print_stacktrace() + + if "genre" in self.metadata[m]: + if self.metadata[m]["genre"]: + genre_sync = False + if "genre_sync_mode" in self.metadata[m]: + if self.metadata[m]["genre_sync_mode"] is None: logger.error("Metadata Error: genre_sync_mode attribute is blank defaulting to append") + elif self.metadata[m]["genre_sync_mode"] not in ["append", "sync"]: logger.error("Metadata Error: genre_sync_mode attribute must be either 'append' or 'sync' defaulting to append") + elif self.metadata[m]["genre_sync_mode"] == "sync": genre_sync = True + genres = [genre.tag for genre in item.genres] + values = util.get_list(self.metadata[m]["genre"]) + if genre_sync: + for genre in (g for g in genres if g not in values): + item.removeGenre(genre) + logger.info("Detail: Genre {} removed".format(genre)) + for value in (v for v in values if v not in genres): + item.addGenre(value) + logger.info("Detail: Genre {} added".format(value)) + else: + logger.error("Metadata Error: genre attribute is blank") + + if "label" in self.metadata[m]: + if self.metadata[m]["label"]: + label_sync = False + if "label_sync_mode" in self.metadata[m]: + if self.metadata[m]["label_sync_mode"] is None: logger.error("Metadata Error: label_sync_mode attribute is blank defaulting to append") + elif self.metadata[m]["label_sync_mode"] not in ["append", "sync"]: logger.error("Metadata Error: label_sync_mode attribute must be either 'append' or 'sync' defaulting to append") + elif self.metadata[m]["label_sync_mode"] == "sync": label_sync = True + labels = [label.tag for label in item.labels] + values = util.get_list(self.metadata[m]["label"]) + if label_sync: + for label in (l for l in labels if l not in values): + item.removeLabel(label) + logger.info("Detail: Label {} removed".format(label)) + for value in (v for v in values if v not in labels): + item.addLabel(v) + logger.info("Detail: Label {} added".format(v)) + else: + logger.error("Metadata Error: label attribute is blank") + + if "seasons" in self.metadata[m] and self.is_show: + if self.metadata[m]["seasons"]: + for season_id in self.metadata[m]["seasons"]: + logger.info("") + logger.info("Updating season {} of {}...".format(season_id, alt_title if used_alt else m)) + if isinstance(season_id, int): + try: season = item.season(season_id) + except NotFound: logger.error("Metadata Error: Season: {} not found".format(season_id)) + else: + edits = {} + add_edit("title", self.metadata[m]["seasons"][season_id], sub=True) + add_edit("summary", self.metadata[m]["seasons"][season_id]) + try: + season.edit(**edits) + season.reload() + logger.info("Season: {} Details Update Successful".format(season_id)) + except BadRequest: + logger.debug("Season: {} Details Update: {}".format(season_id, edits)) + logger.error("Season: {} Details Update Failed".format(season_id)) + util.print_stacktrace() + else: + logger.error("Metadata Error: Season: {} invalid, it must be an integer".format(season_id)) + else: + logger.error("Metadata Error: seasons attribute is blank") + + if "episodes" in self.metadata[m] and self.is_show: + if self.metadata[m]["episodes"]: + for episode_str in self.metadata[m]["episodes"]: + logger.info("") + match = re.search("[Ss]{1}\d+[Ee]{1}\d+", episode_str) + if match: + output = match.group(0)[1:].split("E" if "E" in m.group(0) else "e") + episode_id = int(output[0]) + season_id = int(output[1]) + logger.info("Updating episode S{}E{} of {}...".format(episode_id, season_id, alt_title if used_alt else m)) + try: episode = item.episode(season=season_id, episode=episode_id) + except NotFound: logger.error("Metadata Error: episode {} of season {} not found".format(episode_id, season_id)) + else: + edits = {} + add_edit("title", self.metadata[m]["episodes"][episode_str], sub=True) + add_edit("sort_title", self.metadata[m]["episodes"][episode_str], key="titleSort") + add_edit("rating", self.metadata[m]["episodes"][episode_str]) + add_edit("originally_available", self.metadata[m]["episodes"][episode_str], key="originallyAvailableAt") + add_edit("summary", self.metadata[m]["episodes"][episode_str]) + try: + episode.edit(**edits) + episode.reload() + logger.info("Season: {} Episode: {} Details Update Successful".format(season_id, episode_id)) + except BadRequest: + logger.debug("Season: {} Episode: {} Details Update: {}".format(season_id, episode_id, edits)) + logger.error("Season: {} Episode: {} Details Update Failed".format(season_id, episode_id)) + util.print_stacktrace() + else: + logger.error("Metadata Error: episode {} invlaid must have S##E## format".format(episode_str)) + else: + logger.error("Metadata Error: episodes attribute is blank") diff --git a/modules/radarr.py b/modules/radarr.py new file mode 100644 index 00000000..6a8bdb72 --- /dev/null +++ b/modules/radarr.py @@ -0,0 +1,87 @@ +import logging, re, requests +from modules import util +from modules.util import Failed +from retrying import retry + +logger = logging.getLogger("Plex Meta Manager") + +class RadarrAPI: + def __init__(self, tmdb, params): + self.url_params = {"apikey": "{}".format(params["token"])} + self.base_url = "{}/api{}".format(params["url"], "/v3/" if params["version"] == "v3" else "/") + try: + response = requests.get("{}system/status".format(self.base_url), params=self.url_params) + result = response.json() + except Exception as e: + util.print_stacktrace() + raise Failed("Radarr Error: Could not connect to Radarr at {}".format(params["url"])) + if "error" in result and result["error"] == "Unauthorized": + raise Failed("Radarr Error: Invalid API Key") + if "version" not in result: + raise Failed("Radarr Error: Unexpected Response Check URL") + response = requests.get("{}{}".format(self.base_url, "qualityProfile" if params["version"] == "v3" else "profile"), params=self.url_params) + self.quality_profile_id = None + profiles = "" + for profile in response.json(): + if len(profiles) > 0: + profiles += ", " + profiles += profile["name"] + if profile["name"] == params["quality_profile"]: + self.quality_profile_id = profile["id"] + if not self.quality_profile_id: + raise Failed("Radarr Error: quality_profile: {} does not exist in radarr. Profiles available: {}".format(params["quality_profile"], profiles)) + self.tmdb = tmdb + self.url = params["url"] + self.version = params["version"] + self.token = params["token"] + self.root_folder_path = params["root_folder_path"] + self.add = params["add"] + self.search = params["search"] + + def add_tmdb(self, tmdb_ids): + logger.info("") + logger.debug("TMDb IDs: {}".format(tmdb_ids)) + add_count = 0 + for tmdb_id in tmdb_ids: + try: + movie = self.tmdb.get_movie(tmdb_id) + except Failed as e: + logger.error(e) + continue + + try: + year = movie.release_date.split("-")[0] + except AttributeError: + logger.error("TMDB Error: No year for ({}) {}".format(tmdb_id, movie.title)) + continue + + if year.isdigit() is False: + logger.error("TMDB Error: No release date yet for ({}) {}".format(tmdb_id, movie.title)) + continue + + poster = "https://image.tmdb.org/t/p/original{}".format(movie.poster_path) + + titleslug = re.sub(r"([^\s\w]|_)+", "", "{} {}".format(movie.title, year)).replace(" ", "-").lower() + + url_json = { + "title": movie.title, + "{}".format("qualityProfileId" if self.version == "v3" else "profileId"): self.quality_profile_id, + "year": int(year), + "tmdbid": str(tmdb_id), + "titleslug": titleslug, + "monitored": True, + "rootFolderPath": self.root_folder_path, + "images": [{"covertype": "poster", "url": poster}], + "addOptions": {"searchForMovie": self.search} + } + response = self.send_post("{}movie".format(self.base_url), url_json) + if response.status_code < 400: + logger.info("Added to Radarr | {:<6} | {}".format(tmdb_id, movie.title)) + add_count += 1 + else: + logger.error("Radarr Error: ({}) {}: ({}) {}".format(tmdb_id, movie.title, response.status_code, response.json()[0]["errorMessage"])) + logger.info("{} Movie{} added to Radarr".format(add_count, "s" if add_count > 1 else "")) + + @retry(stop_max_attempt_number=6, wait_fixed=10000) + def send_post(self, url, url_json): + return requests.post(url, json=url_json, params=self.url_params) diff --git a/modules/sonarr.py b/modules/sonarr.py new file mode 100644 index 00000000..5a959288 --- /dev/null +++ b/modules/sonarr.py @@ -0,0 +1,78 @@ +import logging, re, requests +from modules import util +from modules.util import Failed +from retrying import retry + +logger = logging.getLogger("Plex Meta Manager") + +class SonarrAPI: + def __init__(self, tvdb, params, language): + self.url_params = {"apikey": "{}".format(params["token"])} + self.base_url = "{}/api{}".format(params["url"], "/v3/" if params["version"] == "v3" else "/") + try: + response = requests.get("{}system/status".format(self.base_url), params=self.url_params) + result = response.json() + except Exception as e: + util.print_stacktrace() + raise Failed("Sonarr Error: Could not connect to Sonarr at {}".format(params["url"])) + if "error" in result and result["error"] == "Unauthorized": + raise Failed("Sonarr Error: Invalid API Key") + if "version" not in result: + raise Failed("Sonarr Error: Unexpected Response Check URL") + response = requests.get("{}{}".format(self.base_url, "qualityProfile" if params["version"] == "v3" else "profile"), params=self.url_params) + self.quality_profile_id = None + profiles = "" + for profile in response.json(): + if len(profiles) > 0: + profiles += ", " + profiles += profile["name"] + if profile["name"] == params["quality_profile"]: + self.quality_profile_id = profile["id"] + if not self.quality_profile_id: + raise Failed("Sonarr Error: quality_profile: {} does not exist in sonarr. Profiles available: {}".format(params["quality_profile"], profiles)) + self.tvdb = tvdb + self.language = language + self.url = params["url"] + self.version = params["version"] + self.token = params["token"] + self.root_folder_path = params["root_folder_path"] + self.add = params["add"] + self.search = params["search"] + + def add_tvdb(self, tvdb_ids): + logger.info("") + logger.debug("TVDb IDs: {}".format(tvdb_ids)) + add_count = 0 + for tvdb_id in tvdb_ids: + try: + show = self.tvdb.get_series(self.language, tvdb_id=tvdb_id) + except Failed as e: + logger.error(e) + continue + + titleslug = re.sub(r"([^\s\w]|_)+", "", show.title).replace(" ", "-").lower() + + url_json = { + "title": show.title, + "{}".format("qualityProfileId" if self.version == "v3" else "profileId"): self.quality_profile_id, + "languageProfileId": 1, + "tvdbId": int(tvdb_id), + "titleslug": titleslug, + "language": self.language, + "monitored": True, + "rootFolderPath": self.root_folder_path, + "seasons" : [], + "images": [{"covertype": "poster", "url": show.poster_path}], + "addOptions": {"searchForMissingEpisodes": self.search} + } + response = self.send_post("{}series".format(self.base_url), url_json) + if response.status_code < 400: + logger.info("Added to Sonarr | {:<6} | {}".format(tvdb_id, show.title)) + add_count += 1 + else: + logger.error("Sonarr Error: ({}) {}: ({}) {}".format(tvdb_id, show.title, response.status_code, response.json()[0]["errorMessage"])) + logger.info("{} Show{} added to Sonarr".format(add_count, "s" if add_count > 1 else "")) + + @retry(stop_max_attempt_number=6, wait_fixed=10000) + def send_post(self, url, url_json): + return requests.post(url, json=url_json, params=self.url_params) diff --git a/modules/tautulli.py b/modules/tautulli.py new file mode 100644 index 00000000..33b7065b --- /dev/null +++ b/modules/tautulli.py @@ -0,0 +1,62 @@ +import logging, requests +from modules import util +from modules.util import Failed +from retrying import retry + +logger = logging.getLogger("Plex Meta Manager") + +class TautulliAPI: + def __init__(self, params): + try: + response = requests.get("{}/api/v2?apikey={}&cmd=get_library_names".format(params["url"], params["apikey"])).json() + except Exception as e: + util.print_stacktrace() + raise Failed("Tautulli Error: Invalid url") + if response["response"]["result"] != "success": + raise Failed("Tautulli Error: {}".format(response["response"]["message"])) + self.url = params["url"] + self.apikey = params["apikey"] + + def get_popular(self, library, time_range=30, stats_count=20, stats_count_buffer=20, status_message=True): + return self.get_items(library, time_range=time_range, stats_count=stats_count, list_type="popular", stats_count_buffer=stats_count_buffer, status_message=status_message) + + def get_top(self, library, time_range=30, stats_count=20, stats_count_buffer=20, status_message=True): + return self.get_items(library, time_range=time_range, stats_count=stats_count, list_type="top", stats_count_buffer=stats_count_buffer, status_message=status_message) + + def get_items(self, library, time_range=30, stats_count=20, list_type="popular", stats_count_buffer=20, status_message=True): + if status_message: + logger.info("Processing Tautulli Most {}: {} {}".format("Popular" if list_type == "popular" else "Watched", stats_count, "Movies" if library.is_movie else "Shows")) + response = self.send_request("{}/api/v2?apikey={}&cmd=get_home_stats&time_range={}&stats_count={}".format(self.url, self.apikey, time_range, int(stats_count) + int(stats_count_buffer))) + stat_id = "{}_{}".format("popular" if list_type == "popular" else "top", "movies" if library.is_movie else "tv") + + items = None + for entry in response["response"]["data"]: + if entry["stat_id"] == stat_id: + items = entry["rows"] + break + if items is None: + raise Failed("Tautulli Error: No Items found in the response") + + section_id = self.get_section_id(library.name) + rating_keys = [] + count = 0 + for item in items: + if item["section_id"] == section_id and count < int(stats_count): + rating_keys.append(item["rating_key"]) + count += 1 + return rating_keys + + def get_section_id(self, library_name): + response = self.send_request("{}/api/v2?apikey={}&cmd=get_library_names".format(self.url, self.apikey)) + section_id = None + for entry in response["response"]["data"]: + if entry["section_name"] == library_name: + section_id = entry["section_id"] + break + if section_id: return section_id + else: raise Failed("Tautulli Error: No Library named {} in the response".format(library_name)) + + @retry(stop_max_attempt_number=6, wait_fixed=10000) + def send_request(self, url): + logger.debug("Tautulli URL: {}".format(url.replace(self.apikey, "################################"))) + return requests.get(url).json() diff --git a/modules/tests.py b/modules/tests.py new file mode 100644 index 00000000..156d0c9a --- /dev/null +++ b/modules/tests.py @@ -0,0 +1,417 @@ +import logging +from modules import util +from modules.config import Config +from modules.util import Failed + +logger = logging.getLogger("Plex Meta Manager") + +def run_tests(default_dir): + try: + config = Config(default_dir) + logger.info("") + util.seperator("Mapping Tests") + + config.map_guids(config.libraries[0]) + config.map_guids(config.libraries[1]) + config.map_guids(config.libraries[2]) + anidb_tests(config) + imdb_tests(config) + mal_tests(config) + tautulli_tests(config) + tmdb_tests(config) + trakt_tests(config) + tvdb_tests(config) + util.seperator("Finished All Plex Meta Manager Tests") + except KeyboardInterrupt: + util.seperator("Canceled Plex Meta Manager Tests") + +def anidb_tests(config): + if config.AniDB: + util.seperator("AniDB Tests") + + try: + config.AniDB.convert_anidb_to_tvdb(69) + logger.info("Success | Convert AniDB to TVDb") + except Failed as e: + util.print_stacktrace() + logger.error("Failure | Convert AniDB to TVDb: {}".format(e)) + + try: + config.AniDB.convert_anidb_to_imdb(112) + logger.info("Success | Convert AniDB to IMDb") + except Failed as e: + util.print_stacktrace() + logger.error("Failure | Convert AniDB to IMDb: {}".format(e)) + + try: + config.AniDB.convert_tvdb_to_anidb(81797) + logger.info("Success | Convert TVDb to AniDB") + except Failed as e: + util.print_stacktrace() + logger.error("Failure | Convert TVDb to AniDB: {}".format(e)) + + try: + config.AniDB.convert_imdb_to_anidb("tt0245429") + logger.info("Success | Convert IMDb to AniDB") + except Failed as e: + util.print_stacktrace() + logger.error("Failure | Convert IMDb to AniDB: {}".format(e)) + + try: + config.AniDB.get_items("anidb_id", 69, "en", status_message=False) + logger.info("Success | Get AniDB ID") + except Failed as e: + util.print_stacktrace() + logger.error("Failure | Get AniDB ID: {}".format(e)) + + try: + config.AniDB.get_items("anidb_relation", 69, "en", status_message=False) + logger.info("Success | Get AniDB Relation") + except Failed as e: + util.print_stacktrace() + logger.error("Failure | Get AniDB Relation: {}".format(e)) + + try: + config.AniDB.get_items("anidb_popular", 30, "en", status_message=False) + logger.info("Success | Get AniDB Popular") + except Failed as e: + util.print_stacktrace() + logger.error("Failure | Get AniDB Popular: {}".format(e)) + + try: + config.AniDB.validate_anidb_list(["69", "112"], "en") + logger.info("Success | Validate AniDB List") + except Failed as e: + util.print_stacktrace() + logger.error("Failure | Validate AniDB List: {}".format(e)) + + else: + util.seperator("AniDB Not Configured") + +def imdb_tests(config): + if config.IMDb: + util.seperator("IMDb Tests") + + tmdb_ids, tvdb_ids = config.IMDb.get_items("imdb_list", {"url": "https://www.imdb.com/search/title/?groups=top_1000", "limit": 0}, "en", status_message=False) + if len(tmdb_ids) == 1000: logger.info("Success | IMDb URL get TMDb IDs") + else: logger.error("Failure | IMDb URL get TMDb IDs: {} Should be 1000".format(len(tmdb_ids))) + + tmdb_ids, tvdb_ids = config.IMDb.get_items("imdb_list", {"url": "https://www.imdb.com/list/ls026173135/", "limit": 0}, "en", status_message=False) + if len(tmdb_ids) == 250: logger.info("Success | IMDb URL get TMDb IDs") + else: logger.error("Failure | IMDb URL get TMDb IDs: {} Should be 250".format(len(tmdb_ids))) + + tmdb_ids, tvdb_ids = config.IMDb.get_items("imdb_id", "tt0814243", "en", status_message=False) + if len(tmdb_ids) == 1: logger.info("Success | IMDb ID get TMDb IDs") + else: logger.error("Failure | IMDb ID get TMDb IDs: {} Should be 1".format(len(tmdb_ids))) + + else: + util.seperator("IMDb Not Configured") + +def mal_tests(config): + if config.MyAnimeListIDList: + util.seperator("MyAnimeListXML Tests") + + try: + config.MyAnimeListIDList.convert_mal_to_tvdb(21) + logger.info("Success | Convert MyAnimeList to TVDb") + except Failed as e: + util.print_stacktrace() + logger.error("Failure | Convert MyAnimeList to TVDb: {}".format(e)) + + try: + config.MyAnimeListIDList.convert_mal_to_tmdb(199) + logger.info("Success | Convert MyAnimeList to TMDb") + except Failed as e: + util.print_stacktrace() + logger.error("Failure | Convert MyAnimeList to TMDb: {}".format(e)) + + try: + config.MyAnimeListIDList.convert_tvdb_to_mal(81797) + logger.info("Success | Convert TVDb to MyAnimeList") + except Failed as e: + util.print_stacktrace() + logger.error("Failure | Convert TVDb to MyAnimeList: {}".format(e)) + + try: + config.MyAnimeListIDList.convert_tmdb_to_mal(129) + logger.info("Success | Convert TMDb to MyAnimeList") + except Failed as e: + util.print_stacktrace() + logger.error("Failure | Convert TMDb to MyAnimeList: {}".format(e)) + + try: + config.MyAnimeListIDList.find_mal_ids(21) + logger.info("Success | Find MyAnimeList ID") + except Failed as e: + util.print_stacktrace() + logger.error("Failure | Find MyAnimeList ID: {}".format(e)) + + else: + util.seperator("MyAnimeListXML Not Configured") + + if config.MyAnimeList: + util.seperator("MyAnimeList Tests") + + mal_list_tests = [ + ("mal_all", 10), + ("mal_airing", 10), + ("mal_upcoming", 10), + ("mal_tv", 10), + ("mal_movie", 10), + ("mal_ova", 10), + ("mal_special", 10), + ("mal_popular", 10), + ("mal_favorite", 10), + ("mal_suggested", 10), + ("mal_userlist", {"limit": 10, "username": "@me", "status": "completed", "sort_by": "list_score"}), + ("mal_season", {"limit": 10, "season": "fall", "year": 2020, "sort_by": "anime_score"}) + ] + + for mal_list_test in mal_list_tests: + try: + config.MyAnimeList.get_items(mal_list_test[0], mal_list_test[1], status_message=False) + logger.info("Success | Get Anime using {}".format(util.pretty_names[mal_list_test[0]])) + except Failed as e: + util.print_stacktrace() + logger.error("Failure | Get Anime using {}: {}".format(util.pretty_names[mal_list_test[0]], e)) + else: + util.seperator("MyAnimeList Not Configured") + +def tautulli_tests(config): + if config.libraries[0].Tautulli: + util.seperator("Tautulli Tests") + + try: + config.libraries[0].Tautulli.get_section_id(config.libraries[0].name) + logger.info("Success | Get Section ID") + except Failed as e: + util.print_stacktrace() + logger.error("Failure | Get Section ID: {}".format(e)) + + try: + config.libraries[0].Tautulli.get_popular(config.libraries[0], status_message=False) + logger.info("Success | Get Popular") + except Failed as e: + util.print_stacktrace() + logger.error("Failure | Get Popular: {}".format(e)) + + try: + config.libraries[0].Tautulli.get_top(config.libraries[0], status_message=False) + logger.info("Success | Get Top") + except Failed as e: + util.print_stacktrace() + logger.error("Failure | Get Top: {}".format(e)) + else: + util.seperator("Tautulli Not Configured") + +def tmdb_tests(config): + if config.TMDb: + util.seperator("TMDb Tests") + + try: + config.TMDb.convert_imdb_to_tmdb("tt0076759") + logger.info("Success | Convert IMDb to TMDb") + except Failed as e: + util.print_stacktrace() + logger.error("Failure | Convert IMDb to TMDb: {}".format(e)) + + try: + config.TMDb.convert_tmdb_to_imdb(11) + logger.info("Success | Convert TMDb to IMDb") + except Failed as e: + util.print_stacktrace() + logger.error("Failure | Convert TMDb to IMDb: {}".format(e)) + + try: + config.TMDb.convert_imdb_to_tvdb("tt0458290") + logger.info("Success | Convert IMDb to TVDb") + except Failed as e: + util.print_stacktrace() + logger.error("Failure | Convert IMDb to TVDb: {}".format(e)) + + try: + config.TMDb.convert_tvdb_to_imdb(83268) + logger.info("Success | Convert TVDb to IMDb") + except Failed as e: + util.print_stacktrace() + logger.error("Failure | Convert TVDb to IMDb: {}".format(e)) + + tmdb_list_tests = [ + ([11], "Movie"), + ([4194], "Show"), + ([10], "Collection"), + ([1], "Person"), + ([1], "Company"), + ([2739], "Network"), + ([8136], "List") + ] + + for tmdb_list_test in tmdb_list_tests: + try: + config.TMDb.validate_tmdb_list(tmdb_list_test[0], tmdb_type=tmdb_list_test[1]) + logger.info("Success | Get TMDb {}".format(tmdb_list_test[1])) + except Failed as e: + util.print_stacktrace() + logger.error("Failure | Get TMDb {}: {}".format(tmdb_list_test[1], e)) + + tmdb_list_tests = [ + ("tmdb_discover", {"sort_by": "popularity.desc", "limit": 100}, True), + ("tmdb_discover", {"sort_by": "popularity.desc", "limit": 100}, False), + ("tmdb_company", 1, True), + ("tmdb_company", 1, False), + ("tmdb_network", 2739, False), + ("tmdb_keyword", 180547, True), + ("tmdb_keyword", 180547, False), + ("tmdb_now_playing", 10, True), + ("tmdb_popular", 10, True), + ("tmdb_popular", 10, False), + ("tmdb_top_rated", 10, True), + ("tmdb_top_rated", 10, False), + ("tmdb_trending_daily", 10, True), + ("tmdb_trending_daily", 10, False), + ("tmdb_trending_weekly", 10, True), + ("tmdb_trending_weekly", 10, False), + ("tmdb_list", 7068209, True), + ("tmdb_list", 7068209, False), + ("tmdb_movie", 11, True), + ("tmdb_collection", 10, True), + ("tmdb_show", 4194, False) + ] + + for tmdb_list_test in tmdb_list_tests: + try: + config.TMDb.get_items(tmdb_list_test[0], tmdb_list_test[1], tmdb_list_test[2], status_message=False) + logger.info("Success | Get {} using {}".format("Movies" if tmdb_list_test[2] else "Shows", util.pretty_names[tmdb_list_test[0]])) + except Failed as e: + util.print_stacktrace() + logger.error("Failure | Get {} using {}: {}".format("Movies" if tmdb_list_test[2] else "Shows", util.pretty_names[tmdb_list_test[0]], e)) + else: + util.seperator("TMDb Not Configured") + +def trakt_tests(config): + if config.Trakt: + util.seperator("Trakt Tests") + + try: + config.Trakt.convert_imdb_to_tmdb("tt0076759") + logger.info("Success | Convert IMDb to TMDb") + except Failed as e: + util.print_stacktrace() + logger.error("Failure | Convert IMDb to TMDb: {}".format(e)) + + try: + config.Trakt.convert_tmdb_to_imdb(11) + logger.info("Success | Convert TMDb to IMDb") + except Failed as e: + util.print_stacktrace() + logger.error("Failure | Convert TMDb to IMDb: {}".format(e)) + + try: + config.Trakt.convert_imdb_to_tvdb("tt0458290") + logger.info("Success | Convert IMDb to TVDb") + except Failed as e: + util.print_stacktrace() + logger.error("Failure | Convert IMDb to TVDb: {}".format(e)) + + try: + config.Trakt.convert_tvdb_to_imdb(83268) + logger.info("Success | Convert TVDb to IMDb") + except Failed as e: + util.print_stacktrace() + logger.error("Failure | Convert TVDb to IMDb: {}".format(e)) + + try: + config.Trakt.convert_tmdb_to_tvdb(11) + logger.info("Success | Convert TMDb to TVDb") + except Failed as e: + util.print_stacktrace() + logger.error("Failure | Convert TMDb to TVDb: {}".format(e)) + + try: + config.Trakt.convert_tvdb_to_tmdb(83268) + logger.info("Success | Convert TVDb to TMDb") + except Failed as e: + util.print_stacktrace() + logger.error("Failure | Convert TVDb to TMDb: {}".format(e)) + + try: + config.Trakt.validate_trakt_list(["https://trakt.tv/users/movistapp/lists/christmas-movies"]) + logger.info("Success | Get List") + except Failed as e: + util.print_stacktrace() + logger.error("Failure | Get List: {}".format(e)) + + try: + config.Trakt.validate_trakt_watchlist(["me"], True) + logger.info("Success | Get Watchlist Movies") + except Failed as e: + util.print_stacktrace() + logger.error("Failure | Get Watchlist Movies: {}".format(e)) + + try: + config.Trakt.validate_trakt_watchlist(["me"], False) + logger.info("Success | Get Watchlist Shows") + except Failed as e: + util.print_stacktrace() + logger.error("Failure | Get Watchlist Shows: {}".format(e)) + + trakt_list_tests = [ + ("trakt_list", "https://trakt.tv/users/movistapp/lists/christmas-movies", True), + ("trakt_trending", 10, True), + ("trakt_trending", 10, False), + ("trakt_watchlist", "me", True), + ("trakt_watchlist", "me", False) + ] + + for trakt_list_test in trakt_list_tests: + try: + config.Trakt.get_items(trakt_list_test[0], trakt_list_test[1], trakt_list_test[2], status_message=False) + logger.info("Success | Get {} using {}".format("Movies" if trakt_list_test[2] else "Shows", util.pretty_names[trakt_list_test[0]])) + except Failed as e: + util.print_stacktrace() + logger.error("Failure | Get {} using {}: {}".format("Movies" if trakt_list_test[2] else "Shows", util.pretty_names[trakt_list_test[0]], e)) + else: + util.seperator("Trakt Not Configured") + +def tvdb_tests(config): + if config.TVDb: + util.seperator("TVDb Tests") + + tmdb_ids, tvdb_ids = config.TVDb.get_items("tvdb_list", "https://www.thetvdb.com/lists/arrowverse", "en", status_message=False) + if len(tvdb_ids) == 10 and len(tmdb_ids) == 0: logger.info("Success | TVDb URL get TVDb IDs and TMDb IDs") + else: logger.error("Failure | TVDb URL get TVDb IDs and TMDb IDs: {} Should be 10 and {} Should be 0".format(len(tvdb_ids), len(tmdb_ids))) + + tmdb_ids, tvdb_ids = config.TVDb.get_items("tvdb_list", "https://www.thetvdb.com/lists/6957", "en", status_message=False) + if len(tvdb_ids) == 4 and len(tmdb_ids) == 2: logger.info("Success | TVDb URL get TVDb IDs and TMDb IDs") + else: logger.error("Failure | TVDb URL get TVDb IDs and TMDb IDs: {} Should be 4 and {} Should be 2".format(len(tvdb_ids), len(tmdb_ids))) + + try: + config.TVDb.get_items("tvdb_show", "https://www.thetvdb.com/series/arrow", "en", status_message=False) + logger.info("Success | TVDb URL get TVDb Series ID") + except Failed as e: + util.print_stacktrace() + logger.error("Failure | TVDb URL get TVDb Series ID: {}".format(e)) + + try: + config.TVDb.get_items("tvdb_show", 279121, "en", status_message=False) + logger.info("Success | TVDb ID get TVDb Series ID") + except Failed as e: + util.print_stacktrace() + logger.error("Failure | TVDb ID get TVDb Series ID: {}".format(e)) + + try: + config.TVDb.get_items("tvdb_movie", "https://www.thetvdb.com/movies/the-lord-of-the-rings-the-fellowship-of-the-ring", "en", status_message=False) + logger.info("Success | TVDb URL get TVDb Movie ID") + except Failed as e: + util.print_stacktrace() + logger.error("Failure | TVDb URL get TVDb Movie ID: {}".format(e)) + + try: + config.TVDb.get_items("tvdb_movie", 107, "en", status_message=False) + logger.info("Success | TVDb ID get TVDb Movie ID") + except Failed as e: + util.print_stacktrace() + logger.error("Failure | TVDb ID get TVDb Movie ID: {}".format(e)) + + else: + util.seperator("TVDb Not Configured") diff --git a/modules/tmdb.py b/modules/tmdb.py new file mode 100644 index 00000000..c3b531ea --- /dev/null +++ b/modules/tmdb.py @@ -0,0 +1,225 @@ +import logging, os, tmdbv3api +from modules import util +from modules.util import Failed +from retrying import retry +from tmdbv3api.exceptions import TMDbException + +logger = logging.getLogger("Plex Meta Manager") + +class TMDbAPI: + def __init__(self, params): + self.TMDb = tmdbv3api.TMDb() + self.TMDb.api_key = params["apikey"] + self.TMDb.language = params["language"] + response = tmdbv3api.Configuration().info() + if hasattr(response, "status_message"): + raise Failed("TMDb Error: {}".format(response.status_message)) + self.apikey = params["apikey"] + self.language = params["language"] + self.Movie = tmdbv3api.Movie() + self.TV = tmdbv3api.TV() + self.Discover = tmdbv3api.Discover() + self.Trending = tmdbv3api.Trending() + self.Keyword = tmdbv3api.Keyword() + self.List = tmdbv3api.List() + self.Company = tmdbv3api.Company() + self.Network = tmdbv3api.Network() + self.Collection = tmdbv3api.Collection() + self.Person = tmdbv3api.Person() + self.image_url = "https://image.tmdb.org/t/p/original" + + @retry(stop_max_attempt_number=6, wait_fixed=10000, retry_on_exception=util.retry_if_not_failed) + def convert_from_tmdb(self, tmdb_id, convert_to, is_movie): + try: return self.Movie.external_ids(tmdb_id)[convert_to] if is_movie else self.TV.external_ids(tmdb_id)[convert_to] + except TMDbException: raise Failed("TMDB Error: No {} found for TMDb ID {}".format(convert_to.upper().replace("B_", "b "), tmdb_id)) + + + @retry(stop_max_attempt_number=6, wait_fixed=10000, retry_on_exception=util.retry_if_not_failed) + def convert_to_tmdb(self, external_id, external_source, is_movie): + search_results = self.Movie.external(external_id=external_id, external_source=external_source) + search = search_results["movie_results" if is_movie else "tv_results"] + if len(search) == 1: return search[0]["id"] + else: raise Failed("TMDB Error: No TMDb ID found for {} {}".format(external_source.upper().replace("B_", "b "), external_id)) + + def convert_tmdb_to_imdb(self, tmdb_id, is_movie=True): return self.convert_from_tmdb(tmdb_id, "imdb_id", is_movie) + def convert_imdb_to_tmdb(self, imdb_id, is_movie=True): return self.convert_to_tmdb(imdb_id, "imdb_id", is_movie) + def convert_tmdb_to_tvdb(self, tmdb_id): return self.convert_from_tmdb(tmdb_id, "tvdb_id", False) + def convert_tvdb_to_tmdb(self, tvdb_id): return self.convert_to_tmdb(tvdb_id, "tvdb_id", False) + def convert_tvdb_to_imdb(self, tvdb_id): return self.convert_tmdb_to_imdb(self.convert_tvdb_to_tmdb(tvdb_id), False) + def convert_imdb_to_tvdb(self, imdb_id): return self.convert_tmdb_to_tvdb(self.convert_imdb_to_tmdb(imdb_id, False)) + + def get_movie_show_or_collection(self, tmdb_id, is_movie): + if is_movie: + try: return self.get_collection(tmdb_id) + except Failed: + try: return self.get_movie(tmdb_id) + except Failed: raise Failed("TMDb Error: No Movie or Collection found for TMDb ID {}".format(tmdb_id)) + else: return self.get_show(tmdb_id) + + @retry(stop_max_attempt_number=6, wait_fixed=10000, retry_on_exception=util.retry_if_not_failed) + def get_movie(self, tmdb_id): + try: return self.Movie.details(tmdb_id) + except TMDbException as e: raise Failed("TMDb Error: No Movie found for TMDb ID {}: {}".format(tmdb_id, e)) + + @retry(stop_max_attempt_number=6, wait_fixed=10000, retry_on_exception=util.retry_if_not_failed) + def get_show(self, tmdb_id): + try: return self.TV.details(tmdb_id) + except TMDbException as e: raise Failed("TMDb Error: No Show found for TMDb ID {}: {}".format(tmdb_id, e)) + + @retry(stop_max_attempt_number=6, wait_fixed=10000, retry_on_exception=util.retry_if_not_failed) + def get_collection(self, tmdb_id): + try: return self.Collection.details(tmdb_id) + except TMDbException as e: raise Failed("TMDb Error: No Collection found for TMDb ID {}: {}".format(tmdb_id, e)) + + @retry(stop_max_attempt_number=6, wait_fixed=10000, retry_on_exception=util.retry_if_not_failed) + def get_person(self, tmdb_id): + try: return self.Person.details(tmdb_id) + except TMDbException as e: raise Failed("TMDb Error: No Person found for TMDb ID {}: {}".format(tmdb_id, e)) + + @retry(stop_max_attempt_number=6, wait_fixed=10000, retry_on_exception=util.retry_if_not_failed) + def get_company(self, tmdb_id): + try: return self.Company.details(tmdb_id) + except TMDbException as e: raise Failed("TMDb Error: No Company found for TMDb ID {}: {}".format(tmdb_id, e)) + + @retry(stop_max_attempt_number=6, wait_fixed=10000, retry_on_exception=util.retry_if_not_failed) + def get_network(self, tmdb_id): + try: return self.Network.details(tmdb_id) + except TMDbException as e: raise Failed("TMDb Error: No Network found for TMDb ID {}: {}".format(tmdb_id, e)) + + @retry(stop_max_attempt_number=6, wait_fixed=10000, retry_on_exception=util.retry_if_not_failed) + def get_keyword(self, tmdb_id): + try: return self.Keyword.details(tmdb_id) + except TMDbException as e: raise Failed("TMDb Error: No Keyword found for TMDb ID {}: {}".format(tmdb_id, e)) + + @retry(stop_max_attempt_number=6, wait_fixed=10000, retry_on_exception=util.retry_if_not_failed) + def get_list(self, tmdb_id): + try: return self.List.details(tmdb_id, all_details=True) + except TMDbException as e: raise Failed("TMDb Error: No List found for TMDb ID {}: {}".format(tmdb_id, e)) + + def get_pagenation(self, method, amount, is_movie): + ids = [] + count = 0 + for x in range(int(amount / 20) + 1): + if method == "tmdb_popular": tmdb_items = self.Movie.popular(x + 1) if is_movie else self.TV.popular(x + 1) + elif method == "tmdb_top_rated": tmdb_items = self.Movie.top_rated(x + 1) if is_movie else self.TV.top_rated(x + 1) + elif method == "tmdb_now_playing" and is_movie: tmdb_items = self.Movie.now_playing(x + 1) + elif method == "tmdb_trending_daily": tmdb_items = self.Trending.movie_day(x + 1) if is_movie else self.Trending.tv_day(x + 1) + elif method == "tmdb_trending_weekly": tmdb_items = self.Trending.movie_week(x + 1) if is_movie else self.Trending.tv_week(x + 1) + for tmdb_item in tmdb_items: + try: + ids.append(tmdb_item.id if is_movie else self.convert_tmdb_to_tvdb(tmdb_item.id)) + count += 1 + except Failed: + pass + if count == amount: break + if count == amount: break + return ids + + def get_discover(self, attrs, amount, is_movie): + ids = [] + count = 0 + self.Discover.discover_movies(attrs) if is_movie else self.Discover.discover_tv_shows(attrs) + total_pages = int(self.TMDb.total_pages) + total_results = int(self.TMDb.total_results) + amount = total_results if amount == 0 or total_results < amount else amount + for x in range(total_pages): + attrs["page"] = x + 1 + tmdb_items = self.Discover.discover_movies(attrs) if is_movie else self.Discover.discover_tv_shows(attrs) + for tmdb_item in tmdb_items: + try: + ids.append(tmdb_item.id if is_movie else self.convert_tmdb_to_tvdb(tmdb_item.id)) + count += 1 + except Failed: + pass + if count == amount: break + if count == amount: break + return ids, amount + + def get_items(self, method, data, is_movie, status_message=True): + if status_message: + logger.debug("Data: {}".format(data)) + pretty = util.pretty_names[method] if method in util.pretty_names else method + media_type = "Movie" if is_movie else "Show" + movie_ids = [] + show_ids = [] + if method in ["tmdb_discover", "tmdb_company", "tmdb_keyword"] or (method == "tmdb_network" and not is_movie): + if method in ["tmdb_company", "tmdb_network", "tmdb_keyword"]: + tmdb_id = int(data) + if method == "tmdb_company": + tmdb_name = str(self.get_company(tmdb_id)) + attrs = {"with_companies": tmdb_id} + elif method == "tmdb_network": + tmdb_name = str(self.get_network(tmdb_id)) + attrs = {"with_networks": tmdb_id} + elif method == "tmdb_keyword": + tmdb_name = str(self.get_keyword(tmdb_id)) + attrs = {"with_keywords": tmdb_id} + limit = 0 + else: + attrs = data.copy() + limit = int(attrs.pop("limit")) + if is_movie: movie_ids, amount = self.get_discover(attrs, limit, is_movie) + else: show_ids, amount = self.get_discover(attrs, limit, is_movie) + if status_message: + if method in ["tmdb_company", "tmdb_network", "tmdb_keyword"]: + logger.info("Processing {}: ({}) {} ({} {}{})".format(pretty, tmdb_id, tmdb_name, amount, media_type, "" if amount == 1 else "s")) + else: + logger.info("Processing {}: {} {}{}".format(pretty, amount, media_type, "" if amount == 1 else "s")) + for attr, value in attrs.items(): + logger.info(" {}: {}".format(attr, value)) + elif method in ["tmdb_popular", "tmdb_top_rated", "tmdb_now_playing", "tmdb_trending_daily", "tmdb_trending_weekly"]: + if is_movie: movie_ids = self.get_pagenation(method, data, is_movie) + else: show_ids = self.get_pagenation(method, data, is_movie) + if status_message: + logger.info("Processing {}: {} {}{}".format(pretty, data, media_type, "" if data == 1 else "s")) + else: + tmdb_id = int(data) + if method == "tmdb_list": + tmdb_list = self.get_list(tmdb_id) + tmdb_name = tmdb_list.name + for tmdb_item in tmdb_list.items: + if tmdb_item.media_type == "movie": + movie_ids.append(tmdb_item.id) + elif tmdb_item.media_type == "tv": + try: show_ids.append(self.convert_tmdb_to_tvdb(tmdb_item.id)) + except Failed: pass + elif method == "tmdb_movie": + tmdb_name = str(self.get_movie(tmdb_id).title) + movie_ids.append(tmdb_id) + elif method == "tmdb_collection": + tmdb_items = self.get_collection(tmdb_id) + tmdb_name = str(tmdb_items.name) + for tmdb_item in tmdb_items.parts: + movie_ids.append(tmdb_item["id"]) + elif method == "tmdb_show": + tmdb_name = str(self.get_show(tmdb_id).name) + try: show_ids.append(self.convert_tmdb_to_tvdb(tmdb_id)) + except Failed: pass + else: + raise Failed("TMDb Error: Method {} not supported".format(method)) + if status_message and len(movie_ids) > 0: + logger.info("Processing {}: ({}) {} ({} Movie{})".format(pretty, tmdb_id, tmdb_name, len(movie_ids), "" if len(movie_ids) == 1 else "s")) + if status_message and len(show_ids) > 0: + logger.info("Processing {}: ({}) {} ({} Show{})".format(pretty, tmdb_id, tmdb_name, len(show_ids), "" if len(show_ids) == 1 else "s")) + if status_message: + logger.debug("TMDb IDs Found: {}".format(movie_ids)) + logger.debug("TVDb IDs Found: {}".format(show_ids)) + return movie_ids, show_ids + + def validate_tmdb_list(self, tmdb_list, tmdb_type): + tmdb_values = [] + for tmdb_id in tmdb_list: + try: + if tmdb_type == "Movie": self.get_movie(tmdb_id) + elif tmdb_type == "Show": self.get_show(tmdb_id) + elif tmdb_type == "Collection": self.get_collection(tmdb_id) + elif tmdb_type == "Person": self.get_person(tmdb_id) + elif tmdb_type == "Company": self.get_company(tmdb_id) + elif tmdb_type == "Network": self.get_network(tmdb_id) + elif tmdb_type == "List": self.get_list(tmdb_id) + tmdb_values.append(tmdb_id) + except Failed as e: + logger.error(e) + if len(tmdb_values) == 0: + raise Failed("TMDb Error: No valid TMDb IDs in {}".format(tmdb_list)) + return tmdb_values diff --git a/modules/trakt.py b/modules/trakt.py new file mode 100644 index 00000000..26368ea2 --- /dev/null +++ b/modules/trakt.py @@ -0,0 +1,162 @@ +import logging, webbrowser +from modules import util +from modules.util import Failed, TimeoutExpired +from retrying import retry +from ruamel import yaml +from trakt import Trakt +from trakt.objects.episode import Episode +from trakt.objects.movie import Movie +from trakt.objects.season import Season +from trakt.objects.show import Show +from urllib.parse import urlparse + +logger = logging.getLogger("Plex Meta Manager") + +class TraktAPI: + def __init__(self, params, authorization=None): + self.redirect_uri = "urn:ietf:wg:oauth:2.0:oob" + self.aliases = { + "trakt_trending": "Trakt Trending", + "trakt_watchlist": "Trakt Watchlist", + "trakt_list": "Trakt List" + } + self.client_id = params["client_id"] + self.client_secret = params["client_secret"] + self.config_path = params["config_path"] + self.authorization = authorization + Trakt.configuration.defaults.client(self.client_id, self.client_secret) + if not self.save_authorization(self.authorization): + if not self.refresh_authorization(): + self.get_authorization() + + def get_authorization(self): + url = Trakt["oauth"].authorize_url(self.redirect_uri) + logger.info("Navigate to: {}".format(url)) + logger.info("If you get an OAuth error your client_id or client_secret is invalid") + webbrowser.open(url, new=2) + try: pin = util.logger_input("Trakt pin (case insensitive)", timeout=300).strip() + except TimeoutExpired: raise Failed("Input Timeout: Trakt pin required.") + if not pin: raise Failed("Trakt Error: No input Trakt pin required.") + new_authorization = Trakt["oauth"].token(pin, self.redirect_uri) + if not new_authorization: + raise Failed("Trakt Error: Invalid trakt pin. If you're sure you typed it in correctly your client_id or client_secret may be invalid") + if not self.save_authorization(new_authorization): + raise Failed("Trakt Error: New Authorization Failed") + + def check_authorization(self, authorization): + try: + with Trakt.configuration.oauth.from_response(authorization, refresh=True): + if Trakt["users/settings"].get(): + return True + except ValueError: pass + return False + + def refresh_authorization(self): + if self.authorization and "refresh_token" in self.authorization and self.authorization["refresh_token"]: + logger.info("Refreshing Access Token...") + refreshed_authorization = Trakt["oauth"].token_refresh(self.authorization["refresh_token"], self.redirect_uri) + return self.save_authorization(refreshed_authorization) + return False + + def save_authorization(self, authorization): + if authorization and self.check_authorization(authorization): + if self.authorization != authorization: + yaml.YAML().allow_duplicate_keys = True + config, ind, bsi = yaml.util.load_yaml_guess_indent(open(self.config_path)) + config["trakt"]["authorization"] = { + "access_token": authorization["access_token"], + "token_type": authorization["token_type"], + "expires_in": authorization["expires_in"], + "refresh_token": authorization["refresh_token"], + "scope": authorization["scope"], + "created_at": authorization["created_at"] + } + logger.info("Saving authorization information to {}".format(self.config_path)) + yaml.round_trip_dump(config, open(self.config_path, "w"), indent=ind, block_seq_indent=bsi) + self.authorization = authorization + Trakt.configuration.defaults.oauth.from_response(self.authorization) + return True + return False + + def convert_tmdb_to_imdb(self, tmdb_id, is_movie=True): return self.convert_id(tmdb_id, "tmdb", "imdb", "movie" if is_movie else "show") + def convert_imdb_to_tmdb(self, imdb_id, is_movie=True): return self.convert_id(imdb_id, "imdb", "tmdb", "movie" if is_movie else "show") + def convert_tmdb_to_tvdb(self, tmdb_id): return self.convert_id(tmdb_id, "tmdb", "tvdb", "show") + def convert_tvdb_to_tmdb(self, tvdb_id): return self.convert_id(tvdb_id, "tvdb", "tmdb", "show") + def convert_tvdb_to_imdb(self, tvdb_id): return self.convert_id(tvdb_id, "tvdb", "imdb", "show") + def convert_imdb_to_tvdb(self, imdb_id): return self.convert_id(imdb_id, "imdb", "tvdb", "show") + + @retry(stop_max_attempt_number=6, wait_fixed=10000, retry_on_exception=util.retry_if_not_failed) + def convert_id(self, external_id, from_source, to_source, media_type): + lookup = Trakt["search"].lookup(external_id, from_source, media_type) + if lookup: + lookup = lookup[0] if isinstance(lookup, list) else lookup + return lookup.get_key(to_source) + else: + raise Failed("No {} ID found for {} ID {}".format(to_source.upper().replace("B", "b"), from_source.upper().replace("B", "b"), external_id)) + + @retry(stop_max_attempt_number=6, wait_fixed=10000) + def trending(self, amount, is_movie): + return Trakt["movies" if is_movie else "shows"].trending(per_page=amount) + + @retry(stop_max_attempt_number=6, wait_fixed=10000, retry_on_exception=util.retry_if_not_failed) + def watchlist(self, data, is_movie): + items = Trakt["users/{}/watchlist".format(data)].movies() if is_movie else Trakt["users/{}/watchlist".format(data)].shows() + if items is None: raise Failed("Trakt Error: No List found") + else: return [i for i in items] + + @retry(stop_max_attempt_number=6, wait_fixed=10000, retry_on_exception=util.retry_if_not_failed) + def standard_list(self, data): + try: items = Trakt[urlparse(data).path].items() + except AttributeError: items = None + if items is None: raise Failed("Trakt Error: No List found") + else: return items + + def validate_trakt_list(self, values): + trakt_values = [] + for value in values: + try: + self.standard_list(value) + trakt_values.append(value) + except Failed as e: + logger.error(e) + if len(trakt_values) == 0: + raise Failed("Trakt Error: No valid Trakt Lists in {}".format(value)) + return trakt_values + + def validate_trakt_watchlist(self, values, is_movie): + trakt_values = [] + for value in values: + try: + self.watchlist(value, is_movie) + trakt_values.append(value) + except Failed as e: + logger.error(e) + if len(trakt_values) == 0: + raise Failed("Trakt Error: No valid Trakt Watchlists in {}".format(value)) + return trakt_values + + def get_items(self, method, data, is_movie, status_message=True): + if status_message: + logger.debug("Data: {}".format(data)) + pretty = self.aliases[method] if method in self.aliases else method + media_type = "Movie" if is_movie else "Show" + if method == "trakt_trending": + trakt_items = self.trending(int(data), is_movie) + if status_message: + logger.info("Processing {}: {} {}{}".format(pretty, data, media_type, "" if data == 1 else "s")) + else: + if method == "trakt_watchlist": trakt_items = self.watchlist(data, is_movie) + elif method == "trakt_list": trakt_items = self.standard_list(data) + else: raise Failed("Trakt Error: Method {} not supported".format(method)) + if status_message: logger.info("Processing {}: {}".format(pretty, data)) + show_ids = [] + movie_ids = [] + for trakt_item in trakt_items: + if isinstance(trakt_item, Movie): movie_ids.append(int(trakt_item.get_key("tmdb"))) + elif isinstance(trakt_item, Show) and trakt_item.pk[1] not in show_ids: show_ids.append(int(trakt_item.pk[1])) + elif (isinstance(trakt_item, (Season, Episode))) and trakt_item.show.pk[1] not in show_ids: show_ids.append(int(trakt_item.show.pk[1])) + if status_message: + logger.debug("Trakt {} Found: {}".format(media_type, trakt_items)) + logger.debug("TMDb IDs Found: {}".format(movie_ids)) + logger.debug("TVDb IDs Found: {}".format(show_ids)) + return movie_ids, show_ids diff --git a/modules/tvdb.py b/modules/tvdb.py new file mode 100644 index 00000000..76c330dc --- /dev/null +++ b/modules/tvdb.py @@ -0,0 +1,165 @@ +import logging, math, re, requests, time +from lxml import html +from modules import util +from modules.util import Failed +from retrying import retry + +logger = logging.getLogger("Plex Meta Manager") + +class TVDbObj: + def __init__(self, tvdb_url, language, is_movie, TVDb): + tvdb_url = tvdb_url.strip() + if not is_movie and tvdb_url.startswith((TVDb.series_url, TVDb.alt_series_url, TVDb.series_id_url)): + self.media_type = "Series" + elif is_movie and tvdb_url.startswith((TVDb.movies_url, TVDb.alt_movies_url, TVDb.movie_id_url)): + self.media_type = "Movie" + else: + raise Failed("TVDb Error: {} must begin with {}".format(tvdb_url, TVDb.movies_url if is_movie else TVDb.series_url)) + + response = TVDb.send_request(tvdb_url, language) + results = html.fromstring(response).xpath("//*[text()='TheTVDB.com {} ID']/parent::node()/span/text()".format(self.media_type)) + if len(results) > 0: + self.id = int(results[0]) + else: + raise Failed("TVDb Error: Could not find a TVDb {} ID at the URL {}".format(self.media_type, tvdb_url)) + + results = html.fromstring(response).xpath("//div[@class='change_translation_text' and @data-language='eng']/@data-title") + if len(results) > 0 and len(results[0]) > 0: + self.title = results[0] + else: + raise Failed("TVDb Error: Name not found from TVDb URL: {}".format(tvdb_url)) + + results = html.fromstring(response).xpath("//div[@class='row hidden-xs hidden-sm']/div/img/@src") + self.poster_path = results[0] if len(results) > 0 and len(results[0]) > 0 else None + + tmdb_id = None + if is_movie: + results = html.fromstring(response).xpath("//*[text()='TheMovieDB.com']/@href") + if len(results) > 0: + try: tmdb_id = util.regex_first_int(results[0], "TMDb ID") + except Failed as e: logger.error(e) + if not tmdb_id: + results = html.fromstring(response).xpath("//*[text()='IMDB']/@href") + if len(results) > 0: + try: tmdb_id = TVDb.convert_from_imdb(util.get_id_from_imdb_url(results[0]), language) + except Failed as e: logger.error(e) + self.tmdb_id = tmdb_id + self.tvdb_url = tvdb_url + self.language = language + self.is_movie = is_movie + self.TVDb = TVDb + +class TVDbAPI: + def __init__(self, Cache=None, TMDb=None, Trakt=None): + self.Cache = Cache + self.TMDb = TMDb + self.Trakt = Trakt + self.site_url = "https://www.thetvdb.com" + self.alt_site_url = "https://thetvdb.com" + self.list_url = "{}/lists/".format(self.site_url) + self.alt_list_url = "{}/lists/".format(self.alt_site_url) + self.series_url = "{}/series/".format(self.site_url) + self.alt_series_url = "{}/series/".format(self.alt_site_url) + self.movies_url = "{}/movies/".format(self.site_url) + self.alt_movies_url = "{}/movies/".format(self.alt_site_url) + self.series_id_url = "{}/dereferrer/series/".format(self.site_url) + self.movie_id_url = "{}/dereferrer/movie/".format(self.site_url) + + def get_series(self, language, tvdb_url=None, tvdb_id=None): + if not tvdb_url and not tvdb_id: + raise Failed("TVDB Error: getget_seriesmove requires either tvdb_url or tvdb_id") + elif not tvdb_url and tvdb_id: + tvdb_url = "{}{}".format(self.series_id_url, tvdb_id) + return TVDbObj(tvdb_url, language, False, self) + + def get_movie(self, language, tvdb_url=None, tvdb_id=None): + if not tvdb_url and not tvdb_id: + raise Failed("TVDB Error: get_movie requires either tvdb_url or tvdb_id") + elif not tvdb_url and tvdb_id: + tvdb_url = "{}{}".format(self.movie_id_url, tvdb_id) + return TVDbObj(tvdb_url, language, True, self) + + def get_tvdb_ids_from_url(self, tvdb_url, language): + show_ids = [] + movie_ids = [] + tvdb_url = tvdb_url.strip() + if tvdb_url.startswith((self.list_url, self.alt_list_url)): + try: + response = self.send_request(tvdb_url, language) + items = html.fromstring(response).xpath("//div[@class='col-xs-12 col-sm-12 col-md-8 col-lg-8 col-md-pull-4']/div[@class='row']") + for item in items: + title = item.xpath(".//div[@class='col-xs-12 col-sm-9 mt-2']//a/text()")[0] + item_url = item.xpath(".//div[@class='col-xs-12 col-sm-9 mt-2']//a/@href")[0] + if item_url.startswith("/series/"): + try: show_ids.append(self.get_series(language, tvdb_url="{}{}".format(self.site_url, item_url)).id) + except Failed as e: logger.error("{} for series {}".format(e, title)) + elif item_url.startswith("/movies/"): + try: + tmdb_id = self.get_movie(language, tvdb_url="{}{}".format(self.site_url, item_url)).tmdb_id + if tmdb_id: movie_ids.append(tmdb_id) + else: raise Failed("TVDb Error: TMDb ID not found from TVDb URL: {}".format(tvdb_url)) + except Failed as e: + logger.error("{} for series {}".format(e, title)) + else: + logger.error("TVDb Error: Skipping Movie: {}".format(title)) + if len(show_ids) > 0 or len(movie_ids) > 0: + return movie_ids, show_ids + raise Failed("TVDb Error: No TVDb IDs found at {}".format(tvdb_url)) + except requests.exceptions.MissingSchema as e: + util.print_stacktrace() + raise Failed("TVDb Error: URL Lookup Failed for {}".format(tvdb_url)) + else: + raise Failed("TVDb Error: {} must begin with {}".format(tvdb_url, self.list_url)) + + @retry(stop_max_attempt_number=6, wait_fixed=10000) + def send_request(self, url, language): + return requests.get(url, headers={"Accept-Language": language}).content + + def get_items(self, method, data, language, status_message=True): + pretty = util.pretty_names[method] if method in util.pretty_names else method + show_ids = [] + movie_ids = [] + if status_message: + logger.info("Processing {}: {}".format(pretty, data)) + if method == "tvdb_show": + try: show_ids.append(self.get_series(language, tvdb_id=int(data))) + except ValueError: show_ids.append(self.get_series(language, tmdb_url=data)) + elif method == "tvdb_movie": + try: movie_ids.append(self.get_movie(language, tvdb_id=int(data))) + except ValueError: movie_ids.append(self.get_movie(language, tmdb_url=data)) + elif method == "tvdb_list": + tmdb_ids, tvdb_ids = self.get_tvdb_ids_from_url(data, language) + movie_ids.extend(tmdb_ids) + show_ids.extend(tvdb_ids) + else: + raise Failed("TVDb Error: Method {} not supported".format(method)) + if status_message: + 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) + update = False + if not tmdb_id: + tmdb_id, update = self.Cache.get_tmdb_from_imdb(imdb_id) + if update: + 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 update is not False: + self.Cache.update_imdb("movie", update, imdb_id, tmdb_id) + return tmdb_id diff --git a/modules/util.py b/modules/util.py new file mode 100644 index 00000000..760fd60f --- /dev/null +++ b/modules/util.py @@ -0,0 +1,618 @@ +import logging, re, signal, sys, time, traceback + +try: + import msvcrt + windows = True +except ModuleNotFoundError: + windows = False + + +logger = logging.getLogger("Plex Meta Manager") + +class TimeoutExpired(Exception): + pass + +class Failed(Exception): + pass + +def retry_if_not_failed(exception): + return not isinstance(exception, Failed) + +seperating_character = "=" +screen_width = 100 + +method_alias = { + "actors": "actor", "role": "actor", "roles": "actor", + "content_ratings": "content_rating", "contentRating": "content_rating", "contentRatings": "content_rating", + "countries": "country", + "decades": "decade", + "directors": "director", + "genres": "genre", + "studios": "studio", "network": "studio", "networks": "studio", + "writers": "writer", + "years": "year" + } +filter_alias = { + "actor": "actors", + "audio_language": "audio_language", + "collection": "collections", + "content_rating": "contentRating", + "country": "countries", + "director": "directors", + "genre": "genres", + "max_age": "max_age", + "originally_available": "originallyAvailableAt", + "rating": "rating", + "studio": "studio", + "subtitle_language": "subtitle_language", + "writer": "writers", + "video_resolution": "video_resolution", + "year": "year" +} +days_alias = { + "monday": 0, "mon": 0, "m": 0, + "tuesday": 1, "tues": 1, "tue": 1, "tu": 1, "t": 1, + "wednesday": 2, "wed": 2, "w": 2, + "thursday": 3, "thurs": 3, "thur": 3, "thu": 3, "th": 3, "r": 3, + "friday": 4, "fri": 4, "f": 4, + "saturday": 5, "sat": 5, "s": 5, + "sunday": 6, "sun": 6, "su": 6, "u": 6 +} +pretty_days = { + 0: "Monday", + 1: "Tuesday", + 2: "Wednesday", + 3: "Thursday", + 4: "Friday", + 5: "Saturday", + 6: "Sunday" +} +pretty_months = { + 1: "January", + 2: "February", + 3: "March", + 4: "April", + 5: "May", + 6: "June", + 7: "July", + 8: "August", + 9: "September", + 10: "October", + 11: "November", + 12: "December" +} +pretty_seasons = { + "winter": "Winter", + "spring": "Spring", + "summer": "Summer", + "fall": "Fall" +} +pretty_names = { + "anidb_id": "AniDB ID", + "anidb_relation": "AniDB Relation", + "anidb_popular": "AniDB Popular", + "imdb_list": "IMDb List", + "imdb_id": "IMDb ID", + "mal_id": "MyAnimeList ID", + "mal_all": "MyAnimeList All", + "mal_airing": "MyAnimeList Airing", + "mal_upcoming": "MyAnimeList Upcoming", + "mal_tv": "MyAnimeList TV", + "mal_ova": "MyAnimeList OVA", + "mal_movie": "MyAnimeList Movie", + "mal_special": "MyAnimeList Special", + "mal_popular": "MyAnimeList Popular", + "mal_favorite": "MyAnimeList Favorite", + "mal_season": "MyAnimeList Season", + "mal_suggested": "MyAnimeList Suggested", + "mal_userlist": "MyAnimeList Userlist", + "plex_all": "Plex All", + "plex_collection": "Plex Collection", + "plex_search": "Plex Search", + "tautulli_popular": "Tautulli Popular", + "tautulli_watched": "Tautulli Watched", + "tmdb_collection": "TMDb Collection", + "tmdb_collection_details": "TMDb Collection", + "tmdb_company": "TMDb Company", + "tmdb_discover": "TMDb Discover", + "tmdb_keyword": "TMDb Keyword", + "tmdb_list": "TMDb List", + "tmdb_list_details": "TMDb List", + "tmdb_movie": "TMDb Movie", + "tmdb_movie_details": "TMDb Movie", + "tmdb_network": "TMDb Network", + "tmdb_now_playing": "TMDb Now Playing", + "tmdb_popular": "TMDb Popular", + "tmdb_show": "TMDb Show", + "tmdb_show_details": "TMDb Show", + "tmdb_top_rated": "TMDb Top Rated", + "tmdb_trending_daily": "TMDb Trending Daily", + "tmdb_trending_weekly": "TMDb Trending Weekly", + "trakt_list": "Trakt List", + "trakt_trending": "Trakt Trending", + "trakt_watchlist": "Trakt Watchlist", + "tvdb_list": "TVDb List", + "tvdb_movie": "TVDb Movie", + "tvdb_show": "TVDb Show" +} +mal_ranked_name = { + "mal_all": "all", + "mal_airing": "airing", + "mal_upcoming": "upcoming", + "mal_tv": "tv", + "mal_ova": "ova", + "mal_movie": "movie", + "mal_special": "special", + "mal_popular": "bypopularity", + "mal_favorite": "favorite" +} +mal_season_sort = { + "anime_score": "anime_score", + "anime_num_list_users": "anime_num_list_users", + "score": "anime_score", + "members": "anime_num_list_users" +} +mal_pretty = { + "anime_score": "Score", + "anime_num_list_users": "Members", + "list_score": "Score", + "list_updated_at": "Last Updated", + "anime_title": "Title", + "anime_start_date": "Start Date", + "all": "All Anime", + "watching": "Currently Watching", + "completed": "Completed", + "on_hold": "On Hold", + "dropped": "Dropped", + "plan_to_watch": "Plan to Watch" +} +mal_userlist_sort = { + "score": "list_score", + "list_score": "list_score", + "last_updated": "list_updated_at", + "list_updated": "list_updated_at", + "list_updated_at": "list_updated_at", + "title": "anime_title", + "anime_title": "anime_title", + "start_date": "anime_start_date", + "anime_start_date": "anime_start_date" +} +mal_userlist_status = [ + "all", + "watching", + "completed", + "on_hold", + "dropped", + "plan_to_watch" +] +pretty_ids = { + "anidbid": "AniDB", + "imdbid": "IMDb", + "mal_id": "MyAnimeList", + "themoviedb_id": "TMDb", + "thetvdb_id": "TVDb", + "tvdbid": "TVDb" +} +all_lists = [ + "anidb_id", + "anidb_relation", + "anidb_popular", + "imdb_list", + "imdb_id", + "mal_id", + "mal_all", + "mal_airing", + "mal_upcoming", + "mal_tv", + "mal_ova", + "mal_movie", + "mal_special", + "mal_popular", + "mal_favorite", + "mal_season", + "mal_suggested", + "mal_userlist", + "plex_collection", + "plex_search", + "tautulli_popular", + "tautulli_watched", + "tmdb_collection", + "tmdb_collection_details", + "tmdb_company", + "tmdb_discover", + "tmdb_keyword", + "tmdb_list", + "tmdb_list_details", + "tmdb_movie", + "tmdb_movie_details", + "tmdb_network", + "tmdb_now_playing", + "tmdb_popular", + "tmdb_show", + "tmdb_show_details", + "tmdb_top_rated", + "tmdb_trending_daily", + "tmdb_trending_weekly", + "trakt_list", + "trakt_trending", + "trakt_watchlist", + "tvdb_list", + "tvdb_movie", + "tvdb_show" +] +collectionless_lists = [ + "sort_title", "content_rating", + "summary", "tmdb_summary", "tmdb_description", "tmdb_biography", + "collection_order", "plex_collectionless", + "url_poster", "tmdb_poster", "tmdb_profile", "file_poster", + "url_background", "file_background", + "name_mapping" +] +dictionary_lists = [ + "filters", + "mal_season", + "mal_userlist", + "plex_collectionless", + "plex_search", + "tautulli_popular", + "tautulli_watched", + "tmdb_discover" +] +plex_searches = [ + "actor", "actor_details_tmdb", #"actor.not", # Waiting on PlexAPI to fix issue + "country", #"country.not", + "decade", #"decade.not", + "director", "director_details_tmdb", #"director.not", + "genre", #"genre.not", + "studio", #"studio.not", + "writer", "writer_details_tmdb", #"writer.not" + "year" #"year.not", +] +show_only_lists = [ + "tmdb_network", + "tmdb_show", + "tmdb_show_details", + "tvdb_show" +] +movie_only_lists = [ + "actor_details_tmdb", + "director_details_tmdb", + "tmdb_now_playing", + "writer_details_tmdb" +] +movie_only_searches = [ + "actor", "actor_details_tmdb", #"actor.not", # Waiting on PlexAPI to fix issue + "country", #"country.not", + "decade", #"decade.not", + "director", "director_details_tmdb", #"director.not", + "writer", "writer_details_tmdb" #"writer.not" +] +count_lists = [ + "anidb_popular", + "mal_all", + "mal_airing", + "mal_upcoming", + "mal_tv", + "mal_ova", + "mal_movie", + "mal_special", + "mal_popular", + "mal_favorite", + "mal_suggested", + "tmdb_popular", + "tmdb_top_rated", + "tmdb_now_playing", + "tmdb_trending_daily", + "tmdb_trending_weekly", + "trakt_trending" +] +tmdb_lists = [ + "tmdb_collection", + "tmdb_collection_details", + "tmdb_company", + "tmdb_discover", + "tmdb_keyword", + "tmdb_list", + "tmdb_list_details", + "tmdb_movie", + "tmdb_movie_details", + "tmdb_network", + "tmdb_now_playing", + "tmdb_popular", + "tmdb_show", + "tmdb_show_details", + "tmdb_top_rated", + "tmdb_trending_daily", + "tmdb_trending_weekly" +] +tmdb_type = { + "tmdb_collection": "Collection", + "tmdb_collection_details": "Collection", + "tmdb_company": "Company", + "tmdb_keyword": "Keyword", + "tmdb_list": "List", + "tmdb_list_details": "List", + "tmdb_movie": "Movie", + "tmdb_movie_details": "Movie", + "tmdb_network": "Network", + "tmdb_show": "Show", + "tmdb_show_details": "Show" +} +all_filters = [ + "actor", "actor.not", + "audio_language", "audio_language.not", + "collection", "collection.not", + "content_rating", "content_rating.not", + "country", "country.not", + "director", "director.not", + "genre", "genre.not", + "max_age", + "originally_available.gte", "originally_available.lte", + "rating.gte", "rating.lte", + "studio", "studio.not", + "subtitle_language", "subtitle_language.not", + "video_resolution", "video_resolution.not", + "writer", "writer.not", + "year", "year.gte", "year.lte", "year.not" +] +movie_only_filters = [ + "audio_language", "audio_language.not", + "country", "country.not", + "director", "director.not", + "subtitle_language", "subtitle_language.not", + "video_resolution", "video_resolution.not", + "writer", "writer.not" +] +all_details = [ + "sort_title", "content_rating", + "summary", "tmdb_summary", "tmdb_description", "tmdb_biography", + "collection_mode", "collection_order", + "url_poster", "tmdb_poster", "tmdb_profile", "file_poster", + "url_background", "file_background", + "name_mapping", "add_to_arr" +] +discover_movie = [ + "language", "with_original_language", "region", "sort_by", + "certification_country", "certification", "certification.lte", "certification.gte", + "include_adult", + "primary_release_year", "primary_release_date.gte", "primary_release_date.lte", + "release_date.gte", "release_date.lte", "year", + "vote_count.gte", "vote_count.lte", + "vote_average.gte", "vote_average.lte", + "with_cast", "with_crew", "with_people", + "with_companies", + "with_genres", "without_genres", + "with_keywords", "without_keywords", + "with_runtime.gte", "with_runtime.lte" +] +discover_tv = [ + "language", "with_original_language", "timezone", "sort_by", + "air_date.gte", "air_date.lte", + "first_air_date.gte", "first_air_date.lte", "first_air_date_year", + "vote_count.gte", "vote_count.lte", + "vote_average.gte", "vote_average.lte", + "with_genres", "without_genres", + "with_keywords", "without_keywords", + "with_networks", "with_companies", + "with_runtime.gte", "with_runtime.lte", + "include_null_first_air_dates", + "screened_theatrically" +] +discover_movie_sort = [ + "popularity.asc", "popularity.desc", + "release_date.asc", "release_date.desc", + "revenue.asc", "revenue.desc", + "primary_release_date.asc", "primary_release_date.desc", + "original_title.asc", "original_title.desc", + "vote_average.asc", "vote_average.desc", + "vote_count.asc", "vote_count.desc" +] +discover_tv_sort = [ + "vote_average.desc", "vote_average.asc", + "first_air_date.desc", "first_air_date.asc", + "popularity.desc", "popularity.asc" +] + +def adjust_space(old_length, display_title): + display_title = str(display_title) + space_length = old_length - len(display_title) + if space_length > 0: + display_title += " " * space_length + return display_title + +def make_ordinal(n): + n = int(n) + suffix = ["th", "st", "nd", "rd", "th"][min(n % 10, 4)] + if 11 <= (n % 100) <= 13: + suffix = "th" + return str(n) + suffix + +def choose_from_list(datalist, description, data=None, list_type="title", exact=False): + if len(datalist) > 0: + if len(datalist) == 1 and (description != "collection" or datalist[0].title == data): + return datalist[0] + message = "Multiple {}s Found\n0) {}".format(description, "Create New Collection: {}".format(data) if description == "collection" else "Do Nothing") + for i, d in enumerate(datalist, 1): + if list_type == "title": + if d.title == data: + return d + message += "\n{}) {}".format(i, d.title) + else: + message += "\n{}) [{}] {}".format(i, d[0], d[1]) + if exact: + return None + print_multiline(message, info=True) + while True: + try: + selection = int(logger_input("Choose {} number".format(description))) - 1 + if selection >= 0: return datalist[selection] + elif selection == -1: return None + else: logger.info("Invalid {} number".format(description)) + except IndexError: logger.info("Invalid {} number".format(description)) + except TimeoutExpired: + if list_type == "title": + logger.warning("Input Timeout: using {}".format(data)) + return None + else: + logger.warning("Input Timeout: using {}".format(datalist[0][1])) + return datalist[0][1] + else: + return None + +def get_list(data): + if isinstance(data, list): return data + elif isinstance(data, dict): return [data] + else: return str(data).split(", ") + +def get_int_list(data, id_type): + values = get_list(data) + int_values = [] + for value in values: + try: int_values.append(regex_first_int(value, id_type)) + except Failed as e: logger.error(e) + return int_values + +def get_year_list(data, method): + values = get_list(data) + final_years = [] + current_year = datetime.datetime.now().year + for value in values: + try: + if "-" in value: + year_range = re.search("(\\d{4})-(\\d{4}|NOW)", str(value)) + start = year_range.group(1) + end = year_range.group(2) + if end == "NOW": + end = current_year + if int(start) < 1800 or int(start) > current_year: logger.error("Collection Error: Skipping {} starting year {} must be between 1800 and {}".format(method, start, current_year)) + elif int(end) < 1800 or int(end) > current_year: logger.error("Collection Error: Skipping {} ending year {} must be between 1800 and {}".format(method, end, current_year)) + elif int(start) > int(end): logger.error("Collection Error: Skipping {} starting year {} cannot be greater then ending year {}".format(method, start, end)) + else: + for i in range(int(start), int(end) + 1): + final_years.append(i) + else: + year = re.search("(\\d+)", str(value)).group(1) + if int(start) < 1800 or int(start) > current_year: + logger.error("Collection Error: Skipping {} year {} must be between 1800 and {}".format(method, year, current_year)) + else: + if len(str(year)) != len(str(value)): + logger.warning("Collection Warning: {} can be replaced with {}".format(value, year)) + final_years.append(year) + except AttributeError: + logger.error("Collection Error: Skipping {} failed to parse year from {}".format(method, value)) + return final_years + +def logger_input(prompt, timeout=60): + if windows: return windows_input(prompt, timeout) + elif hasattr(signal, "SIGALRM"): return unix_input(prompt, timeout) + else: raise SystemError("Input Timeout not supported on this system") + +def alarm_handler(signum, frame): + raise TimeoutExpired + +def unix_input(prompt, timeout=60): + prompt = "| {}: ".format(prompt) + signal.signal(signal.SIGALRM, alarm_handler) + signal.alarm(timeout) + try: return input(prompt) + finally: signal.alarm(0) + +def old_windows_input(prompt, timeout=60, timer=time.monotonic): + prompt = "| {}: ".format(prompt) + sys.stdout.write(prompt) + sys.stdout.flush() + endtime = timer() + timeout + result = [] + while timer() < endtime: + if msvcrt.kbhit(): + result.append(msvcrt.getwche()) + if result[-1] == "\n": + out = "".join(result[:-1]) + logger.debug("{}{}".format(prompt[2:], out)) + return out + time.sleep(0.04) + raise TimeoutExpired + +def windows_input(prompt, timeout=5): + sys.stdout.write("| {}: ".format(prompt)) + sys.stdout.flush() + result = [] + start_time = time.time() + while True: + if msvcrt.kbhit(): + chr = msvcrt.getwche() + if ord(chr) == 13: # enter_key + out = "".join(result) + print("") + logger.debug("{}: {}".format(prompt, out)) + return out + elif ord(chr) >= 32: #space_char + result.append(chr) + if (time.time() - start_time) > timeout: + print("") + raise TimeoutExpired + + +def print_multiline(lines, info=False, warning=False, error=False, critical=False): + for i, line in enumerate(lines.split("\n")): + if critical: logger.critical(line) + elif error: logger.error(line) + elif warning: logger.warning(line) + elif info: logger.info(line) + else: logger.debug(line) + if i == 0: + logger.handlers[1].setFormatter(logging.Formatter(" " * 65 + "| %(message)s")) + logger.handlers[1].setFormatter(logging.Formatter("[%(asctime)s] %(filename)-27s %(levelname)-10s | %(message)s")) + +def print_stacktrace(): + print_multiline(traceback.format_exc()) + +def my_except_hook(exctype, value, tb): + for line in traceback.format_exception(etype=exctype, value=value, tb=tb): + print_multiline(line, critical=True) + +def get_id_from_imdb_url(imdb_url): + match = re.search("(tt\\d+)", str(imdb_url)) + if match: return match.group(1) + else: raise Failed("Regex Error: Failed to parse IMDb ID from IMDb URL: {}".format(imdb_url)) + +def regex_first_int(data, id_type, default=None): + match = re.search("(\\d+)", str(data)) + if match: + return int(match.group(1)) + elif default: + logger.warning("Regex Warning: Failed to parse {} from {} using {} as default".format(id_type, data, default)) + return int(default) + else: + raise Failed("Regex Error: Failed to parse {} from {}".format(id_type, data)) + +def remove_not(method): + return method[:-4] if method.endswith(".not") else method + +def get_centered_text(text): + if len(text) > screen_width - 2: + raise Failed("text must be shorter then screen_width") + space = screen_width - len(text) - 2 + if space % 2 == 1: + text += " " + space -= 1 + side = int(space / 2) + return "{}{}{}".format(" " * side, text, " " * side) + +def seperator(text=None): + logger.handlers[0].setFormatter(logging.Formatter("%(message)-{}s".format(screen_width - 2))) + logger.handlers[1].setFormatter(logging.Formatter("[%(asctime)s] %(filename)-27s %(levelname)-10s %(message)-{}s".format(screen_width - 2))) + logger.info("|{}|".format(seperating_character * screen_width)) + if text: + logger.info("| {} |".format(get_centered_text(text))) + logger.info("|{}|".format(seperating_character * screen_width)) + logger.handlers[0].setFormatter(logging.Formatter("| %(message)-{}s |".format(screen_width - 2))) + logger.handlers[1].setFormatter(logging.Formatter("[%(asctime)s] %(filename)-27s %(levelname)-10s | %(message)-{}s |".format(screen_width - 2))) + +def print_return(length, text): + print(adjust_space(length, "| {}".format(text)), end="\r") + return len(text) + 2 + +def print_end(length, text=None): + if text: logger.info(adjust_space(length, text)) + else: print(adjust_space(length, " "), end="\r") diff --git a/plex_meta_manager.py b/plex_meta_manager.py new file mode 100644 index 00000000..c1e575dc --- /dev/null +++ b/plex_meta_manager.py @@ -0,0 +1,86 @@ +import argparse, logging, os, re, schedule, sys, time, traceback, datetime +from modules import tests, util +from modules.config import Config + +parser = argparse.ArgumentParser() +parser.add_argument("--test", dest="test", help=argparse.SUPPRESS, action="store_true", default=False) +parser.add_argument("-c", "--config", dest="config", help="Run with desired *.yml file", type=str) +parser.add_argument("-t", "--time", dest="time", help="Time to update each day use format HH:MM (Default: 03:00)", default="03:00", type=str) +parser.add_argument("-r", "--run", dest="run", help="Run without the scheduler", action="store_true", default=False) +parser.add_argument("-d", "--divider", dest="divider", help="Character that divides the sections (Default: '=')", default="=", type=str) +parser.add_argument("-w", "--width", dest="width", help="Screen Width (Default: 100)", default=100, type=int) +args = parser.parse_args() + +if not re.match("^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$", args.time): + raise util.Failed("Argument Error: time argument invalid: {} must be in the HH:MM format".format(args.time)) + +util.seperating_character = args.divider[0] +if 90 <= args.width <= 300: + util.screen_width = args.width +else: + raise util.Failed("Argument Error: width argument invalid: {} must be an integer between 90 and 300".format(args.width)) + +default_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config") +if args.config and os.path.exists(args.config): default_dir = os.path.join(os.path.dirname(os.path.abspath(args.config))) +elif args.config and not os.path.exists(args.config): raise util.Failed("Config Error: config not found at {}".format(os.path.abspath(args.config))) +elif not os.path.exists(os.path.join(default_dir, "config.yml")): raise util.Failed("Config Error: config not found at {}".format(os.path.abspath(default_dir))) + +os.makedirs(os.path.join(default_dir, "logs"), exist_ok=True) + +logger = logging.getLogger("Plex Meta Manager") +logger.setLevel(logging.DEBUG) + +def fmt_filter(record): + record.levelname = "[{}]".format(record.levelname) + record.filename = "[{}:{}]".format(record.filename, record.lineno) + return True + +file_handler = logging.handlers.TimedRotatingFileHandler(os.path.join(default_dir, "logs", "meta.log"), when="midnight", backupCount=10, encoding="utf-8") +file_handler.addFilter(fmt_filter) +file_handler.setFormatter(logging.Formatter("[%(asctime)s] %(filename)-27s %(levelname)-10s | %(message)-100s |")) + +cmd_handler = logging.StreamHandler() +cmd_handler.setFormatter(logging.Formatter("| %(message)-100s |")) +cmd_handler.setLevel(logging.INFO) + +logger.addHandler(cmd_handler) +logger.addHandler(file_handler) + +sys.excepthook = util.my_except_hook + +util.seperator() +logger.info(util.get_centered_text(" ")) +logger.info(util.get_centered_text(" ____ _ __ __ _ __ __ ")) +logger.info(util.get_centered_text("| _ \| | _____ __ | \/ | ___| |_ __ _ | \/ | __ _ _ __ __ _ __ _ ___ _ __ ")) +logger.info(util.get_centered_text("| |_) | |/ _ \ \/ / | |\/| |/ _ \ __/ _` | | |\/| |/ _` | '_ \ / _` |/ _` |/ _ \ '__|")) +logger.info(util.get_centered_text("| __/| | __/> < | | | | __/ || (_| | | | | | (_| | | | | (_| | (_| | __/ | ")) +logger.info(util.get_centered_text("|_| |_|\___/_/\_\ |_| |_|\___|\__\__,_| |_| |_|\__,_|_| |_|\__,_|\__, |\___|_| ")) +logger.info(util.get_centered_text(" |___/ ")) +logger.info(util.get_centered_text(" Version: 1.0.0 ")) +util.seperator() + +if args.test: + tests.run_tests(default_dir) + sys.exit(0) + +def start(config_path): + try: + util.seperator("Starting Daily Run") + config = Config(default_dir, config_path) + config.update_libraries() + except Exception as e: + util.print_stacktrace() + logger.critical(e) + logger.info("") + util.seperator("Finished Daily Run") + +try: + if args.run: + start(args.config) + else: + schedule.every().day.at(args.time).do(start, args.config) + while True: + schedule.run_pending() + time.sleep(1) +except KeyboardInterrupt: + util.seperator("Exiting Plex Meta Manager") diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..4135ff03 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,13 @@ +# Remove +# Less common, pinned +PlexAPI==4.2.0 +tmdbv3api==1.7.3 +trakt.py==4.2.0 +# More common, flexible +bs4 +lxml +requests>=2.4.2 +ruamel.yaml +schedule +retrying +mutagen