wi1-bot 1.2.12__tar.gz → 1.3.1__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.
Files changed (56) hide show
  1. {wi1-bot-1.2.12 → wi1-bot-1.3.1}/.gitignore +3 -1
  2. {wi1-bot-1.2.12 → wi1-bot-1.3.1}/.pre-commit-config.yaml +1 -1
  3. {wi1-bot-1.2.12 → wi1-bot-1.3.1}/PKG-INFO +3 -1
  4. {wi1-bot-1.2.12 → wi1-bot-1.3.1}/README.md +2 -0
  5. {wi1-bot-1.2.12 → wi1-bot-1.3.1}/config.yaml.template +6 -0
  6. {wi1-bot-1.2.12 → wi1-bot-1.3.1}/pyproject.toml +13 -16
  7. {wi1-bot-1.2.12 → wi1-bot-1.3.1}/tests/movie_downloaded.py +5 -5
  8. wi1-bot-1.3.1/wi1_bot/_version.py +4 -0
  9. {wi1-bot-1.2.12 → wi1-bot-1.3.1}/wi1_bot/arr/download.py +3 -1
  10. {wi1-bot-1.2.12 → wi1-bot-1.3.1}/wi1_bot/arr/episode.py +4 -1
  11. {wi1-bot-1.2.12 → wi1-bot-1.3.1}/wi1_bot/arr/movie.py +4 -1
  12. {wi1-bot-1.2.12 → wi1-bot-1.3.1}/wi1_bot/arr/radarr.py +32 -20
  13. {wi1-bot-1.2.12 → wi1-bot-1.3.1}/wi1_bot/arr/sonarr.py +53 -21
  14. {wi1-bot-1.2.12 → wi1-bot-1.3.1}/wi1_bot/config.py +2 -0
  15. {wi1-bot-1.2.12 → wi1-bot-1.3.1}/wi1_bot/discord/bot.py +41 -16
  16. {wi1-bot-1.2.12 → wi1-bot-1.3.1}/wi1_bot/discord/cogs/movie.py +8 -3
  17. {wi1-bot-1.2.12 → wi1-bot-1.3.1}/wi1_bot/discord/cogs/series.py +42 -15
  18. {wi1-bot-1.2.12 → wi1-bot-1.3.1}/wi1_bot/scripts/start.py +4 -4
  19. wi1-bot-1.3.1/wi1_bot/scripts/transcode_item.py +43 -0
  20. {wi1-bot-1.2.12 → wi1-bot-1.3.1}/wi1_bot/transcoder/transcode_queue.py +12 -5
  21. {wi1-bot-1.2.12 → wi1-bot-1.3.1}/wi1_bot/transcoder/transcoder.py +87 -69
  22. {wi1-bot-1.2.12 → wi1-bot-1.3.1}/wi1_bot/webhook.py +18 -7
  23. {wi1-bot-1.2.12 → wi1-bot-1.3.1}/wi1_bot.egg-info/PKG-INFO +3 -1
  24. {wi1-bot-1.2.12 → wi1-bot-1.3.1}/wi1_bot.egg-info/SOURCES.txt +1 -14
  25. {wi1-bot-1.2.12 → wi1-bot-1.3.1}/wi1_bot.egg-info/entry_points.txt +0 -1
  26. wi1-bot-1.3.1/wi1_bot.egg-info/requires.txt +18 -0
  27. wi1-bot-1.2.12/dashboard/.gitignore +0 -23
  28. wi1-bot-1.2.12/dashboard/package-lock.json +0 -28006
  29. wi1-bot-1.2.12/dashboard/package.json +0 -43
  30. wi1-bot-1.2.12/dashboard/public/index.html +0 -16
  31. wi1-bot-1.2.12/dashboard/src/App.tsx +0 -7
  32. wi1-bot-1.2.12/dashboard/src/Log.css +0 -10
  33. wi1-bot-1.2.12/dashboard/src/Log.tsx +0 -49
  34. wi1-bot-1.2.12/dashboard/src/index.css +0 -47
  35. wi1-bot-1.2.12/dashboard/src/index.tsx +0 -15
  36. wi1-bot-1.2.12/dashboard/src/react-app-env.d.ts +0 -1
  37. wi1-bot-1.2.12/dashboard/tsconfig.json +0 -20
  38. wi1-bot-1.2.12/wi1_bot/_version.py +0 -5
  39. wi1-bot-1.2.12/wi1_bot/scripts/add_tag.py +0 -23
  40. wi1-bot-1.2.12/wi1_bot/scripts/transcode_item.py +0 -29
  41. wi1-bot-1.2.12/wi1_bot/transcoder/websocket.py +0 -47
  42. wi1-bot-1.2.12/wi1_bot.egg-info/requires.txt +0 -20
  43. {wi1-bot-1.2.12 → wi1-bot-1.3.1}/.flake8 +0 -0
  44. {wi1-bot-1.2.12 → wi1-bot-1.3.1}/.github/workflows/pypi-publish.yml +0 -0
  45. {wi1-bot-1.2.12 → wi1-bot-1.3.1}/LICENSE +0 -0
  46. {wi1-bot-1.2.12 → wi1-bot-1.3.1}/setup.cfg +0 -0
  47. {wi1-bot-1.2.12 → wi1-bot-1.3.1}/wi1_bot/__init__.py +0 -0
  48. {wi1-bot-1.2.12 → wi1-bot-1.3.1}/wi1_bot/arr/__init__.py +0 -0
  49. {wi1-bot-1.2.12 → wi1-bot-1.3.1}/wi1_bot/discord/__init__.py +0 -0
  50. {wi1-bot-1.2.12 → wi1-bot-1.3.1}/wi1_bot/discord/cogs/__init__.py +0 -0
  51. {wi1-bot-1.2.12 → wi1-bot-1.3.1}/wi1_bot/discord/helpers.py +0 -0
  52. {wi1-bot-1.2.12 → wi1-bot-1.3.1}/wi1_bot/push.py +0 -0
  53. {wi1-bot-1.2.12 → wi1-bot-1.3.1}/wi1_bot/scripts/__init__.py +0 -0
  54. {wi1-bot-1.2.12 → wi1-bot-1.3.1}/wi1_bot/transcoder/__init__.py +0 -0
  55. {wi1-bot-1.2.12 → wi1-bot-1.3.1}/wi1_bot.egg-info/dependency_links.txt +0 -0
  56. {wi1-bot-1.2.12 → wi1-bot-1.3.1}/wi1_bot.egg-info/top_level.txt +0 -0
