showtime-cli 0.3.24__tar.gz → 0.3.26__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: showtime-cli
3
- Version: 0.3.24
3
+ Version: 0.3.26
4
4
  Summary: Command line show tracker using the TVMaze public API
5
5
  Author-email: Evgeniy Vasilev <aquilax@gmail.com>
6
6
  License: MIT
@@ -18,7 +18,7 @@ Classifier: Topic :: Utilities
18
18
  Requires-Python: >=3.12
19
19
  Description-Content-Type: text/markdown
20
20
  License-File: LICENSE
21
- Requires-Dist: cmd2==4.2.1
21
+ Requires-Dist: cmd2==4.2.4
22
22
  Requires-Dist: ratelimit==2.2.1
23
23
  Requires-Dist: tinydb==4.9.0
24
24
  Requires-Dist: terminaltables==3.1.10
@@ -37,10 +37,39 @@ Dynamic: license-file
37
37
 
38
38
  # showtime
39
39
 
40
- Small command line interactive tv show tracker using the TvMaze API.
40
+ Small command line tracker for TV series using TVMaze and movies using TMDB.
41
41
 
42
42
  ## Installation
43
43
 
44
44
  ```sh
45
45
  pip install showtime-cli
46
46
  ```
47
+
48
+ ## Movies
49
+
50
+ Movie search and add use TMDB. Set a TMDB API Read Access Token with
51
+ `TMDB_ACCESS_TOKEN`, or add it to `~/.showtime.ini`:
52
+
53
+ ```ini
54
+ [TMDB]
55
+ AccessToken = your-tmdb-read-access-token
56
+ ```
57
+
58
+ The token is needed for `movie_search`, `movie_add`, and IMDb imports. Movie
59
+ tracking and watched status are stored locally:
60
+
61
+ ```text
62
+ movie_search <query>
63
+ movie_add <tmdb_id>
64
+ movie_add_watched <tmdb_id>
65
+ movie_remove <tmdb_id>
66
+ movies [query]
67
+ movie_watch <tmdb_id>
68
+ movie_unwatch <tmdb_id>
69
+ movie_import_imdb_ratings <filename>
70
+ ```
71
+
72
+ IMDb imports read the `Const`, `Your Rating`, `Date Rated`, and `Title Type`
73
+ columns. Movie and TV Movie rows are resolved through TMDB, added if needed,
74
+ and marked watched on their IMDb rating date. Other title types and unresolved
75
+ IMDb IDs are skipped.
@@ -0,0 +1,38 @@
1
+ # showtime
2
+
3
+ Small command line tracker for TV series using TVMaze and movies using TMDB.
4
+
5
+ ## Installation
6
+
7
+ ```sh
8
+ pip install showtime-cli
9
+ ```
10
+
11
+ ## Movies
12
+
13
+ Movie search and add use TMDB. Set a TMDB API Read Access Token with
14
+ `TMDB_ACCESS_TOKEN`, or add it to `~/.showtime.ini`:
15
+
16
+ ```ini
17
+ [TMDB]
18
+ AccessToken = your-tmdb-read-access-token
19
+ ```
20
+
21
+ The token is needed for `movie_search`, `movie_add`, and IMDb imports. Movie
22
+ tracking and watched status are stored locally:
23
+
24
+ ```text
25
+ movie_search <query>
26
+ movie_add <tmdb_id>
27
+ movie_add_watched <tmdb_id>
28
+ movie_remove <tmdb_id>
29
+ movies [query]
30
+ movie_watch <tmdb_id>
31
+ movie_unwatch <tmdb_id>
32
+ movie_import_imdb_ratings <filename>
33
+ ```
34
+
35
+ IMDb imports read the `Const`, `Your Rating`, `Date Rated`, and `Title Type`
36
+ columns. Movie and TV Movie rows are resolved through TMDB, added if needed,
37
+ and marked watched on their IMDb rating date. Other title types and unresolved
38
+ IMDb IDs are skipped.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "showtime-cli"
7
- version = "0.3.24"
7
+ version = "0.3.26"
8
8
  description = "Command line show tracker using the TVMaze public API"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -22,7 +22,7 @@ classifiers = [
22
22
  "Topic :: Utilities",
23
23
  ]
