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.
- {showtime_cli-0.3.24 → showtime_cli-0.3.26}/PKG-INFO +32 -3
- showtime_cli-0.3.26/README.md +38 -0
- {showtime_cli-0.3.24 → showtime_cli-0.3.26}/pyproject.toml +2 -2
- showtime_cli-0.3.26/showtime/api.py +160 -0
- {showtime_cli-0.3.24 → showtime_cli-0.3.26}/showtime/command.py +80 -2
- {showtime_cli-0.3.24 → showtime_cli-0.3.26}/showtime/config.py +7 -0
- {showtime_cli-0.3.24 → showtime_cli-0.3.26}/showtime/database.py +47 -3
- {showtime_cli-0.3.24 → showtime_cli-0.3.26}/showtime/output.py +28 -1
- {showtime_cli-0.3.24 → showtime_cli-0.3.26}/showtime/showtime.py +135 -12
- {showtime_cli-0.3.24 → showtime_cli-0.3.26}/showtime/types.py +24 -2
- {showtime_cli-0.3.24 → showtime_cli-0.3.26}/showtime_cli.egg-info/PKG-INFO +32 -3
- {showtime_cli-0.3.24 → showtime_cli-0.3.26}/showtime_cli.egg-info/requires.txt +1 -1
- showtime_cli-0.3.24/README.md +0 -9
- showtime_cli-0.3.24/showtime/api.py +0 -91
- {showtime_cli-0.3.24 → showtime_cli-0.3.26}/LICENSE +0 -0
- {showtime_cli-0.3.24 → showtime_cli-0.3.26}/setup.cfg +0 -0
- {showtime_cli-0.3.24 → showtime_cli-0.3.26}/showtime/__init__.py +0 -0
- {showtime_cli-0.3.24 → showtime_cli-0.3.26}/showtime_cli.egg-info/SOURCES.txt +0 -0
- {showtime_cli-0.3.24 → showtime_cli-0.3.26}/showtime_cli.egg-info/dependency_links.txt +0 -0
- {showtime_cli-0.3.24 → showtime_cli-0.3.26}/showtime_cli.egg-info/entry_points.txt +0 -0
- {showtime_cli-0.3.24 → showtime_cli-0.3.26}/showtime_cli.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: showtime-cli
|
|
3
|
-
Version: 0.3.
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
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 =
|
|
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 =
|
|
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,
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
showtime_cli-0.3.24/README.md
DELETED
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|