@@ -1,5 +1,7 @@
1
1
  config.yaml
2
+ /tests/files/
3
+
4
+ build/
2
5
  dist/
3
6
  *.egg-info/
4
7
  _version.py
5
- /tests/files/
@@ -24,6 +24,6 @@ repos:
24
24
  - id: mypy
25
25
  name: mypy
26
26
  language: system
27
- entry: mypy -p wi1_bot
27
+ entry: mypy
28
28
  pass_filenames: false
29
29
  types: [python]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: wi1-bot
3
- Version: 1.2.12
3
+ Version: 1.3.1
4
4
  Summary: Discord bot for Radarr/Sonarr integration
5
5
  Home-page: https://github.com/wthueb/wi1-bot
6
6
  Author-email: William Huebner <wilhueb@gmail.com>
@@ -57,6 +57,8 @@ Requires Python >=3.10.
57
57
 
58
58
  ### TODO
59
59
 
60
+ - Use `mypy --strict`
61
+ - Use Discord slash commands instead of normal text commands
60
62
  - Web dashboard for seeing transcode queue, transcode progress, quotas
61
63
  - Enforce quotas
62
64
  - !linktmdb
@@ -19,6 +19,8 @@ Requires Python >=3.10.
19
19
 
20
20
  ### TODO
21
21
 
22
+ - Use `mypy --strict`
23
+ - Use Discord slash commands instead of normal text commands
22
24
  - Web dashboard for seeing transcode queue, transcode progress, quotas
23
25
  - Enforce quotas
24
26
  - !linktmdb
@@ -4,12 +4,16 @@ radarr:
4
4
  url: http://localhost:7878
5
5
  # radarr api key (Settings->General->Security->API Key)
6
6
  api_key: XXX
7
+ # radarr root folder (absolute path)
8
+ root_folder: /full/path/movies
7
9
 
8
10
  sonarr:
9
11
  # sonarr url you use to get to the dashboard