24
24
  dependencies = [
25
- "cmd2==4.2.1",
25
+ "cmd2==4.2.4",
26
26
  "ratelimit==2.2.1",
27
27
  "tinydb==4.9.0",
28
28
  "terminaltables==3.1.10",
@@ -0,0 +1,160 @@
1
+ """API client module"""
2
+
3
+ from dataclasses import dataclass
4
+ import json
5
+ from urllib.parse import urlencode, urlparse, urlunparse
6
+ import urllib.request
7
+ from typing import Any, Dict, List, Optional
8
+
9
+ from ratelimit import limits, sleep_and_retry
10
+
11
+ from showtime.types import MovieId, ShowId, TMDBMovie, TVMazeEpisode, TVMazeShow
12
+
13
+ API_BASE_URL = "https://api.tvmaze.com"
14
+ TMDB_API_BASE_URL = "https://api.themoviedb.org/3"
15
+
16
+
17
+ def episode_to_model(episode: Dict) -> TVMazeEpisode:
18
+ return TVMazeEpisode(
19
+ id=episode['id'],
20
+ season=episode['season'],
21
+ number=episode['number'],
22
+ name=episode['name'],
23
+ airdate=episode['airdate'],
24
+ runtime=episode['runtime'],
25
+ )
26
+
27
+
28
+ def show_to_model(show: Dict) -> TVMazeShow:
29
+ return TVMazeShow(
30
+ id=show['id'],
31
+ name=show['name'],
32
+ premiered=show['premiered'],
33
+ status=show['status'],
34
+ url=show['url'],
35
+ externals=show['externals']
36
+ )
37
+
38
+
39
+ def search_to_model(show_wrapped) -> TVMazeShow:
40
+ return show_to_model(show_wrapped['show'])
41
+
42
+
43
+ def movie_to_model(movie: Dict) -> TMDBMovie:
44
+ return TMDBMovie(
45
+ id=movie['id'],
46
+ title=movie['title'],
47
+ release_date=movie.get('release_date') or None,
48
+ runtime=movie.get('runtime'),
49
+ original_language=movie.get('original_language'),
50
+ )
51
+
52
+
53
+ @dataclass
54
+ class HTTPResponse:
55
+ data: bytes
56
+
57
+
58
+ class HTTPClient():
59
+ """HTTP Client"""
60
+
61
+ def request(self, _method: str, url: str, fields: Optional[dict[str, str]]=None,
62
+ headers: Optional[dict[str, str]]=None) -> Any:
63
+ request_headers = {
64
+ "User-Agent": "showtime-cli",
65
+ "Accept": "application/json"
66
+ }
67
+ if headers:
68
+ request_headers.update(headers)
69
+
70
+ url_parts = list(urlparse(url))
71
+ url_parts[4] = urlencode(fields or {})
72
+ final_url = urlunparse(url_parts)
73
+ request = urllib.request.Request(final_url, headers=request_headers)
74
+ with urllib.request.urlopen(request) as response:
75
+ return HTTPResponse(data=response.read())
76
+
77
+
78
+ class Api():
79
+ """API Client"""
80
+
81
+ def __init__(self, http: HTTPClient, tmdb_access_token: Optional[str]=None) -> None:
82
+ self.http = http
83
+ self.tmdb_access_token = tmdb_access_token
84
+
85
+ def _tmdb_headers(self) -> dict[str, str]:
86
+ if not self.tmdb_access_token:
87
+ raise RuntimeError(
88
+ "TMDB access token is required. Set TMDB_ACCESS_TOKEN or TMDB.AccessToken in your config."
89
+ )
90
+ return {"Authorization": f"Bearer {self.tmdb_access_token}"}
91
+
92
+ @sleep_and_retry
93
+ @limits(calls=20, period=10)
94
+ def _tmdb_request(self, url: str, fields: Optional[dict[str, str]]=None) -> Any:
95
+ """Makes a rate-limited TMDB request"""
96
+ headers = self._tmdb_headers()
97
+ if fields is None:
98
+ return self.http.request('GET', url, headers=headers)
99
+ return self.http.request('GET', url, fields=fields, headers=headers)
100
+
101
+ @sleep_and_retry
102
+ @limits(calls=20, period=10)
103
+ def _tvmaze_request(self, url: str, fields: Optional[dict[str, str]]=None) -> Any:
104
+ """Makes a rate-limited TVMaze request"""
105
+ if fields is None:
106
+ return self.http.request('GET', url)
107
+ return self.http.request('GET', url, fields=fields)
108
+
109
+ def episodes_list(self, show_id: ShowId) -> List[TVMazeEpisode]:
110
+ """returns list of episodes for a show"""
111
+ response = self._tvmaze_request(f"{API_BASE_URL}/shows/{show_id}/episodes")
112
+ raw_episodes = json.loads(response.data.decode('utf-8'))
113
+ return list(map(episode_to_model, raw_episodes))
114
+
115
+ def show_get(self, show_id: ShowId) -> Optional[TVMazeShow]:
116
+ """returns show information"""
117
+ response = self._tvmaze_request(f"{API_BASE_URL}/shows/{show_id}")
118
+ raw_show = json.loads(response.data.decode('utf-8'))
119
+ return show_to_model(raw_show)
120
+
121
+ def show_search(self, query: str) -> List[TVMazeShow]:
122
+ """returns list of shows matching search string"""
123
+ response = self._tvmaze_request(f"{API_BASE_URL}/search/shows", fields={'q': query})
124
+ raw_shows = json.loads(response.data.decode('utf-8'))
125
+ return list(map(search_to_model, raw_shows))
126
+
127
+ def movie_search(self, query: str) -> List[TMDBMovie]:
128
+ """Returns movies matching the search string"""
129
+ response = self._tmdb_request(
130
+ f"{TMDB_API_BASE_URL}/search/movie", fields={'query': query, 'include_adult': 'true'}
131
+ )
132
+ raw_movies = json.loads(response.data.decode('utf-8'))
133
+ return list(map(movie_to_model, raw_movies['results']))
134
+
135
+ def movie_get(self, movie_id: MovieId) -> TMDBMovie:
136
+ """Returns movie information"""
137
+ response = self._tmdb_request(f"{TMDB_API_BASE_URL}/movie/{movie_id}")
138
+ raw_movie = json.loads(response.data.decode('utf-8'))
139
+ return movie_to_model(raw_movie)
140
+
141
+ def movie_external_ids(self, movie_id: MovieId) -> Dict[str, Optional[str]]:
142
+ """Returns all external identifiers for a TMDB movie"""
143
+ response = self._tmdb_request(f"{TMDB_API_BASE_URL}/movie/{movie_id}/external_ids")
144
+ raw_ids = json.loads(response.data.decode('utf-8'))
145
+ return {key: value for key, value in raw_ids.items() if key != 'id'}
146
+
147
+ def movie_find_by_imdb_id(self, imdb_id: str) -> Optional[MovieId]:
148
+ """Returns the TMDB movie ID matching an IMDb identifier"""
149
+ response = self._tmdb_request(
150
+ f"{TMDB_API_BASE_URL}/find/{imdb_id}", fields={'external_source': 'imdb_id'}
151
+ )
152
+ result = json.loads(response.data.decode('utf-8'))
153
+ movies = result.get('movie_results', [])
154
+ return MovieId(movies[0]['id']) if movies else None
155
+
156
+
157
+ def get_default_pool_manager():
158
+ return HTTPClient()
159
+
160
+
@@ -15,12 +15,13 @@ from showtime.config import Config
15
15
  from showtime.database import get_cashed_write_db, get_memory_db
16
16
  from showtime.output import Output
17
17
  from showtime.showtime import ShowtimeApp
18
- from showtime.types import Episode, EpisodeId, Show, ShowId
18
+ from showtime.types import Episode, EpisodeId, MovieId, Show, ShowId
19
19
 
20
20
  from . import __version__
21
21
 
22
22
  SHOW_CATEGORY = 'Show management'
23
23
  EPISODE_CATEGORY = 'Episode management'
24
+ MOVIE_CATEGORY = 'Movie management'
24
25
 
25
26
 
26
27
  class Showtime(Cmd):
@@ -90,6 +91,83 @@ class Showtime(Cmd):
90
91
  search_result_table = self.output.format_search_results(search_result)
91
92
  self.output.poutput(search_result_table)
92
93
 
94
+ @cmd2.with_category(MOVIE_CATEGORY)
95
+ def do_movie_search(self, statement: Statement) -> None:
96
+ """Search TMDB for movies [movie_search <query>]"""
97
+ try:
98
+ search_result = self.app.movie_search_api(statement)
99
+ except RuntimeError as error:
100
+ self.output.perror(str(error))
101
+ return
102
+ self.output.poutput(self.output.format_movie_search_results(search_result))
103
+
104
+ @cmd2.with_category(MOVIE_CATEGORY)
105
+ def do_movie_add(self, statement: Statement) -> None:
106
+ """Add a movie from TMDB [movie_add <tmdb_id>]"""
107
+ try:
108
+ movie = self.app.movie_add(MovieId(statement))
109
+ except RuntimeError as error:
110
+ self.output.perror(str(error))
111
+ return
112
+ self.output.poutput(f"Added movie: ({movie.id}) {movie.title}")
113
+
114
+ @cmd2.with_category(MOVIE_CATEGORY)
115
+ def do_movie_add_watched(self, statement: Statement) -> None:
116
+ """Add a movie and mark it watched [movie_add_watched <tmdb_id>]"""
117
+ try:
118
+ movie = self.app.movie_add_watched(MovieId(statement), self._get_current_datetime())
119
+ except RuntimeError as error:
120
+ self.output.perror(str(error))
121
+ return
122
+ self.output.poutput(f"Added and marked as watched: ({movie.id}) {movie.title}")
123
+
124
+ @cmd2.with_category(MOVIE_CATEGORY)
125
+ def do_movie_remove(self, statement: Statement) -> None:
126
+ """Remove a tracked movie [movie_remove <tmdb_id>]"""
127
+ movie_id = MovieId(statement)
128
+ movie = self.app.movie_get(movie_id)
129
+ if not movie:
130
+ self.output.perror(f'Movie {movie_id} not found')
131
+ return
132
+ self.app.movie_remove(movie_id)
133
+ self.output.poutput(f"Removed movie: ({movie_id}) {movie['title']}")
134
+
135
+ @cmd2.with_category(MOVIE_CATEGORY)
136
+ def do_movies(self, query: Statement) -> None:
137
+ """Show tracked movies [movies <query>]"""
138
+ movies = self.app.movie_search(query)
139
+ self.output.ppaged(self.output.movies_table(movies))
140
+
141
+ def _set_movie_watched(self, statement: Statement, watched: bool) -> None:
142
+ movie_id = MovieId(statement)
143
+ movie = self.app.movie_get(movie_id)
144
+ if not movie:
145
+ self.output.perror(f'Movie {movie_id} not found')
146
+ return
147
+ self.app.movie_update_watched(movie_id, watched, self._get_current_datetime())
148
+
149
+ @cmd2.with_category(MOVIE_CATEGORY)
150
+ def do_movie_watch(self, statement: Statement) -> None:
151
+ """Mark a movie as watched [movie_watch <tmdb_id>]"""
152
+ self._set_movie_watched(statement, True)
153
+
154
+ @cmd2.with_category(MOVIE_CATEGORY)
155
+ def do_movie_unwatch(self, statement: Statement) -> None:
156
+ """Mark a movie as unwatched [movie_unwatch <tmdb_id>]"""
157
+ self._set_movie_watched(statement, False)
158
+
159
+ @cmd2.with_category(MOVIE_CATEGORY)
160
+ def do_movie_import_imdb_ratings(self, file_name: Statement) -> None:
161
+ """Import watched movies from an IMDb ratings export [movie_import_imdb_ratings <filename>]"""
162
+ try:
163
+ imported, skipped = self.app.import_imdb_ratings(
164
+ str(file_name), on_progress=self.output.pfeedback
165
+ )
166
+ except (OSError, RuntimeError, ValueError) as error:
167
+ self.output.perror(str(error))
168
+ return
169
+ self.output.poutput(f"Imported {imported} movie ratings; skipped {skipped} rows")
170
+
93
171
  @cmd2.with_category(SHOW_CATEGORY)
94
172
  def do_follow(self, statement: Statement) -> None:
95
173
  """Follow show(s) by id [follow <show_id>[,<show_id>...]]"""
@@ -364,9 +442,9 @@ class Showtime(Cmd):
364
442
 
365
443
 
366
444
  def main() -> None:
367
- api = Api(get_default_pool_manager())
368
445
  config = Config()
369
446
  config.load()
447
+ api = Api(get_default_pool_manager(), tmdb_access_token=config.get('TMDB', 'AccessToken') or None)
370
448
  dry_run = os.getenv('SHOWTIME_DRY_RUN') is not None
371
449
  database_filename = config.get('Database', 'Path')
372
450
  database = get_memory_db() if dry_run else get_cashed_write_db(database_filename)
@@ -19,6 +19,9 @@ class Config(ConfigParser):
19
19
  self.add_section('History')
20
20
  self.set('History', 'Path', str(os.path.expanduser('~/.showtime_history')))
21
21
 
22
+ self.add_section('TMDB')
23
+ self.set('TMDB', 'AccessToken', '')
24
+
22
25
  if file_name == '':
23
26
  for location in self.common_locations:
24
27
  if os.path.exists(location):
@@ -26,3 +29,7 @@ class Config(ConfigParser):
26
29
 
27
30
  if file_name:
28
31
  self.read(file_name)
32
+
33
+ tmdb_access_token = os.getenv('TMDB_ACCESS_TOKEN')
34
+ if tmdb_access_token:
35
+ self.set('TMDB', 'AccessToken', tmdb_access_token)
@@ -2,7 +2,7 @@
2
2
 
3
3
  from contextlib import contextmanager
4
4
  from datetime import date, datetime
5
- from typing import (Iterator, Tuple, Dict, Generator, List, Optional, cast)
5
+ from typing import (Any, Iterator, Tuple, Dict, Generator, List, Optional, cast)
6
6
 
7
7
  import dateutil.parser
8
8
  from tinydb import TinyDB, where
@@ -10,11 +10,12 @@ from tinydb.middlewares import CachingMiddleware
10
10
  from tinydb.queries import QueryLike
11
11
  from tinydb.storages import JSONStorage, MemoryStorage
12
12
 
13
- from showtime.types import (Episode, EpisodeId, Show, ShowId, ShowStatus,
14
- TVMazeEpisode, TVMazeShow, ShowWithCount)
13
+ from showtime.types import (Episode, EpisodeId, Movie, MovieId, Show, ShowId, ShowStatus,
14
+ TMDBMovie, TVMazeEpisode, TVMazeShow, ShowWithCount)
15
15
 
16
16
  SHOW = 'show'
17
17
  EPISODE = 'episode'
18
+ MOVIE = 'movie'
18
19
 
19
20
  NOT_WATCHED_VALUE = ''
20
21
 
@@ -67,6 +68,49 @@ class Database(TinyDB):
67
68
  })
