wi1-bot 1.4.9__tar.gz → 1.4.11__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.
- {wi1-bot-1.4.9 → wi1-bot-1.4.11}/PKG-INFO +6 -5
- {wi1-bot-1.4.9 → wi1-bot-1.4.11}/README.md +5 -0
- {wi1-bot-1.4.9 → wi1-bot-1.4.11}/pyproject.toml +5 -2
- {wi1-bot-1.4.9 → wi1-bot-1.4.11}/wi1_bot/_version.py +2 -2
- wi1-bot-1.4.11/wi1_bot/arr/__init__.py +5 -0
- {wi1-bot-1.4.9 → wi1-bot-1.4.11}/wi1_bot/arr/episode.py +1 -2
- {wi1-bot-1.4.9 → wi1-bot-1.4.11}/wi1_bot/arr/radarr.py +1 -3
- wi1-bot-1.4.11/wi1_bot/arr/util.py +31 -0
- {wi1-bot-1.4.9 → wi1-bot-1.4.11}/wi1_bot/discord/bot.py +15 -25
- {wi1-bot-1.4.9 → wi1-bot-1.4.11}/wi1_bot/discord/cogs/movie.py +10 -30
- {wi1-bot-1.4.9 → wi1-bot-1.4.11}/wi1_bot/discord/cogs/series.py +8 -24
- {wi1-bot-1.4.9 → wi1-bot-1.4.11}/wi1_bot/discord/helpers.py +1 -3
- {wi1-bot-1.4.9 → wi1-bot-1.4.11}/wi1_bot/push.py +1 -3
- {wi1-bot-1.4.9 → wi1-bot-1.4.11}/wi1_bot/scripts/rescan.py +1 -3
- {wi1-bot-1.4.9 → wi1-bot-1.4.11}/wi1_bot/scripts/start.py +1 -2
- {wi1-bot-1.4.9 → wi1-bot-1.4.11}/wi1_bot/transcoder/transcoder.py +21 -36
- {wi1-bot-1.4.9 → wi1-bot-1.4.11}/wi1_bot/webhook.py +10 -49
- {wi1-bot-1.4.9 → wi1-bot-1.4.11}/wi1_bot.egg-info/PKG-INFO +6 -5
- {wi1-bot-1.4.9 → wi1-bot-1.4.11}/wi1_bot.egg-info/SOURCES.txt +1 -0
- {wi1-bot-1.4.9 → wi1-bot-1.4.11}/wi1_bot.egg-info/requires.txt +1 -1
- wi1-bot-1.4.9/wi1_bot/arr/__init__.py +0 -4
- {wi1-bot-1.4.9 → wi1-bot-1.4.11}/.dockerignore +0 -0
- {wi1-bot-1.4.9 → wi1-bot-1.4.11}/.github/workflows/pypi-publish.yml +0 -0
- {wi1-bot-1.4.9 → wi1-bot-1.4.11}/.gitignore +0 -0
- {wi1-bot-1.4.9 → wi1-bot-1.4.11}/.pre-commit-config.yaml +0 -0
- {wi1-bot-1.4.9 → wi1-bot-1.4.11}/.vscode/launch.json +0 -0
- {wi1-bot-1.4.9 → wi1-bot-1.4.11}/.vscode/settings.json +0 -0
- {wi1-bot-1.4.9 → wi1-bot-1.4.11}/Dockerfile +0 -0
- {wi1-bot-1.4.9 → wi1-bot-1.4.11}/LICENSE +0 -0
- {wi1-bot-1.4.9 → wi1-bot-1.4.11}/compose.yaml +0 -0
- {wi1-bot-1.4.9 → wi1-bot-1.4.11}/config.yaml.template +0 -0
- {wi1-bot-1.4.9 → wi1-bot-1.4.11}/setup.cfg +0 -0
- {wi1-bot-1.4.9 → wi1-bot-1.4.11}/tests/movie_downloaded.py +0 -0
- {wi1-bot-1.4.9 → wi1-bot-1.4.11}/wi1_bot/__init__.py +0 -0
- {wi1-bot-1.4.9 → wi1-bot-1.4.11}/wi1_bot/arr/download.py +0 -0
- {wi1-bot-1.4.9 → wi1-bot-1.4.11}/wi1_bot/arr/movie.py +0 -0
- {wi1-bot-1.4.9 → wi1-bot-1.4.11}/wi1_bot/arr/sonarr.py +0 -0
- {wi1-bot-1.4.9 → wi1-bot-1.4.11}/wi1_bot/config.py +0 -0
- {wi1-bot-1.4.9 → wi1-bot-1.4.11}/wi1_bot/discord/__init__.py +0 -0
- {wi1-bot-1.4.9 → wi1-bot-1.4.11}/wi1_bot/discord/cogs/__init__.py +0 -0
- {wi1-bot-1.4.9 → wi1-bot-1.4.11}/wi1_bot/scripts/__init__.py +0 -0
- {wi1-bot-1.4.9 → wi1-bot-1.4.11}/wi1_bot/scripts/add_tag.py +0 -0
- {wi1-bot-1.4.9 → wi1-bot-1.4.11}/wi1_bot/scripts/transcode_item.py +0 -0
- {wi1-bot-1.4.9 → wi1-bot-1.4.11}/wi1_bot/transcoder/__init__.py +0 -0
- {wi1-bot-1.4.9 → wi1-bot-1.4.11}/wi1_bot/transcoder/transcode_queue.py +0 -0
- {wi1-bot-1.4.9 → wi1-bot-1.4.11}/wi1_bot.egg-info/dependency_links.txt +0 -0
- {wi1-bot-1.4.9 → wi1-bot-1.4.11}/wi1_bot.egg-info/entry_points.txt +0 -0
- {wi1-bot-1.4.9 → wi1-bot-1.4.11}/wi1_bot.egg-info/top_level.txt +0 -0
@@ -1,8 +1,7 @@
|
|
1
1
|
Metadata-Version: 2.1
|
2
2
|
Name: wi1-bot
|
3
|
-
Version: 1.4.
|
3
|
+
Version: 1.4.11
|
4
4
|
Summary: Discord bot for Radarr/Sonarr integration
|
5
|
-
Home-page: https://github.com/wthueb/wi1-bot
|
6
5
|
Author-email: William Huebner <wilhueb@gmail.com>
|
7
6
|
License: MIT License
|
8
7
|
|
@@ -27,7 +26,6 @@ License: MIT License
|
|
27
26
|
SOFTWARE.
|
28
27
|
|
29
28
|
Project-URL: Homepage, https://github.com/wthueb/wi1-bot
|
30
|
-
Platform: UNKNOWN
|
31
29
|
Classifier: Programming Language :: Python :: 3
|
32
30
|
Classifier: Programming Language :: Python :: 3 :: Only
|
33
31
|
Classifier: Programming Language :: Python :: 3.10
|
@@ -57,6 +55,11 @@ Requires Python >=3.10.
|
|
57
55
|
|
58
56
|
### TODO
|
59
57
|
|
58
|
+
- https://github.com/kkroening/ffmpeg-python
|
59
|
+
- `ffmpeg -codecs`, `ffmpeg -hwaccels`
|
60
|
+
- ffmpeg filters for deinterlacing, scaling
|
61
|
+
- https://docs.nvidia.com/video-technologies/video-codec-sdk/12.0/ffmpeg-with-nvidia-gpu/index.html#hwaccel-transcode-with-scaling
|
62
|
+
- ffmpeg remove bad subtitle streams
|
60
63
|
- use sqlite
|
61
64
|
- have config.discord.users be a dict with 'quotas' and 'name' for *arr tags
|
62
65
|
- Better pushover notifications
|
@@ -87,5 +90,3 @@ Requires Python >=3.10.
|
|
87
90
|
- Tautulli API (get_history) to show who has already seen the movie
|
88
91
|
- User leaderboard
|
89
92
|
- movies/shows added, Tautulli watch counts
|
90
|
-
|
91
|
-
|
@@ -19,6 +19,11 @@ Requires Python >=3.10.
|
|
19
19
|
|
20
20
|
### TODO
|
21
21
|
|
22
|
+
- https://github.com/kkroening/ffmpeg-python
|
23
|
+
- `ffmpeg -codecs`, `ffmpeg -hwaccels`
|
24
|
+
- ffmpeg filters for deinterlacing, scaling
|
25
|
+
- https://docs.nvidia.com/video-technologies/video-codec-sdk/12.0/ffmpeg-with-nvidia-gpu/index.html#hwaccel-transcode-with-scaling
|
26
|
+
- ffmpeg remove bad subtitle streams
|
22
27
|
- use sqlite
|
23
28
|
- have config.discord.users be a dict with 'quotas' and 'name' for *arr tags
|
24
29
|
- Better pushover notifications
|
@@ -14,7 +14,7 @@ requires-python = ">=3.10"
|
|
14
14
|
dependencies = [
|
15
15
|
"discord.py==2.3.2",
|
16
16
|
"Flask==3.0.2",
|
17
|
-
"mongoengine==0.
|
17
|
+
"mongoengine==0.29.1",
|
18
18
|
"pyarr==5.2.0",
|
19
19
|
"PyYAML==6.0.1",
|
20
20
|
"requests==2.31.0"
|
@@ -39,7 +39,7 @@ dev = [
|
|
39
39
|
]
|
40
40
|
|
41
41
|
[build-system]
|
42
|
-
requires = ["setuptools==
|
42
|
+
requires = ["setuptools==64.*", "setuptools-scm"]
|
43
43
|
build-backend = "setuptools.build_meta"
|
44
44
|
|
45
45
|
[tool.setuptools]
|
@@ -48,6 +48,9 @@ packages = ["wi1_bot"]
|
|
48
48
|
[tool.setuptools_scm]
|
49
49
|
write_to = "wi1_bot/_version.py"
|
50
50
|
|
51
|
+
[tool.ruff]
|
52
|
+
line-length = 100
|
53
|
+
|
51
54
|
[tool.ruff.lint]
|
52
55
|
select = ["E", "F", "I"]
|
53
56
|
|
@@ -21,8 +21,7 @@ class Episode:
|
|
21
21
|
self.imdb_id: str = series_imdb_id
|
22
22
|
|
23
23
|
self.full_title: str = (
|
24
|
-
f"{self.series_title} S{self.season_num:02d}E{self.ep_num:02d} -"
|
25
|
-
f" {self.ep_title}"
|
24
|
+
f"{self.series_title} S{self.season_num:02d}E{self.ep_num:02d} - {self.ep_title}"
|
26
25
|
)
|
27
26
|
|
28
27
|
if self.imdb_id:
|
@@ -33,9 +33,7 @@ class Radarr:
|
|
33
33
|
|
34
34
|
user_movie_ids = tag_detail["movieIds"]
|
35
35
|
|
36
|
-
return [
|
37
|
-
Movie(m) for m in possible_movies if "id" in m and m["id"] in user_movie_ids
|
38
|
-
]
|
36
|
+
return [Movie(m) for m in possible_movies if "id" in m and m["id"] in user_movie_ids]
|
39
37
|
|
40
38
|
def add_movie(self, movie: Movie, profile: str = "good") -> bool:
|
41
39
|
if self._radarr.get_movie(movie.tmdb_id, tmdb=True):
|
@@ -0,0 +1,31 @@
|
|
1
|
+
import logging
|
2
|
+
import pathlib
|
3
|
+
|
4
|
+
from wi1_bot.config import RemotePathMapping, config
|
5
|
+
|
6
|
+
|
7
|
+
def replace_remote_paths(path: pathlib.Path) -> pathlib.Path:
|
8
|
+
if "general" not in config or "remote_path_mappings" not in config["general"]:
|
9
|
+
return path
|
10
|
+
|
11
|
+
mappings = config["general"]["remote_path_mappings"]
|
12
|
+
|
13
|
+
most_specific: RemotePathMapping | None = None
|
14
|
+
|
15
|
+
for mapping in mappings:
|
16
|
+
if path.is_relative_to(mapping["remote"]):
|
17
|
+
mapping_len = len(pathlib.Path(mapping["remote"]).parts)
|
18
|
+
most_specific_len = (
|
19
|
+
len(pathlib.Path(most_specific["remote"]).parts) if most_specific is not None else 0
|
20
|
+
)
|
21
|
+
|
22
|
+
if mapping_len > most_specific_len:
|
23
|
+
most_specific = mapping
|
24
|
+
|
25
|
+
if most_specific is not None:
|
26
|
+
remote_path = path
|
27
|
+
path = pathlib.Path(most_specific["local"]) / path.relative_to(most_specific["remote"])
|
28
|
+
|
29
|
+
logging.getLogger(__name__).debug(f"replaced remote path mapping: {remote_path} -> {path}")
|
30
|
+
|
31
|
+
return path
|
@@ -26,9 +26,7 @@ async def check_channel(ctx: commands.Context[Any]) -> bool:
|
|
26
26
|
|
27
27
|
|
28
28
|
@bot.event
|
29
|
-
async def on_command_error(
|
30
|
-
ctx: commands.Context[Any], error: commands.CommandError
|
31
|
-
) -> None:
|
29
|
+
async def on_command_error(ctx: commands.Context[Any], error: commands.CommandError) -> None:
|
32
30
|
match error:
|
33
31
|
case commands.CommandNotFound() | commands.CheckFailure():
|
34
32
|
pass
|
@@ -42,9 +40,7 @@ async def on_command_error(
|
|
42
40
|
await reply(ctx.message, str(error))
|
43
41
|
case _:
|
44
42
|
logger.error(
|
45
|
-
"".join(
|
46
|
-
traceback.format_exception(type(error), error, error.__traceback__)
|
47
|
-
)
|
43
|
+
"".join(traceback.format_exception(type(error), error, error.__traceback__))
|
48
44
|
)
|
49
45
|
|
50
46
|
await reply(
|
@@ -71,10 +67,10 @@ async def before_invoke(ctx: commands.Context[Any]) -> None:
|
|
71
67
|
logger.info(f"got command from {ctx.message.author}: {ctx.message.content}")
|
72
68
|
|
73
69
|
|
74
|
-
@
|
75
|
-
@bot.command(
|
70
|
+
@bot.command( # type: ignore[arg-type]
|
76
71
|
name="downloads", aliases=["queue", "q"], help="see the status of movie downloads"
|
77
72
|
)
|
73
|
+
@commands.cooldown(1, 10)
|
78
74
|
async def downloads_cmd(ctx: commands.Context[Any]) -> None:
|
79
75
|
async with ctx.typing():
|
80
76
|
queue = radarr.get_downloads() + sonarr.get_downloads()
|
@@ -88,8 +84,8 @@ async def downloads_cmd(ctx: commands.Context[Any]) -> None:
|
|
88
84
|
await reply(ctx.message, "\n\n".join(map(str, queue)), title="download progress")
|
89
85
|
|
90
86
|
|
91
|
-
@
|
92
|
-
@
|
87
|
+
@bot.command(name="quota", help="see your used space on the plex") # type: ignore[arg-type]
|
88
|
+
@commands.cooldown(1, 60, commands.BucketType.user)
|
93
89
|
async def quota_cmd(ctx: commands.Context[Any]) -> None:
|
94
90
|
async with ctx.typing():
|
95
91
|
used = (
|
@@ -99,10 +95,8 @@ async def quota_cmd(ctx: commands.Context[Any]) -> None:
|
|
99
95
|
|
100
96
|
maximum: float = 0
|
101
97
|
|
102
|
-
|
98
|
+
if "quotas" in config["discord"]:
|
103
99
|
maximum = config["discord"]["quotas"][ctx.message.author.id]
|
104
|
-
except KeyError:
|
105
|
-
pass
|
106
100
|
|
107
101
|
pct = used / maximum * 100 if maximum != 0 else 100
|
108
102
|
|
@@ -114,15 +108,15 @@ async def quota_cmd(ctx: commands.Context[Any]) -> None:
|
|
114
108
|
await reply(ctx.message, msg)
|
115
109
|
|
116
110
|
|
117
|
-
@
|
118
|
-
@
|
111
|
+
@bot.command(name="quotas", help="see everyone's used space on the plex") # type: ignore[arg-type]
|
112
|
+
@commands.cooldown(1, 60)
|
119
113
|
async def quotas_cmd(ctx: commands.Context[Any]) -> None:
|
120
|
-
|
121
|
-
quotas = config["discord"]["quotas"]
|
122
|
-
except ValueError:
|
114
|
+
if "quotas" not in config["discord"]:
|
123
115
|
await reply(ctx.message, "quotas are not implemented here")
|
124
116
|
return
|
125
117
|
|
118
|
+
quotas = config["discord"]["quotas"]
|
119
|
+
|
126
120
|
if not quotas:
|
127
121
|
await reply(ctx.message, "quotas are not implemented here")
|
128
122
|
|
@@ -130,9 +124,7 @@ async def quotas_cmd(ctx: commands.Context[Any]) -> None:
|
|
130
124
|
msg = []
|
131
125
|
|
132
126
|
for user_id, total in quotas.items():
|
133
|
-
used = (
|
134
|
-
radarr.get_quota_amount(user_id) + sonarr.get_quota_amount(user_id)
|
135
|
-
) / 1024**3
|
127
|
+
used = (radarr.get_quota_amount(user_id) + sonarr.get_quota_amount(user_id)) / 1024**3
|
136
128
|
|
137
129
|
pct = used / total * 100 if total != 0 else 100
|
138
130
|
|
@@ -147,11 +139,9 @@ async def quotas_cmd(ctx: commands.Context[Any]) -> None:
|
|
147
139
|
)
|
148
140
|
|
149
141
|
|
150
|
-
@bot.command(name="addtag", help="add a user tag") # type: ignore
|
142
|
+
@bot.command(name="addtag", help="add a user tag") # type: ignore[arg-type]
|
151
143
|
@commands.has_role("plex-admin")
|
152
|
-
async def addtag_cmd(
|
153
|
-
ctx: commands.Context[Any], name: str, user: discord.Member
|
154
|
-
) -> None:
|
144
|
+
async def addtag_cmd(ctx: commands.Context[Any], name: str, user: discord.Member) -> None:
|
155
145
|
tag = f"{name}: {user.id}"
|
156
146
|
|
157
147
|
radarr.create_tag(tag)
|
@@ -18,9 +18,7 @@ class MovieCog(commands.Cog):
|
|
18
18
|
self.radarr = Radarr(config["radarr"]["url"], config["radarr"]["api_key"])
|
19
19
|
|
20
20
|
@commands.command(name="addmovie", help="add a movie to the plex")
|
21
|
-
async def addmovie_cmd(
|
22
|
-
self, ctx: commands.Context[Any], *, query: str = ""
|
23
|
-
) -> None:
|
21
|
+
async def addmovie_cmd(self, ctx: commands.Context[Any], *, query: str = "") -> None:
|
24
22
|
if not query:
|
25
23
|
await reply(ctx.message, "usage: !addmovie KEYWORDS...")
|
26
24
|
return
|
@@ -36,9 +34,7 @@ class MovieCog(commands.Cog):
|
|
36
34
|
)
|
37
35
|
return
|
38
36
|
|
39
|
-
resp, to_add = await select_from_list(
|
40
|
-
self.bot, ctx.message, "addmovie", potential
|
41
|
-
)
|
37
|
+
resp, to_add = await select_from_list(self.bot, ctx.message, "addmovie", potential)
|
42
38
|
|
43
39
|
if not to_add:
|
44
40
|
return
|
@@ -48,16 +44,12 @@ class MovieCog(commands.Cog):
|
|
48
44
|
for movie in to_add:
|
49
45
|
if not self.radarr.add_movie(movie):
|
50
46
|
if self.radarr.movie_downloaded(movie):
|
51
|
-
await reply(
|
52
|
-
resp, f"{movie} is already DOWNLOADED on the plex (idiot)"
|
53
|
-
)
|
47
|
+
await reply(resp, f"{movie} is already DOWNLOADED on the plex (idiot)")
|
54
48
|
else:
|
55
49
|
await reply(resp, f"{movie} is already on the plex (idiot)")
|
56
50
|
continue
|
57
51
|
|
58
|
-
self.logger.info(
|
59
|
-
f"{ctx.message.author.name} has added the movie {movie.full_title}"
|
60
|
-
)
|
52
|
+
self.logger.info(f"{ctx.message.author.name} has added the movie {movie.full_title}")
|
61
53
|
|
62
54
|
push.send(
|
63
55
|
f"{ctx.message.author.name} has added the movie {movie.full_title}",
|
@@ -74,18 +66,12 @@ class MovieCog(commands.Cog):
|
|
74
66
|
await asyncio.sleep(10)
|
75
67
|
|
76
68
|
if not self.radarr.add_tag(added, ctx.message.author.id):
|
77
|
-
push.send(
|
78
|
-
f"get {ctx.message.author.name} a tag", title="tag needed", priority=1
|
79
|
-
)
|
69
|
+
push.send(f"get {ctx.message.author.name} a tag", title="tag needed", priority=1)
|
80
70
|
|
81
|
-
await ctx.send(
|
82
|
-
f"hey <@!{config['discord']['admin_id']}> get this guy a tag"
|
83
|
-
)
|
71
|
+
await ctx.send(f"hey <@!{config['discord']['admin_id']}> get this guy a tag")
|
84
72
|
|
85
73
|
@commands.command(name="delmovie", help="delete a movie from the plex")
|
86
|
-
async def delmovie_cmd(
|
87
|
-
self, ctx: commands.Context[Any], *, query: str = ""
|
88
|
-
) -> None:
|
74
|
+
async def delmovie_cmd(self, ctx: commands.Context[Any], *, query: str = "") -> None:
|
89
75
|
if not query:
|
90
76
|
await reply(ctx.message, "usage: !delmovie KEYWORDS...")
|
91
77
|
return
|
@@ -102,9 +88,7 @@ class MovieCog(commands.Cog):
|
|
102
88
|
)
|
103
89
|
return
|
104
90
|
else:
|
105
|
-
potential = self.radarr.lookup_user_library(
|
106
|
-
query, ctx.message.author.id
|
107
|
-
)[:50]
|
91
|
+
potential = self.radarr.lookup_user_library(query, ctx.message.author.id)[:50]
|
108
92
|
|
109
93
|
if not potential:
|
110
94
|
await reply(
|
@@ -114,9 +98,7 @@ class MovieCog(commands.Cog):
|
|
114
98
|
)
|
115
99
|
return
|
116
100
|
|
117
|
-
resp, to_delete = await select_from_list(
|
118
|
-
self.bot, ctx.message, "delmovie", potential
|
119
|
-
)
|
101
|
+
resp, to_delete = await select_from_list(self.bot, ctx.message, "delmovie", potential)
|
120
102
|
|
121
103
|
if not to_delete:
|
122
104
|
return
|
@@ -124,9 +106,7 @@ class MovieCog(commands.Cog):
|
|
124
106
|
for movie in to_delete:
|
125
107
|
self.radarr.del_movie(movie)
|
126
108
|
|
127
|
-
self.logger.info(
|
128
|
-
f"{ctx.message.author.name} has deleted the movie {movie.full_title}"
|
129
|
-
)
|
109
|
+
self.logger.info(f"{ctx.message.author.name} has deleted the movie {movie.full_title}")
|
130
110
|
|
131
111
|
push.send(
|
132
112
|
f"{ctx.message.author.name} has deleted the movie {movie.full_title}",
|
@@ -46,9 +46,7 @@ class SeriesCog(commands.Cog):
|
|
46
46
|
)
|
47
47
|
return
|
48
48
|
|
49
|
-
resp, to_add = await select_from_list(
|
50
|
-
self.bot, ctx.message, "addshow", potential
|
51
|
-
)
|
49
|
+
resp, to_add = await select_from_list(self.bot, ctx.message, "addshow", potential)
|
52
50
|
|
53
51
|
if not to_add:
|
54
52
|
return
|
@@ -58,16 +56,12 @@ class SeriesCog(commands.Cog):
|
|
58
56
|
for series in to_add:
|
59
57
|
if not self.sonarr.add_series(series):
|
60
58
|
if self.sonarr.series_downloaded(series):
|
61
|
-
await reply(
|
62
|
-
resp, f"{series} is already DOWNLOADED on the plex (idiot)"
|
63
|
-
)
|
59
|
+
await reply(resp, f"{series} is already DOWNLOADED on the plex (idiot)")
|
64
60
|
else:
|
65
61
|
await reply(resp, f"{series} is already on the plex (idiot)")
|
66
62
|
continue
|
67
63
|
|
68
|
-
self.logger.info(
|
69
|
-
f"{ctx.message.author.name} has added the show {series.full_title}"
|
70
|
-
)
|
64
|
+
self.logger.info(f"{ctx.message.author.name} has added the show {series.full_title}")
|
71
65
|
|
72
66
|
push.send(
|
73
67
|
f"{ctx.message.author.name} has added the show {series.full_title}",
|
@@ -91,17 +85,13 @@ class SeriesCog(commands.Cog):
|
|
91
85
|
priority=1,
|
92
86
|
)
|
93
87
|
|
94
|
-
await ctx.send(
|
95
|
-
f"hey <@!{config['discord']['admin_id']}> get this guy a tag"
|
96
|
-
)
|
88
|
+
await ctx.send(f"hey <@!{config['discord']['admin_id']}> get this guy a tag")
|
97
89
|
|
98
90
|
return
|
99
91
|
|
100
92
|
@commands.command(name="delshow", help="delete a show from the plex")
|
101
93
|
@commands.has_any_role("plex-admin", "plex-shows")
|
102
|
-
async def delshow_command(
|
103
|
-
self, ctx: commands.Context[Any], *, query: str = ""
|
104
|
-
) -> None:
|
94
|
+
async def delshow_command(self, ctx: commands.Context[Any], *, query: str = "") -> None:
|
105
95
|
if not query:
|
106
96
|
await reply(ctx.message, "usage: !delshow KEYWORDS...")
|
107
97
|
return
|
@@ -118,9 +108,7 @@ class SeriesCog(commands.Cog):
|
|
118
108
|
)
|
119
109
|
return
|
120
110
|
else:
|
121
|
-
potential = self.sonarr.lookup_user_library(
|
122
|
-
query, ctx.message.author.id
|
123
|
-
)[:50]
|
111
|
+
potential = self.sonarr.lookup_user_library(query, ctx.message.author.id)[:50]
|
124
112
|
|
125
113
|
if not potential:
|
126
114
|
await reply(
|
@@ -130,9 +118,7 @@ class SeriesCog(commands.Cog):
|
|
130
118
|
)
|
131
119
|
return
|
132
120
|
|
133
|
-
resp, to_delete = await select_from_list(
|
134
|
-
self.bot, ctx.message, "delshow", potential
|
135
|
-
)
|
121
|
+
resp, to_delete = await select_from_list(self.bot, ctx.message, "delshow", potential)
|
136
122
|
|
137
123
|
if not to_delete:
|
138
124
|
return
|
@@ -140,9 +126,7 @@ class SeriesCog(commands.Cog):
|
|
140
126
|
for series in to_delete:
|
141
127
|
self.sonarr.del_series(series)
|
142
128
|
|
143
|
-
self.logger.info(
|
144
|
-
f"{ctx.message.author.name} has deleted the show {series.full_title}"
|
145
|
-
)
|
129
|
+
self.logger.info(f"{ctx.message.author.name} has deleted the show {series.full_title}")
|
146
130
|
|
147
131
|
push.send(
|
148
132
|
f"{ctx.message.author.name} has deleted the show {series.full_title}",
|
@@ -13,9 +13,7 @@ async def member_has_role(member: discord.Member | discord.User, role: str) -> b
|
|
13
13
|
return False
|
14
14
|
|
15
15
|
|
16
|
-
async def reply(
|
17
|
-
msg: discord.Message, content: str, title: str = "", error: bool = False
|
18
|
-
) -> None:
|
16
|
+
async def reply(msg: discord.Message, content: str, title: str = "", error: bool = False) -> None:
|
19
17
|
if len(content) > 2048:
|
20
18
|
while len(content) > 2048 - len("\n..."):
|
21
19
|
content = content[: content.rfind("\n")].rstrip()
|
@@ -3,9 +3,7 @@ import requests
|
|
3
3
|
from wi1_bot.config import config
|
4
4
|
|
5
5
|
|
6
|
-
def send(
|
7
|
-
msg: str, title: str | None = None, url: str | None = None, priority: int = 0
|
8
|
-
) -> None:
|
6
|
+
def send(msg: str, title: str | None = None, url: str | None = None, priority: int = 0) -> None:
|
9
7
|
if "pushover" not in config:
|
10
8
|
return
|
11
9
|
|
@@ -38,9 +38,7 @@ def rescan_sonarr() -> None:
|
|
38
38
|
def main() -> None:
|
39
39
|
parser = argparse.ArgumentParser(description="Rescan all movies/shows")
|
40
40
|
|
41
|
-
parser.add_argument(
|
42
|
-
"service", nargs="?", choices=["radarr", "sonarr"], help="radarr or sonarr"
|
43
|
-
)
|
41
|
+
parser.add_argument("service", nargs="?", choices=["radarr", "sonarr"], help="radarr or sonarr")
|
44
42
|
|
45
43
|
args = parser.parse_args()
|
46
44
|
|
@@ -25,8 +25,7 @@ def main() -> None:
|
|
25
25
|
"detailed": {
|
26
26
|
"class": "logging.Formatter",
|
27
27
|
"format": (
|
28
|
-
"[%(asctime)s] %(levelname)s [%(name)s.%(funcName)s:%(lineno)d]"
|
29
|
-
" %(message)s"
|
28
|
+
"[%(asctime)s] %(levelname)s [%(name)s.%(funcName)s:%(lineno)d] %(message)s"
|
30
29
|
),
|
31
30
|
},
|
32
31
|
},
|
@@ -9,7 +9,7 @@ from time import sleep
|
|
9
9
|
from typing import Any
|
10
10
|
|
11
11
|
from wi1_bot import push
|
12
|
-
from wi1_bot.arr import Radarr, Sonarr
|
12
|
+
from wi1_bot.arr import Radarr, Sonarr, replace_remote_paths
|
13
13
|
from wi1_bot.config import config
|
14
14
|
|
15
15
|
from .transcode_queue import TranscodeItem, queue
|
@@ -51,9 +51,7 @@ class Transcoder:
|
|
51
51
|
if remove:
|
52
52
|
queue.remove(item)
|
53
53
|
except Exception:
|
54
|
-
self.logger.warning(
|
55
|
-
"got exception when trying to transcode", exc_info=True
|
56
|
-
)
|
54
|
+
self.logger.warning("got exception when trying to transcode", exc_info=True)
|
57
55
|
|
58
56
|
sleep(3)
|
59
57
|
|
@@ -96,6 +94,7 @@ class Transcoder:
|
|
96
94
|
tmp_log_path = tmp_folder / "wi1_bot.transcoder.log"
|
97
95
|
|
98
96
|
with open(tmp_log_path, "w") as ffmpeg_log_file:
|
97
|
+
ffmpeg_log_file.write(f"ffmpeg command: {shlex.join(command)}\n")
|
99
98
|
assert proc.stdout is not None
|
100
99
|
for line in proc.stdout:
|
101
100
|
ffmpeg_log_file.write(line)
|
@@ -107,35 +106,25 @@ class Transcoder:
|
|
107
106
|
try:
|
108
107
|
transcode_to.unlink(missing_ok=True)
|
109
108
|
except Exception:
|
110
|
-
self.logger.debug(
|
111
|
-
f"failed to delete transcoded file: {transcode_to}"
|
112
|
-
)
|
109
|
+
self.logger.debug(f"failed to delete transcoded file: {transcode_to}")
|
113
110
|
|
114
111
|
if (
|
115
112
|
"Error opening input files" in last_output
|
116
113
|
or "No such file or directory" in last_output
|
117
114
|
):
|
118
|
-
self.logger.info(
|
119
|
-
f"file does not exist: {path}, skipping transcoding"
|
120
|
-
)
|
115
|
+
self.logger.info(f"file does not exist: {path}, skipping transcoding")
|
121
116
|
return True
|
122
117
|
|
123
118
|
if "File name too long" in last_output:
|
124
|
-
self.logger.info(
|
125
|
-
f"file name is too long: {path}, skipping transcoding"
|
126
|
-
)
|
119
|
+
self.logger.info(f"file name is too long: {path}, skipping transcoding")
|
127
120
|
return True
|
128
121
|
|
129
122
|
if "received signal 15" in last_output:
|
130
|
-
self.logger.info(
|
131
|
-
f"transcoding interrupted by signal: {path}, will retry"
|
132
|
-
)
|
123
|
+
self.logger.info(f"transcoding interrupted by signal: {path}, will retry")
|
133
124
|
return False
|
134
125
|
|
135
126
|
if "cannot open shared object file" in last_output:
|
136
|
-
self.logger.error(
|
137
|
-
"ffmpeg error: missing shared object file, will retry"
|
138
|
-
)
|
127
|
+
self.logger.error("ffmpeg error: missing shared object file, will retry")
|
139
128
|
push.send(f"ffmpeg error: {last_output}", title="ffmpeg error")
|
140
129
|
return False
|
141
130
|
|
@@ -160,9 +149,7 @@ class Transcoder:
|
|
160
149
|
return True
|
161
150
|
|
162
151
|
if not path.exists():
|
163
|
-
self.logger.debug(
|
164
|
-
f"file doesn't exist: {item.path}, deleting transcoded file"
|
165
|
-
)
|
152
|
+
self.logger.debug(f"file doesn't exist: {item.path}, deleting transcoded file")
|
166
153
|
|
167
154
|
transcode_to.unlink(missing_ok=True)
|
168
155
|
return True
|
@@ -171,24 +158,26 @@ class Transcoder:
|
|
171
158
|
shutil.move(transcode_to, new_path)
|
172
159
|
path.unlink()
|
173
160
|
|
174
|
-
self._rescan_content(item,
|
161
|
+
self._rescan_content(item, new_path)
|
175
162
|
|
176
163
|
self.logger.info(f"transcoded: {path.name} -> {new_path.name}")
|
177
164
|
# push.send(f"{path.name} -> {new_path.name}", title="file transcoded")
|
178
165
|
|
179
166
|
return True
|
180
167
|
|
181
|
-
def _rescan_content(self, item: TranscodeItem, new_path:
|
168
|
+
def _rescan_content(self, item: TranscodeItem, new_path: pathlib.Path) -> None:
|
182
169
|
if item.content_id is not None:
|
183
|
-
if new_path.
|
170
|
+
if new_path.is_relative_to(
|
171
|
+
replace_remote_paths(pathlib.Path(config["radarr"]["root_folder"]))
|
172
|
+
):
|
173
|
+
# have to rescan the movie twice: Radarr/Radarr#7668
|
174
|
+
# TODO: create function that waits for comand to finish
|
184
175
|
self.radarr.rescan_movie(item.content_id)
|
185
|
-
# radarr bug that it doesn't see the deleted file and the new file
|
186
|
-
# in one rescan?
|
187
|
-
# have to sleep in between to ensure initial command finishes
|
188
|
-
# or use pyarr.get_command() to see command status
|
189
176
|
sleep(5)
|
190
177
|
self.radarr.rescan_movie(item.content_id)
|
191
|
-
elif new_path.
|
178
|
+
elif new_path.is_relative_to(
|
179
|
+
replace_remote_paths(pathlib.Path(config["sonarr"]["root_folder"]))
|
180
|
+
):
|
192
181
|
self.sonarr.rescan_series(item.content_id)
|
193
182
|
|
194
183
|
def _get_duration(self, path: str) -> timedelta:
|
@@ -211,20 +200,16 @@ class Transcoder:
|
|
211
200
|
|
212
201
|
return duration
|
213
202
|
|
214
|
-
def _build_ffmpeg_command(
|
215
|
-
self, item: TranscodeItem, transcode_to: pathlib.Path
|
216
|
-
) -> list[str]:
|
203
|
+
def _build_ffmpeg_command(self, item: TranscodeItem, transcode_to: pathlib.Path) -> list[str]:
|
217
204
|
command: list[Any] = [
|
218
205
|
"ffmpeg",
|
219
206
|
"-hide_banner",
|
220
207
|
"-y",
|
221
208
|
]
|
222
209
|
|
223
|
-
|
210
|
+
if "transcoding" in config and "hwaccel" in config["transcoding"]:
|
224
211
|
command.extend(["-hwaccel", config["transcoding"]["hwaccel"]])
|
225
212
|
command.extend(["-hwaccel_output_format", config["transcoding"]["hwaccel"]])
|
226
|
-
except KeyError:
|
227
|
-
pass
|
228
213
|
|
229
214
|
command.extend(["-probesize", "100M"])
|
230
215
|
command.extend(["-analyzeduration", "250M"])
|
@@ -7,8 +7,8 @@ from typing import Any
|
|
7
7
|
from flask import Flask, request
|
8
8
|
|
9
9
|
from wi1_bot import push, transcoder
|
10
|
-
from wi1_bot.arr import Radarr, Sonarr
|
11
|
-
from wi1_bot.config import
|
10
|
+
from wi1_bot.arr import Radarr, Sonarr, replace_remote_paths
|
11
|
+
from wi1_bot.config import config
|
12
12
|
|
13
13
|
app = Flask(__name__)
|
14
14
|
|
@@ -21,40 +21,7 @@ sonarr = Sonarr(config["sonarr"]["url"], config["sonarr"]["api_key"])
|
|
21
21
|
|
22
22
|
|
23
23
|
def on_grab(req: dict[str, Any]) -> None:
|
24
|
-
push.send(
|
25
|
-
req["release"]["releaseTitle"], title=f"file grabbed ({req['downloadClient']})"
|
26
|
-
)
|
27
|
-
|
28
|
-
|
29
|
-
def replace_remote_paths(path: pathlib.Path) -> pathlib.Path:
|
30
|
-
if "general" not in config or "remote_path_mappings" not in config["general"]:
|
31
|
-
return path
|
32
|
-
|
33
|
-
mappings = config["general"]["remote_path_mappings"]
|
34
|
-
|
35
|
-
most_specific: RemotePathMapping | None = None
|
36
|
-
|
37
|
-
for mapping in mappings:
|
38
|
-
if path.is_relative_to(mapping["remote"]):
|
39
|
-
mapping_len = len(pathlib.Path(mapping["remote"]).parts)
|
40
|
-
most_specific_len = (
|
41
|
-
len(pathlib.Path(most_specific["remote"]).parts)
|
42
|
-
if most_specific is not None
|
43
|
-
else 0
|
44
|
-
)
|
45
|
-
|
46
|
-
if mapping_len > most_specific_len:
|
47
|
-
most_specific = mapping
|
48
|
-
|
49
|
-
if most_specific is not None:
|
50
|
-
remote_path = path
|
51
|
-
path = pathlib.Path(most_specific["local"]) / path.relative_to(
|
52
|
-
most_specific["remote"]
|
53
|
-
)
|
54
|
-
|
55
|
-
logger.debug(f"replaced remote path mapping: {remote_path} -> {path}")
|
56
|
-
|
57
|
-
return path
|
24
|
+
push.send(req["release"]["releaseTitle"], title=f"file grabbed ({req['downloadClient']})")
|
58
25
|
|
59
26
|
|
60
27
|
def on_download(req: dict[str, Any]) -> None:
|
@@ -66,9 +33,7 @@ def on_download(req: dict[str, Any]) -> None:
|
|
66
33
|
movie_json = radarr._radarr.get_movie(content_id)
|
67
34
|
assert isinstance(movie_json, dict)
|
68
35
|
|
69
|
-
quality_profile = radarr.get_quality_profile_name(
|
70
|
-
movie_json["qualityProfileId"]
|
71
|
-
)
|
36
|
+
quality_profile = radarr.get_quality_profile_name(movie_json["qualityProfileId"])
|
72
37
|
|
73
38
|
movie_folder = req["movie"]["folderPath"]
|
74
39
|
relative_path = req["movieFile"]["relativePath"]
|
@@ -82,9 +47,7 @@ def on_download(req: dict[str, Any]) -> None:
|
|
82
47
|
series_json = sonarr._sonarr.get_series(content_id)
|
83
48
|
assert isinstance(series_json, dict)
|
84
49
|
|
85
|
-
quality_profile = sonarr.get_quality_profile_name(
|
86
|
-
series_json["qualityProfileId"]
|
87
|
-
)
|
50
|
+
quality_profile = sonarr.get_quality_profile_name(series_json["qualityProfileId"])
|
88
51
|
|
89
52
|
series_folder = req["series"]["path"]
|
90
53
|
relative_path = req["episodeFile"]["relativePath"]
|
@@ -96,12 +59,12 @@ def on_download(req: dict[str, Any]) -> None:
|
|
96
59
|
else:
|
97
60
|
raise ValueError("unknown download request")
|
98
61
|
|
62
|
+
if "transcoding" not in config:
|
63
|
+
return
|
64
|
+
|
99
65
|
path = replace_remote_paths(path)
|
100
66
|
|
101
|
-
|
102
|
-
quality_options = config["transcoding"]["profiles"][quality_profile]
|
103
|
-
except KeyError:
|
104
|
-
return
|
67
|
+
quality_options = config["transcoding"]["profiles"][quality_profile]
|
105
68
|
|
106
69
|
# python 3.11: TranscodingProfile and typing.LiteralString
|
107
70
|
def get_key(d: Any, k: str) -> Any:
|
@@ -142,9 +105,7 @@ def index() -> Any:
|
|
142
105
|
if request.json["eventType"] == "Download":
|
143
106
|
on_download(request.json)
|
144
107
|
except Exception:
|
145
|
-
logger.warning(
|
146
|
-
f"error handling request: {request.data.decode()}", exc_info=True
|
147
|
-
)
|
108
|
+
logger.warning(f"error handling request: {request.data.decode()}", exc_info=True)
|
148
109
|
|
149
110
|
return "", 200
|
150
111
|
|
@@ -1,8 +1,7 @@
|
|
1
1
|
Metadata-Version: 2.1
|
2
2
|
Name: wi1-bot
|
3
|
-
Version: 1.4.
|
3
|
+
Version: 1.4.11
|
4
4
|
Summary: Discord bot for Radarr/Sonarr integration
|
5
|
-
Home-page: https://github.com/wthueb/wi1-bot
|
6
5
|
Author-email: William Huebner <wilhueb@gmail.com>
|
7
6
|
License: MIT License
|
8
7
|
|
@@ -27,7 +26,6 @@ License: MIT License
|
|
27
26
|
SOFTWARE.
|
28
27
|
|
29
28
|
Project-URL: Homepage, https://github.com/wthueb/wi1-bot
|
30
|
-
Platform: UNKNOWN
|
31
29
|
Classifier: Programming Language :: Python :: 3
|
32
30
|
Classifier: Programming Language :: Python :: 3 :: Only
|
33
31
|
Classifier: Programming Language :: Python :: 3.10
|
@@ -57,6 +55,11 @@ Requires Python >=3.10.
|
|
57
55
|
|
58
56
|
### TODO
|
59
57
|
|
58
|
+
- https://github.com/kkroening/ffmpeg-python
|
59
|
+
- `ffmpeg -codecs`, `ffmpeg -hwaccels`
|
60
|
+
- ffmpeg filters for deinterlacing, scaling
|
61
|
+
- https://docs.nvidia.com/video-technologies/video-codec-sdk/12.0/ffmpeg-with-nvidia-gpu/index.html#hwaccel-transcode-with-scaling
|
62
|
+
- ffmpeg remove bad subtitle streams
|
60
63
|
- use sqlite
|
61
64
|
- have config.discord.users be a dict with 'quotas' and 'name' for *arr tags
|
62
65
|
- Better pushover notifications
|
@@ -87,5 +90,3 @@ Requires Python >=3.10.
|
|
87
90
|
- Tautulli API (get_history) to show who has already seen the movie
|
88
91
|
- User leaderboard
|
89
92
|
- movies/shows added, Tautulli watch counts
|
90
|
-
|
91
|
-
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|