10
12
  url: http://localhost:8989
11
13
  # sonarr api key (Settings->General->Security->API Key)
12
14
  api_key: XXX
15
+ # sonarr root folder (absolute path)
16
+ root_folder: /full/path/shows
13
17
 
14
18
  discord:
15
19
  # discord bot token
@@ -45,6 +49,8 @@ transcoding:
45
49
  profiles:
46
50
  # name of profile must match name in radarr/sonarr
47
51
  good:
52
+ # -map 0 in ffmpeg, optional
53
+ copy_all_streams: true
48
54
  # -c:v in ffmpeg, optional
49
55
  video_codec: hevc_nvenc
50
56
  # -b:v in ffmpeg, optional
@@ -16,28 +16,26 @@ classifiers = [
16
16
  dynamic = ["version"]
17
17
  requires-python = ">=3.10"
18
18
  dependencies = [
19
- "discord.py==1.7.3",
19
+ "discord.py==2.1.0",
20
20
  "Flask==2.1.2",
21
21
  "mongoengine==0.24.1",
22
22
  "pyarr==3.1.3",
23
- "pymongo==4.1.1",
24
23
  "python-pushover==0.4",
25
- "PyYAML==6.0",
26
- "websockets==10.3"
24
+ "PyYAML==6.0"
27
25
  ]
28
26
 
29
27
  [project.optional-dependencies]
30
28
  dev = [
31
- "black==22.3.0",
32
- "discord.py-stubs==1.7.3",
33
- "flake8==4.0.1",
34
- "isort==5.10.1",
29
+ "black==22.12.0",
30
+ "flake8==6.0.0",
31
+ "isort==5.12.0",
35
32
  "mongo-types==0.15.1",
36
- "mypy==0.950",
37
- "pre-commit==2.19.0",
38
- "requests==2.27.1",
39
- "types-PyYAML==6.0.7",
40
- "types-requests==2.27.29"
33
+ "mypy==0.991",
34
+ "pre-commit==3.0.2",
35
+ "requests==2.28.2",
36
+ "types-flask==1.1.6",
37
+ "types-PyYAML==6.0.12.3",
38
+ "types-requests==2.28.11.8"
41
39
  ]
42
40
 
43
41
  [project.urls]
@@ -46,7 +44,6 @@ Homepage = "https://github.com/wthueb/wi1-bot"
46
44
  [project.scripts]
47
45
  wi1-bot = "wi1_bot.scripts.start:main"
48
46
  transcode-item = "wi1_bot.scripts.transcode_item:main"
49
- add-tag = "wi1_bot.scripts.add_tag:main"
50
47
 
51
48
  [tool.setuptools]
52
49
  packages = ["wi1_bot"]
@@ -55,13 +52,13 @@ packages = ["wi1_bot"]
55
52
  write_to = "wi1_bot/_version.py"
56
53
 
57
54
  [tool.mypy]
58
- namespace_packages = true
55
+ packages = "wi1_bot"
56
+ strict = true
59
57
 
60
58
  [[tool.mypy.overrides]]
61
59
  module = [
62
60
  "pushover",
63
61
  "pyarr",
64
- "setuptools_scm"
65
62
  ]
66
63
  ignore_missing_imports = true
67
64
 
@@ -1,13 +1,13 @@
1
+ import pathlib
1
2
  import shutil
2
3
 
3
4
  import requests
4
5
 
5
6
  from wi1_bot.config import config
6
7
 
7
- shutil.copy(
8
- "./tests/files/jellyfish-10-mbps-hd-h264.mkv.bak",
9
- "./tests/files/jellyfish-10-mbps-hd-h264.mkv",
10
- )
8
+ path = pathlib.Path("./tests/files/jellyfish-10-mbps-hd-h264.mkv")
9
+
10
+ shutil.copy(f"{path}.bak", path)
11
11
 
12
12
  header = {
13
13
  "X-Api-Key": config["radarr"]["api_key"],
@@ -19,7 +19,7 @@ data = {
19
19
  "id": 1, # just to get quality profile
20
20
  "folderPath": "./tests/files",
21
21
  },
22
- "movieFile": {"relativePath": "jellyfish-10-mbps-hd-h264.mkv"},
22
+ "movieFile": {"relativePath": path.name},
23
23
  }
24
24
 
25
25
  requests.post("http://localhost:9000/", json=data)
@@ -0,0 +1,4 @@
1
+ # file generated by setuptools_scm
2
+ # don't change, don't track in version control
3
+ __version__ = version = '1.3.1'
4
+ __version_tuple__ = version_tuple = (1, 3, 1)
@@ -1,9 +1,11 @@
1
+ from typing import Any
2
+
1
3
  from .episode import Episode
2
4
  from .movie import Movie
3
5
 
4
6
 
5
7
  class Download:
6
- def __init__(self, data: dict) -> None:
8
+ def __init__(self, data: dict[str, Any]) -> None:
7
9
  self.content: Movie | Episode | str
8
10
 
9
11
  if "movie" in data:
@@ -1,7 +1,10 @@
1
+ from typing import Any
2
+
3
+
1
4
  class Episode:
2
5
  def __init__(
3
6
  self,
4
- ep_json: dict,
7
+ ep_json: dict[str, Any],
5
8
  *,
6
9
  series_title: str,
7
10
  series_tvdb_id: int,
@@ -1,5 +1,8 @@
1
+ from typing import Any
2
+
3
+
1
4
  class Movie:
2
- def __init__(self, movie_json: dict) -> None:
5
+ def __init__(self, movie_json: dict[str, Any]) -> None:
3
6
  self._json = movie_json
4
7
 
5
8
  self.title: str = movie_json["title"]
@@ -1,34 +1,37 @@
1
1
  from shutil import rmtree
2
+ from typing import Any, cast
2
3
 
3
4
  from pyarr import RadarrAPI
4
5
 
5
6
  from .download import Download
6
7
  from .movie import Movie
7
8
 
9
+ json = dict[str, Any]
10
+
8
11
 
9
12
  class Radarr:
10
13
  def __init__(self, url: str, api_key: str) -> None:
11
14
  self._radarr = RadarrAPI(url, api_key)
12
15
 
13
16
  def lookup_movie(self, query: str) -> list[Movie]:
14
- possible_movies = self._radarr.lookup_movie(query)
17
+ possible_movies = cast(list[json], self._radarr.lookup_movie(query))
15
18
 
16
19
  return [Movie(m) for m in possible_movies]
17
20
 
18
21
  def lookup_library(self, query: str) -> list[Movie]:
19
- possible_movies = self._radarr.lookup_movie(query)
22
+ possible_movies = cast(list[json], self._radarr.lookup_movie(query))
20
23
 
21
24
  return [Movie(m) for m in possible_movies if "id" in m]
22
25
 
23
- def lookup_user_movies(self, query: str, user_id: int) -> list[Movie]:
26
+ def lookup_user_library(self, query: str, user_id: int) -> list[Movie]:
24
27
  try:
25
28
  tag_id = self._get_tag_for_user_id(user_id)
26
29
  except ValueError:
27
30
  return []
28
31
 
29
- tag_detail = self._radarr.get_tag_detail(tag_id)
32
+ tag_detail = cast(dict[str, Any], self._radarr.get_tag_detail(tag_id))
30
33
 
31
- possible_movies = self._radarr.lookup_movie(query)
34
+ possible_movies = cast(list[json], self._radarr.lookup_movie(query))
32
35
 
33
36
  user_movie_ids = tag_detail["movieIds"]
34
37
 
@@ -42,7 +45,7 @@ class Radarr:
42
45
 
43
46
  quality_profile_id = self._get_quality_profile_id(profile)
44
47
 
45
- root_folder = self._radarr.get_root_folder()[0]["path"]
48
+ root_folder = cast(list[json], self._radarr.get_root_folder())[0]["path"]
46
49
 
47
50
  self._radarr.add_movie(
48
51
  db_id=movie.tmdb_id,
@@ -53,7 +56,7 @@ class Radarr:
53
56
  return True
54
57
 
55
58
  def del_movie(self, movie: Movie) -> None:
56
- potential = self._radarr.get_movie(movie.tmdb_id)
59
+ potential = cast(list[json], self._radarr.get_movie(movie.tmdb_id))
57
60
 
58
61
  if not potential:
59
62
  raise ValueError(f"{movie} is not in the library")
@@ -71,7 +74,7 @@ class Radarr:
71
74
  pass
72
75
 
73
76
  def movie_downloaded(self, movie: Movie) -> bool:
74
- potential = self._radarr.get_movie(movie.tmdb_id)
77
+ potential = cast(list[json], self._radarr.get_movie(movie.tmdb_id))
75
78
 
76
79
  if not potential:
77
80
  return False
@@ -88,9 +91,12 @@ class Radarr:
88
91
 
89
92
  def add_tag(self, movie: Movie | list[Movie], user_id: int) -> bool:
90
93
  if isinstance(movie, Movie):
91
- ids = [self._radarr.get_movie(movie.tmdb_id)[0]["id"]]
94
+ ids = [cast(list[json], self._radarr.get_movie(movie.tmdb_id))[0]["id"]]
92
95
  else:
93
- ids = [self._radarr.get_movie(m.tmdb_id)[0]["id"] for m in movie]
96
+ ids = [
97
+ cast(list[json], self._radarr.get_movie(m.tmdb_id))[0]["id"]
98
+ for m in movie
99
+ ]
94
100
 
95
101
  try:
96
102
  tag_id = self._get_tag_for_user_id(user_id)
@@ -106,7 +112,7 @@ class Radarr:
106
112
  return True
107
113
 
108
114
  def get_downloads(self) -> list[Download]:
109
- queue = self._radarr.get_queue_details()
115
+ queue = cast(list[json], self._radarr.get_queue_details())
110
116
 
111
117
  downloads = [Download(d) for d in queue]
112
118
 
@@ -120,46 +126,52 @@ class Radarr:
120
126
 
121
127
  total = 0
122
128
 
123
- for movie in self._radarr.get_movie():
129
+ for movie in cast(list[json], self._radarr.get_movie()):
124
130
  if tag_id in movie["tags"]:
125
131
  total += movie["sizeOnDisk"]
126
132
 
127
133
  return total
128
134
 
129
- def get_quality_profile_name(self, profile_id: int):
130
- profiles = self._radarr.get_quality_profile()
135
+ def get_quality_profile_name(self, profile_id: int) -> str:
136
+ profiles = cast(list[json], self._radarr.get_quality_profile())
131
137
 
132
138
  for profile in profiles:
133
139
  if profile["id"] == profile_id:
134
- return profile["name"]
140
+ return cast(str, profile["name"])
135
141
 
136
142
  raise ValueError(f"no quality profile with the id {profile_id}")
137
143
 
138
144
  def rescan_movie(self, movie_id: int) -> None:
139
- self._radarr.post_command("RescanMovie", movieIds=[movie_id])
145
+ self._radarr.post_command("RescanMovie", movieId=movie_id)
146
+
147
+ def refresh_movie(self, movie_id: int) -> None:
148
+ self._radarr.post_command("RefreshMovie", movieIds=[movie_id])
140
149
 
141
150
  def search_missing(self) -> None:
142
151
  self._radarr.post_command(name="MissingMoviesSearch")
143
152
 
144
153
  def _get_quality_profile_id(self, name: str) -> int:
145
- profiles = self._radarr.get_quality_profile()
154
+ profiles = cast(list[json], self._radarr.get_quality_profile())
146
155
 
147
156
  for profile in profiles:
148
157
  if profile["name"].lower() == name.lower():
149
- return profile["id"]
158
+ return cast(int, profile["id"])
150
159
 
151
160
  raise ValueError(f"no quality profile with the name {name}")
152
161
 
153
162
  def _get_tag_for_user_id(self, user_id: int) -> int:
154
- tags = self._radarr.get_tag()
163
+ tags = cast(list[json], self._radarr.get_tag())
155
164
 
156
165
  for tag in tags:
157
166
  if str(user_id) in tag["label"]:
158
- return tag["id"]
167
+ return cast(int, tag["id"])
159
168
 
160
169
  raise ValueError(f"no tag with the user id {user_id}")
161
170
 
162
171
 
172
+ __all__ = ["Movie"]
173
+
174
+
163
175
  if __name__ == "__main__":
164
176
  from wi1_bot.config import config
165
177
 
@@ -1,12 +1,15 @@
1
1
  from shutil import rmtree
2
+ from typing import Any, cast
2
3
 
3
4
  from pyarr import SonarrAPI
4
5
 
5
6
  from .download import Download
6
7
 
8
+ json = dict[str, Any]
9
+
7
10
 
8
11
  class Series:
9
- def __init__(self, series_json: dict) -> None:
12
+ def __init__(self, series_json: dict[str, Any]) -> None:
10
13
  self.title: str = series_json["title"]
11
14
  self.year: int = series_json["year"]
12
15
  self.tvdb_id: int = series_json["tvdbId"]
@@ -32,33 +35,59 @@ class Series:
32
35
  return str(self.__dict__)
33
36
 
34
37
 
38
+ class SonarrError(Exception):
39
+ pass
40
+
41
+
35
42
  class Sonarr:
36
43
  def __init__(self, url: str, api_key: str) -> None:
37
44
  self._sonarr = SonarrAPI(url, api_key)
38
45
 
39
46
  def lookup_series(self, query: str) -> list[Series]:
40
- possible_series = self._sonarr.lookup_series(query)
47
+ possible_series = cast(
48
+ list[json] | dict[str, Any], self._sonarr.lookup_series(query)
49
+ )
50
+
51
+ if isinstance(possible_series, dict):
52
+ raise SonarrError(possible_series["message"])
41
53
 
42
54
  return [Series(s) for s in possible_series]
43
55
 
44
56
  def lookup_library(self, query: str) -> list[Series]:
45
- possible_series = self._sonarr.lookup_series(query)
57
+ possible_series = cast(list[json], self._sonarr.lookup_series(query))
46
58
 
47
59
  return [Series(s) for s in possible_series if "id" in s]
48
60
 
61
+ def lookup_user_library(self, query: str, user_id: int) -> list[Series]:
62
+ try:
63
+ tag_id = self._get_tag_for_user_id(user_id)
64
+ except ValueError:
65
+ return []
66
+
67
+ possible_series = cast(list[json], self._sonarr.lookup_series(query))
68
+
69
+ # self._sonarr.get_tag_detail is broken/not supported in v1 sonarr API so have
70
+ # to filter the tmdb lookup (probably slower but saves an API call)
71
+ user_series = [s for s in possible_series if tag_id in s["tags"]]
72
+
73
+ return [Series(s) for s in user_series]
74
+
49
75
  def add_series(self, series: Series, profile: str = "good") -> bool:
50
76
  if series.db_id is not None:
51
77
  return False
52
78
 
53
79
  quality_profile_id = self._get_quality_profile_id(profile)
54
80
 
55
- root_folder = self._sonarr.get_root_folder()[0]["path"]
81
+ root_folder = cast(list[json], self._sonarr.get_root_folder())[0]["path"]
56
82
 
57
- series_json = self._sonarr.add_series(
58
- tvdb_id=series.tvdb_id,
59
- quality_profile_id=quality_profile_id,
60
- root_dir=root_folder,
61
- search_for_missing_episodes=True,
83
+ series_json = cast(
84
+ json,
85
+ self._sonarr.add_series(
86
+ tvdb_id=series.tvdb_id,
87
+ quality_profile_id=quality_profile_id,
88
+ root_dir=root_folder,
89
+ search_for_missing_episodes=True,
90
+ ),
62
91
  )
63
92
 
64
93
  series.db_id = series_json["id"]
@@ -69,7 +98,7 @@ class Sonarr:
69
98
  if series.db_id is None:
70
99
  raise ValueError(f"{series} is not in the library")
71
100
 
72
- series_json = self._sonarr.get_series(series.db_id)
101
+ series_json = cast(json, self._sonarr.get_series(series.db_id))
73
102
 
74
103
  self._sonarr.del_series(series.db_id, delete_files=True)
75
104
 
@@ -100,7 +129,7 @@ class Sonarr:
100
129
 
101
130
  return False
102
131
 
103
- series_json = self._sonarr.get_series(series.db_id)
132
+ series_json = cast(json, self._sonarr.get_series(series.db_id))
104
133
 
105
134
  series_json["tags"].append(tag_id)
106
135
 
@@ -109,7 +138,7 @@ class Sonarr:
109
138
  return True
110
139
 
111
140
  def get_downloads(self) -> list[Download]:
112
- queue = self._sonarr.get_queue()
141
+ queue = cast(list[json], self._sonarr.get_queue())
113
142
 
114
143
  downloads = [Download(d) for d in queue]
115
144
 
@@ -123,18 +152,21 @@ class Sonarr:
123
152
 
124
153
  total = 0
125
154
 
126
- for series in self._sonarr.get_series():
155
+ for series in cast(list[json], self._sonarr.get_series()):
127
156
  if tag_id in series["tags"]:
128
- total += series["sizeOnDisk"]
157
+ try:
158
+ total += series["sizeOnDisk"]
159
+ except KeyError:
160
+ continue
129
161
 
130
162
  return total
131
163
 
132
- def get_quality_profile_name(self, profile_id: int):
133
- profiles = self._sonarr.get_quality_profile()
164
+ def get_quality_profile_name(self, profile_id: int) -> str:
165
+ profiles = cast(list[json], self._sonarr.get_quality_profile())
134
166
 
135
167
  for profile in profiles:
136
168
  if profile["id"] == profile_id:
137
- return profile["name"]
169
+ return cast(str, profile["name"])
138
170
 
139
171
  raise ValueError(f"no quality profile with the id {profile_id}")
140
172
 
@@ -142,19 +174,19 @@ class Sonarr:
142
174
  self._sonarr.post_command("RescanSeries", seriesId=series_id)
143
175
 
144
176
  def _get_quality_profile_id(self, name: str) -> int:
145
- profiles = self._sonarr.get_quality_profile()
177
+ profiles = cast(list[json], self._sonarr.get_quality_profile())
146
178
 
147
179
  for profile in profiles:
148
180
  if profile["name"].lower() == name.lower():
149
- return profile["id"]
181
+ return cast(int, profile["id"])
150
182
 
151
183
  raise ValueError(f"no quality profile with the name {name}")
152
184
 
153
185
  def _get_tag_for_user_id(self, user_id: int) -> int:
154
- tags = self._sonarr.get_tag()
186
+ tags = cast(list[json], self._sonarr.get_tag())
155
187
 
156
188
  for tag in tags:
157
189
  if str(user_id) in tag["label"]:
158
- return tag["id"]
190
+ return cast(int, tag["id"])
159
191
 
160
192
  raise ValueError(f"no tag with the user id {user_id}")
@@ -26,6 +26,7 @@ if _config_path is None:
26
26
  class ArrConfig(TypedDict):
27
27
  url: str
28
28
  api_key: str
29
+ root_folder: str
29
30
 
30
31
 
31
32
  class PushoverConfig(TypedDict):
@@ -79,6 +80,7 @@ class DiscordConfig(DiscordConfigOptional):
79
80
 
80
81
 
81
82
  class TranscodingProfile(TypedDict, total=False):
83
+ copy_all_streams: bool
82
84
  video_codec: str
83
85
  video_bitrate: int
84
86
  audio_codec: str
@@ -1,4 +1,7 @@
1
+ import asyncio
1
2
  import logging
3
+ import traceback
4
+ from typing import Any
2
5
 
3
6
  import discord
4
7
  from discord.ext import commands
@@ -18,23 +21,32 @@ sonarr = Sonarr(config["sonarr"]["url"], config["sonarr"]["api_key"])
18
21
 
19
22
 
20
23
  @bot.check
21
- async def check_channel(ctx: commands.Context) -> bool:
24
+ async def check_channel(ctx: commands.Context[Any]) -> bool:
22
25
  return ctx.channel.id == config["discord"]["channel_id"]
23
26
 
24
27
 
25
28
  @bot.event
26
- async def on_command_error(ctx: commands.Context, error: commands.CommandError) -> None:
29
+ async def on_command_error(
30
+ ctx: commands.Context[Any], error: commands.CommandError
31
+ ) -> None:
27
32
  match error:
28
- case commands.CommandNotFound():
33
+ case commands.CommandNotFound() | commands.CheckFailure():
29
34
  pass
30
35
  case commands.MissingRole():
31
36
  await reply(ctx.message, "you don't have permission to do that")
32
37
  case commands.MemberNotFound():
33
38
  await reply(ctx.message, "that user doesn't exist")
39
+ case commands.CommandOnCooldown():
40
+ await reply(ctx.message, str(error))
34
41
  case commands.MissingRequiredArgument():
35
42
  await reply(ctx.message, str(error))
36
43
  case _:
37
- logger.error(repr(error))
44
+ logger.error(
45
+ "".join(
46
+ traceback.format_exception(type(error), error, error.__traceback__)
47
+ )
48
+ )
49
+
38
50
  await reply(
39
51
  ctx.message,
40
52
  f"something went wrong (<@!{config['discord']['admin_id']}>)",
@@ -55,15 +67,15 @@ async def on_ready() -> None:
55
67
 
56
68
 
57
69
  @bot.before_invoke
58
- async def before_invoke(ctx: commands.Context) -> None:
70
+ async def before_invoke(ctx: commands.Context[Any]) -> None:
59
71
  logger.debug(f"got command from {ctx.message.author}: {ctx.message.content}")
60
72
 
61
73
 
62
- @commands.cooldown(1, 10) # one time every 10 seconds
74
+ @commands.cooldown(1, 10) # type: ignore
63
75
  @bot.command(
64
76
  name="downloads", aliases=["queue", "q"], help="see the status of movie downloads"
65
77
  )
66
- async def downloads_cmd(ctx: commands.Context) -> None:
78
+ async def downloads_cmd(ctx: commands.Context[Any]) -> None:
67
79
  async with ctx.typing():
68
80
  queue = radarr.get_downloads() + sonarr.get_downloads()
69
81
 
@@ -76,9 +88,9 @@ async def downloads_cmd(ctx: commands.Context) -> None:
76
88
  await reply(ctx.message, "\n\n".join(map(str, queue)), title="download progress")
77
89
 
78
90
 
79
- @commands.cooldown(1, 60, commands.BucketType.user)
91
+ @commands.cooldown(1, 60, commands.BucketType.user) # type: ignore
80
92
  @bot.command(name="quota", help="see your used space on the plex")
81
- async def quota_cmd(ctx: commands.Context) -> None:
93
+ async def quota_cmd(ctx: commands.Context[Any]) -> None:
82
94
  async with ctx.typing():
83
95
  used = (
84
96
  radarr.get_quota_amount(ctx.message.author.id)
@@ -102,9 +114,9 @@ async def quota_cmd(ctx: commands.Context) -> None:
102
114
  await reply(ctx.message, msg)
103
115
 
104
116
 
105
- @commands.cooldown(1, 60)
117
+ @commands.cooldown(1, 60) # type: ignore
106
118
  @bot.command(name="quotas", help="see everyone's used space on the plex")
107
- async def quotas_cmd(ctx: commands.Context) -> None:
119
+ async def quotas_cmd(ctx: commands.Context[Any]) -> None:
108
120
  try:
109
121
  quotas = config["discord"]["quotas"]
110
122
  except ValueError:
@@ -135,17 +147,30 @@ async def quotas_cmd(ctx: commands.Context) -> None:
135
147
  )
136
148
 
137
149
 
138
- bot.add_cog(MovieCog(bot))
139
- bot.add_cog(SeriesCog(bot))
150
+ @bot.command(name="addtag", help="add a user tag") # type: ignore
151
+ @commands.has_role("plex-admin")
152
+ async def addtag_cmd(
153
+ ctx: commands.Context[Any], name: str, user: discord.Member
154
+ ) -> None:
155
+ tag = f"{name}: {user.id}"
140
156
 
157
+ radarr.create_tag(tag)
158
+ sonarr.create_tag(tag)
141
159
 
142
- def run() -> None:
160
+ await reply(ctx.message, f"tag `{tag}` added for {user.display_name}")
161
+
162
+
163
+ async def run() -> None:
143
164
  logger.debug("starting bot")
144
165
 
145
- bot.run(config["discord"]["bot_token"])
166
+ async with bot:
167
+ await bot.add_cog(MovieCog(bot))
168
+ await bot.add_cog(SeriesCog(bot))
169
+
170
+ await bot.start(config["discord"]["bot_token"])
146
171
 
147
172
 
148
173
  if __name__ == "__main__":
149
174
  logger.addHandler(logging.StreamHandler())
150
175
 
151
- bot.run(config["discord"]["bot_token"])
176
+ asyncio.run(run())