68
69
  return EpisodeId(episode.id)
69
70
 
71
+ def add_movie(self, tmdb_movie: TMDBMovie) -> MovieId:
72
+ """Adds a movie if it is not already added"""
73
+ if not self.table(MOVIE).contains(where('id') == tmdb_movie.id):
74
+ self.table(MOVIE).insert({
75
+ 'id': tmdb_movie.id,
76
+ 'title': tmdb_movie.title,
77
+ 'release_date': tmdb_movie.release_date,
78
+ 'runtime': tmdb_movie.runtime,
79
+ 'watched': NOT_WATCHED_VALUE,
80
+ 'external_ids': tmdb_movie.external_ids or {},
81
+ 'original_language': tmdb_movie.original_language,
82
+ })
83
+ else:
84
+ updates: Dict[str, Any] = {}
85
+ if tmdb_movie.external_ids is not None:
86
+ updates['external_ids'] = tmdb_movie.external_ids
87
+ if tmdb_movie.original_language is not None:
88
+ updates['original_language'] = tmdb_movie.original_language
89
+ if updates:
90
+ self.table(MOVIE).update(updates, where('id') == tmdb_movie.id)
91
+ return MovieId(tmdb_movie.id)
92
+
93
+ def get_movies(self) -> List[Movie]:
94
+ """Returns movies sorted by title"""
95
+ return cast(List[Movie], sorted(self.table(MOVIE).all(), key=lambda movie: movie['title'].lower()))
96
+
97
+ def get_movie(self, movie_id: MovieId) -> Optional[Movie]:
98
+ """Returns a single movie"""
99
+ return cast(Optional[Movie], self.table(MOVIE).get(where('id') == movie_id))
100
+
101
+ def delete_movie(self, movie_id: MovieId) -> List[int]:
102
+ """Removes a movie from the database"""
103
+ return self.table(MOVIE).remove(where('id') == movie_id)
104
+
105
+ def update_movie_external_ids(self, movie_id: MovieId, external_ids: Dict[str, Optional[str]]) -> List[int]:
106
+ """Stores external identifiers for a movie"""
107
+ return self.table(MOVIE).update({'external_ids': external_ids}, where('id') == movie_id)
108
+
109
+ def update_movie_watched(self, movie_id: MovieId, watched: bool, when: datetime) -> List[int]:
110
+ """Updates the watched date of a movie"""
111
+ watched_value = when.isoformat() if watched else NOT_WATCHED_VALUE
112
+ return self.table(MOVIE).update({'watched': watched_value}, where('id') == movie_id)
113
+
70
114
  def get_shows(self) -> List[Show]:
