├── .gitignore ├── pytrakt.json ├── requirements.txt ├── database.py ├── LICENSE ├── README.md ├── TimeToTrakt.py ├── processor.py └── searcher.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.json 2 | -------------------------------------------------------------------------------- /pytrakt.json: -------------------------------------------------------------------------------- 1 | { 2 | "OAUTH_EXPIRES_AT": 0 3 | } -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pytrakt~=3.4.30 2 | tinydb==4.6.1 -------------------------------------------------------------------------------- /database.py: -------------------------------------------------------------------------------- 1 | from tinydb import TinyDB 2 | 3 | # Create databases to keep track of completed processes 4 | database = TinyDB("localStorage.json") 5 | syncedEpisodesTable = database.table("SyncedEpisodes") 6 | userMatchedShowsTable = database.table("TvTimeTraktUserMatched") 7 | syncedMoviesTable = database.table("SyncedMovies") 8 | userMatchedMoviesTable = database.table("TvTimeTraktUserMatchedMovies") 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Luke Arran 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TV Time to Trakt - Import Script 2 | 3 | A Python script to import TV Time tracked episode and movie data into Trakt.TV - using data export provided by TV Time through a GDPR request. 4 | 5 | This script was made possible by the following contributors. 6 | 7 | 8 | 9 | 10 | 11 | # Notes 12 | 13 | 1. The script is using limited data provided from a GDPR request - so the accuracy isn't 100%. But you will be prompted to manually pick the Trakt show/movie, when it can't be determined automatically. 14 | 2. A delay of 1 second is added between each episode/movie to ensure fair use of Trakt's API server. You can adjust this for your own import, but make sure it's at least 0.75 second to remain within the rate limit: https://trakt.docs.apiary.io/#introduction/rate-limiting 15 | 3. Episodes which have been processed will be saved to a TinyDB file `localStorage.json` - when you restart the script, the program will skip those episodes which have been marked 'imported'. Processed movies are also stored in the same file. 16 | 17 | # Setup 18 | 19 | ## Get your Data 20 | 21 | TV Time's API is not open. In order to get access to your personal data, you will have to request it from TV Time's support via a GDPR request - or maybe just ask for it, whatever works, it's your data. 22 | 23 | 1. Copy the template provided by [www.datarequests.org](https://www.datarequests.org/blog/sample-letter-gdpr-access-request/) into an email 24 | 2. Send it to support@tvtime.com 25 | 3. Wait a few working days for their team to process your request 26 | 4. Extract the data somewhere safe on your local system 27 | 28 | ## Register API Access at Trakt 29 | 30 | 1. Go to "Settings" under your profile 31 | 2. Select ["Your API Applications"](https://trakt.tv/oauth/applications) 32 | 3. Select "New Application" 33 | 4. Provide a name into "Name" e.g. John Smith Import from TV Time 34 | 5. Paste "urn:ietf:wg:oauth:2.0:oob" into "Redirect uri:" 35 | 6. Click "Save App" 36 | 7. Make note of your details to be used later. 37 | 38 | ## Setup Script 39 | 40 | ### Install Required Libraries 41 | 42 | Install the following frameworks via Pip: 43 | 44 | ``` 45 | python -m pip install -r requirements.txt 46 | ``` 47 | 48 | ### Setup Configuration 49 | 50 | Create a new file named `config.json` in the same directory of `TimeToTrakt.py`, using the below JSON contents (replace the values with your own). 51 | 52 | Use forward slash or double backslash for `GDPR_WORKSPACE_PATH` if you encounter `json.decoder.JSONDecodeError: Invalid \escape: line 4 column 31 (char 206)`, as seen [here](https://github.com/lukearran/TvTimeToTrakt/issues/18) and [here](https://github.com/lukearran/TvTimeToTrakt/issues/39). 53 | 54 | The movie and show data is usually in "tracking-prod-records.csv" and "tracking-prod-records-v2.csv" respectively, however please check this is where your data is actually stored. 55 | 56 | Date format can be left as the default value unless you receive an error. If you receive an error about the time data not matching format, update this value using the [docs](https://docs.python.org/3/library/datetime.html#strftime-and-strptime-format-codes). 57 | ``` 58 | { 59 | "CLIENT_ID": "YOUR_CLIENT_ID", 60 | "CLIENT_SECRET": "YOUR_CLIENT_SECRET", 61 | "MOVIE_DATA_PATH": "DIRECTORY_OF_YOUR_GDPR_REQUEST_MOVIE_DATA", 62 | "SHOW_DATA_PATH": "DIRECTORY_OF_YOUR_GDPR_REQUEST_SHOW_DATA", 63 | "TRAKT_USERNAME": "YOUR_TRAKT_USERNAME", 64 | "DATE_FORMAT": "%Y-%m-%d %H:%M:%S" 65 | } 66 | ``` 67 | 68 | Once the config is in place, execute the program using `python TimeToTrakt.py`. The process isn't 100% automated - you will need to pop back, especially with large imports, to check if the script requires a manual user input. 69 | -------------------------------------------------------------------------------- /TimeToTrakt.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import csv 3 | import json 4 | import logging 5 | import os 6 | from dataclasses import dataclass 7 | from datetime import datetime 8 | 9 | import trakt.core 10 | from trakt import init 11 | 12 | from processor import TVShowProcessor, MovieProcessor 13 | from searcher import TVTimeTVShow, TVTimeMovie 14 | 15 | # Setup logger 16 | logging.basicConfig( 17 | format="%(asctime)s [%(levelname)s] :: %(message)s", 18 | encoding='utf-8', 19 | level=logging.INFO, 20 | datefmt="%Y-%m-%d %H:%M:%S", 21 | # datefmt="%x %X", #Uncomment for locale date and time, if preffered 22 | ) 23 | 24 | # Adjust this value to increase/decrease your requests between episodes. 25 | # Make to remain within the rate limit: https://trakt.docs.apiary.io/#introduction/rate-limiting 26 | DELAY_BETWEEN_ITEMS_IN_SECONDS = 1 27 | 28 | 29 | @dataclass 30 | class Config: 31 | trakt_username: str 32 | client_id: str 33 | client_secret: str 34 | movie_path: str 35 | show_path: str 36 | date_format: str 37 | 38 | 39 | def is_authenticated() -> bool: 40 | with open("pytrakt.json") as f: 41 | data = json.load(f) 42 | days_before_expiration = ( 43 | datetime.fromtimestamp(data["OAUTH_EXPIRES_AT"]) - datetime.now() 44 | ).days 45 | return days_before_expiration >= 1 46 | 47 | 48 | def get_configuration() -> Config: 49 | try: 50 | with open("config.json") as f: 51 | data = json.load(f) 52 | 53 | return Config( 54 | data["TRAKT_USERNAME"], 55 | data["CLIENT_ID"], 56 | data["CLIENT_SECRET"], 57 | data["MOVIE_DATA_PATH"], 58 | data["SHOW_DATA_PATH"], 59 | data["DATE_FORMAT"] 60 | ) 61 | except FileNotFoundError: 62 | logging.info("config.json not found prompting user for input") 63 | return Config( 64 | input("Enter your Trakt.tv username: "), 65 | input("Enter you Client id: "), 66 | input("Enter your Client secret: "), 67 | input("Enter your Movie Data Path: "), 68 | input("Enter your Show Data Path: "), 69 | input("Please enter the date format: ") 70 | ) 71 | 72 | 73 | config = get_configuration() 74 | 75 | WATCHED_SHOWS_PATH = config.show_path 76 | WATCHED_MOVIES_PATH = config.movie_path 77 | 78 | def init_trakt_auth() -> bool: 79 | if is_authenticated(): 80 | return True 81 | trakt.core.AUTH_METHOD = trakt.core.OAUTH_AUTH 82 | return init( 83 | config.trakt_username, 84 | store=True, 85 | client_id=config.client_id, 86 | client_secret=config.client_secret, 87 | ) 88 | 89 | 90 | def process_watched_shows() -> None: 91 | with open(WATCHED_SHOWS_PATH, newline="", encoding="UTF-8") as csvfile: 92 | reader = csv.DictReader(csvfile, delimiter=",") 93 | total_rows = len(list(reader)) 94 | csvfile.seek(0, 0) 95 | 96 | # Ignore the header row 97 | next(reader, None) 98 | for rows_count, row in enumerate(reader): 99 | if row["episode_number"] == "": # if not an episode entry 100 | continue 101 | if row["series_name"] == "": # if the series name is blank 102 | continue 103 | tv_time_show = TVTimeTVShow(row) 104 | TVShowProcessor().process_item(tv_time_show, "{:.2f}%".format(rows_count / total_rows * 100)) 105 | 106 | def process_watched_movies() -> None: 107 | with open(WATCHED_MOVIES_PATH, newline="", encoding="UTF-8") as csvfile: 108 | reader = filter(lambda p: p["movie_name"] != "", csv.DictReader(csvfile, delimiter=",")) 109 | watched_list = [row["movie_name"] for row in reader if row["type"] == "watch"] 110 | csvfile.seek(0, 0) 111 | total_rows = len(list(reader)) 112 | csvfile.seek(0, 0) 113 | 114 | # Ignore the header row 115 | next(reader, None) 116 | for rows_count, row in enumerate(reader): 117 | movie = TVTimeMovie(row) 118 | MovieProcessor(watched_list).process_item(movie, "{:.2f}%".format(rows_count / total_rows * 100)) 119 | 120 | 121 | def menu_selection() -> int: 122 | # Display a menu selection 123 | print(">> What do you want to do?") 124 | print(" 1) Import Watch History for TV Shows from TV Time") 125 | print(" 2) Import Watched Movies from TV Time") 126 | print(" 3) Do both 1 and 2 (default)") 127 | print(" 4) Exit") 128 | 129 | while True: 130 | try: 131 | selection = input("Enter your menu selection: ") 132 | selection = 3 if not selection else int(selection) 133 | break 134 | except ValueError: 135 | logging.warning("Invalid input. Please enter a numerical number.") 136 | # Check if the input is valid 137 | if not 1 <= selection <= 4: 138 | logging.warning("Sorry - that's an unknown menu selection") 139 | exit() 140 | # Exit if the 4th option was chosen 141 | if selection == 4: 142 | logging.info("Exiting as per user's selection.") 143 | exit() 144 | 145 | return selection 146 | 147 | 148 | def start(): 149 | selection = menu_selection() 150 | 151 | # Create the initial authentication with Trakt, before starting the process 152 | if not init_trakt_auth(): 153 | logging.error( 154 | "ERROR: Unable to complete authentication to Trakt - please try again." 155 | ) 156 | 157 | if selection == 1: 158 | logging.info("Processing watched shows.") 159 | process_watched_shows() 160 | # TODO: Add support for followed shows 161 | elif selection == 2: 162 | logging.info("Processing movies.") 163 | process_watched_movies() 164 | elif selection == 3: 165 | logging.info("Processing both watched shows and movies.") 166 | process_watched_shows() 167 | process_watched_movies() 168 | 169 | 170 | if __name__ == "__main__": 171 | # Check that the user has provided the GDPR path 172 | if os.path.isfile(config.movie_path) and os.path.isfile(config.show_path): 173 | start() 174 | else: 175 | logging.error( 176 | f"Oops! The file provided does not exist on the local system. Please check it, and try again." 177 | ) 178 | -------------------------------------------------------------------------------- /processor.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import sys 4 | import time 5 | from abc import ABC, abstractmethod 6 | 7 | import trakt.core 8 | from tinydb import Query 9 | from tinydb.table import Document 10 | 11 | from database import syncedEpisodesTable, syncedMoviesTable 12 | from searcher import TVShowSearcher, MovieSearcher, TraktTVShow, TraktMovie, TraktItem, TVTimeItem, TVTimeTVShow, \ 13 | TVTimeMovie 14 | 15 | 16 | class Processor(ABC): 17 | @abstractmethod 18 | def _get_synced_items(self, tv_time_item: TVTimeItem) -> list[Document]: 19 | pass 20 | 21 | @abstractmethod 22 | def _log_already_imported(self, tv_time_item: TVTimeItem, progress: str) -> None: 23 | pass 24 | 25 | @abstractmethod 26 | def _should_continue(self, tv_time_item: TVTimeItem) -> bool: 27 | pass 28 | 29 | @abstractmethod 30 | def _search(self, tv_time_item: TVTimeItem) -> TraktItem: 31 | pass 32 | 33 | @abstractmethod 34 | def _process(self, tv_time_item: TVTimeItem, trakt_item: TraktItem, progress: str) -> None: 35 | pass 36 | 37 | def process_item(self, tv_time_item: TVTimeItem, progress: str, delay: int = 1) -> None: 38 | # Query the local database for previous entries indicating that 39 | # the item has already been imported in the past. Which will 40 | # ease pressure on Trakt's API server during a retry of the import 41 | # process, and just save time overall without needing to create network requests. 42 | synced_episodes = self._get_synced_items(tv_time_item) 43 | if len(synced_episodes) != 0: 44 | self._log_already_imported(tv_time_item, progress) 45 | return 46 | 47 | # If the query returned no results, then continue to import it into Trakt 48 | # Create a repeating loop, which will break on success, but repeats on failures 49 | error_streak = 0 50 | while True: 51 | # If more than 10 errors occurred in one streak, whilst trying to import the item 52 | # then give up, and move onto the next item, but warn the user. 53 | if error_streak > 10: 54 | logging.warning("An error occurred 10 times in a row... skipping episode...") 55 | break 56 | 57 | if not self._should_continue(tv_time_item): 58 | break 59 | 60 | try: 61 | # Sleep for a second between each process, before going onto the next watched item. 62 | # This is required to remain within the API rate limit, and use the API server fairly. 63 | # Other developers share the service, for free - so be considerate of your usage. 64 | time.sleep(delay) 65 | 66 | trakt_item = self._search(tv_time_item) 67 | if trakt_item is None: 68 | break 69 | 70 | self._process(tv_time_item, trakt_item, progress) 71 | 72 | error_streak = 0 73 | break 74 | # Catch errors which occur because of an incorrect array index. This occurs when 75 | # an incorrect Trakt show has been selected, with season/episodes which don't match TV Time. 76 | # It can also occur due to a bug in Trakt Py, whereby some seasons contain an empty array of episodes. 77 | except IndexError: 78 | self._handle_index_error(tv_time_item, trakt_item, progress) 79 | break 80 | except trakt.core.errors.NotFoundException: 81 | self._handle_not_found_exception(tv_time_item, progress) 82 | break 83 | except trakt.core.errors.RateLimitException: 84 | logging.warning( 85 | "The program is running too quickly and has hit Trakt's API rate limit!" 86 | " Please increase the delay between" 87 | " movies via the variable 'DELAY_BETWEEN_EPISODES_IN_SECONDS'." 88 | " The program will now wait 60 seconds before" 89 | " trying again." 90 | ) 91 | time.sleep(60) 92 | error_streak += 1 93 | # Catch a JSON decode error - this can be raised when the API server is down and produces an HTML page, 94 | # instead of JSON 95 | except json.decoder.JSONDecodeError: 96 | logging.warning( 97 | f"({progress}) - A JSON decode error occurred whilst processing {tv_time_item.name}" 98 | " This might occur when the server is down and has produced" 99 | " a HTML document instead of JSON. The script will wait 60 seconds before trying again." 100 | ) 101 | 102 | time.sleep(60) 103 | error_streak += 1 104 | # Catch a CTRL + C keyboard input, and exits the program 105 | except KeyboardInterrupt: 106 | sys.exit("Cancel requested...") 107 | except Exception as e: 108 | logging.error( 109 | f"Got unknown error {e}," 110 | f" while processing {tv_time_item.name}" 111 | ) 112 | error_streak += 1 113 | 114 | @abstractmethod 115 | def _handle_index_error(self, tv_time_item: TVTimeItem, trakt_item: TraktItem, progress: str) -> None: 116 | pass 117 | 118 | @abstractmethod 119 | def _handle_not_found_exception(self, tv_time_item: TVTimeItem, progress: str) -> None: 120 | pass 121 | 122 | 123 | class TVShowProcessor(Processor): 124 | def __init__(self): 125 | super().__init__() 126 | 127 | def _get_synced_items(self, tv_time_show: TVTimeTVShow) -> list[Document]: 128 | episode_completed_query = Query() 129 | return syncedEpisodesTable.search(episode_completed_query.episodeId == tv_time_show.episode_id) 130 | 131 | def _log_already_imported(self, tv_time_show: TVTimeTVShow, progress: str) -> None: 132 | logging.info( 133 | f"({progress}) - Already imported," 134 | f" skipping \'{tv_time_show.name}\' Season {tv_time_show.season_number} /" 135 | f" Episode {tv_time_show.episode_number}." 136 | ) 137 | 138 | def _should_continue(self, tv_time_show: TVTimeTVShow) -> bool: 139 | return True 140 | 141 | def _search(self, tv_time_show: TVTimeTVShow) -> TraktTVShow: 142 | return TVShowSearcher(tv_time_show).search(tv_time_show.title) 143 | 144 | def _process(self, tv_time_show: TVTimeTVShow, trakt_show: TraktItem, progress: str) -> None: 145 | logging.info( 146 | f"({progress}) - Processing '{tv_time_show.name}'" 147 | f" Season {tv_time_show.season_number} /" 148 | f" Episode {tv_time_show.episode_number}" 149 | ) 150 | 151 | season = trakt_show.seasons[tv_time_show.parse_season_number(trakt_show)] 152 | episode = season.episodes[int(tv_time_show.episode_number) - 1] 153 | episode.mark_as_seen(tv_time_show.date_watched) 154 | # Add the episode to the local database as imported, so it can be skipped, 155 | # if the process is repeated 156 | syncedEpisodesTable.insert({"episodeId": tv_time_show.episode_id}) 157 | logging.info( 158 | f"'{tv_time_show.name} Season {tv_time_show.season_number}," 159 | f" Episode {tv_time_show.episode_number}' marked as seen" 160 | ) 161 | 162 | def _handle_index_error(self, tv_time_show: TVTimeTVShow, trakt_show: TraktTVShow, progress: str) -> None: 163 | tv_show_slug = trakt_show.to_json()["shows"][0]["ids"]["ids"]["slug"] 164 | logging.warning( 165 | f"({progress}) - {tv_time_show.name} Season {tv_time_show.season_number}," 166 | f" Episode {tv_time_show.episode_number} does not exist in Trakt!" 167 | f" (https://trakt.tv/shows/{tv_show_slug}/seasons/{tv_time_show.season_number}/episodes/{tv_time_show.episode_number})" 168 | ) 169 | 170 | def _handle_not_found_exception(self, tv_time_show: TVTimeTVShow, progress: str) -> None: 171 | logging.warning( 172 | f"({progress}) - {tv_time_show.name} Season {tv_time_show.season_number}," 173 | f" Episode {tv_time_show.episode_number} does not exist (search) in Trakt!" 174 | ) 175 | 176 | 177 | class MovieProcessor(Processor): 178 | def __init__(self, watched_list: list): 179 | super().__init__() 180 | self._watched_list = watched_list 181 | 182 | def _get_synced_items(self, tv_time_movie: TVTimeMovie) -> list[Document]: 183 | movie_query = Query() 184 | return syncedMoviesTable.search( 185 | (movie_query.movie_name == tv_time_movie.name) & (movie_query.type == "watched") 186 | ) 187 | 188 | def _log_already_imported(self, tv_time_movie: TVTimeMovie, progress: str) -> None: 189 | logging.info(f"({progress}) - Already imported, skipping '{tv_time_movie.name}'.") 190 | 191 | def _should_continue(self, tv_time_movie: TVTimeMovie) -> bool: 192 | # If movie is watched but this is an entry for watchlist, then skip 193 | if tv_time_movie.name in self._watched_list and tv_time_movie.activity_type not in ["watch", "rewatch"]: 194 | logging.info(f"Skipping '{tv_time_movie.name}' to avoid redundant watchlist entry.") 195 | return False 196 | 197 | return True 198 | 199 | def _search(self, tv_time_movie: TVTimeMovie) -> TraktMovie: 200 | return MovieSearcher().search(tv_time_movie.title) 201 | 202 | def _process(self, tv_time_movie: TVTimeMovie, trakt_movie: TraktMovie, progress: str) -> None: 203 | logging.info(f"({progress}) - Processing '{tv_time_movie.name}'") 204 | 205 | watchlist_query = Query() 206 | movies_in_watchlist = syncedMoviesTable.search( 207 | (watchlist_query.movie_name == tv_time_movie.name) & (watchlist_query.type == "watchlist") 208 | ) 209 | 210 | if tv_time_movie.activity_type in ["watch", "rewatch"]: 211 | trakt_movie.mark_as_seen(tv_time_movie.date_watched) 212 | # Add the episode to the local database as imported, so it can be skipped, 213 | # if the process is repeated 214 | syncedMoviesTable.insert( 215 | {"movie_name": tv_time_movie.name, "type": "watched"} 216 | ) 217 | logging.info(f"'{tv_time_movie.name}' marked as seen") 218 | elif len(movies_in_watchlist) == 0: 219 | trakt_movie.add_to_watchlist() 220 | # Add the episode to the local database as imported, so it can be skipped, 221 | # if the process is repeated 222 | syncedMoviesTable.insert( 223 | {"movie_name": tv_time_movie.name, "type": "watchlist"} 224 | ) 225 | logging.info(f"'{tv_time_movie.name}' added to watchlist") 226 | else: 227 | logging.warning(f"{tv_time_movie.name} already in watchlist") 228 | 229 | def _handle_index_error(self, tv_time_movie: TVTimeMovie, trakt_movie: TraktMovie, progress: str) -> None: 230 | movie_slug = trakt_movie.to_json()["movies"][0]["ids"]["ids"]["slug"] 231 | logging.warning( 232 | f"({progress}) - {tv_time_movie.name}" 233 | f" does not exist in Trakt! (https://trakt.tv/movies/{movie_slug}/)" 234 | ) 235 | 236 | def _handle_not_found_exception(self, tv_time_movie: TVTimeMovie, progress: str) -> None: 237 | logging.warning(f"({progress}) - {tv_time_movie.name} does not exist (search) in Trakt!") 238 | -------------------------------------------------------------------------------- /searcher.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import re 4 | import sys 5 | from abc import ABC, abstractmethod 6 | from dataclasses import dataclass 7 | from datetime import datetime 8 | from typing import Optional, TypeVar, Union, Any 9 | 10 | from tinydb import Query 11 | from tinydb.table import Table 12 | from trakt.movies import Movie 13 | from trakt.tv import TVShow 14 | 15 | from database import userMatchedShowsTable, userMatchedMoviesTable 16 | 17 | TraktTVShow = TypeVar("TraktTVShow") 18 | TraktMovie = TypeVar("TraktMovie") 19 | TraktItem = Union[TraktTVShow, TraktMovie] 20 | try: 21 | with open("config.json") as f: 22 | DATE_TIME_FORMAT = json.load(f)["DATE_FORMAT"] 23 | except FileNotFoundError: 24 | logging.info("config.json not found prompting user for input") 25 | 26 | @dataclass 27 | class Title: 28 | name: str 29 | without_year: str 30 | year: Optional[int] 31 | 32 | def __init__(self, title: str, year: Optional[int] = None): 33 | """ 34 | Creates a Title object. If year is not passed, it tries to parse it from the title. 35 | """ 36 | self.name = title 37 | if year is not None: 38 | self.without_year = title 39 | self.year = year 40 | else: 41 | try: 42 | # Use a regex expression to get the value within the brackets e.g. The Americans (2017) 43 | year_search = re.search(r"\(([A-Za-z0-9_]+)\)", title) 44 | self.year = int(year_search.group(1)) 45 | # Then, get the title without the year value included 46 | self.without_year = title.split("(")[0].strip() 47 | except Exception: 48 | # If the above failed, then the title doesn't include a year 49 | # so create the value with "defaults" 50 | self.name = title 51 | self.without_year = title 52 | self.year = None 53 | 54 | def items_with_same_name(self, items: list[TraktItem]) -> list[TraktItem]: 55 | with_same_name = [] 56 | 57 | for item in items: 58 | if self.matches(item.title): 59 | # If the title included the year of broadcast, then we can be more picky in the results 60 | # to look for an item with a broadcast year that matches 61 | if self.year: 62 | # If the item title is a 1:1 match, with the same broadcast year, then bingo! 63 | if (self.name == item.title) and (item.year == self.year): 64 | # Clear previous results, and only use this one 65 | with_same_name = [item] 66 | break 67 | 68 | # Otherwise, only add the item if the broadcast year matches 69 | if item.year == self.year: 70 | with_same_name.append(item) 71 | # If the item doesn't have the broadcast year, then add all the results 72 | else: 73 | with_same_name.append(item) 74 | 75 | return with_same_name 76 | 77 | def matches(self, other: str) -> bool: 78 | """ 79 | Shows in TV Time are often different to Trakt.TV - in order to improve results and automation, 80 | calculate how many words are in the title, and return true if more than 50% of the title is a match, 81 | It seems to improve automation, and reduce manual selection... 82 | """ 83 | 84 | # If the name is a complete match, then don't bother comparing them! 85 | if self.name == other: 86 | return True 87 | 88 | # Go through each word of the TV Time title, and check if it's in the Trakt title 89 | words_matched = [word for word in self.name.split() if word in other] 90 | 91 | # Then calculate what percentage of words matched 92 | quotient = len(words_matched) / len(other.split()) 93 | percentage = quotient * 100 94 | 95 | # If more than 50% of words in the TV Time title exist in the Trakt title, 96 | # then return the title as a possibility to use 97 | return percentage > 50 98 | 99 | 100 | class TVTimeItem: 101 | def __init__(self, name: str, updated_at: str): 102 | self.name = name 103 | self.title = Title(name) 104 | # Get the date which the show was marked 'watched' in TV Time 105 | # and parse the watched date value into a Python object 106 | self.date_watched = datetime.strptime( 107 | updated_at, DATE_TIME_FORMAT 108 | ) 109 | 110 | 111 | class TVTimeTVShow(TVTimeItem): 112 | def __init__(self, row: Any): 113 | super().__init__(row["series_name"], row["created_at"]) 114 | self.episode_id = row["episode_id"] 115 | self.season_number = row["season_number"] 116 | self.episode_number = row["episode_number"] 117 | 118 | def parse_season_number(self, trakt_show: TraktTVShow) -> int: 119 | """ 120 | Since the Trakt.Py starts the indexing of seasons in the array from 0 (e.g. Season 1 in Index 0), then 121 | subtract the TV Time numerical value by 1, so it starts from 0 as well. However, when a TV series includes 122 | a 'special' season, Trakt.Py will place this as the first season in the array - so, don't subtract, since 123 | this will match TV Time's existing value. 124 | """ 125 | 126 | season_number = int(self.season_number) 127 | # Gen get the Season Number from the first item in the array 128 | first_season_no = trakt_show.seasons[0].season 129 | 130 | # If the season number is 0, then the Trakt show contains a "special" season 131 | if first_season_no == 0: 132 | # No need to modify the value, as the TV Time value will match Trakt 133 | return season_number 134 | # Otherwise, if the Trakt seasons start with no specials, then return the seasonNo, 135 | # but subtracted by one (e.g. Season 1 in TV Time, will be 0) 136 | else: 137 | # Only subtract if the TV Time season number is greater than 0. 138 | if season_number != 0: 139 | return season_number - 1 140 | # Otherwise, the TV Time season is a special! Then you don't need to change the starting position 141 | else: 142 | return season_number 143 | 144 | 145 | class TVTimeMovie(TVTimeItem): 146 | def __init__(self, row: Any): 147 | super().__init__(row["movie_name"], row["updated_at"]) 148 | self.activity_type = row["type"] 149 | 150 | # Release date is available for movies 151 | if row["release_date"][0:4] == "0000": # some entries had a release date of 0000 152 | return 153 | 154 | if row["release_date"] == "": 155 | return 156 | 157 | release_date = datetime.strptime( 158 | row["release_date"], DATE_TIME_FORMAT 159 | ) 160 | 161 | # Check that date is valid 162 | if release_date.year > 1800: 163 | self.title = Title(self.title.name, release_date.year) 164 | 165 | 166 | class Searcher(ABC): 167 | def __init__(self, user_matched_table: Table): 168 | self.name = "" 169 | self.items_with_same_name: Optional[TraktItem] = None 170 | self._user_matched_table = user_matched_table 171 | 172 | def search(self, title: Title) -> Optional[TraktItem]: 173 | self.name = title.name 174 | # If the title contains a year, then replace the local variable with the stripped version. 175 | if title.year: 176 | self.name = title.without_year 177 | self.items_with_same_name = title.items_with_same_name(self.search_trakt(self.name)) 178 | 179 | single_result = self._check_single_result() 180 | if single_result: 181 | return single_result 182 | elif len(self.items_with_same_name) < 1: 183 | return None 184 | 185 | # If the search contains multiple results, then we need to confirm with the user which show 186 | # the script should use, or access the local database to see if the user has already provided 187 | # a manual selection 188 | 189 | should_return, query_result = self._search_local() 190 | if should_return: 191 | return query_result 192 | # If the user has not provided a manual selection already in the process 193 | # then prompt the user to make a selection 194 | else: 195 | return self._handle_multiple_manually() 196 | 197 | @abstractmethod 198 | def search_trakt(self, name: str) -> list[TraktItem]: 199 | pass 200 | 201 | @abstractmethod 202 | def _print_manual_selection(self): 203 | pass 204 | 205 | def _search_local(self) -> tuple[bool, TraktItem]: 206 | user_matched_query = Query() 207 | query_result = self._user_matched_table.search(user_matched_query.Name == self.name) 208 | # If the local database already contains an entry for a manual selection 209 | # then don't bother prompting the user to select it again! 210 | if len(query_result) == 1: 211 | first_match = query_result[0] 212 | first_match_selected_index = int(first_match.get("UserSelectedIndex")) 213 | skip_show = first_match.get("Skip") 214 | if skip_show: 215 | return True, None 216 | else: 217 | return True, self.items_with_same_name[first_match_selected_index] 218 | else: 219 | return False, None 220 | 221 | def _handle_multiple_manually(self) -> Optional[TraktItem]: 222 | self._print_manual_selection() 223 | while True: 224 | try: 225 | # Get the user's selection, either a numerical input, or a string 'SKIP' value 226 | index_selected = input("Please make a selection from above (or enter SKIP): ") 227 | if index_selected == "SKIP": 228 | break 229 | 230 | index_selected = int(index_selected) - 1 231 | break 232 | except KeyboardInterrupt: 233 | sys.exit("Cancel requested...") 234 | except Exception: 235 | logging.error(f"Sorry! Please select a value between 0 to {len(self.items_with_same_name)}") 236 | 237 | # If the user entered 'SKIP', then exit from the loop with no selection, which 238 | # will trigger the program to move onto the next episode 239 | if index_selected == "SKIP": 240 | # Record that the user has skipped the TV Show for import, so that 241 | # manual input isn't required everytime 242 | self._user_matched_table.insert( 243 | {"Name": self.name, "UserSelectedIndex": 0, "Skip": True} 244 | ) 245 | return None 246 | else: 247 | selected_show = self.items_with_same_name[int(index_selected)] 248 | 249 | self._user_matched_table.insert( 250 | { 251 | "Name": self.name, 252 | "UserSelectedIndex": index_selected, 253 | "Skip": False, 254 | } 255 | ) 256 | 257 | return selected_show 258 | 259 | def _check_single_result(self) -> Optional[TraktItem]: 260 | complete_match_names = [name_from_search for name_from_search in self.items_with_same_name if 261 | name_from_search.title == self.name] 262 | if len(complete_match_names) == 1: 263 | return complete_match_names[0] 264 | elif len(self.items_with_same_name) == 1: 265 | return self.items_with_same_name[0] 266 | 267 | 268 | class TVShowSearcher(Searcher): 269 | def __init__(self, tv_show: TVTimeTVShow): 270 | super().__init__(userMatchedShowsTable) 271 | self.tv_show = tv_show 272 | 273 | def search_trakt(self, name: str) -> list[TraktItem]: 274 | return TVShow.search(name) 275 | 276 | def _print_manual_selection(self) -> None: 277 | print( 278 | f"INFO - MANUAL INPUT REQUIRED: The TV Time data for Show '{self.name}'" 279 | f" (Season {self.tv_show.season_number}, Episode {self.tv_show.episode_number}) has" 280 | f" {len(self.items_with_same_name)} matching Trakt shows with the same name.\a" 281 | ) 282 | 283 | for idx, item in enumerate(self.items_with_same_name): 284 | print( 285 | f"({idx + 1}) {item.title} - {item.year} - {len(item.seasons)}" 286 | f" Season(s) - More Info: https://trakt.tv/{item.ext}" 287 | ) 288 | 289 | 290 | class MovieSearcher(Searcher): 291 | def __init__(self): 292 | super().__init__(userMatchedMoviesTable) 293 | 294 | def search_trakt(self, name: str) -> list[TraktItem]: 295 | return Movie.search(name) 296 | 297 | def _print_manual_selection(self) -> None: 298 | print( 299 | f"INFO - MANUAL INPUT REQUIRED: The TV Time data for Movie '{self.name}'" 300 | f" has {len(self.items_with_same_name)}" 301 | f" matching Trakt movies with the same name.\a" 302 | ) 303 | 304 | for idx, item in enumerate(self.items_with_same_name): 305 | print(f"({idx + 1}) {item.title} - {item.year} - More Info: https://trakt.tv/{item.ext}") 306 | --------------------------------------------------------------------------------