71
115
  """Returns list of all added shows"""
72
116
  return cast(List[Show], self.table(SHOW).all())
@@ -5,7 +5,7 @@ from typing import Callable, Dict, List
5
5
 
6
6
  from terminaltables import AsciiTable as Table # type: ignore
7
7
 
8
- from showtime.types import (DecoratedEpisode, Episode, Show, TVMazeEpisode,
8
+ from showtime.types import (DecoratedEpisode, Episode, Movie, Show, TMDBMovie, TVMazeEpisode,
9
9
  TVMazeShow, ShowWithCount)
10
10
 
11
11
  PrintFunction = Callable[[str], None]
@@ -79,6 +79,33 @@ class Output():
79
79
  ])
80
80
  return str(Table(data, title='Search Results').table)
81
81
 
82
+ def format_movie_search_results(self, search_result: List[TMDBMovie]) -> str:
83
+ """Formats TMDB movie search results as a table"""
84
+ data = [['ID', 'Title', 'Released', 'Language', 'Runtime']]
85
+ for movie in search_result:
86
+ data.append([
87
+ str(movie.id),
88
+ movie.title,
89
+ movie.release_date or '',
90
+ movie.original_language or '',
91
+ str(movie.runtime) if movie.runtime is not None else '',
92
+ ])
93
+ return str(Table(data, title='Movie Search Results').table)
94
+
95
+ def movies_table(self, movies: List[Movie]) -> str:
96
+ """Formats tracked movies as a table"""
97
+ data = [['ID', 'Title', 'Released', 'Language', 'Runtime', 'Watched']]
98
+ for movie in movies:
99
+ data.append([
100
+ str(movie['id']),
101
+ movie['title'],
102
+ movie['release_date'] or '',
103
+ movie.get('original_language') or '',
104
+ str(movie['runtime']) if movie['runtime'] is not None else '',
105
+ movie['watched'],
106
+ ])
107
+ return str(Table(data, title='Tracked Movies').table)
108
+
82
109
  def format_episodes(self, show: Show, episodes: List[Episode]) -> str:
83
110
  """Formats as table list of episodes"""
84
111
  title = f"({show['id']}) {show['name']} - {show['premiered']}"
@@ -3,20 +3,12 @@ from datetime import date, datetime
3
3
  from typing import Callable, Dict, List, Optional, Set, Union, cast
4
4
 
5
5
  import dateutil.parser
6
- from ratelimit import limits, sleep_and_retry
7
6
 
8
7
  from showtime.api import Api
9
8
  from showtime.config import Config
10
9
  from showtime.database import Database, transaction, NOT_WATCHED_VALUE
11
- from showtime.types import (DecoratedEpisode, Episode, EpisodeId, Show, ShowId, ShowWithCount,
12
- TVMazeEpisode, TVMazeShow)
13
-
14
-
15
- @sleep_and_retry
16
- @limits(calls=20, period=10)
17
- def _get_episodes(api: Api, show_id: ShowId) -> List[TVMazeEpisode]:
18
- """Downloads show information from API"""
19
- return api.episodes_list(show_id)
10
+ from showtime.types import (DecoratedEpisode, Episode, EpisodeId, Movie, MovieId, Show, ShowId, ShowWithCount,
11
+ TMDBMovie, TVMazeEpisode, TVMazeShow)
20
12
 
21
13
 
22
14
  def needs_update(episode: Episode, tv_maze_episode: TVMazeEpisode):
@@ -53,6 +45,137 @@ class ShowtimeApp():
53
45
  shows = [s for s in shows if query in s['name'].lower()]
54
46
  return sorted(shows, key=lambda k: k['name'])
55
47
 
48
+ def movie_search(self, query: str) -> List[Movie]:
49
+ """Searches tracked movies using the database"""
50
+ movies = self.database.get_movies()
51
+ if query:
52
+ query = query.lower()
53
+ movies = [movie for movie in movies if query in movie['title'].lower()]
54
+ return movies
55
+
56
+ def movie_search_api(self, query: str) -> List[TMDBMovie]:
57
+ """Searches TMDB for movie titles"""
58
+ return self.api.movie_search(query)
59
+
60
+ def _movie_with_external_ids(self, movie_id: MovieId) -> TMDBMovie:
61
+ movie = self.api.movie_get(movie_id)
62
+ external_ids = self.api.movie_external_ids(movie_id)
63
+ return movie._replace(external_ids=external_ids)
64
+
65
+ def movie_add(self, movie_id: MovieId) -> TMDBMovie:
66
+ """Adds a movie from TMDB to the database"""
67
+ movie = self._movie_with_external_ids(movie_id)
68
+ with transaction(self.database) as transacted_db:
69
+ transacted_db.add_movie(movie)
70
+ return movie
71
+
72
+ def movie_add_watched(self, movie_id: MovieId, when: datetime) -> TMDBMovie:
73
+ """Adds a movie and marks it as watched"""
74
+ movie = self._movie_with_external_ids(movie_id)
75
+ with transaction(self.database) as transacted_db:
76
+ transacted_db.add_movie(movie)
77
+ transacted_db.update_movie_watched(movie.id, True, when)
78
+ return movie
79
+
80
+ def import_imdb_ratings(self, file_name: str,
81
+ on_progress: Optional[Callable[[str], None]] = None) -> tuple[int, int]:
82
+ """Imports watched dates from an IMDb ratings export"""
83
+ imported = 0
84
+ skipped = 0
85
+ with open(file_name, newline='', encoding='utf-8-sig') as csv_file:
86
+ reader = csv.DictReader(csv_file)
87
+ required_headers = {'Const', 'Your Rating', 'Date Rated', 'Title Type'}
88
+ if reader.fieldnames is None or not required_headers.issubset(reader.fieldnames):
89
+ raise ValueError('IMDb export must include Const, Your Rating, Date Rated, and Title Type columns')
90
+ rows = list(reader)
91
+
92
+ if on_progress:
93
+ on_progress(f"Starting IMDb ratings import ({len(rows)} rows)")
94
+
95
+ imdb_to_tmdb: Dict[str, MovieId] = {}
96
+ with transaction(self.database) as transacted_db:
97
+ tracked_movies = transacted_db.get_movies()
98
+ for movie_number, movie in enumerate(tracked_movies, start=1):
99
+ external_ids = movie.get('external_ids')
100
+ if external_ids is None:
101
+ if on_progress:
102
+ on_progress(
103
+ f"Fetching IMDb ID for {movie['title']} ({movie_number}/{len(tracked_movies)})"
104
+ )
105
+ external_ids = self.api.movie_external_ids(MovieId(movie['id']))
106
+ transacted_db.update_movie_external_ids(MovieId(movie['id']), external_ids)
107
+ imdb_id = external_ids.get('imdb_id')
108
+ if imdb_id:
109
+ imdb_to_tmdb[imdb_id] = MovieId(movie['id'])
110
+
111
+ total_rows = len(rows)
112
+ for row_number, row in enumerate(rows, start=1):
113
+ title = (row.get('Title') or row.get('Const') or 'Unknown title').strip()
114
+ title_type = (row.get('Title Type') or '').strip().casefold()
115
+ if title_type not in {'movie', 'tv movie', 'tvmovie', 'video', 'short', 'tv short', 'tvshort'}:
116
+ skipped += 1
117
+ if on_progress:
118
+ on_progress(f"Skipped {row_number}/{total_rows}: {title} (not a movie)")
119
+ continue
120
+
121
+ imdb_id = (row.get('Const') or '').strip()
122
+ rating = (row.get('Your Rating') or '').strip()
123
+ date_rated = (row.get('Date Rated') or '').strip()
124
+ if not imdb_id or not rating or not date_rated:
125
+ skipped += 1
126
+ if on_progress:
127
+ on_progress(f"Skipped {row_number}/{total_rows}: {title} (incomplete rating)")
128
+ continue
129
+
130
+ try:
131
+ watched_at = dateutil.parser.parse(date_rated)
132
+ except (dateutil.parser.ParserError, OverflowError):
133
+ skipped += 1
134
+ if on_progress:
135
+ on_progress(f"Skipped {row_number}/{total_rows}: {title} (invalid date)")
136
+ continue
137
+
138
+ movie_id = imdb_to_tmdb.get(imdb_id)
139
+ if movie_id is None:
140
+ if on_progress:
141
+ on_progress(f"Resolving {row_number}/{total_rows}: {title}")
142
+ movie_id = self.api.movie_find_by_imdb_id(imdb_id)
143
+ if movie_id is None:
144
+ skipped += 1
145
+ if on_progress:
146
+ on_progress(f"Skipped {row_number}/{total_rows}: {title} (not found in TMDB)")
147
+ continue
148
+ tmdb_movie = self._movie_with_external_ids(movie_id)
149
+ if not tmdb_movie.external_ids or tmdb_movie.external_ids.get('imdb_id') != imdb_id:
150
+ skipped += 1
151
+ if on_progress:
152
+ on_progress(f"Skipped {row_number}/{total_rows}: {title} (IMDb ID mismatch)")
153
+ continue
154
+ transacted_db.add_movie(tmdb_movie)
155
+ movie_id = MovieId(tmdb_movie.id)
156
+ imdb_to_tmdb[imdb_id] = movie_id
157
+
158
+ transacted_db.update_movie_watched(movie_id, True, watched_at)
159
+ imported += 1
160
+ if on_progress:
161
+ on_progress(f"Imported {row_number}/{total_rows}: {title}")
162
+
163
+ return imported, skipped
164
+
165
+ def movie_get(self, movie_id: MovieId) -> Optional[Movie]:
166
+ """Returns a tracked movie"""
167
+ return self.database.get_movie(movie_id)
168
+
169
+ def movie_remove(self, movie_id: MovieId) -> List[int]:
170
+ """Removes a tracked movie"""
171
+ with transaction(self.database) as transacted_db:
172
+ return transacted_db.delete_movie(movie_id)
173
+
174
+ def movie_update_watched(self, movie_id: MovieId, watched: bool, when: datetime) -> List[int]:
175
+ """Marks a movie as watched or unwatched"""
176
+ with transaction(self.database) as transacted_db:
177
+ return transacted_db.update_movie_watched(movie_id, watched, when)
178
+
56
179
  def _sync_episodes(self, db: Database, show_id: ShowId, tv_maze_episodes: List[TVMazeEpisode],
57
180
  on_insert: Optional[Callable[[TVMazeEpisode], None]] = None,
58
181
  on_update: Optional[Callable[[TVMazeEpisode], None]] = None):
@@ -101,7 +224,7 @@ class ShowtimeApp():
101
224
  with transaction(self.database) as transacted_db:
102
225
  _show_id = transacted_db.add_show(show)
103
226
  # add episodes to db
104
- episodes = _get_episodes(self.api, _show_id)
227
+ episodes = self.api.episodes_list(_show_id)
105
228
  self._sync_episodes(transacted_db, _show_id, episodes,
106
229
  on_insert=on_episode_insert, on_update=on_episode_update)
107
230
  if on_show_added:
@@ -170,7 +293,7 @@ class ShowtimeApp():
170
293
  tv_maze_show = self.api.show_get(show_id)
171
294
  if tv_maze_show:
172
295
  transacted_db.update_show(show_id, tv_maze_show)
173
- tv_maze_episodes = _get_episodes(self.api, show_id)
296
+ tv_maze_episodes = self.api.episodes_list(show_id)
174
297
  self._sync_episodes(transacted_db, show_id, tv_maze_episodes,
175
298
  on_insert=on_episode_insert, on_update=on_episode_update)
176
299
 
@@ -1,11 +1,12 @@
1
1
  """Showtime Types Module"""
2
2
 
3
3
  from enum import Enum
4
- from typing import NamedTuple, Dict
5
- from typing_extensions import TypedDict
4
+ from typing import Dict, NamedTuple, Optional
5
+ from typing_extensions import NotRequired, TypedDict
6
6
 
7
7
  ShowId = int
8
8
  EpisodeId = int
9
+ MovieId = int
9
10
  Date = str
10
11
 
11
12
 
@@ -29,6 +30,27 @@ class TVMazeEpisode(NamedTuple):
29
30
  runtime: int
30
31
 
31
32
 
33
+ class TMDBMovie(NamedTuple):
34
+ """TMDB movie result"""
35
+ id: MovieId
36
+ title: str
37
+ release_date: str | None
38
+ runtime: int | None
39
+ external_ids: Optional[Dict[str, Optional[str]]] = None
40
+ original_language: Optional[str] = None
41
+
42
+
43
+ class Movie(TypedDict):
44
+ """DB movie"""
45
+ id: MovieId
46
+ title: str
47
+ release_date: Date | None
48
+ runtime: int | None
49
+ watched: Date
50
+ external_ids: NotRequired[Dict[str, Optional[str]]]
51
+ original_language: NotRequired[Optional[str]]
52
+
53
+
32
54
  class ShowStatus(Enum):
33
55
  """API Show status"""
34
56
  ENDED = 'Ended'
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: showtime-cli
3
- Version: 0.3.24
3
+ Version: 0.3.26
4
4
  Summary: Command line show tracker using the TVMaze public API
5
5
  Author-email: Evgeniy Vasilev <aquilax@gmail.com>
6
6
  License: MIT
@@ -18,7 +18,7 @@ Classifier: Topic :: Utilities
18
18
  Requires-Python: >=3.12
19
19
  Description-Content-Type: text/markdown
20
20
  License-File: LICENSE
21
- Requires-Dist: cmd2==4.2.1
21
+ Requires-Dist: cmd2==4.2.4
22
22
  Requires-Dist: ratelimit==2.2.1
23
23
  Requires-Dist: tinydb==4.9.0
24
24
  Requires-Dist: terminaltables==3.1.10
@@ -37,10 +37,39 @@ Dynamic: license-file
37
37
 
38
38
  # showtime
39
39
 
40
- Small command line interactive tv show tracker using the TvMaze API.
40
+ Small command line tracker for TV series using TVMaze and movies using TMDB.
41
41
 
42
42
  ## Installation
43
43
 
44
44
  ```sh
45
45
  pip install showtime-cli
46
46
  ```
47
+
48
+ ## Movies
49
+
50
+ Movie search and add use TMDB. Set a TMDB API Read Access Token with
51
+ `TMDB_ACCESS_TOKEN`, or add it to `~/.showtime.ini`:
52
+
53
+ ```ini
54
+ [TMDB]
55
+ AccessToken = your-tmdb-read-access-token
56
+ ```
57
+
58
+ The token is needed for `movie_search`, `movie_add`, and IMDb imports. Movie
59
+ tracking and watched status are stored locally:
60
+
61
+ ```text
62
+ movie_search <query>
63
+ movie_add <tmdb_id>
64
+ movie_add_watched <tmdb_id>
65
+ movie_remove <tmdb_id>
66
+ movies [query]
67
+ movie_watch <tmdb_id>
68
+ movie_unwatch <tmdb_id>
69
+ movie_import_imdb_ratings <filename>
70
+ ```
71
+
72
+ IMDb imports read the `Const`, `Your Rating`, `Date Rated`, and `Title Type`
73
+ columns. Movie and TV Movie rows are resolved through TMDB, added if needed,
74
+ and marked watched on their IMDb rating date. Other title types and unresolved
75
+ IMDb IDs are skipped.
@@ -1,4 +1,4 @@
1
- cmd2==4.2.1
1
+ cmd2==4.2.4
2
2
  ratelimit==2.2.1
3
3
  tinydb==4.9.0
4
4
  terminaltables==3.1.10
@@ -1,9 +0,0 @@
1
- # showtime
2
-
3
- Small command line interactive tv show tracker using the TvMaze API.
4
-
5
- ## Installation
6
-
7
- ```sh
8
- pip install showtime-cli
9
- ```
@@ -1,91 +0,0 @@
1
- """API client module"""
2
-
3
- from dataclasses import dataclass
4
- import json
5
- from urllib.parse import urlencode, urlparse, urlunparse
6
- import urllib.request
7
- from typing import Any, Dict, List, Optional
8
-
9
- from showtime.types import ShowId, TVMazeEpisode, TVMazeShow
10
-
11
- API_BASE_URL = "https://api.tvmaze.com"
12
-
13
-
14
- def episode_to_model(episode: Dict) -> TVMazeEpisode:
15
- return TVMazeEpisode(
16
- id=episode['id'],
17
- season=episode['season'],
18
- number=episode['number'],
19
- name=episode['name'],
20
- airdate=episode['airdate'],
21
- runtime=episode['runtime'],
22
- )
23
-
24
-
25
- def show_to_model(show: Dict) -> TVMazeShow:
26
- return TVMazeShow(
27
- id=show['id'],
28
- name=show['name'],
29
- premiered=show['premiered'],
30
- status=show['status'],
31
- url=show['url'],
32
- externals=show['externals']
33
- )
34
-
35
-
36
- def search_to_model(show_wrapped) -> TVMazeShow:
37
- return show_to_model(show_wrapped['show'])
38
-
39
-
40
- @dataclass
41
- class HTTPResponse:
42
- data: str
43
-
44
-
45
- class HTTPClient():
46
- """HTTP Client"""
47
-
48
- def request(self, _method: str, url: str, fields: dict[str, str]={}) -> Any:
49
- headers = {
50
- "User-Agent": "showtime-cli",
51
- "Accept": "application/json"
52
- }
53
-
54
- url_parts = list(urlparse(url))
55
- url_parts[4] = urlencode(fields)
56
- final_url = urlunparse(url_parts)
57
- print(final_url)
58
- request = urllib.request.Request(final_url, headers=headers)
59
- with urllib.request.urlopen(request) as response:
60
- return HTTPResponse(data=response.read())
61
-
62
-
63
- class Api():
64
- """API Client"""
65
-
66
- def __init__(self, http: HTTPClient) -> None:
67
- self.http = http
68
-
69
- def episodes_list(self, show_id: ShowId) -> List[TVMazeEpisode]:
70
- """returns list of episodes for a show"""
71
- response = self.http.request('GET', f"{API_BASE_URL}/shows/{show_id}/episodes")
72
- raw_episodes = json.loads(response.data.decode('utf-8'))
73
- return list(map(episode_to_model, raw_episodes))
74
-
75
- def show_get(self, show_id: ShowId) -> Optional[TVMazeShow]:
76
- """returns show information"""
77
- response = self.http.request('GET', f"{API_BASE_URL}/shows/{show_id}")
78
- raw_show = json.loads(response.data.decode('utf-8'))
79
- return show_to_model(raw_show)
80
-
81
- def show_search(self, query: str) -> List[TVMazeShow]:
82
- """returns list of shows matching search string"""
83
- response = self.http.request('GET', f"{API_BASE_URL}/search/shows", fields={'q': query})
84
- raw_shows = json.loads(response.data.decode('utf-8'))
85
- return list(map(search_to_model, raw_shows))
86
-
87
-
88
- def get_default_pool_manager():
89
- return HTTPClient()
90
-
91
-
File without changes
File without changes