├── .gitignore
├── README.md
├── bark.py
├── doc
├── Emby_Notifier.drawio
├── Emby_Notifier.drawio.png
├── bark.jpg
├── template-emby-episode.json
├── template-emby-movie.json
├── template-jelly-movie.json
├── template-jellyfin-episode.json
├── wechat_emby.jpg
├── wechat_jelly.jpg
├── 启用通知.png
├── 接受测试消息.png
├── 添加webhooks.png
├── 添加通知.png
├── 设置webhook.png
├── 选择generic_destination.png
├── 选择事件.png
├── 通知设置.png
├── 配置.png
└── 配置notifier.png
├── docker-compose.yml
├── dockerfile
├── dockerfile-aarch64
├── log.py
├── main.py
├── media.py
├── my_httpd.py
├── my_utils.py
├── sender.py
├── tgbot.py
├── tmdb_api.py
├── tvdb_api.py
└── wxapp.py
/.gitignore:
--------------------------------------------------------------------------------
1 | *.env
2 | test*.json
3 | *.log
4 | __pycache__/
5 | *.pyc
6 | *.pyo
7 | *.pyd
8 | **/test*
9 | **/_tmp*
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Emby Notifier
2 |
3 | > 这是另一个项目 [watchdog_for_Emby](https://github.com/Ccccx159/watchdog_for_Emby/tree/main) 的最新优化版本,取消了 nfo 文件的监视依赖,该版本不再需要手动设置媒体库路径,对通过网盘挂载生成的媒体库更加友好~
4 |
5 | ## 重大更新!!!
6 |
7 | v4.0.0 版本现已支持 [bark](https://bark.day.app/#/) 进行推送
8 |
9 | v3.0.0 版本现已支持 **企业微信** 进行推送,媒体信息通过图文卡片进行推送,仅支持 **企业微信** app 接收【注: 微信中的企业微信插件无法接收此类消息】
10 |
11 | v2.0.0 版本现已支持 Jellyfin Server!!!详细配置请参看章节 [Jellyfin Server 设置](#jellyfin-server-设置)
12 |
13 | ## Emby Server 版本 (重要!!!)
14 |
15 |
16 | **4.8.0.80 及更新版本的 Emby Server!!!**
17 |
18 | 本项目是基于 Emby Server 官方插件 Webhooks 实现的,在 4.8.0.80 版本以前需要激活 Emby Premiere 才能使用 Webhooks 插件。
19 |
20 | 在 4.8.0.80 版本,Webhooks 被集成到控制台 “通知” 功能中,免费用户也可使用,因此建议使用本项目的朋友更新 Emby Server 到指定版本。
21 |
22 | 需要注意的是,群晖套件中心的 Emby Server 最新在线版本为 4.7.14.0,因此需要 Emby 官方网站下载相应平台的安装包进行手动安装。
23 |
24 | ## Contributors
25 |
26 | [](https://github.com/Ccccx159/Emby_Notifier/graphs/contributors)
27 |
28 | ## 修订版本
29 |
30 |
31 | | 版本 | 日期 | 修订说明 |
32 | | ----- | ----- | ----- |
33 | | v4.1.0 | 2025.04.10 |
1. 微信增加图文消息类型支持;2. 优化 TVDB_API_KEY 未配置时仍然查询TVDB导致报错的问题;3. 修复 BARK_DEVICE_KEYS 未配置时启动报错的问题4. 修改readme中TG变量说明 |
34 | | v4.0.1 | 2025.02.05 | 1. 环境校验补充增加 bark 参数检查;2. 修复仅配置 bark sender 时配置校验失败问题;3. 修改 wechat token 缓存文件命名,并修改 git ignore 文件 |
35 | | v4.0.0 | 2025.01.31 | 1. 新增 bark 推送支持,详细配置请参看 [bark 官网](https://bark.day.app/#/); |
36 | | v3.1.0 | 2025.01.28 | 1. 新增 TMDB_IMAGE_DOMAIN 环境变量, TMDB图片地址,默认`https://image.tmdb.org`,可配置为其它中转代理以加速TMDB图片显示,如:`https://static-mdb.v.geilijiasu.com`|
37 | | v3.0.5 | 2024.12.26 | 1. 取消 tg 相关参数的强校验,仅设置时进行可用性校验2. 修复TMDB部分剧集的 air_date 参数为导致的推送失败问题|
38 | | v3.0.4 | 2024.10.02 | 1. 增加兼容性,当“still_path”获取失败时由海报“poster”代替|
39 | | v3.0.3 | 2024.09.21 | 1. 修复同时配置 tg 和微信时,由于 tg 推送失败导致微信不推送的问题|
40 | | v3.0.2 | 2024.08.18 | 1. 修复 emby 推送的媒体信息缺少 server url 导致字段缺失报错,默认填充 ;|
41 | | v3.0.1 | 2024.08.02 | 1. 去除企业微信"推送用户"参数的校验,默认推送给虽有用户;2. 修复测试消息推送失败的问题3. 新增 aarch64 dockerfile 支持|
42 | | v3.0.0 | 2024.07.29 | 1. 新增企业微信支持;|
43 | | v2.2.3 | 2024.06.12 | 1. 兼容高版本 Emby Server 新增媒体事件通知的处理,包括修复 PremiereDate 和 剧集的 episode tmdb id 等兼容性导致的消息推送失败问题;2. 修复 tvdb_api 中一处日志等级错误;|
44 | | v2.2.2 | 2024.06.11 | 1. 修复 tvdb id 环境变量为空时校验失败的问题;2. 修复 chat id 被设置为用户 id 导致的校验失败问题 |
45 | | v2.2.1 | 2024.05.30 | 1. 修复非首季剧集搜索失败问题。TMDB /search/tv 接口参数 "first_air_date_year" 特指 “首季” 的初次发布年份,后续季发布年份不同于首季时,使用该字段出现搜索失败的情况,将搜索参数修改为 "year" 修复该问题。 |
46 | | v2.2.0 | 2024.05.24 | 1. 增加必填参数有效性校验,基于 TMDB authorization 和 tgbot getMe method 等方法进行认证,通过后才启动服务2. 将 TVDB_API_KEY 修改为 “可选”,兼容性修改。使用过程中出现 Server 的通知消息中无 ProviderIds 信息,tvdb api 请求返回 502 等情况,导致服务不够稳定,为了提高兼容性,将 TVDB_API_KEY 设置为可选配置,若配置,则对于有 tvdb_id 且无 tmdb_id 的影片可以有效提高信息检索准确性3. 优化部分 Requests 请求的异常处理 |
47 | | v2.1.0 | 2024.05.22 | 1. 新增测试 message 推送,当Emby Server发送测试通知时,将消息推送到对应 tg chat。仅输出日志容易导致使用者误解为无响应2. 修改 README 中对 TMDB_API_TOKEN 的中文解释为 "API 读访问令牌"3. tgbot sendmessage 方法新增异常信息打印4. 修复日志文件默认路径错误,并当设置LOG_EXPORT=True时,将欢迎信息同步写入日志文件 |
48 | | v2.0.0 | 2024.05.17 | 1. 支持 Jellyfin Server2. 优化部分日志信息,方便调试和跟踪问题3. 优化逻辑,当无法匹配id时,默认使用第一个搜索结果,避免因为id缺失导致无结果(对于较少部分id缺失的媒体文件,由于缺少匹配和校验机制,可能出现推送结果与实际影片不符现象) |
49 | | v1.0.4 | 2024.05.16 | 1. 推送消息新增 “服务器名称” tag,当Notifier服务被应用于多个 server 时,易于区分2. tgbot 推送失败时,增加日志输出 api 返回内容 |
50 | | v1.0.3 | 2024.05.08 | 1. 修复环境变量校验中的一处环境变量名称笔误,该错误会导致无法正常启动服务2. dockerfile 增加环境变量PYTHONUNBUFFERED=1,避免因日志缓存无法即时获取信息 |
51 | | v1.0.2 | 2024.05.08 | 1. 修复当 LOG_LEVEL 未设置时,默认等级 INFO 不生效,仍然维持 WARNING 等级的错误;2. 新增 welcome 日志,输出项目名称,作者,版本等信息;3. 新增环境变量校验,当必选项未设置时,将不会启动服务; |
52 | | v1.0.1 | 2024.04.30 | 1. 修改默认日志等级为 INFO,同步修改docker-compose模板和README;2. 优化错误日志逻辑;3. 新增部分 info 日志,成功处理时给出适当响应;4. 封装搜索和校验 TMDB ID 部分代码,减少重复 |
53 | | v1.0.0 | 2024.04.29 | 新增项目 |
54 |
55 |
56 | ## 简介
57 |
58 | **Emby Notifier** 是一个基于 Emby Server Webhooks 实现的自动通知工具。Emby Server 通过 Webhooks 插件,可以在影片刮削完成后,自动推送事件到指定的 URL。本项目通过监听 Emby Server 推送的 Webhooks 事件,获取影片的基本信息,通过 TMDB 的 API 查询影片的详细信息,然后通过 Telegram Bot 推送至指定频道。
59 |
60 | ## 环境变量和服务端口
61 |
62 | 端口:8000
63 |
64 | Telegram、WeChat、Bark 三种通知方式至少配置一种。
65 |
66 | | 参数 | 要求 | 说明 |
67 | | -- | -- | -- |
68 | | TMDB_API_TOKEN | 必须 | TMDB API 读访问令牌(API Read Access Token) |
69 | | TVDB_API_KEY | 可选 | Your TVDB API Key |
70 | | TG_BOT_TOKEN | 可选 | Your Telegram Bot Token |
71 | | TG_CHAT_ID | 可选 | Your Telegram Channel's Chat ID |
72 | | LOG_LEVEL | 可选 | 日志等级 [DEBUG, INFO, WARNING] 三个等级,默认 INFO|
73 | | LOG_EXPORT | 可选 | 日志写文件标志 [True, False] 是否将日志输出到文件,默认 False|
74 | | LOG_PATH | 可选 | 日志文件保存路径,默认 /var/tmp/emby_notifier_tg |
75 | | WECHAT_CORP_ID | 可选 | (企业微信)企业 id |
76 | | WECHAT_CORP_SECRET | 可选 | (企业微信)应用的凭证秘钥 |
77 | | WECHAT_AGENT_ID | 可选 | (企业微信)应用 agentid |
78 | | WECHAT_USER_ID | 可选 | (企业微信)用户 id,默认为“@all” |
79 | | WECHAT_MSG_TYPE | 可选 | (企业微信)消息类型,支持图文类型(news)与模板卡片(news_notice),默认模板卡片 |
80 | | BARK_SERVER | 可选 | bark 服务地址,默认为公共服务器:https://api.day.app |
81 | | BARK_DEVICE_KEYS | 可选 | bark 设备密钥,支持设置多个设备密钥,用逗号分隔。e.g. "abcdefqweqwe,qwewqeqeqw,qweqweqweq,qweqweqwe" |
82 |
83 | ## docker Run
84 |
85 | ~~~shell
86 | docker run -d --name=emby-notifier-tg --restart=unless-stopped \
87 | -e TMDB_API_TOKEN=Your_TMDB_API_Token \
88 | -e TVDB_API_KEY=Your_TVDB_API_Key \
89 | -e TG_BOT_TOKEN=Your_Telegram_Bot_Token \
90 | -e TG_CHAT_ID=Your_Telegram_Chat_ID \
91 | -p 8000:8000 \
92 | b1gfac3c4t/emby_notifier_tg:latest
93 |
94 | ~~~
95 |
96 | ## docker-compose
97 |
98 | ```yaml
99 | version: '3'
100 | services:
101 | emby_notifier_tg:
102 | build:
103 | context: .
104 | dockerfile: dockerfile
105 | image: b1gfac3c4t/emby_notifier_tg:latest
106 | environment:
107 | - TZ=Asia/Shanghai
108 | # 这里所有的环境变量都不要使用引号
109 | # 必填参数
110 | - TMDB_API_TOKEN=
111 | # 可选参数
112 | - TG_BOT_TOKEN=
113 | - TG_CHAT_ID=
114 | - TVDB_API_KEY=
115 | - LOG_LEVEL=INFO # [DEBUG, INFO, WARNING] 三个等级,默认 INFO
116 | - LOG_EXPORT=False # [True, False0] 是否将日志输出到文件,默认 False
117 | - LOG_PATH=/var/tmp/emby_notifier_tg/ # 默认 /var/tmp/emby_notifier_tg/
118 | - WECHAT_CORP_ID=xxxxx # 企业微信:企业 id
119 | - WECHAT_CORP_SECRET=xxxxxx # 企业微信:应用凭证秘钥
120 | - WECHAT_AGENT_ID=xxxxx # 企业微信:应用 agentid
121 | - WECHAT_USER_ID=xxxxxx # 企业微信:用户 id,不设置时默认为 “@all”
122 | - WECHAT_MSG_TYPE=news_notice # 企业微信:消息类型,支持 news/news_notice,不设置默认为 news_notice
123 | network_mode: "bridge"
124 | ports:
125 | - "8000:8000"
126 | restart: unless-stopped
127 | ```
128 |
129 | ```bash
130 | docker-compose up -d
131 | ```
132 |
133 | ## Emby Server 设置
134 |
135 | 1. 打开 Emby Server 控制台,点击左侧菜单栏的 “设置” -> “通知” -> “添加 Webhooks”
136 |
137 | 
138 |
139 | 
140 |
141 | 2. 在弹出的对话框中,填写 Webhooks 的 URL,例如:`http://192.168.1.100:8000`,选择数据类型为 `application/json`
142 |
143 | 
144 |
145 | 3. 点击 “发送测试通知” 按钮,观察 Notifier 的日志输出,如果输出了测试通知的信息,说明 Webhooks 设置成功
146 |
147 | 
148 |
149 | Notifier 日志中出现以下信息,说明 Webhooks 设置成功
150 | ```shell
151 | [WARNING] : Unsupported event type: system.notificationtest
152 | ```
153 |
154 | 4. 选择通知事件:媒体库 -> 新媒体已添加,点击保存
155 |
156 | 
157 |
158 | ## Jellyfin Server 设置
159 |
160 | 1. 打开 Jellyfin Server 控制台,点击左侧菜单栏的 “插件”,点击 “Webhooks” 插件进行配置
161 |
162 | 
163 |
164 | 2. 添加类型为 "Generic Destination" 的 webhook
165 |
166 | 
167 |
168 | 3. 配置 Generic Destination
169 |
170 | 
171 |
172 | 4. 点击左侧 “通知”,进入通知配置界面
173 |
174 | 
175 |
176 | 5. 启用通知
177 |
178 | 
179 |
180 | ## 媒体信息检索流程
181 |
182 | 
183 |
184 |
185 | ## 局限性
186 |
187 | Emby Server 的新媒体添加事件的触发时机受限于对新增文件的监视方式和扫描媒体库的频率,如果 Emby Server 触发新媒体添加事件,则 Notifier 也就无法推送通知。
188 |
189 | ## 效果展示
190 |
191 | ### telegram
192 |
193 | 电影:
194 |
195 | 
196 |
197 | 剧集:
198 |
199 | 
200 |
201 | ### 企业微信
202 |
203 | 
204 |
205 | 
206 |
207 | ### bark
208 |
209 | 
210 |
211 | ## 参考文档
212 |
213 | + tmdb api 文档:https://developers.themoviedb.org/3
214 | + telegram bot api 文档:https://core.telegram.org/bots/api
--------------------------------------------------------------------------------
/bark.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/python3
2 | # -*- coding: UTF-8 -*-
3 |
4 | import requests, json, os
5 | import time
6 | import log
7 |
8 |
9 | # server API, default is https://api.day.app
10 | BARK_SERVER = os.getenv("BARK_SERVER", "https://api.day.app")
11 | # single or multiple device keys, separated by commas
12 | # e.g. "abcdefqweqwe,qwewqeqeqw,qweqweqweq,qweqweqwe"
13 | BARK_DEVICE_KEYS = os.getenv("BARK_DEVICE_KEYS", "")
14 | BARK_DEVICE_KEYS = BARK_DEVICE_KEYS.split(",")
15 |
16 | def send_message(content: dict):
17 | if not BARK_SERVER or not BARK_DEVICE_KEYS:
18 | log.logger.error("Bark server or device keys not set.")
19 | return
20 |
21 | url = f"{BARK_SERVER}/push"
22 | # content 追加 device_keys
23 | content["device_keys"] = BARK_DEVICE_KEYS
24 | log.logger.warning(content)
25 | try:
26 | response = requests.post(url, json=content)
27 | response.raise_for_status()
28 | return response
29 | except requests.exceptions.ConnectionError as e:
30 | log.logger.error(f"Connection error: {e}")
31 | raise
32 | except Exception as e:
33 | log.logger.error(f"Error sending message: {e}")
34 | raise
--------------------------------------------------------------------------------
/doc/Emby_Notifier.drawio:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 |
101 |
102 |
103 |
104 |
105 |
106 |
107 |
108 |
109 |
110 |
111 |
112 |
113 |
114 |
115 |
116 |
117 |
118 |
119 |
120 |
121 |
122 |
123 |
124 |
125 |
126 |
127 |
128 |
129 |
130 |
131 |
132 |
133 |
134 |
135 |
136 |
137 |
138 |
139 |
140 |
141 |
142 |
143 |
144 |
--------------------------------------------------------------------------------
/doc/Emby_Notifier.drawio.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Ccccx159/Emby_Notifier/fdd2c7feb10f8e1189715e9ea726419e2b1a17b4/doc/Emby_Notifier.drawio.png
--------------------------------------------------------------------------------
/doc/bark.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Ccccx159/Emby_Notifier/fdd2c7feb10f8e1189715e9ea726419e2b1a17b4/doc/bark.jpg
--------------------------------------------------------------------------------
/doc/template-emby-episode.json:
--------------------------------------------------------------------------------
1 | {
2 | "tv": [
3 | {
4 | "Title": "新 神雕侠侣 (1995) - S1, Ep2 - 故人之子 在 46c89a983401",
5 | "Description": "儒文双英在过的洞中又吃又住,过要他们付帐。愁跟三夫妇来洞穴,要杀元女泄愤,三不敌,中针倒地,过用计使各人脱险,但最后二女被愁掳去。过不慎接触毒针,欧阳锋来救他,但要过认他作父,后更传蛤蟆功给杨过。郭靖、黄蓉遇过,蓉认出他是杨康之子,又因武三娘中毒死去,靖便把过和儒文兄弟带回桃花岛,希望过能在岛上长大成人。",
6 | "Date": "2024-06-26T10:10:25.2607497Z",
7 | "Event": "library.new",
8 | "Item": {
9 | "Name": "故人之子",
10 | "ServerId": "1e42f547a683452097426ca9d044a2d9",
11 | "Id": "1718",
12 | "DateCreated": "2021-07-16T15:38:12.0195950Z",
13 | "Container": "mkv",
14 | "SortName": "故人之子",
15 | "PremiereDate": "1995-08-01T00:00:00.0000000Z",
16 | "ExternalUrls": [
17 | {
18 | "Name": "IMDb",
19 | "Url": "https://www.imdb.com/title/tt8189092"
20 | },
21 | {
22 | "Name": "TheTVDB",
23 | "Url": "https://thetvdb.com/?tab=episode&id=4768466"
24 | },
25 | {
26 | "Name": "Trakt",
27 | "Url": "https://trakt.tv/search/imdb/tt8189092"
28 | }
29 | ],
30 | "Path": "/mnt/share1/神雕侠侣(1995)/S01/Return.of.The.Condor.Heroes.1995.S01.E02.2160P(4K).WEB-DL.H265.AAC.2Audios-Vampire.mkv",
31 | "Overview": "儒文双英在过的洞中又吃又住,过要他们付帐。愁跟三夫妇来洞穴,要杀元女泄愤,三不敌,中针倒地,过用计使各人脱险,但最后二女被愁掳去。过不慎接触毒针,欧阳锋来救他,但要过认他作父,后更传蛤蟆功给杨过。郭靖、黄蓉遇过,蓉认出他是杨康之子,又因武三娘中毒死去,靖便把过和儒文兄弟带回桃花岛,希望过能在岛上长大成人。",
32 | "Taglines": [],
33 | "Genres": [
34 | "Action",
35 | "Adventure",
36 | "Drama"
37 | ],
38 | "RunTimeTicks": 27318990000,
39 | "Size": 2706347790,
40 | "FileName": "Return.of.The.Condor.Heroes.1995.S01.E02.2160P(4K).WEB-DL.H265.AAC.2Audios-Vampire.mkv",
41 | "Bitrate": 7925176,
42 | "ProductionYear": 1995,
43 | "IndexNumber": 2,
44 | "ParentIndexNumber": 1,
45 | "RemoteTrailers": [],
46 | "ProviderIds": {
47 | "Tvdb": "4768466",
48 | "Imdb": "tt8189092"
49 | },
50 | "IsFolder": false,
51 | "ParentId": "1716",
52 | "Type": "Episode",
53 | "Studios": [],
54 | "GenreItems": [
55 | {
56 | "Name": "Action",
57 | "Id": 1816
58 | },
59 | {
60 | "Name": "Adventure",
61 | "Id": 1817
62 | },
63 | {
64 | "Name": "Drama",
65 | "Id": 1834
66 | }
67 | ],
68 | "TagItems": [],
69 | "ParentLogoItemId": "1708",
70 | "ParentBackdropItemId": "1708",
71 | "ParentBackdropImageTags": [
72 | "474beccf76df6b2dc897fa537bf57eb3"
73 | ],
74 | "SeriesName": "神雕侠侣 (1995)",
75 | "SeriesId": "1708",
76 | "SeasonId": "1716",
77 | "PrimaryImageAspectRatio": 1.7777777777777777,
78 | "SeriesPrimaryImageTag": "e9d6acf3e50b107c0e3057dbb14797b6",
79 | "SeasonName": "第 1 季",
80 | "ImageTags": {
81 | "Primary": "e9d1f9ec17824696a7a7ac087052f2f7"
82 | },
83 | "BackdropImageTags": [],
84 | "ParentLogoImageTag": "285929a2deec7a985da35cfba1555e86",
85 | "MediaType": "Video",
86 | "Width": 2796,
87 | "Height": 2160
88 | },
89 | "Server": {
90 | "Name": "46c89a983401",
91 | "Id": "1e42f547a683452097426ca9d044a2d9",
92 | "Version": "4.8.8.0"
93 | }
94 | },
95 | {
96 | "Title": "新 长风渡 - S1, Ep1 - 第1集 在 46c89a983401",
97 | "Description": "大荣立朝,迄今不过两代。如今大荣皇帝缠绵病榻,中央对地方的控制日渐虚弱,国库亏空。十三州节度使割据,拥兵自重。风平浪静的表象之下,隐隐有暗流涌动。然而,这种山雨欲来的肃杀气氛,暂时似乎并未影响到一派富丽锦绣的徉州城。 徉州城内,对于十六岁的少女柳玉茹来说,她此时唯一挂心的事,便是叶家大公子叶世安不知何时归来。",
98 | "Date": "2024-06-26T10:10:25.2606851Z",
99 | "Event": "library.new",
100 | "Item": {
101 | "Name": "第1集",
102 | "ServerId": "1e42f547a683452097426ca9d044a2d9",
103 | "Id": "1715",
104 | "DateCreated": "2023-09-01T02:25:38.3483099Z",
105 | "Container": "mp4",
106 | "SortName": "第1集",
107 | "PremiereDate": "2023-06-18T00:00:00.0000000Z",
108 | "ExternalUrls": [
109 | {
110 | "Name": "TheTVDB",
111 | "Url": "https://thetvdb.com/?tab=episode&id=9584007"
112 | }
113 | ],
114 | "Path": "/mnt/share1/长风渡(2023)/S01/Destined.2023.S01.E01.Episode.1.WEBDL-1080p.AVC.8bit.EAC3.5.1-长风渡.mp4",
115 | "Overview": "大荣立朝,迄今不过两代。如今大荣皇帝缠绵病榻,中央对地方的控制日渐虚弱,国库亏空。十三州节度使割据,拥兵自重。风平浪静的表象之下,隐隐有暗流涌动。然而,这种山雨欲来的肃杀气氛,暂时似乎并未影响到一派富丽锦绣的徉州城。 徉州城内,对于十六岁的少女柳玉茹来说,她此时唯一挂心的事,便是叶家大公子叶世安不知何时归来。",
116 | "Taglines": [],
117 | "Genres": [],
118 | "RunTimeTicks": 28861550000,
119 | "Size": 773723825,
120 | "FileName": "Destined.2023.S01.E01.Episode.1.WEBDL-1080p.AVC.8bit.EAC3.5.1-长风渡.mp4",
121 | "Bitrate": 2144649,
122 | "ProductionYear": 2023,
123 | "IndexNumber": 1,
124 | "ParentIndexNumber": 1,
125 | "RemoteTrailers": [],
126 | "ProviderIds": {
127 | "Tvdb": "9584007"
128 | },
129 | "IsFolder": false,
130 | "ParentId": "1712",
131 | "Type": "Episode",
132 | "Studios": [],
133 | "GenreItems": [],
134 | "TagItems": [],
135 | "ParentLogoItemId": "1707",
136 | "ParentBackdropItemId": "1707",
137 | "ParentBackdropImageTags": [
138 | "580c76b83c53ba2c711d8b2569407fad"
139 | ],
140 | "SeriesName": "长风渡",
141 | "SeriesId": "1707",
142 | "SeasonId": "1712",
143 | "PrimaryImageAspectRatio": 1.7777777777777777,
144 | "SeriesPrimaryImageTag": "e4e94715ddd4fc5552166c8f288fb683",
145 | "SeasonName": "第 1 季",
146 | "ImageTags": {
147 | "Primary": "5f68daffa52c5f52656e04d1903495cf"
148 | },
149 | "BackdropImageTags": [],
150 | "ParentLogoImageTag": "9af22b5501b8d55f5da5a867000f01d1",
151 | "MediaType": "Video",
152 | "Width": 1920,
153 | "Height": 800
154 | },
155 | "Server": {
156 | "Name": "46c89a983401",
157 | "Id": "1e42f547a683452097426ca9d044a2d9",
158 | "Version": "4.8.8.0"
159 | }
160 | },
161 | {
162 | "Title": "新 幽游白书 - S1, Ep2 - Episode 2 在 46c89a983401",
163 | "Description": "Rogue yokai steal three dangerous objects; as Yusuke grapples with his new powers, children playing at a park mysteriously collapse to the ground.",
164 | "Date": "2024-06-26T10:10:25.2606138Z",
165 | "Event": "library.new",
166 | "Item": {
167 | "Name": "Episode 2",
168 | "ServerId": "1e42f547a683452097426ca9d044a2d9",
169 | "Id": "1711",
170 | "DateCreated": "2024-05-24T18:58:22.7925371Z",
171 | "Container": "mp4",
172 | "SortName": "Episode 2",
173 | "PremiereDate": "2023-12-14T00:00:00.0000000Z",
174 | "ExternalUrls": [
175 | {
176 | "Name": "IMDb",
177 | "Url": "https://www.imdb.com/title/tt14920270"
178 | },
179 | {
180 | "Name": "TheTVDB",
181 | "Url": "https://thetvdb.com/?tab=episode&id=10139634"
182 | },
183 | {
184 | "Name": "Trakt",
185 | "Url": "https://trakt.tv/search/imdb/tt14920270"
186 | }
187 | ],
188 | "Path": "/mnt/share1/幽游白书(2023)/S01/Yu.Yu.Hakusho.2023.S01.E02.Episode.2.WEBDL-1080p.x264.8bit.EAC3.5.1-AOC.mp4",
189 | "Overview": "Rogue yokai steal three dangerous objects; as Yusuke grapples with his new powers, children playing at a park mysteriously collapse to the ground.",
190 | "Taglines": [],
191 | "Genres": [
192 | "Action",
193 | "Adventure",
194 | "Comedy"
195 | ],
196 | "CommunityRating": 7.6,
197 | "RunTimeTicks": 26772800000,
198 | "Size": 3539377897,
199 | "FileName": "Yu.Yu.Hakusho.2023.S01.E02.Episode.2.WEBDL-1080p.x264.8bit.EAC3.5.1-AOC.mp4",
200 | "Bitrate": 10576041,
201 | "ProductionYear": 2023,
202 | "IndexNumber": 2,
203 | "ParentIndexNumber": 1,
204 | "RemoteTrailers": [],
205 | "ProviderIds": {
206 | "Tvdb": "10139634",
207 | "Imdb": "tt14920270"
208 | },
209 | "IsFolder": false,
210 | "ParentId": "1709",
211 | "Type": "Episode",
212 | "Studios": [],
213 | "GenreItems": [
214 | {
215 | "Name": "Action",
216 | "Id": 1816
217 | },
218 | {
219 | "Name": "Adventure",
220 | "Id": 1817
221 | },
222 | {
223 | "Name": "Comedy",
224 | "Id": 1818
225 | }
226 | ],
227 | "TagItems": [],
228 | "ParentLogoItemId": "1706",
229 | "ParentBackdropItemId": "1706",
230 | "ParentBackdropImageTags": [
231 | "3e365d58f2cbf26cbfd4ef3bee8e608a"
232 | ],
233 | "SeriesName": "幽游白书",
234 | "SeriesId": "1706",
235 | "SeasonId": "1709",
236 | "PrimaryImageAspectRatio": 1.7777777777777777,
237 | "SeriesPrimaryImageTag": "7f228daf324f3c99ec9aba09e7407e7d",
238 | "SeasonName": "第 1 季",
239 | "ImageTags": {
240 | "Primary": "200315a5dd69dc732cc1439b8c55111d"
241 | },
242 | "BackdropImageTags": [],
243 | "ParentLogoImageTag": "942d53b691d47d193c7acd701580e1fb",
244 | "ParentThumbItemId": "1706",
245 | "ParentThumbImageTag": "993aa698cac97b53b34eb59ee888ef85",
246 | "MediaType": "Video",
247 | "Width": 1920,
248 | "Height": 1080
249 | },
250 | "Server": {
251 | "Name": "46c89a983401",
252 | "Id": "1e42f547a683452097426ca9d044a2d9",
253 | "Version": "4.8.8.0"
254 | }
255 | }
256 | ]
257 | }
--------------------------------------------------------------------------------
/doc/template-emby-movie.json:
--------------------------------------------------------------------------------
1 | {
2 | "mv": [
3 | {
4 | "Title": "新 哈利·波特20周年:回到霍格沃茨 在 46c89a983401",
5 | "Description": "《哈利波特》首集电影上映20周年!为此官方精心安排特别节目《哈利波特20周年:重返霍格华兹》,向魔法世界的幕后创造者——华纳兄弟与其电影制作团队致敬。节目邀请来了当初出演的众演员,一同分享拍摄现场的回忆,并以观众未曾领略的幕后视角,回顾大家心中经典的魔法时刻。",
6 | "Date": "2024-06-26T09:46:20.8464848Z",
7 | "Event": "library.new",
8 | "Item": {
9 | "Name": "哈利·波特20周年:回到霍格沃茨",
10 | "OriginalTitle": "Harry Potter 20th Anniversary: Return to Hogwarts",
11 | "ServerId": "1e42f547a683452097426ca9d044a2d9",
12 | "Id": "568",
13 | "DateCreated": "2024-03-04T18:19:58.0000000Z",
14 | "Container": "mpegts",
15 | "SortName": "哈利·波特20周年:回到霍格沃茨",
16 | "PremiereDate": "2022-01-01T00:00:00.0000000Z",
17 | "ExternalUrls": [
18 | {
19 | "Name": "IMDb",
20 | "Url": "https://www.imdb.com/title/tt16116174"
21 | },
22 | {
23 | "Name": "MovieDb",
24 | "Url": "https://www.themoviedb.org/movie/899082"
25 | },
26 | {
27 | "Name": "TheTVDB",
28 | "Url": "https://thetvdb.com/dereferrer/movie/318407"
29 | },
30 | {
31 | "Name": "Trakt",
32 | "Url": "https://trakt.tv/search/tmdb/899082?id_type=movie"
33 | }
34 | ],
35 | "CriticRating": 93,
36 | "Path": "/mnt/share2/Harry.Potter.20th.Anniversary-.Return.to.Hogwarts(2022)/Harry.Potter.20th.Anniversary-.Return.to.Hogwarts.2022.ENG.1080p.HD.WEBRip.1.47GiB.AAC.x264-PortalGoods.ts",
37 | "OfficialRating": "PG-13",
38 | "Overview": "《哈利波特》首集电影上映20周年!为此官方精心安排特别节目《哈利波特20周年:重返霍格华兹》,向魔法世界的幕后创造者——华纳兄弟与其电影制作团队致敬。节目邀请来了当初出演的众演员,一同分享拍摄现场的回忆,并以观众未曾领略的幕后视角,回顾大家心中经典的魔法时刻。",
39 | "Taglines": [
40 | "Welcome back to where the magic began."
41 | ],
42 | "Genres": [
43 | "纪录"
44 | ],
45 | "CommunityRating": 8,
46 | "RunTimeTicks": 61762534110,
47 | "Size": 1575861120,
48 | "FileName": "Harry.Potter.20th.Anniversary-.Return.to.Hogwarts.2022.ENG.1080p.HD.WEBRip.1.47GiB.AAC.x264-PortalGoods.ts",
49 | "Bitrate": 2041187,
50 | "ProductionYear": 2022,
51 | "RemoteTrailers": [
52 | {
53 | "Url": "https://www.youtube.com/watch?v=fFGS4zZWGoA"
54 | }
55 | ],
56 | "ProviderIds": {
57 | "Imdb": "tt16116174",
58 | "Tvdb": "318407",
59 | "Tmdb": "899082"
60 | },
61 | "IsFolder": false,
62 | "ParentId": "564",
63 | "Type": "Movie",
64 | "Studios": [
65 | {
66 | "Name": "Pulse Films",
67 | "Id": 602
68 | },
69 | {
70 | "Name": "Warner Horizon Unscripted Television",
71 | "Id": 603
72 | },
73 | {
74 | "Name": "Casey Patterson Entertainment",
75 | "Id": 604
76 | }
77 | ],
78 | "GenreItems": [
79 | {
80 | "Name": "纪录",
81 | "Id": 601
82 | }
83 | ],
84 | "TagItems": [],
85 | "PrimaryImageAspectRatio": 0.7,
86 | "ImageTags": {
87 | "Primary": "f4ba03846d9eea68dadff16a001fdddf",
88 | "Logo": "dc75034ad89f54f56edde455d87f8e9a",
89 | "Thumb": "dd3a7eb86f3421ae1c9b2a290b5f5eeb"
90 | },
91 | "BackdropImageTags": [
92 | "95235c1a08a5c2597fb918138cd8f213"
93 | ],
94 | "MediaType": "Video",
95 | "Width": 1920,
96 | "Height": 1080
97 | },
98 | "Server": {
99 | "Name": "46c89a983401",
100 | "Id": "1e42f547a683452097426ca9d044a2d9",
101 | "Version": "4.8.8.0"
102 | }
103 | },
104 | {
105 | "Title": "新 门徒 在 46c89a983401",
106 | "Description": "生活在香港的昆哥(刘德华饰演)表面上脯经营着一家小店做小生意,其真实身份却是掌叼控着庞大誊贩毒脉络的毒枭老大,他的妻子(袁咏仪饰演)已经有了两个女儿,如今又身怀六甲,这让昆哥产生了金盆洗手的想法。其实她的妻子对他的毒枭背景早以心知肚明,只是为了家庭,一直未吐露出来。阿力(吴彦祖饰演)是跟随昆哥多年的“门徒”,他的真是身份其实是警方的卧底。虽然得到昆哥的信任,可一直以来却始终掌握不到关于制毒贩毒的详细情况,这让他非常苦恼,就在他灰心丧气的时候,昆哥忽然要带他去东南亚深入制毒的秘密基地…… 古天乐和张静初在片中饰演一对夫妻,古天乐饰演的丈夫是个瘾君子,妻子阿芬(张静初饰演)为了向丈夫证明人是可以戒毒的,从而也染上了毒瘾。为了便于自己吸毒,丧尽天良的丈夫居然逼迫自己的老婆和三岁的女儿替自己贩毒。而阿芬在渴望戒毒的时候偶然结识了阿力,两人不知不觉发生一段微妙的情感纠葛……",
107 | "Date": "2024-06-26T09:46:23.7587664Z",
108 | "Event": "library.new",
109 | "Item": {
110 | "Name": "门徒",
111 | "OriginalTitle": "門徒",
112 | "ServerId": "1e42f547a683452097426ca9d044a2d9",
113 | "Id": "569",
114 | "DateCreated": "2023-08-08T00:47:29.0000000Z",
115 | "Container": "mp4",
116 | "SortName": "门徒",
117 | "PremiereDate": "2007-04-13T00:00:00.0000000Z",
118 | "ExternalUrls": [
119 | {
120 | "Name": "IMDb",
121 | "Url": "https://www.imdb.com/title/tt0841150"
122 | },
123 | {
124 | "Name": "MovieDb",
125 | "Url": "https://www.themoviedb.org/movie/16657"
126 | },
127 | {
128 | "Name": "TheTVDB",
129 | "Url": "https://thetvdb.com/dereferrer/movie/17184"
130 | },
131 | {
132 | "Name": "Trakt",
133 | "Url": "https://trakt.tv/search/tmdb/16657?id_type=movie"
134 | }
135 | ],
136 | "Path": "/mnt/share2/门徒(2007)/Protégé.2007.Bluray-1080p.h264.8bit.AAC.5.1.Radarr.mp4",
137 | "OfficialRating": "R",
138 | "Overview": "生活在香港的昆哥(刘德华饰演)表面上脯经营着一家小店做小生意,其真实身份却是掌叼控着庞大誊贩毒脉络的毒枭老大,他的妻子(袁咏仪饰演)已经有了两个女儿,如今又身怀六甲,这让昆哥产生了金盆洗手的想法。其实她的妻子对他的毒枭背景早以心知肚明,只是为了家庭,一直未吐露出来。阿力(吴彦祖饰演)是跟随昆哥多年的“门徒”,他的真是身份其实是警方的卧底。虽然得到昆哥的信任,可一直以来却始终掌握不到关于制毒贩毒的详细情况,这让他非常苦恼,就在他灰心丧气的时候,昆哥忽然要带他去东南亚深入制毒的秘密基地…… 古天乐和张静初在片中饰演一对夫妻,古天乐饰演的丈夫是个瘾君子,妻子阿芬(张静初饰演)为了向丈夫证明人是可以戒毒的,从而也染上了毒瘾。为了便于自己吸毒,丧尽天良的丈夫居然逼迫自己的老婆和三岁的女儿替自己贩毒。而阿芬在渴望戒毒的时候偶然结识了阿力,两人不知不觉发生一段微妙的情感纠葛……",
139 | "Taglines": [],
140 | "Genres": [
141 | "剧情",
142 | "惊悚"
143 | ],
144 | "CommunityRating": 7.2,
145 | "RunTimeTicks": 65309540000,
146 | "Size": 3547500274,
147 | "FileName": "Protégé.2007.Bluray-1080p.h264.8bit.AAC.5.1.Radarr.mp4",
148 | "Bitrate": 4345460,
149 | "ProductionYear": 2007,
150 | "RemoteTrailers": [
151 | {
152 | "Url": "https://www.youtube.com/watch?v=41kTbNGYkwU"
153 | }
154 | ],
155 | "ProviderIds": {
156 | "Imdb": "tt0841150",
157 | "Tvdb": "17184",
158 | "Tmdb": "16657"
159 | },
160 | "IsFolder": false,
161 | "ParentId": "565",
162 | "Type": "Movie",
163 | "Studios": [
164 | {
165 | "Name": "China Film Group Corporation",
166 | "Id": 623
167 | },
168 | {
169 | "Name": "MediaCorp Raintree Pictures",
170 | "Id": 624
171 | },
172 | {
173 | "Name": "Artforce International",
174 | "Id": 625
175 | },
176 | {
177 | "Name": "Beijing Jinyinma Movie & TV Culture Co.",
178 | "Id": 626
179 | },
180 | {
181 | "Name": "Bona Film Group",
182 | "Id": 627
183 | }
184 | ],
185 | "GenreItems": [
186 | {
187 | "Name": "剧情",
188 | "Id": 621
189 | },
190 | {
191 | "Name": "惊悚",
192 | "Id": 622
193 | }
194 | ],
195 | "TagItems": [],
196 | "PrimaryImageAspectRatio": 0.6666666666666666,
197 | "ImageTags": {
198 | "Primary": "38c31a0335124c2f6030cae6a2305c9e",
199 | "Logo": "caaa23720f48dc57f8d89b8a5c3cd342"
200 | },
201 | "BackdropImageTags": [
202 | "4724656064d2ec559962bdd8d541514a"
203 | ],
204 | "MediaType": "Video",
205 | "Width": 1920,
206 | "Height": 1080
207 | },
208 | "Server": {
209 | "Name": "46c89a983401",
210 | "Id": "1e42f547a683452097426ca9d044a2d9",
211 | "Version": "4.8.8.0"
212 | }
213 | },
214 | {
215 | "Title": "新 怒火·重案 在 46c89a983401",
216 | "Description": "重案组布网围剿国际毒枭,突然杀出一组蒙面悍匪“黑吃黑”,更冷血屠杀众警察。重案组督察张崇邦亲睹战友被杀,深入追查发现,悍匪首领竟是昔日战友邱刚敖。原来敖也曾是警队明日之星,而将敖推向罪恶深渊的人,却正是邦。宿命令二人再次纠缠,一切恩怨张崇邦如何作出了断。",
217 | "Date": "2024-06-26T09:46:25.6102318Z",
218 | "Event": "library.new",
219 | "Item": {
220 | "Name": "怒火·重案",
221 | "OriginalTitle": "怒火",
222 | "ServerId": "1e42f547a683452097426ca9d044a2d9",
223 | "Id": "570",
224 | "DateCreated": "2022-08-17T23:06:43.0000000Z",
225 | "Container": "mkv",
226 | "SortName": "怒火·重案",
227 | "PremiereDate": "2021-07-28T00:00:00.0000000Z",
228 | "ExternalUrls": [
229 | {
230 | "Name": "IMDb",
231 | "Url": "https://www.imdb.com/title/tt8165192"
232 | },
233 | {
234 | "Name": "MovieDb",
235 | "Url": "https://www.themoviedb.org/movie/513692"
236 | },
237 | {
238 | "Name": "TheTVDB",
239 | "Url": "https://thetvdb.com/dereferrer/movie/16676"
240 | },
241 | {
242 | "Name": "Trakt",
243 | "Url": "https://trakt.tv/search/tmdb/513692?id_type=movie"
244 | }
245 | ],
246 | "CriticRating": 91,
247 | "Path": "/mnt/share2/怒火·重案(2021)/Crossfire.2021.1080p.WEB-DL.H264.2Audios-TJUPT.mkv",
248 | "OfficialRating": "NR",
249 | "Overview": "重案组布网围剿国际毒枭,突然杀出一组蒙面悍匪“黑吃黑”,更冷血屠杀众警察。重案组督察张崇邦亲睹战友被杀,深入追查发现,悍匪首领竟是昔日战友邱刚敖。原来敖也曾是警队明日之星,而将敖推向罪恶深渊的人,却正是邦。宿命令二人再次纠缠,一切恩怨张崇邦如何作出了断。",
250 | "Taglines": [],
251 | "Genres": [
252 | "动作",
253 | "犯罪"
254 | ],
255 | "CommunityRating": 6.5,
256 | "RunTimeTicks": 77094660000,
257 | "Size": 3135687333,
258 | "FileName": "Crossfire.2021.1080p.WEB-DL.H264.2Audios-TJUPT.mkv",
259 | "Bitrate": 3253856,
260 | "ProductionYear": 2021,
261 | "RemoteTrailers": [
262 | {
263 | "Url": "https://www.youtube.com/watch?v=rX_YV8HE6XM"
264 | },
265 | {
266 | "Url": "https://www.youtube.com/watch?v=_hL0sAde1DA"
267 | },
268 | {
269 | "Url": "https://www.youtube.com/watch?v=spy-dhYLRxI"
270 | }
271 | ],
272 | "ProviderIds": {
273 | "Imdb": "tt8165192",
274 | "Tvdb": "16676",
275 | "Tmdb": "513692"
276 | },
277 | "IsFolder": false,
278 | "ParentId": "566",
279 | "Type": "Movie",
280 | "Studios": [
281 | {
282 | "Name": "Tencent Pictures",
283 | "Id": 650
284 | },
285 | {
286 | "Name": "Emperor Film Production",
287 | "Id": 651
288 | },
289 | {
290 | "Name": "Super Bullet Pictures",
291 | "Id": 652
292 | }
293 | ],
294 | "GenreItems": [
295 | {
296 | "Name": "动作",
297 | "Id": 648
298 | },
299 | {
300 | "Name": "犯罪",
301 | "Id": 649
302 | }
303 | ],
304 | "TagItems": [],
305 | "PrimaryImageAspectRatio": 0.75,
306 | "ImageTags": {
307 | "Primary": "3093b55b1fb2f46aa5e0b336478181b6",
308 | "Logo": "77a90e7525e51a9060ce07d364e14255",
309 | "Thumb": "cd90c0053609d24b42089100ffeab9d4"
310 | },
311 | "BackdropImageTags": [
312 | "fab55fdb3f1a71d796d35f184f85186d"
313 | ],
314 | "MediaType": "Video",
315 | "Width": 1920,
316 | "Height": 800
317 | },
318 | "Server": {
319 | "Name": "46c89a983401",
320 | "Id": "1e42f547a683452097426ca9d044a2d9",
321 | "Version": "4.8.8.0"
322 | }
323 | },
324 | {
325 | "Title": "新 刀锋战士3 在 46c89a983401",
326 | "Description": "这个世界上总是有两种势力,一方是邪恶的黑暗势力,而另一方则是永远和黑暗势力相抗争的正义的力量。黑暗势力似乎一刻都不会安宁,他们总是不甘心一直“黑暗”下去。在遥远的沙漠深处,吸血鬼的首领唤醒了沉睡七千年的吸血鬼德雷克(多米尼克·珀塞尔饰),这位古老的吸血鬼家族的祖先因为纯正的血统而拥有吸血鬼后辈永远无法企及的超强法力,那就是阳光无法阻挡他的黑暗力量,对于其他吸血鬼来说是致命的阳光对他却没有任何的影响,他在白天还能自由行动。吸血鬼们将他唤醒的目的是希望通过对他基因的研究,找出吸血鬼对抗日光的秘密。",
327 | "Date": "2024-06-26T09:46:29.0054906Z",
328 | "Event": "library.new",
329 | "Item": {
330 | "Name": "刀锋战士3",
331 | "OriginalTitle": "Blade: Trinity",
332 | "ServerId": "1e42f547a683452097426ca9d044a2d9",
333 | "Id": "571",
334 | "DateCreated": "2022-01-26T00:01:25.0000000Z",
335 | "Container": "mkv",
336 | "SortName": "刀锋战士3",
337 | "PremiereDate": "2004-12-08T00:00:00.0000000Z",
338 | "ExternalUrls": [
339 | {
340 | "Name": "IMDb",
341 | "Url": "https://www.imdb.com/title/tt0359013"
342 | },
343 | {
344 | "Name": "MovieDb",
345 | "Url": "https://www.themoviedb.org/movie/36648"
346 | },
347 | {
348 | "Name": "TheTVDB",
349 | "Url": "https://thetvdb.com/dereferrer/movie/1700"
350 | },
351 | {
352 | "Name": "Trakt",
353 | "Url": "https://trakt.tv/search/tmdb/36648?id_type=movie"
354 | }
355 | ],
356 | "CriticRating": 25,
357 | "Path": "/mnt/share2/刀锋战士3(2004)/Blade Trinity.2004.Bluray-1080p.DTS.5.1.x264.HDCLASSiCS.mkv",
358 | "OfficialRating": "R",
359 | "Overview": "这个世界上总是有两种势力,一方是邪恶的黑暗势力,而另一方则是永远和黑暗势力相抗争的正义的力量。黑暗势力似乎一刻都不会安宁,他们总是不甘心一直“黑暗”下去。在遥远的沙漠深处,吸血鬼的首领唤醒了沉睡七千年的吸血鬼德雷克(多米尼克·珀塞尔饰),这位古老的吸血鬼家族的祖先因为纯正的血统而拥有吸血鬼后辈永远无法企及的超强法力,那就是阳光无法阻挡他的黑暗力量,对于其他吸血鬼来说是致命的阳光对他却没有任何的影响,他在白天还能自由行动。吸血鬼们将他唤醒的目的是希望通过对他基因的研究,找出吸血鬼对抗日光的秘密。",
360 | "Taglines": [
361 | "The final hunt begins."
362 | ],
363 | "Genres": [
364 | "动作",
365 | "恐怖",
366 | "奇幻"
367 | ],
368 | "CommunityRating": 5.8,
369 | "RunTimeTicks": 67728750000,
370 | "Size": 8533847475,
371 | "FileName": "Blade Trinity.2004.Bluray-1080p.DTS.5.1.x264.HDCLASSiCS.mkv",
372 | "Bitrate": 10080029,
373 | "ProductionYear": 2004,
374 | "RemoteTrailers": [
375 | {
376 | "Url": "https://www.youtube.com/watch?v=qcHEDGs7eAY"
377 | },
378 | {
379 | "Url": "https://www.youtube.com/watch?v=fPcNbsW69Eg"
380 | }
381 | ],
382 | "ProviderIds": {
383 | "Imdb": "tt0359013",
384 | "Tvdb": "1700",
385 | "Tmdb": "36648",
386 | "TmdbCollection": "735",
387 | "official website": "http://www.warnerbros.com/blade-trinity"
388 | },
389 | "IsFolder": false,
390 | "ParentId": "567",
391 | "Type": "Movie",
392 | "Studios": [
393 | {
394 | "Name": "New Line Cinema",
395 | "Id": 679
396 | },
397 | {
398 | "Name": "Amen Ra Films",
399 | "Id": 680
400 | },
401 | {
402 | "Name": "Shawn Danielle Productions Ltd.",
403 | "Id": 681
404 | },
405 | {
406 | "Name": "Imaginary Forces",
407 | "Id": 682
408 | },
409 | {
410 | "Name": "Marvel Enterprises",
411 | "Id": 683
412 | },
413 | {
414 | "Name": "Peter Frankfurt Productions",
415 | "Id": 684
416 | }
417 | ],
418 | "GenreItems": [
419 | {
420 | "Name": "动作",
421 | "Id": 648
422 | },
423 | {
424 | "Name": "恐怖",
425 | "Id": 677
426 | },
427 | {
428 | "Name": "奇幻",
429 | "Id": 678
430 | }
431 | ],
432 | "TagItems": [],
433 | "PrimaryImageAspectRatio": 0.6666666666666666,
434 | "ImageTags": {
435 | "Primary": "9fb25e37ad6768d154d9eed92776866d",
436 | "Logo": "a830ca2684a36a57d4be1ff7770c62cf",
437 | "Thumb": "d7c346e41e01635c0bf11dc5d73016a0"
438 | },
439 | "BackdropImageTags": [
440 | "be16bc26dfdc94cdc5ca7e3cd483e4a2"
441 | ],
442 | "MediaType": "Video",
443 | "Width": 1920,
444 | "Height": 816
445 | },
446 | "Server": {
447 | "Name": "46c89a983401",
448 | "Id": "1e42f547a683452097426ca9d044a2d9",
449 | "Version": "4.8.8.0"
450 | }
451 | }
452 | ]
453 | }
--------------------------------------------------------------------------------
/doc/template-jelly-movie.json:
--------------------------------------------------------------------------------
1 | {
2 | "ServerId": "b552c5f7ec134aeb81c5343c13f1358e",
3 | "ServerName": "jelly",
4 | "ServerVersion": "10.8.11",
5 | "ServerUrl": "https://jellyfin.xxxxxxxxxxx.com:8096",
6 | "NotificationType": "ItemAdded",
7 | "Timestamp": "2024-06-26T19:15:05.6099559+08:00",
8 | "UtcTimestamp": "2024-06-26T11:15:05.6099561Z",
9 | "Name": "刀锋战士3",
10 | "Overview": "这个世界上总是有两种势力,一方是邪恶的黑暗势力,而另一方则是永远和黑暗势力相抗争的正义的力量。黑暗势力似乎一刻都不会安宁,他们总是不甘心一直“黑暗”下去。在遥远的沙漠深处,吸血鬼的首领唤醒了沉睡七千年的吸血鬼德雷克(多米尼克·珀塞尔饰),这位古老的吸血鬼家族的祖先因为纯正的血统而拥有吸血鬼后辈永远无法企及的超强法力,那就是阳光无法阻挡他的黑暗力量,对于其他吸血鬼来说是致命的阳光对他却没有任何的影响,他在白天还能自由行动。吸血鬼们将他唤醒的目的是希望通过对他基因的研究,找出吸血鬼对抗日光的秘密。",
11 | "Tagline": "",
12 | "ItemId": "c0310975ae1f32f5d64cfdc9dd1bb86d",
13 | "ItemType": "Movie",
14 | "RunTimeTicks": 67728750000,
15 | "RunTime": "01:52:52",
16 | "Year": 2004,
17 | "Provider_tmdb": "36648",
18 | "Provider_imdb": "tt0359013",
19 | "Provider_tmdbcollection": "735",
20 | "Video_0_Title": "1080p H264 SDR",
21 | "Video_0_Type": "Video",
22 | "Video_0_Codec": "h264",
23 | "Video_0_Profile": "High",
24 | "Video_0_Level": 41,
25 | "Video_0_Height": 816,
26 | "Video_0_Width": 1920,
27 | "Video_0_AspectRatio": "2.35:1",
28 | "Video_0_Interlaced": false,
29 | "Video_0_FrameRate": 23.975986,
30 | "Video_0_VideoRange": "SDR",
31 | "Video_0_ColorSpace": null,
32 | "Video_0_ColorTransfer": null,
33 | "Video_0_ColorPrimaries": null,
34 | "Video_0_PixelFormat": "yuv420p",
35 | "Video_0_RefFrames": 1,
36 | "Audio_0_Title": "DTS - Eng - 5.1 - Default",
37 | "Audio_0_Type": "Audio",
38 | "Audio_0_Language": "eng",
39 | "Audio_0_Codec": "dts",
40 | "Audio_0_Channels": 6,
41 | "Audio_0_Bitrate": 1536000,
42 | "Audio_0_SampleRate": 48000,
43 | "Audio_0_Default": true
44 | }
--------------------------------------------------------------------------------
/doc/template-jellyfin-episode.json:
--------------------------------------------------------------------------------
1 | {
2 | "ServerId": "b552c5f7ec134aeb81c5343c13f1358e",
3 | "ServerName": "jelly",
4 | "ServerVersion": "10.8.11",
5 | "ServerUrl": "https://jellyfin.xxxxxxxxxxx.com:8096",
6 | "NotificationType": "ItemAdded",
7 | "Timestamp": "2024-06-26T19:23:03.9042888+08:00",
8 | "UtcTimestamp": "2024-06-26T11:23:03.9042889Z",
9 | "Name": "第 2 集",
10 | "Overview": "作恶的妖怪偷走了三件危险物品。幽助努力应对他的新能力,而在公园玩耍的孩子们神秘地倒在地上。",
11 | "Tagline": "",
12 | "ItemId": "139a8526725bd660fae1c8b0a03e5104",
13 | "ItemType": "Episode",
14 | "RunTimeTicks": 26772800000,
15 | "RunTime": "00:44:37",
16 | "Year": 2023,
17 | "SeriesName": "幽游白书",
18 | "SeasonNumber": 1,
19 | "SeasonNumber00": "01",
20 | "SeasonNumber000": "001",
21 | "EpisodeNumber": 2,
22 | "EpisodeNumber00": "02",
23 | "EpisodeNumber000": "002",
24 | "Provider_imdb": "tt14920270",
25 | "Video_0_Title": "1080p H264 SDR",
26 | "Video_0_Type": "Video",
27 | "Video_0_Codec": "h264",
28 | "Video_0_Profile": "High",
29 | "Video_0_Level": 40,
30 | "Video_0_Height": 1080,
31 | "Video_0_Width": 1920,
32 | "Video_0_AspectRatio": "16:9",
33 | "Video_0_Interlaced": false,
34 | "Video_0_FrameRate": 24,
35 | "Video_0_VideoRange": "SDR",
36 | "Video_0_ColorSpace": null,
37 | "Video_0_ColorTransfer": null,
38 | "Video_0_ColorPrimaries": null,
39 | "Video_0_PixelFormat": "yuv420p",
40 | "Video_0_RefFrames": 1,
41 | "Audio_0_Title": "Audio Media Handler - Eng - Dolby Digital+ - 5.1",
42 | "Audio_0_Type": "Audio",
43 | "Audio_0_Language": "eng",
44 | "Audio_0_Codec": "eac3",
45 | "Audio_0_Channels": 6,
46 | "Audio_0_Bitrate": 640000,
47 | "Audio_0_SampleRate": 48000,
48 | "Audio_0_Default": false,
49 | "Audio_1_Title": "Audio Media Handler - Eng - Dolby Digital+ - 5.1",
50 | "Audio_1_Type": "Audio",
51 | "Audio_1_Language": "eng",
52 | "Audio_1_Codec": "eac3",
53 | "Audio_1_Channels": 6,
54 | "Audio_1_Bitrate": 640000,
55 | "Audio_1_SampleRate": 48000,
56 | "Audio_1_Default": false,
57 | "Audio_2_Title": "Audio Media Handler - Eng - Dolby Digital+ - Stereo",
58 | "Audio_2_Type": "Audio",
59 | "Audio_2_Language": "eng",
60 | "Audio_2_Codec": "eac3",
61 | "Audio_2_Channels": 2,
62 | "Audio_2_Bitrate": 128000,
63 | "Audio_2_SampleRate": 48000,
64 | "Audio_2_Default": false,
65 | "Audio_3_Title": "Audio Media Handler - Eng - Dolby Digital+ - Stereo",
66 | "Audio_3_Type": "Audio",
67 | "Audio_3_Language": "eng",
68 | "Audio_3_Codec": "eac3",
69 | "Audio_3_Channels": 2,
70 | "Audio_3_Bitrate": 128000,
71 | "Audio_3_SampleRate": 48000,
72 | "Audio_3_Default": false,
73 | "Audio_4_Title": "Audio Media Handler - Fra - Dolby Digital+ - 5.1",
74 | "Audio_4_Type": "Audio",
75 | "Audio_4_Language": "fra",
76 | "Audio_4_Codec": "eac3",
77 | "Audio_4_Channels": 6,
78 | "Audio_4_Bitrate": 640000,
79 | "Audio_4_SampleRate": 48000,
80 | "Audio_4_Default": false,
81 | "Audio_5_Title": "Audio Media Handler - Fra - Dolby Digital+ - 5.1",
82 | "Audio_5_Type": "Audio",
83 | "Audio_5_Language": "fra",
84 | "Audio_5_Codec": "eac3",
85 | "Audio_5_Channels": 6,
86 | "Audio_5_Bitrate": 640000,
87 | "Audio_5_SampleRate": 48000,
88 | "Audio_5_Default": false,
89 | "Audio_6_Title": "Audio Media Handler - Fra - Dolby Digital+ - Stereo",
90 | "Audio_6_Type": "Audio",
91 | "Audio_6_Language": "fra",
92 | "Audio_6_Codec": "eac3",
93 | "Audio_6_Channels": 2,
94 | "Audio_6_Bitrate": 128000,
95 | "Audio_6_SampleRate": 48000,
96 | "Audio_6_Default": false,
97 | "Audio_7_Title": "Audio Media Handler - Fra - Dolby Digital+ - Stereo",
98 | "Audio_7_Type": "Audio",
99 | "Audio_7_Language": "fra",
100 | "Audio_7_Codec": "eac3",
101 | "Audio_7_Channels": 2,
102 | "Audio_7_Bitrate": 128000,
103 | "Audio_7_SampleRate": 48000,
104 | "Audio_7_Default": false,
105 | "Audio_8_Title": "Audio Media Handler - Hin - Dolby Digital+ - 5.1",
106 | "Audio_8_Type": "Audio",
107 | "Audio_8_Language": "hin",
108 | "Audio_8_Codec": "eac3",
109 | "Audio_8_Channels": 6,
110 | "Audio_8_Bitrate": 640000,
111 | "Audio_8_SampleRate": 48000,
112 | "Audio_8_Default": false,
113 | "Audio_9_Title": "Audio Media Handler - Hin - Dolby Digital+ - Stereo",
114 | "Audio_9_Type": "Audio",
115 | "Audio_9_Language": "hin",
116 | "Audio_9_Codec": "eac3",
117 | "Audio_9_Channels": 2,
118 | "Audio_9_Bitrate": 128000,
119 | "Audio_9_SampleRate": 48000,
120 | "Audio_9_Default": false,
121 | "Audio_10_Title": "Audio Media Handler - Jpn - Dolby Digital+ - 5.1 - Default",
122 | "Audio_10_Type": "Audio",
123 | "Audio_10_Language": "jpn",
124 | "Audio_10_Codec": "eac3",
125 | "Audio_10_Channels": 6,
126 | "Audio_10_Bitrate": 768000,
127 | "Audio_10_SampleRate": 48000,
128 | "Audio_10_Default": true,
129 | "Audio_11_Title": "Audio Media Handler - Pol - Dolby Digital+ - 5.1",
130 | "Audio_11_Type": "Audio",
131 | "Audio_11_Language": "pol",
132 | "Audio_11_Codec": "eac3",
133 | "Audio_11_Channels": 6,
134 | "Audio_11_Bitrate": 640000,
135 | "Audio_11_SampleRate": 48000,
136 | "Audio_11_Default": false,
137 | "Audio_12_Title": "Audio Media Handler - Pol - Dolby Digital+ - Stereo",
138 | "Audio_12_Type": "Audio",
139 | "Audio_12_Language": "pol",
140 | "Audio_12_Codec": "eac3",
141 | "Audio_12_Channels": 2,
142 | "Audio_12_Bitrate": 128000,
143 | "Audio_12_SampleRate": 48000,
144 | "Audio_12_Default": false,
145 | "Audio_13_Title": "Audio Media Handler - Por - Dolby Digital+ - 5.1",
146 | "Audio_13_Type": "Audio",
147 | "Audio_13_Language": "por",
148 | "Audio_13_Codec": "eac3",
149 | "Audio_13_Channels": 6,
150 | "Audio_13_Bitrate": 640000,
151 | "Audio_13_SampleRate": 48000,
152 | "Audio_13_Default": false,
153 | "Audio_14_Title": "Audio Media Handler - Por - Dolby Digital+ - 5.1",
154 | "Audio_14_Type": "Audio",
155 | "Audio_14_Language": "por",
156 | "Audio_14_Codec": "eac3",
157 | "Audio_14_Channels": 6,
158 | "Audio_14_Bitrate": 640000,
159 | "Audio_14_SampleRate": 48000,
160 | "Audio_14_Default": false,
161 | "Audio_15_Title": "Audio Media Handler - Por - Dolby Digital+ - Stereo",
162 | "Audio_15_Type": "Audio",
163 | "Audio_15_Language": "por",
164 | "Audio_15_Codec": "eac3",
165 | "Audio_15_Channels": 2,
166 | "Audio_15_Bitrate": 128000,
167 | "Audio_15_SampleRate": 48000,
168 | "Audio_15_Default": false,
169 | "Audio_16_Title": "Audio Media Handler - Por - Dolby Digital+ - Stereo",
170 | "Audio_16_Type": "Audio",
171 | "Audio_16_Language": "por",
172 | "Audio_16_Codec": "eac3",
173 | "Audio_16_Channels": 2,
174 | "Audio_16_Bitrate": 128000,
175 | "Audio_16_SampleRate": 48000,
176 | "Audio_16_Default": false,
177 | "Subtitle_0_Title": "Eng - 默认 - MOV_TEXT",
178 | "Subtitle_0_Type": "Subtitle",
179 | "Subtitle_0_Language": "eng",
180 | "Subtitle_0_Codec": "mov_text",
181 | "Subtitle_0_Default": true,
182 | "Subtitle_0_Forced": false,
183 | "Subtitle_0_External": false,
184 | "Subtitle_1_Title": "Eng - MOV_TEXT",
185 | "Subtitle_1_Type": "Subtitle",
186 | "Subtitle_1_Language": "eng",
187 | "Subtitle_1_Codec": "mov_text",
188 | "Subtitle_1_Default": false,
189 | "Subtitle_1_Forced": false,
190 | "Subtitle_1_External": false,
191 | "Subtitle_2_Title": "Eng - MOV_TEXT",
192 | "Subtitle_2_Type": "Subtitle",
193 | "Subtitle_2_Language": "eng",
194 | "Subtitle_2_Codec": "mov_text",
195 | "Subtitle_2_Default": false,
196 | "Subtitle_2_Forced": false,
197 | "Subtitle_2_External": false,
198 | "Subtitle_3_Title": "Eng - MOV_TEXT",
199 | "Subtitle_3_Type": "Subtitle",
200 | "Subtitle_3_Language": "eng",
201 | "Subtitle_3_Codec": "mov_text",
202 | "Subtitle_3_Default": false,
203 | "Subtitle_3_Forced": false,
204 | "Subtitle_3_External": false,
205 | "Subtitle_4_Title": "Ara - MOV_TEXT",
206 | "Subtitle_4_Type": "Subtitle",
207 | "Subtitle_4_Language": "ara",
208 | "Subtitle_4_Codec": "mov_text",
209 | "Subtitle_4_Default": false,
210 | "Subtitle_4_Forced": false,
211 | "Subtitle_4_External": false,
212 | "Subtitle_5_Title": "Fra - MOV_TEXT",
213 | "Subtitle_5_Type": "Subtitle",
214 | "Subtitle_5_Language": "fra",
215 | "Subtitle_5_Codec": "mov_text",
216 | "Subtitle_5_Default": false,
217 | "Subtitle_5_Forced": false,
218 | "Subtitle_5_External": false,
219 | "Subtitle_6_Title": "Fra - MOV_TEXT",
220 | "Subtitle_6_Type": "Subtitle",
221 | "Subtitle_6_Language": "fra",
222 | "Subtitle_6_Codec": "mov_text",
223 | "Subtitle_6_Default": false,
224 | "Subtitle_6_Forced": false,
225 | "Subtitle_6_External": false,
226 | "Subtitle_7_Title": "Fra - MOV_TEXT",
227 | "Subtitle_7_Type": "Subtitle",
228 | "Subtitle_7_Language": "fra",
229 | "Subtitle_7_Codec": "mov_text",
230 | "Subtitle_7_Default": false,
231 | "Subtitle_7_Forced": false,
232 | "Subtitle_7_External": false,
233 | "Subtitle_8_Title": "Fra - MOV_TEXT",
234 | "Subtitle_8_Type": "Subtitle",
235 | "Subtitle_8_Language": "fra",
236 | "Subtitle_8_Codec": "mov_text",
237 | "Subtitle_8_Default": false,
238 | "Subtitle_8_Forced": false,
239 | "Subtitle_8_External": false,
240 | "Subtitle_9_Title": "Und - MOV_TEXT",
241 | "Subtitle_9_Type": "Subtitle",
242 | "Subtitle_9_Language": "und",
243 | "Subtitle_9_Codec": "mov_text",
244 | "Subtitle_9_Default": false,
245 | "Subtitle_9_Forced": false,
246 | "Subtitle_9_External": false,
247 | "Subtitle_10_Title": "Jpn - MOV_TEXT",
248 | "Subtitle_10_Type": "Subtitle",
249 | "Subtitle_10_Language": "jpn",
250 | "Subtitle_10_Codec": "mov_text",
251 | "Subtitle_10_Default": false,
252 | "Subtitle_10_Forced": false,
253 | "Subtitle_10_External": false,
254 | "Subtitle_11_Title": "Pol - MOV_TEXT",
255 | "Subtitle_11_Type": "Subtitle",
256 | "Subtitle_11_Language": "pol",
257 | "Subtitle_11_Codec": "mov_text",
258 | "Subtitle_11_Default": false,
259 | "Subtitle_11_Forced": false,
260 | "Subtitle_11_External": false,
261 | "Subtitle_12_Title": "Pol - MOV_TEXT",
262 | "Subtitle_12_Type": "Subtitle",
263 | "Subtitle_12_Language": "pol",
264 | "Subtitle_12_Codec": "mov_text",
265 | "Subtitle_12_Default": false,
266 | "Subtitle_12_Forced": false,
267 | "Subtitle_12_External": false,
268 | "Subtitle_13_Title": "Pol - MOV_TEXT",
269 | "Subtitle_13_Type": "Subtitle",
270 | "Subtitle_13_Language": "pol",
271 | "Subtitle_13_Codec": "mov_text",
272 | "Subtitle_13_Default": false,
273 | "Subtitle_13_Forced": false,
274 | "Subtitle_13_External": false,
275 | "Subtitle_14_Title": "Por - MOV_TEXT",
276 | "Subtitle_14_Type": "Subtitle",
277 | "Subtitle_14_Language": "por",
278 | "Subtitle_14_Codec": "mov_text",
279 | "Subtitle_14_Default": false,
280 | "Subtitle_14_Forced": false,
281 | "Subtitle_14_External": false,
282 | "Subtitle_15_Title": "Ukr - MOV_TEXT",
283 | "Subtitle_15_Type": "Subtitle",
284 | "Subtitle_15_Language": "ukr",
285 | "Subtitle_15_Codec": "mov_text",
286 | "Subtitle_15_Default": false,
287 | "Subtitle_15_Forced": false,
288 | "Subtitle_15_External": false,
289 | "Video_1_Title": "240p PNG SDR",
290 | "Video_1_Type": "Video",
291 | "Video_1_Codec": "png",
292 | "Video_1_Profile": null,
293 | "Video_1_Level": -99,
294 | "Video_1_Height": 197,
295 | "Video_1_Width": 350,
296 | "Video_1_AspectRatio": "16:9",
297 | "Video_1_Interlaced": false,
298 | "Video_1_FrameRate": 90000,
299 | "Video_1_VideoRange": "SDR",
300 | "Video_1_ColorSpace": null,
301 | "Video_1_ColorTransfer": null,
302 | "Video_1_ColorPrimaries": null,
303 | "Video_1_PixelFormat": "rgb24",
304 | "Video_1_RefFrames": 1
305 | }
--------------------------------------------------------------------------------
/doc/wechat_emby.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Ccccx159/Emby_Notifier/fdd2c7feb10f8e1189715e9ea726419e2b1a17b4/doc/wechat_emby.jpg
--------------------------------------------------------------------------------
/doc/wechat_jelly.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Ccccx159/Emby_Notifier/fdd2c7feb10f8e1189715e9ea726419e2b1a17b4/doc/wechat_jelly.jpg
--------------------------------------------------------------------------------
/doc/启用通知.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Ccccx159/Emby_Notifier/fdd2c7feb10f8e1189715e9ea726419e2b1a17b4/doc/启用通知.png
--------------------------------------------------------------------------------
/doc/接受测试消息.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Ccccx159/Emby_Notifier/fdd2c7feb10f8e1189715e9ea726419e2b1a17b4/doc/接受测试消息.png
--------------------------------------------------------------------------------
/doc/添加webhooks.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Ccccx159/Emby_Notifier/fdd2c7feb10f8e1189715e9ea726419e2b1a17b4/doc/添加webhooks.png
--------------------------------------------------------------------------------
/doc/添加通知.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Ccccx159/Emby_Notifier/fdd2c7feb10f8e1189715e9ea726419e2b1a17b4/doc/添加通知.png
--------------------------------------------------------------------------------
/doc/设置webhook.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Ccccx159/Emby_Notifier/fdd2c7feb10f8e1189715e9ea726419e2b1a17b4/doc/设置webhook.png
--------------------------------------------------------------------------------
/doc/选择generic_destination.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Ccccx159/Emby_Notifier/fdd2c7feb10f8e1189715e9ea726419e2b1a17b4/doc/选择generic_destination.png
--------------------------------------------------------------------------------
/doc/选择事件.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Ccccx159/Emby_Notifier/fdd2c7feb10f8e1189715e9ea726419e2b1a17b4/doc/选择事件.png
--------------------------------------------------------------------------------
/doc/通知设置.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Ccccx159/Emby_Notifier/fdd2c7feb10f8e1189715e9ea726419e2b1a17b4/doc/通知设置.png
--------------------------------------------------------------------------------
/doc/配置.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Ccccx159/Emby_Notifier/fdd2c7feb10f8e1189715e9ea726419e2b1a17b4/doc/配置.png
--------------------------------------------------------------------------------
/doc/配置notifier.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Ccccx159/Emby_Notifier/fdd2c7feb10f8e1189715e9ea726419e2b1a17b4/doc/配置notifier.png
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '3'
2 | services:
3 | emby_notifier_tg:
4 | build:
5 | context: .
6 | dockerfile: dockerfile
7 | image: b1gfac3c4t/emby_notifier_tg:latest
8 | environment:
9 | - TZ=Asia/Shanghai
10 | # 这里所有的环境变量都不要使用引号
11 | # 必填参数
12 | - TMDB_API_TOKEN=
13 | - TG_BOT_TOKEN=
14 | - TG_CHAT_ID=
15 | # 可选参数
16 | - TVDB_API_KEY=
17 | - LOG_LEVEL=INFO # [DEBUG, INFO, WARNING] 三个等级,默认 INFO
18 | - LOG_EXPORT=False # [True, False0] 是否将日志输出到文件,默认 False
19 | - LOG_PATH=/var/tmp/emby_notifier_tg/ # 默认 /var/tmp/emby_notifier_tg/
20 | - WECHAT_CORP_ID=xxxxx # 企业微信:企业 id
21 | - WECHAT_CORP_SECRET=xxxxxx # 企业微信:应用凭证秘钥
22 | - WECHAT_AGENT_ID=xxxxx # 企业微信:应用 agentid
23 | - WECHAT_USER_ID=xxxxxx # 企业微信:用户 id,不设置时默认为 “@all”
24 | network_mode: "bridge"
25 | ports:
26 | - "8000:8000"
--------------------------------------------------------------------------------
/dockerfile:
--------------------------------------------------------------------------------
1 | FROM python:3.11-alpine3.18
2 |
3 | LABEL maintainer="Xu@nCh3n"
4 |
5 | ENV TZ=Asia/Shanghai LANG=zh_CN.UTF-8 PYTHONUNBUFFERED=1
6 |
7 | EXPOSE 8000
8 |
9 | RUN set -eux && \
10 | \
11 | apk --no-cache update && apk -U --no-cache add git && \
12 | \
13 | mkdir -p /usr/src/myapp/ && \
14 | git clone https://github.com/Ccccx159/Emby_Notifier.git /usr/src/myapp/ && \
15 | python3 -m pip install --no-cache-dir requests colorlog aiohttp -q;
16 |
17 | ENTRYPOINT ["python3"]
18 | CMD ["/usr/src/myapp/main.py"]
--------------------------------------------------------------------------------
/dockerfile-aarch64:
--------------------------------------------------------------------------------
1 | FROM arm64v8/python:alpine3.19
2 |
3 | LABEL maintainer="Xu@nCh3n"
4 |
5 | ENV TZ=Asia/Shanghai LANG=zh_CN.UTF-8 PYTHONUNBUFFERED=1
6 |
7 | EXPOSE 8000
8 |
9 | RUN set -eux && \
10 | \
11 | apk --no-cache update && apk -U --no-cache add git && \
12 | \
13 | mkdir -p /usr/src/myapp/ && \
14 | git clone https://github.com/Ccccx159/Emby_Notifier.git /usr/src/myapp/ && \
15 | python3 -m pip install --no-cache-dir requests colorlog aiohttp -q;
16 |
17 | ENTRYPOINT ["python3"]
18 | CMD ["/usr/src/myapp/main.py"]
--------------------------------------------------------------------------------
/log.py:
--------------------------------------------------------------------------------
1 | import logging, colorlog, datetime, re, os
2 |
3 | '''
4 | Loggers:记录器,提供应用程序代码能直接使用的接口;
5 |
6 | Handlers:处理器,将记录器产生的日志发送至目的地;
7 |
8 | Filters:过滤器,提供更好的粒度控制,决定哪些日志会被输出;
9 |
10 | Formatters:格式化器,设置日志内容的组成结构和消息字段。
11 | %(name)s Logger的名字 #也就是其中的.getLogger里的路径,或者我们用他的文件名看我们填什么
12 | %(levelno)s 数字形式的日志级别 #日志里面的打印的对象的级别
13 | %(levelname)s 文本形式的日志级别 #级别的名称
14 | %(pathname)s 调用日志输出函数的模块的完整路径名,可能没有
15 | %(filename)s 调用日志输出函数的模块的文件名
16 | %(module)s 调用日志输出函数的模块名
17 | %(funcName)s 调用日志输出函数的函数名
18 | %(lineno)d 调用日志输出函数的语句所在的代码行
19 | %(created)f 当前时间,用UNIX标准的表示时间的浮 点数表示
20 | %(relativeCreated)d 输出日志信息时的,自Logger创建以 来的毫秒数
21 | %(asctime)s 字符串形式的当前时间。默认格式是 “2003-07-08 16:49:45,896”。逗号后面的是毫秒
22 | %(thread)d 线程ID。可能没有
23 | %(threadName)s 线程名。可能没有
24 | %(process)d 进程ID。可能没有
25 | %(message)s用户输出的消息
26 | '''
27 |
28 |
29 |
30 |
31 | '''日志颜色配置'''
32 | log_colors_config = {
33 | #颜色支持 blue蓝,green绿色,red红色,yellow黄色,cyan青色
34 | 'DEBUG': 'cyan',
35 | 'INFO': 'green',
36 | 'WARNING': 'yellow',
37 | 'ERROR': 'red',
38 | 'CRITICAL': 'red,bg_white',
39 | }
40 |
41 | '''创建logger记录器'''
42 | logger = logging.getLogger('my_logger')
43 |
44 | # 输出到控制台
45 | console_handler = logging.StreamHandler()
46 |
47 | '''日志级别设置'''
48 | log_level = os.getenv('LOG_LEVEL', 'INFO')
49 | if log_level in ['DEBUG', 'INFO', 'WARNING']:
50 | level = getattr(logging, log_level)
51 | else:
52 | level = getattr(logging, 'INFO')
53 |
54 | logger.setLevel(level)
55 | console_handler.setLevel(level)
56 |
57 | # 输出到文件
58 | log_export = os.getenv('LOG_EXPORT', 'False')
59 | if log_export.lower() == 'true':
60 | path = os.getenv('LOG_PATH', '/var/tmp/emby_notifier_tg/')
61 | os.makedirs(path, exist_ok=True) # This will create the directory if it does not exist, and do nothing if it does.
62 | '''Get the current date as the log file name'''
63 | fileName = datetime.datetime.now().strftime('%Y-%m-%d') + '.log' # Use strftime to format the date
64 | file_handler = logging.FileHandler(filename=os.path.join(path, fileName), mode='a', encoding='utf8')
65 | # Set the minimum output level of the log saved to the file
66 | file_handler.setLevel(level)
67 | file_formatter = logging.Formatter(
68 | fmt='[%(asctime)s] [%(filename)s|%(funcName)s|%(lineno)d] [%(levelname)s] : %(message)s',
69 | datefmt='%Y-%m-%d %H:%M:%S'
70 | )
71 | file_handler.setFormatter(file_formatter)
72 |
73 |
74 | #控制台的日志格式
75 | console_formatter = colorlog.ColoredFormatter(
76 | #输出那些信息,时间,文件名,函数名等等
77 | fmt='[%(asctime)s] [%(filename)s|%(funcName)s|%(lineno)d] %(log_color)s[%(levelname)s]%(white)s : %(message)s',
78 | #时间格式
79 | datefmt='%Y-%m-%d %H:%M:%S',
80 | log_colors=log_colors_config
81 | )
82 | console_handler.setFormatter(console_formatter)
83 |
84 | # 避免重复添加handler,导致日志重复输出
85 | if not logger.handlers:
86 | logger.addHandler(console_handler)
87 | if log_export.lower() == 'true':
88 | logger.addHandler(file_handler)
89 |
90 | console_handler.close()
91 | if log_export.lower() == 'true':
92 | file_handler.close()
93 |
94 |
95 |
96 | def SensitiveData(s, head=2, tail=4, mask='*****'):
97 | # 定义可能包含敏感信息的模式
98 | patterns = [
99 | r'\b\d{%d,}\b', # 匹配连续的数字
100 | r'\b[A-Fa-f0-9]{40}\b', # 匹配BTIH哈希
101 | r'\b[A-Fa-f0-9]{34}\b', # 匹配MD5哈希 2 + 32 (passkey=xxxxxx, 等号被编码为 %3d, 因此需要加2个字符长度)
102 | ]
103 |
104 | # 对每个模式进行处理
105 | for pattern in patterns:
106 | if '%d' in pattern:
107 | pattern = pattern % (head + tail)
108 | s = re.sub(pattern, lambda m: m.group()[:head] + mask + m.group()[-tail:], s)
109 |
110 | return s
--------------------------------------------------------------------------------
/main.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/python3
2 | # -*- coding: UTF-8 -*-
3 |
4 | import asyncio
5 | import log, my_httpd, tmdb_api, tvdb_api, tgbot
6 | import os, time
7 | import sender
8 | from sender import Sender
9 |
10 | AUTHOR = "xu4n_ch3n"
11 | VERSION = "4.1.0"
12 | UPDATETIME = "2025-04-10"
13 | DESCRIPTION = "Emby Notifier is a media notification service for Emby Server. Now Jellyfin Server is alreay supported."
14 | REPOSITORY = "https://github.com/Ccccx159/Emby_Notifier"
15 | CONTRIBUTORS = "xiaoQQya"
16 |
17 | WELCOME = f"""
18 | ███████╗███╗ ███╗██████╗ ██╗ ██╗ ███╗ ██╗ ██████╗ ████████╗██╗███████╗██╗███████╗██████╗
19 | ██╔════╝████╗ ████║██╔══██╗╚██╗ ██╔╝ ████╗ ██║██╔═══██╗╚══██╔══╝██║██╔════╝██║██╔════╝██╔══██╗
20 | █████╗ ██╔████╔██║██████╔╝ ╚████╔╝ ██╔██╗ ██║██║ ██║ ██║ ██║█████╗ ██║█████╗ ██████╔╝
21 | ██╔══╝ ██║╚██╔╝██║██╔══██╗ ╚██╔╝ ██║╚██╗██║██║ ██║ ██║ ██║██╔══╝ ██║██╔══╝ ██╔══██╗
22 | ███████╗██║ ╚═╝ ██║██████╔╝ ██║ ██║ ╚████║╚██████╔╝ ██║ ██║██║ ██║███████╗██║ ██║
23 | ╚══════╝╚═╝ ╚═╝╚═════╝ ╚═╝ ╚═╝ ╚═══╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝
24 | """
25 |
26 | CONTENT_STR = f"""
27 | Welcome to Emby Notifier!
28 | Author: {AUTHOR}
29 | Version: {VERSION}
30 | Update Time: {UPDATETIME}
31 | Description: {DESCRIPTION}
32 | Repository: {REPOSITORY}
33 | Contributors: {CONTRIBUTORS}
34 |
35 | """
36 |
37 | CONTENT = {
38 | "content": "Welcome to Emby Notifier!",
39 | "author": AUTHOR,
40 | "version": VERSION,
41 | "update_time": UPDATETIME,
42 | "intro": DESCRIPTION,
43 | "repo": REPOSITORY,
44 | "contributors": CONTRIBUTORS,
45 | }
46 |
47 |
48 | def welcome():
49 | print("\033[1;32m")
50 | print(WELCOME)
51 | print(f"\n{CONTENT_STR}")
52 | print("\033[0m")
53 |
54 | def env_check():
55 | print(f"{'Checking environment variables...':<30}")
56 | print("\n--------Media Database info:")
57 | print(f"{'TMDB_API_TOKEN:':<15} {'(req)'} {os.getenv('TMDB_API_TOKEN', 'None')}")
58 | print(f"{'TVDB_API_KEY:':<15} {'(opt)'} {os.getenv('TVDB_API_KEY', 'None')}")
59 | print("\n--------Telegram Bot info:")
60 | print(f"{'TG_BOT_TOKEN:':<15} {'(req)'} {os.getenv('TG_BOT_TOKEN', 'None')}")
61 | print(f"{'TG_CHAT_ID:':<15} {'(req)'} {os.getenv('TG_CHAT_ID', 'None')}")
62 | print("\n--------Wechat App info:")
63 | print(f"{'WECHAT_CORP_ID:':<15} {'(req)'} {os.getenv('WECHAT_CORP_ID', 'None')}")
64 | print(f"{'WECHAT_CORP_SECRET:':<15} {'(req)'} {os.getenv('WECHAT_CORP_SECRET', 'None')}")
65 | print(f"{'WECHAT_AGENT_ID:':<15} {'(req)'} {os.getenv('WECHAT_AGENT_ID', 'None')}")
66 | print(f"{'WECHAT_USER_ID:':<15} {'(req)'} {os.getenv('WECHAT_USER_ID', 'None')}")
67 | print("\n--------Bark Server info:")
68 | print(f"{'BARK_SERVER:':<15} {'(opt)'} {os.getenv('BARK_SERVER', 'https://api.day.app')}")
69 | print(f"{'BARK_DEVICE_KEYS:':<15} {'(opt)'} {os.getenv('BARK_DEVICE_KEYS', 'None')}")
70 | print("\n--------Log info:")
71 | print(f"{'LOG_LEVEL:':<15} {'(opt)'} {os.getenv('LOG_LEVEL', 'INFO')}")
72 | print(f"{'LOG_EXPORT:':<15} {'(opt)'} {os.getenv('LOG_EXPORT', 'False')}")
73 | print(f"{'LOG_PATH:':<15} {'(opt)'} {os.getenv('LOG_PATH', '/var/tmp/emby_notifier_tg')}")
74 |
75 | # 检查媒体数据库信息
76 | try:
77 | if os.getenv('TMDB_API_TOKEN') is None:
78 | raise Exception("TMDB_API_TOKEN is required.")
79 | if os.getenv('TG_BOT_TOKEN') is None and os.getenv('WECHAT_CORP_ID') is None and os.getenv('BARK_DEVICE_KEYS') is None:
80 | raise Exception("You must set up at least one notification method, such as a Telegram bot or a WeChat Work application.")
81 | if os.getenv('TG_BOT_TOKEN') and os.getenv('TG_CHAT_ID') is None:
82 | raise Exception("TG_CHAT_ID is required.")
83 | if os.getenv('WECHAT_CORP_ID') and (os.getenv('WECHAT_CORP_SECRET') is None or os.getenv('WECHAT_AGENT_ID') is None):
84 | raise Exception("Wechat Application config is not completed.")
85 | if os.getenv('BARK_SERVER') and os.getenv('BARK_DEVICE_KEYS') is None:
86 | raise Exception("Bark Server config is not completed.")
87 | except Exception as e:
88 | log.logger.error(e)
89 | print("\033[1;31m")
90 | print("ERROR!!! Please set the required environment variables, and restart the service.")
91 | print("\033[0m")
92 | while True:
93 | time.sleep(1)
94 |
95 | if 'True' == os.getenv('LOG_EXPORT'):
96 | # 如果有日志文件输出,则向日志文件输出欢迎信息
97 | file_path = os.getenv('LOG_PATH', '/var/tmp/emby_notifier_tg/') + '/' + time.strftime("%Y-%m-%d", time.localtime()) + '.log'
98 | print(f"{WELCOME}\n{CONTENT_STR}", file=open(file_path, 'w'))
99 |
100 |
101 | def require_check():
102 | log.logger.info("Checking requirements...")
103 | try:
104 | # check TMDB_API_TOKEN valid
105 | log.logger.info("Checking TMDB_API_TOKEN...")
106 | tmdb_api.login()
107 |
108 | # check TG_BOT_TOKEN valid and # check TG_CHAT_ID valid
109 | if os.getenv('TG_BOT_TOKEN') or os.getenv('TG_CHAT_ID'):
110 | log.logger.info("Checking TG_BOT_TOKEN...")
111 | tgbot.bot_authorization()
112 | log.logger.info("Checking TG_CHAT_ID...")
113 | tgbot.get_chat()
114 | else:
115 | log.logger.warning("No TG_BOT_TOKEN or TG_CHAT_ID found.")
116 |
117 | # send welcome message
118 | global Sender
119 | sender.Sender = sender.SenderManager()
120 | sender.Sender.send_welcome(CONTENT)
121 |
122 | except Exception as e:
123 | log.logger.error(e)
124 | raise e
125 |
126 |
127 |
128 | # 运行主事件循环
129 | if __name__ == "__main__":
130 | welcome()
131 | env_check()
132 | require_check()
133 | asyncio.run(my_httpd.my_httpd())
134 |
--------------------------------------------------------------------------------
/media.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/python3
2 | # -*- coding: UTF-8 -*-
3 | import os
4 | import abc, json, time
5 | import my_utils, tmdb_api, tvdb_api, tgbot
6 | import log
7 | import sender
8 | from sender import Sender
9 |
10 | from datetime import datetime
11 |
12 |
13 | class IMedia(abc.ABC):
14 |
15 | def __init__(self):
16 | self.info_ = {
17 | "Name": "abc",
18 | "Type": "Movie/Episode",
19 | "PremiereYear": 1970,
20 | "ProviderIds": {"Tmdb": "123", "Imdb": "456", "Tvdb": "789"},
21 | "Series": 0,
22 | "Season": 0,
23 | }
24 | self.media_detail_ = {
25 | "server_type": "Emby/Jellyfin",
26 | "server_url": "https://emby.example.com",
27 | "server_name": "My_Emby_Server",
28 | "media_name": "movie_name",
29 | "media_type": "Movie/Episode",
30 | "media_rating": 0.0,
31 | "media_rel": "1970-01-01",
32 | "media_intro": "This is a movie/episode introduction.",
33 | "media_tmdburl": "https://www.themoviedb.org/movie(tv)/123456?language=zh-CN",
34 | "media_poster": "https://image.tmdb.org/t/p/w500/sFeFWK3SI662yC2sx4clmWttWVj.jpg",
35 | "media_backdrop": "https://image.tmdb.org/t/p/w500/sFeFWK3SI662yC2sx4clmWttWVj.jpg",
36 | "media_still": "https://image.tmdb.org/t/p/w500/sFeFWK3SI662yC2sx4clmWttWVj.jpg",
37 | "tv_season": 0,
38 | "tv_episode": 0,
39 | "tv_episode_name": "episode_name",
40 | }
41 | self.poster_ = ""
42 | self.server_name_ = ""
43 | self.escape_ch = ["_", "*", "`", "["]
44 |
45 | @abc.abstractmethod
46 | def parse_info(self, emby_media_info):
47 | pass
48 |
49 | @abc.abstractmethod
50 | def get_details(self):
51 | pass
52 |
53 | @abc.abstractmethod
54 | def send_caption(self):
55 | pass
56 |
57 | def _get_id(self):
58 | log.logger.info(json.dumps(self.info_, ensure_ascii=False))
59 | medias, err = tmdb_api.search_media(
60 | self.info_["Type"], self.info_["Name"], self.info_["PremiereYear"]
61 | )
62 | if err:
63 | log.logger.error(err)
64 | raise Exception(err)
65 | Tvdb_id = self.info_["ProviderIds"].get("Tvdb", "-1")
66 | for m in medias:
67 | ext_ids, err = tmdb_api.get_external_ids(self.info_["Type"], m["id"])
68 | if err:
69 | log.logger.warning(err)
70 | continue
71 | if Tvdb_id == str(ext_ids.get("tvdb_id")):
72 | self.info_["ProviderIds"]["Tmdb"] = str(m["id"])
73 | break
74 | if "Tmdb" not in self.info_["ProviderIds"]:
75 | log.logger.warn(f"No matched media found for {self.info_['Name']} {self.info_['PremiereYear']} in TMDB.")
76 | if self.info_["Type"] == "Movie":
77 | log.logger.warn(f"Use the first search result: {medias[0]['title']} {medias[0]['release_date'][:4]}.")
78 | else:
79 | log.logger.warn(f"Use the first search result: {medias[0]['original_name']} {medias[0]['first_air_date'][:4]}.")
80 | self.info_["ProviderIds"]["Tmdb"] = medias[0]["id"]
81 |
82 |
83 | class Movie(IMedia):
84 | def __init__(self):
85 | super().__init__()
86 | self.info_["Type"] = "Movie"
87 |
88 | def __str__(self) -> str:
89 | return json.dumps(self.info_, ensure_ascii=False)
90 |
91 | def parse_info(self, emby_media_info):
92 | movie_item = emby_media_info["Item"]
93 | self.info_["Name"] = movie_item["Name"]
94 | self.info_["PremiereYear"] = (
95 | int(movie_item["PremiereDate"])
96 | if movie_item["PremiereDate"].isdigit()
97 | else (
98 | datetime.fromisoformat(
99 | movie_item["PremiereDate"].replace("Z", "+00:00")
100 | ).year
101 | if my_utils.emby_version_check(emby_media_info["Server"]["Version"])
102 | else my_utils.iso8601_convert_CST(movie_item["PremiereDate"]).year
103 | )
104 | )
105 | self.info_["ProviderIds"] = movie_item["ProviderIds"]
106 | self.media_detail_["server_type"] = emby_media_info["Server"]["Type"]
107 | self.media_detail_["server_name"] = emby_media_info["Server"]["Name"]
108 | self.media_detail_["server_url"] = emby_media_info["Server"]["Url"]
109 | log.logger.debug(self.info_)
110 |
111 | def send_caption(self):
112 | sender.Sender.send_media_details(self.media_detail_)
113 |
114 | def get_details(self):
115 | if "Tmdb" not in self.info_["ProviderIds"]:
116 | self._get_id()
117 |
118 | movie_details, err = tmdb_api.get_movie_details(
119 | self.info_["ProviderIds"]["Tmdb"]
120 | )
121 | if err:
122 | log.logger.error(err)
123 | raise Exception(err)
124 |
125 | poster, err = tmdb_api.get_movie_poster(self.info_["ProviderIds"]["Tmdb"])
126 | if err:
127 | log.logger.error(err)
128 | raise Exception(err)
129 |
130 | backdrop, err = tmdb_api.get_movie_backdrop_path(self.info_["ProviderIds"]["Tmdb"])
131 | if err:
132 | log.logger.error(err)
133 | raise Exception(err)
134 |
135 | self.media_detail_["media_name"] = movie_details["title"]
136 | self.media_detail_["media_type"] = "Movie"
137 | self.media_detail_["media_rating"] = movie_details["vote_average"]
138 | self.media_detail_["media_rel"] = movie_details["release_date"]
139 | self.media_detail_["media_intro"] = movie_details["overview"]
140 | self.media_detail_["media_tmdburl"] = f"https://www.themoviedb.org/movie/{self.info_['ProviderIds']['Tmdb']}?language=zh-CN"
141 | self.media_detail_["media_poster"] = poster
142 | self.media_detail_["media_backdrop"] = backdrop
143 | log.logger.debug(self.media_detail_)
144 |
145 |
146 | class Episode(IMedia):
147 | def __init__(self):
148 | super().__init__()
149 | self.info_["Type"] = "Episode"
150 |
151 | def __str__(self) -> str:
152 | return json.dumps(self.info_, ensure_ascii=False)
153 |
154 | def parse_info(self, emby_media_info):
155 | episode_item = emby_media_info["Item"]
156 | self.info_["Name"] = episode_item["SeriesName"]
157 | try:
158 | self.info_["PremiereYear"] = (
159 | int(episode_item["PremiereDate"])
160 | if episode_item["PremiereDate"].isdigit()
161 | else (
162 | datetime.fromisoformat(
163 | episode_item["PremiereDate"].replace("Z", "+00:00")
164 | ).year
165 | if my_utils.emby_version_check(emby_media_info["Server"]["Version"])
166 | else my_utils.iso8601_convert_CST(episode_item["PremiereDate"]).year
167 | )
168 | )
169 | except Exception as e:
170 | log.logger.error(e)
171 | self.info_["PremiereYear"] = -1
172 | self.info_["ProviderIds"] = episode_item["ProviderIds"]
173 | self.info_["Series"] = episode_item["IndexNumber"]
174 | self.info_["Season"] = episode_item["ParentIndexNumber"]
175 | self.media_detail_["server_type"] = emby_media_info["Server"]["Type"]
176 | self.media_detail_["server_name"] = emby_media_info["Server"]["Name"]
177 | self.media_detail_["server_url"] = emby_media_info["Server"]["Url"]
178 | log.logger.debug(self.info_)
179 |
180 | def get_details(self):
181 | if "Tvdb" in self.info_["ProviderIds"] and not os.getenv("TVDB_API_KEY") is None:
182 | tvdb_id, err = tvdb_api.get_seriesid_by_episodeid(self.info_["ProviderIds"]["Tvdb"])
183 | if err:
184 | log.logger.warn(err)
185 | self.info_["ProviderIds"].pop("Tvdb")
186 | else:
187 | self.info_["ProviderIds"]["Tvdb"] = str(tvdb_id)
188 |
189 | if "Tmdb" in self.info_["ProviderIds"]:
190 | # remove
191 | self.info_["ProviderIds"].pop("Tmdb")
192 |
193 | self._get_id()
194 | tv_details, err = tmdb_api.get_tv_episode_details(
195 | self.info_["ProviderIds"]["Tmdb"],
196 | self.info_["Season"],
197 | self.info_["Series"],
198 | )
199 | if err:
200 | log.logger.error(err)
201 | raise Exception(err)
202 |
203 | poster, err = tmdb_api.get_tv_season_poster(
204 | self.info_["ProviderIds"]["Tmdb"], self.info_["Season"]
205 | )
206 | if err:
207 | log.logger.error(err)
208 | raise Exception(err)
209 |
210 | still, err = tmdb_api.get_tv_episode_still_paths(self.info_["ProviderIds"]["Tmdb"], self.info_["Season"], self.info_["Series"])
211 | if err:
212 | log.logger.error(err)
213 | log.logger.warning("No still path found. use poster instead.")
214 | still = poster
215 |
216 | # tv_datails["air_date"] 为 None 时,查询season的air_date
217 | if tv_details["air_date"] is None:
218 | log.logger.warning("No air_date found for this episode, will use season air_date.")
219 | season_details, err = tmdb_api.get_tv_season_details(self.info_["ProviderIds"]["Tmdb"], self.info_["Season"])
220 | if season_details:
221 | tv_details["air_date"] = season_details["air_date"]
222 | else:
223 | log.logger.error(err)
224 | log.logger.warning("No air_date found for this episode, will use current year.")
225 | tv_details["air_date"] = str(datetime.now().year)
226 |
227 | self.media_detail_["media_name"] = self.info_["Name"]
228 | self.media_detail_["media_type"] = "Episode"
229 | self.media_detail_["media_rating"] = tv_details["vote_average"]
230 | self.media_detail_["media_rel"] = tv_details["air_date"]
231 | self.media_detail_["media_intro"] = tv_details["overview"]
232 | self.media_detail_["media_tmdburl"] = f"https://www.themoviedb.org/tv/{self.info_['ProviderIds']['Tmdb']}?language=zh-CN"
233 | self.media_detail_["media_poster"] = poster
234 | self.media_detail_["media_still"] = still
235 | self.media_detail_["tv_season"] = tv_details["season_number"]
236 | self.media_detail_["tv_episode"] = tv_details["episode_number"]
237 | self.media_detail_["tv_episode_name"] = tv_details["name"]
238 | log.logger.debug(self.media_detail_)
239 |
240 |
241 | def send_caption(self):
242 | sender.Sender.send_media_details(self.media_detail_)
243 |
244 |
245 | def create_media(emby_media_info):
246 | if emby_media_info["Item"]["Type"] == "Movie":
247 | return Movie()
248 | elif emby_media_info["Item"]["Type"] == "Episode":
249 | return Episode()
250 | else:
251 | raise Exception("Unsupported media type.")
252 |
253 |
254 | def jellyfin_msg_preprocess(msg):
255 | # jellyfin msg 部分字段中包含 "\n",不处理会导致 json.loads() 失败
256 | if "\n" in msg:
257 | msg = msg.replace("\n", "")
258 | original_msg = json.loads(msg)
259 | # 通过字段 "NotificationType" 判断当前是否为 Jellyfin 事件
260 | if "NotificationType" in original_msg:
261 | if original_msg["NotificationType"] != "ItemAdded" or original_msg["ItemType"] not in ["Movie", "Episode"]:
262 | log.logger.warning(f"Unsupported event type: {original_msg['NotificationType'] or original_msg['ItemType']}")
263 | return None
264 | jellyfin_msg = {
265 | "Title": "event title",
266 | "Event": "library.new",
267 | "Item": {
268 | "Type": "Movie/Episode",
269 | "Name": "movie name",
270 | "SeriesName": "series name",
271 | "PremiereDate": "",
272 | "IndexNumber": 0,
273 | "ParentIndexNumber": 0,
274 | "ProviderIds": {}, # {"Tvdb": "5406258", "Imdb": "tt16116174", "Tmdb": "899082"}
275 | },
276 | "Server": {
277 | "Name": "server name",
278 | "Type": "Jellyfin",
279 | "Url": "Jellyfin server url",
280 | },
281 | }
282 | jellyfin_msg["Server"]["Name"] = original_msg["ServerName"]
283 | jellyfin_msg["Server"]["Type"] = "Jellyfin"
284 | jellyfin_msg["Server"]["Url"] = original_msg["ServerUrl"]
285 | jellyfin_msg["Event"] = "library.new"
286 |
287 | if original_msg["ItemType"] == "Movie":
288 | jellyfin_msg["Title"] = f"新 {original_msg['Name']} 在 {original_msg['ServerName']}"
289 | jellyfin_msg["Item"]["Type"] = "Movie"
290 | jellyfin_msg["Item"]["Name"] = original_msg["Name"]
291 | elif original_msg["ItemType"] == "Episode":
292 | jellyfin_msg["Title"] = f"新 {original_msg['SeriesName']} S{original_msg['SeasonNumber00']} - E{original_msg['EpisodeNumber00']} 在 {original_msg['ServerName']}"
293 | jellyfin_msg["Item"]["Type"] = "Episode"
294 | jellyfin_msg["Item"]["SeriesName"] = original_msg["SeriesName"]
295 | jellyfin_msg["Item"]["IndexNumber"] = original_msg["EpisodeNumber"]
296 | jellyfin_msg["Item"]["ParentIndexNumber"] = original_msg["SeasonNumber"]
297 | else:
298 | raise Exception("Unsupported media type.")
299 |
300 | jellyfin_msg["Item"]["PremiereDate"] = str(original_msg["Year"])
301 | if "Provider_tmdb" in original_msg:
302 | jellyfin_msg["Item"]["ProviderIds"]["Tmdb"] = original_msg["Provider_tmdb"]
303 | if "Provider_tvdb" in original_msg:
304 | jellyfin_msg["Item"]["ProviderIds"]["Tvdb"] = original_msg["Provider_tvdb"]
305 | if "Provider_imdb" in original_msg:
306 | jellyfin_msg["Item"]["ProviderIds"]["Imdb"] = original_msg["Provider_imdb"]
307 | # FIXME: Jellyfin 部分剧集没有提供 Provider_imdb 和 Provider_tvdb 信息
308 | if jellyfin_msg["Item"]["ProviderIds"] == {}:
309 | log.logger.warning(f"Jellyfin Server not get any ProviderIds for Event: {jellyfin_msg['Title']}")
310 | return jellyfin_msg
311 | else:
312 | original_msg["Server"]["Type"] = "Emby"
313 | # emby 推送的媒体信息不包含 server url,当前默认直接设置为 https://emby.media
314 | original_msg["Server"]["Url"] = "https://emby.media"
315 | return original_msg
316 |
317 |
318 | def process_media(emby_media_info):
319 | emby_media_info = jellyfin_msg_preprocess(emby_media_info)
320 | if not emby_media_info:
321 | return
322 | log.logger.info(f"Received message: {emby_media_info['Title']}")
323 | if emby_media_info["Event"] != "library.new":
324 | log.logger.warning(f"Unsupported event type: {emby_media_info['Event']}")
325 | if emby_media_info["Event"] == "system.notificationtest":
326 | log.logger.warning("This is a notification test message. Please check your Telegram chat, if you received a message from Emby Notifier, it works!")
327 | sender.Sender.send_test_msg(
328 | f"🎉 *Congratulations!* 🎉 \n\nEmby Notifier worked! \n\nThis is a test message from *{emby_media_info['Server']['Name']}*! Now you can try adding a new media item to your Emby Server, whether it is a movie or a TV series~ \n\n{time.strftime('%Y-%m-%d %H:%M:%S', time.localtime())}"
329 | )
330 |
331 | return
332 | try:
333 | md = create_media(emby_media_info)
334 | md.parse_info(emby_media_info)
335 | md.get_details()
336 | md.send_caption()
337 | except Exception as e:
338 | raise e
339 | else:
340 | log.logger.info(f"Message processing completed: {emby_media_info['Title']}")
341 |
--------------------------------------------------------------------------------
/my_httpd.py:
--------------------------------------------------------------------------------
1 | from aiohttp import web
2 | import asyncio
3 | import log, media
4 | import json, traceback
5 | import my_utils
6 |
7 |
8 | async def worker(msg_queue):
9 | log.logger.info("Emby Notifier started.")
10 | while True:
11 | msg = await msg_queue.get() # 从队列中获取消息
12 | # 在这里进行消息处理,如发送到其他地方或执行其他操作
13 | # 仅支持 "Event": "library.new" 类型时间,其余不处理
14 | try:
15 | # 将unicode编码转换为中文字符
16 | if my_utils.contains_unicode_escape(msg):
17 | log.logger.debug("msg contains unicode escape sequences.")
18 | msg = msg.encode('utf-8').decode('unicode_escape')
19 | log.logger.debug(f"Decoded message: {msg}")
20 | media.process_media(msg)
21 | except Exception as e:
22 | log.logger.error(traceback.format_exc())
23 |
24 |
25 | async def handle_post(request):
26 | # 从 POST 请求中读取数据
27 | data = await request.text()
28 | log.logger.debug(data)
29 | # check content-type
30 | if request.content_type != "application/json":
31 | log.logger.error(
32 | f"Unsupported content type: {request.content_type}, please check your webhooks setting, "
33 | + "and choose 'application/json' as request content type."
34 | )
35 | else:
36 | # 将数据放入队列
37 | await request.app["msg_queue"].put(data)
38 |
39 | # 返回 200 OK
40 | return web.Response()
41 |
42 |
43 | async def my_httpd():
44 | # 创建消息队列
45 | msg_queue = asyncio.Queue()
46 |
47 | # 创建 aiohttp 应用,并将消息队列存储在应用对象中
48 | app = web.Application()
49 | app["msg_queue"] = msg_queue
50 |
51 | # 添加路由,自定义 post 处理函数
52 | app.router.add_post("/", handle_post)
53 |
54 | # 创建 worker 任务协程
55 | worker_task = asyncio.create_task(worker(msg_queue))
56 |
57 | # 运行 aiohttp 服务器
58 | runner = web.AppRunner(app)
59 | await runner.setup()
60 | # 使用 localhost:8000 无法监听本地网络地址,因此使用 0.0.0.0:8000 进行监听
61 | site = web.TCPSite(runner, "0.0.0.0", 8000)
62 | await site.start()
63 | log.logger.info("HTTP server started at http://localhost:8000")
64 |
65 | # 等待 worker 任务完成
66 | await worker_task
67 |
--------------------------------------------------------------------------------
/my_utils.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime, timezone, timedelta
2 | import re
3 |
4 | def iso8601_convert_CST(iso_time_str):
5 | """
6 | Converts an ISO 8601 formatted string to the China Standard Time (CST) timezone.
7 |
8 | Args:
9 | iso_time_str (str): The ISO 8601 formatted string representing a datetime.
10 |
11 | Returns:
12 | datetime: The converted datetime object in the China Standard Time (CST) timezone.
13 | """
14 | dt = datetime.fromisoformat(iso_time_str)
15 | return dt.astimezone(timezone.utc).astimezone(timezone(timedelta(hours=8)))
16 |
17 |
18 | def contains_unicode_escape(s):
19 | """
20 | Checks if the given string contains any Unicode escape sequences.
21 |
22 | Args:
23 | s (str): The string to check.
24 |
25 | Returns:
26 | bool: True if the string contains Unicode escape sequences, False otherwise.
27 | """
28 | return re.search(r"\\u[0-9a-fA-F]{4}", s) is not None
29 |
30 | def emby_version_check(version):
31 | """
32 | Checks if the Emby version is greater than or equal to 4.8.1.0.
33 |
34 | Args:
35 | version (str): The Emby version to check.
36 |
37 | Returns:
38 | bool: True if the Emby version is greater than or equal to 4.8.1.0, False otherwise.
39 | """
40 | ver_4810 = [4, 8, 1, 0]
41 | ver = list(map(int, version.split('.')))
42 | len_diff = len(ver) - len(ver_4810)
43 | if len_diff > 0:
44 | ver_4810.extend([0] * len_diff)
45 | elif len_diff < 0:
46 | ver.extend([0] * abs(len_diff))
47 | return ver >= ver_4810
48 |
49 |
50 |
--------------------------------------------------------------------------------
/sender.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/python3
2 | # -*- coding: UTF-8 -*-
3 |
4 | import requests, os, time
5 | import log
6 | import wxapp
7 | import tgbot
8 | import bark
9 | import traceback
10 |
11 | Sender = None
12 |
13 |
14 | class MessageSender:
15 | def send_welcome(self, welcome: dict):
16 | raise NotImplementedError
17 |
18 | def send_test_msg(self, test_content: str):
19 | raise NotImplementedError
20 |
21 | def send_media_details(self, media: dict):
22 | raise NotImplementedError
23 |
24 |
25 | class TelegramSender(MessageSender):
26 | # 设置str属性,用于标识发送者
27 | def __str__(self):
28 | return "Telegram"
29 |
30 | def send_welcome(self, welcome: dict):
31 | msg = f"{welcome['content']}\nAuthor: {welcome['author']}\nVersion: {welcome['version']}\nUpdate Time: {welcome['update_time']}\nDescription: {welcome['intro']}\nRepository: {welcome['repo']}\n"
32 | for ch in ["_", "*", "`", "["]:
33 | msg = msg.replace(ch, f"\\{ch}")
34 | tgbot.send_message(msg)
35 |
36 | def send_test_msg(self, test_content: str):
37 | for ch in ["_", "*", "`", "["]:
38 | test_content = test_content.replace(ch, f"\\{ch}")
39 | tgbot.send_message(test_content)
40 |
41 | def send_media_details(self, media: dict):
42 | caption = (
43 | "#影视更新 #{server_name}\n"
44 | + "\[{type_ch}]\n"
45 | + "片名: *{title}* ({year})\n"
46 | + "{episode}"
47 | + "评分: {rating}\n\n"
48 | + "上映日期: {rel}\n\n"
49 | + "内容简介: {intro}\n\n"
50 | + "相关链接: [TMDB]({tmdb_url})\n"
51 | )
52 | server_name = media["server_name"]
53 | for ch in ["_", "*", "`", "["]:
54 | server_name = server_name.replace(ch, f"\\{ch}")
55 | caption = caption.format(
56 | server_name=server_name,
57 | type_ch="电影" if media["media_type"] == "Movie" else "剧集",
58 | title=(
59 | media["media_name"]
60 | if media["media_type"] == "Movie"
61 | else f"{media['media_name']} {media['tv_episode_name']}"
62 | ),
63 | # 部分电视剧没有 air_date 导致无法获取当前剧集的上映年份,增加年份字段判断保护
64 | year=media["media_rel"][0:4] if media["media_rel"] else "Unknown",
65 | episode=(
66 | f"已更新至 第{media['tv_season']}季 第{media['tv_episode']}集\n"
67 | if media["media_type"] == "Episode"
68 | else ""
69 | ),
70 | rating=media["media_rating"],
71 | rel=media["media_rel"],
72 | intro=media["media_intro"],
73 | tmdb_url=media["media_tmdburl"],
74 | )
75 | poster = media["media_poster"]
76 | tgbot.send_photo(caption, poster)
77 |
78 |
79 | class WechatAppSender(MessageSender):
80 | def __str__(self):
81 | return "WechatApp"
82 |
83 | def send_welcome(self, welcome: dict):
84 | wxapp.send_welcome_card(welcome)
85 |
86 | def send_test_msg(self, test_content: str):
87 | wxapp.send_text(test_content)
88 |
89 | def send_media_details(self, media: dict):
90 | msgtype = os.getenv("WECHAT_MSG_TYPE", "news_notice")
91 | if msgtype == "news_notice":
92 | card_details = {
93 | "card_type": "news_notice",
94 | "source": {
95 | "icon_url": f"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/{media.get('server_type', 'Emby').lower()}.png",
96 | "desc": f"{media.get('server_type')} Server",
97 | "desc_color": 0,
98 | },
99 | "main_title": {
100 | "title": f"#{media.get('server_name')} 影视更新",
101 | },
102 | "card_image": {
103 | "url": f"{media.get('media_backdrop') if media.get('media_type') == 'Movie' else media.get('media_still')}",
104 | "aspect_ratio": 2.25,
105 | },
106 | "vertical_content_list": [
107 | {
108 | "title": f"[{'电影' if media.get('media_type') == 'Movie' else '剧集'}] {media.get('media_name')} ({media.get('media_rel')[:4]})"
109 | + (
110 | f" 第 {media.get('tv_season')} 季 | 第 {media.get('tv_episode')} 集"
111 | if media.get("media_type") == "Episode"
112 | else ""
113 | ),
114 | "desc": f"{media.get('media_intro')}",
115 | }
116 | ],
117 | "horizontal_content_list": [
118 | {"keyname": "上映日期", "value": f"{media.get('media_rel')}"},
119 | {"keyname": "评分", "value": f"{media.get('media_rating')}"},
120 | ],
121 | "jump_list": [
122 | {
123 | "type": 1,
124 | "url": f"{media.get('media_tmdburl')}",
125 | "title": "TMDB",
126 | },
127 | ],
128 | "card_action": {"type": 1, "url": f"{media.get('server_url')}"},
129 | }
130 | wxapp.send_news_notice(card_details)
131 | elif msgtype == "news":
132 | article = {
133 | "title" : f"[影视更新][{'电影' if media.get('media_type') == 'Movie' else '剧集'}] {media.get('media_name')} ({media.get('media_rel')[:4]})"
134 | + (
135 | f" 第 {media.get('tv_season')} 季 | 第 {media.get('tv_episode')} 集"
136 | if media.get("media_type") == "Episode"
137 | else ""
138 | ),
139 | "description" : f"{media.get('media_intro')}",
140 | "url" : f"{media.get('media_tmdburl')}",
141 | "picurl" : f"{media.get('media_backdrop') if media.get('media_type') == 'Movie' else media.get('media_still')}"
142 | }
143 | wxapp.send_news(article)
144 |
145 |
146 | class BarkSender(MessageSender):
147 | def __str__(self):
148 | return "Bark"
149 |
150 | def send_welcome(self, welcome: dict):
151 | payload = {
152 | "title": f"🎊 Welcome to EMBY Notifier {welcome['version']}",
153 | "body": f"Emby Notifier is a media notification service for Emby Server. Now Jellyfin Server is alreay supported.",
154 | "url": f"{welcome['repo']}"
155 | }
156 | bark.send_message(payload)
157 |
158 | def send_test_msg(self, test_content: str):
159 | # test_content: This is a test message from *Aliyun_Shared*!
160 | # 将*中间的字符串提取出来作为server_name
161 | server_name = test_content.split("*")[3]
162 | payload = {
163 | "title": "🎉 EMBY Notifier Test",
164 | "body": f"Congratulation! This is a test message from {server_name}! Now you can try adding a new media item to your Emby Server, whether it is a movie or a TV series~"
165 | }
166 | bark.send_message(payload)
167 |
168 | def send_media_details(self, media: dict):
169 | payload = {
170 | "title": f"🎬 #{media.get('server_name')} 影视更新",
171 | "body": f"[{'电影' if media['media_type'] == 'Movie' else '剧集'}] {media['media_name']} ({media['media_rel'][:4]})"
172 | + (
173 | f" 第 {media.get('tv_season')} 季 | 第 {media.get('tv_episode')} 集"
174 | if media.get("media_type") == "Episode"
175 | else ""
176 | ),
177 | "icon": f"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/{media.get('server_type', 'Emby').lower()}.png",
178 | "url": f"{media['media_tmdburl']}",
179 | }
180 | bark.send_message(payload)
181 |
182 |
183 | class SenderManager:
184 | def __init__(self):
185 | self.senders = []
186 | self._initialize_senders()
187 |
188 | def _initialize_senders(self):
189 | tg_bot_token = os.getenv("TG_BOT_TOKEN")
190 | tg_chat_id = os.getenv("TG_CHAT_ID")
191 | if tg_bot_token and tg_chat_id:
192 | self.senders.append(TelegramSender())
193 |
194 | wechat_corp_id = os.getenv("WECHAT_CORP_ID")
195 | wechat_corp_secret = os.getenv("WECHAT_CORP_SECRET")
196 | wechat_agent_id = os.getenv("WECHAT_AGENT_ID")
197 | if wechat_corp_id and wechat_corp_secret and wechat_agent_id:
198 | self.senders.append(WechatAppSender())
199 |
200 | bark_server = os.getenv("BARK_SERVER")
201 | bark_device_keys = os.getenv("BARK_DEVICE_KEYS")
202 | log.logger.debug(f"bark_server: {bark_server}, bark_device_keys: {bark_device_keys}")
203 | if bark_server and bark_device_keys:
204 | self.senders.append(BarkSender())
205 |
206 | def send_welcome(self, welcome_message: dict):
207 | for sender in self.senders:
208 | try:
209 | sender.send_welcome(welcome_message)
210 | except ValueError as e:
211 | print(f"Error {sender} sending welcome message: {e}")
212 |
213 | def send_test_msg(self, test_content):
214 | for sender in self.senders:
215 | try:
216 | sender.send_test_msg(test_content)
217 | except:
218 | log.logger.error(f"Error sending test message by {sender}")
219 | log.logger.error(traceback.format_exc())
220 | continue
221 |
222 | def send_media_details(self, media):
223 | for sender in self.senders:
224 | try:
225 | sender.send_media_details(media)
226 | except:
227 | log.logger.error(f"Error sending media details by {sender}")
228 | log.logger.error(traceback.format_exc())
229 | continue
--------------------------------------------------------------------------------
/tgbot.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/python3
2 | # -*- coding: UTF-8 -*-
3 |
4 | import requests, json, os
5 | import log
6 |
7 | # 填充电报机器人的token
8 | TG_BOT_URL = "https://api.telegram.org/bot%s/" % os.getenv("TG_BOT_TOKEN")
9 | # 填充电报频道 chat_id
10 | TG_CHAT_ID = os.getenv("TG_CHAT_ID")
11 |
12 |
13 | def send_message(text):
14 | payload = {
15 | "method": "sendMessage",
16 | "chat_id": TG_CHAT_ID,
17 | "text": text,
18 | "parse_mode": "Markdown",
19 | }
20 | log.logger.debug(log.SensitiveData(json.dumps(payload, ensure_ascii=False)))
21 | try:
22 | res = requests.post(TG_BOT_URL, json=payload)
23 | res.raise_for_status()
24 | except Exception as e:
25 | log.logger.error(json.dumps(payload,ensure_ascii=False))
26 | log.logger.error(res.text)
27 | raise e
28 |
29 |
30 | def send_photo(caption, photo):
31 | payload = {
32 | "method": "sendPhoto",
33 | "chat_id": TG_CHAT_ID,
34 | "photo": photo,
35 | "caption": caption,
36 | "parse_mode": "Markdown",
37 | }
38 | log.logger.debug(log.SensitiveData(json.dumps(payload, ensure_ascii=False)))
39 | try:
40 | res = requests.post(TG_BOT_URL, json=payload)
41 | res.raise_for_status()
42 | except Exception as e:
43 | log.logger.error(json.dumps(payload,ensure_ascii=False))
44 | log.logger.error(res.text)
45 | raise e
46 |
47 | def bot_authorization():
48 | try:
49 | res = requests.get(TG_BOT_URL + "getMe")
50 | res.raise_for_status()
51 | log.logger.debug(log.SensitiveData(res.text))
52 | log.logger.info(f"Telegram bot authorization successful. Current bot: {res.json()['result']['username']}")
53 | except requests.exceptions.ConnectionError as e:
54 | log.logger.error(f"Telegram bot authorization failed. Check network connection: {e}")
55 | raise e
56 | except Exception as e:
57 | log.logger.error(f"Telegram bot authorization failed. Error: {e}")
58 | raise e
59 |
60 | def get_chat():
61 | payload = {
62 | "method": "getChat",
63 | "chat_id": TG_CHAT_ID,
64 | }
65 | try:
66 | res = requests.post(TG_BOT_URL, json=payload)
67 | res.raise_for_status()
68 | log.logger.debug(log.SensitiveData(res.text))
69 | chat_type = res.json()['result']['type']
70 | if chat_type == 'private':
71 | log.logger.info(f"Telegram getChat successful. Chat User: [{res.json()['result']['username']}], type: {chat_type}")
72 | elif chat_type == 'channel':
73 | log.logger.info(f"Telegram getChat successful. Chat title: [{res.json()['result']['title']}], type: {chat_type}")
74 | else:
75 | log.logger.warning(f"Telegram getChat successful. Chat type: {chat_type}, Chat Detail: {res.json()['result']}")
76 | except requests.exceptions.ConnectionError as e:
77 | log.logger.error(f"Telegram getChat failed. Check network connection: {e}")
78 | raise e
79 | except Exception as e:
80 | log.logger.error(json.dumps(payload,ensure_ascii=False))
81 | log.logger.error(f"Telegram getChat failed. Error: {e}")
82 | raise e
--------------------------------------------------------------------------------
/tmdb_api.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/python3
2 | # -*- coding: UTF-8 -*-
3 |
4 | import requests, os, log
5 |
6 | TMDB_API = "https://api.themoviedb.org/3"
7 |
8 | TMDB_API_TOKEN = os.getenv("TMDB_API_TOKEN")
9 | TMDB_IMAGE_DOMAIN = os.getenv("TMDB_IMAGE_DOMAIN", "https://image.tmdb.org")
10 |
11 | TMDB_API_HEADERS = {
12 | "accept": "application/json",
13 | "Authorization": "Bearer {}".format(TMDB_API_TOKEN),
14 | }
15 |
16 | TMDB_MEDIA_TYPES = {
17 | "Movie": "movie",
18 | "Episode": "tv",
19 | }
20 |
21 | TMDB_LANG = "zh-CN"
22 |
23 |
24 | def login():
25 | """
26 | Logs in to the TMDB API.
27 |
28 | Returns:
29 | tuple: A tuple containing a boolean value indicating the success of the login and an error message if login fails.
30 | """
31 | login_url = f"{TMDB_API}/authentication"
32 | try:
33 | response = requests.get(login_url, headers=TMDB_API_HEADERS, timeout=5)
34 | response.raise_for_status()
35 | log.logger.info("TMDB login successful.")
36 | except requests.exceptions.ConnectionError as e:
37 | log.logger.error(f"TMDB login failed. Check network connection: {e}")
38 | raise e
39 | except requests.exceptions.RequestException as e:
40 | log.logger.error(f"TMDB login failed. {response.json()['status_message']} Current API token: {TMDB_API_TOKEN}")
41 | raise e
42 |
43 |
44 | def search_media(media_type, name, year):
45 | """
46 | Search for movies or TV shows on TMDB API.
47 |
48 | Args:
49 | type (str): The type of media to search for. Valid values are 'Movie' or 'Episode'.
50 | name (str): The name of the movie or TV show to search for.
51 | year (int): The year of release for the movie or TV show.
52 |
53 | Returns:
54 | list: A list of search results as dictionaries.
55 | str: An error message if the search fails.
56 | """
57 | media_type = (
58 | TMDB_MEDIA_TYPES[media_type] if media_type in TMDB_MEDIA_TYPES else media_type
59 | )
60 | search_url = f"{TMDB_API}/search/{media_type}?query={name}&language={TMDB_LANG}&page=1"
61 | if year != -1:
62 | search_url += f"&year={year}"
63 | try:
64 | response = requests.get(search_url, headers=TMDB_API_HEADERS)
65 | response.raise_for_status()
66 | return response.json().get("results", []), None
67 | except requests.exceptions.RequestException as e:
68 | return (
69 | [],
70 | f"TMDB search for {name} failed. Check network connection or API token: {e}",
71 | )
72 |
73 |
74 | def get_external_ids(media_type, tmdb_id):
75 | """
76 | Fetches the external IDs for a given media type and TMDB ID.
77 |
78 | Args:
79 | media_type (str): The type of media (e.g., 'Movie', 'Episode').
80 | tmdb_id (int): The TMDB ID of the media.
81 |
82 | Returns:
83 | tuple: A tuple containing the response JSON and an error message (if any).
84 | - response_json (dict): The JSON response containing the external IDs.
85 | - error_message (str): An error message if the request fails, otherwise None.
86 | """
87 | media_type = (
88 | TMDB_MEDIA_TYPES[media_type] if media_type in TMDB_MEDIA_TYPES else media_type
89 | )
90 | external_ids_url = (
91 | f"{TMDB_API}/{media_type}/{tmdb_id}/external_ids?language={TMDB_LANG}"
92 | )
93 | try:
94 | response = requests.get(external_ids_url, headers=TMDB_API_HEADERS)
95 | response.raise_for_status()
96 | return response.json(), None
97 | except requests.exceptions.RequestException as e:
98 | return None, f"Failed to fetch external ids for TMDB ID {tmdb_id}. Error: {e}"
99 |
100 |
101 | def get_movie_details(tmdb_id):
102 | """
103 | Fetches detailed information about a movie from TMDB.
104 |
105 | Args:
106 | tmdb_id (int): The TMDB ID of the movie.
107 |
108 | Returns:
109 | tuple: A tuple containing the response JSON and an error message (if any).
110 | - response_json (dict): The JSON response containing the movie details.
111 | - error_message (str): An error message if the request fails, otherwise None.
112 | """
113 | movie_details_url = f"{TMDB_API}/movie/{tmdb_id}?language={TMDB_LANG}"
114 | try:
115 | response = requests.get(movie_details_url, headers=TMDB_API_HEADERS)
116 | response.raise_for_status()
117 | return response.json(), None
118 | except requests.exceptions.RequestException as e:
119 | return None, f"Failed to fetch movie details for TMDB ID {tmdb_id}. Error: {e}"
120 |
121 |
122 | def get_movie_poster(tmdb_id):
123 | """
124 | Fetches the poster URL for a movie from TMDB.
125 |
126 | Args:
127 | tmdb_id (int): The TMDB ID of the movie.
128 |
129 | Returns:
130 | tuple: A tuple containing the poster URL and an error message (if any).
131 | - poster_url (str): The URL of the movie poster.
132 | - error_message (str): An error message if the request fails, otherwise None.
133 | """
134 | movie_details, err_info = get_movie_details(tmdb_id)
135 | if movie_details:
136 | poster_path = movie_details.get("poster_path")
137 | if poster_path:
138 | poster_url = f"{TMDB_IMAGE_DOMAIN}/t/p/w500{poster_path}"
139 | return poster_url, None
140 | return None, f"No poster path found for movie {tmdb_id}."
141 | return None, err_info
142 |
143 |
144 | def get_tv_season_details(tmdb_id, season_number):
145 | """
146 | Fetches detailed information about a specific season of a TV show from TMDB.
147 |
148 | Args:
149 | tmdb_id (int): The TMDB ID of the TV show.
150 | season_number (int): The season number.
151 |
152 | Returns:
153 | tuple: A tuple containing the response JSON and an error message (if any).
154 | - response_json (dict): The JSON response containing the season details.
155 | - error_message (str): An error message if the request fails, otherwise None.
156 | """
157 | tv_season_url = (
158 | f"{TMDB_API}/tv/{tmdb_id}/season/{season_number}?language={TMDB_LANG}"
159 | )
160 | try:
161 | response = requests.get(tv_season_url, headers=TMDB_API_HEADERS)
162 | response.raise_for_status()
163 | return response.json(), None
164 | except requests.exceptions.RequestException as e:
165 | return (
166 | None,
167 | f"Failed to fetch TV season details for season {season_number}. Error: {e}",
168 | )
169 |
170 |
171 | def get_tv_season_poster(tmdb_id, season_number):
172 | """
173 | Fetches the poster URL for a specific season of a TV show from TMDB.
174 |
175 | Args:
176 | tmdb_id (int): The TMDB ID of the TV show.
177 | season_number (int): The season number.
178 |
179 | Returns:
180 | tuple: A tuple containing the poster URL and an error message (if any).
181 | - poster_url (str): The URL of the season poster.
182 | - error_message (str): An error message if the request fails, otherwise None.
183 | """
184 | season_details, err_info = get_tv_season_details(tmdb_id, season_number)
185 | if season_details:
186 | poster_path = season_details.get("poster_path")
187 | if poster_path:
188 | poster_url = f"{TMDB_IMAGE_DOMAIN}/t/p/w500{poster_path}"
189 | return poster_url, None
190 | return None, "No poster path found for TV {tmdb_id} season {season_number}."
191 | return None, err_info
192 |
193 |
194 | def get_tv_episode_details(tmdb_id, season_number, episode_number):
195 | """
196 | Fetches detailed information about a specific episode of a TV show from TMDB.
197 |
198 | Args:
199 | tmdb_id (int): The TMDB ID of the TV show.
200 | season_number (int): The season number of the episode.
201 | episode_number (int): The episode number within the season.
202 |
203 | Returns:
204 | tuple: A tuple containing the response JSON and an error message (if any).
205 | - response_json (dict): The JSON response containing the episode details.
206 | - error_message (str): An error message if the request fails, otherwise None.
207 | """
208 | tv_episode_url = f"{TMDB_API}/tv/{tmdb_id}/season/{season_number}/episode/{episode_number}?language={TMDB_LANG}"
209 | try:
210 | response = requests.get(tv_episode_url, headers=TMDB_API_HEADERS)
211 | response.raise_for_status()
212 | return response.json(), None
213 | except requests.exceptions.RequestException as e:
214 | return (
215 | None,
216 | f"Failed to fetch TV episode details for S{season_number}E{episode_number}. Error: {e}",
217 | )
218 |
219 |
220 | def get_movie_backdrop_path(tmdb_id):
221 | """
222 | Get the backdrop path for a movie based on its TMDB ID.
223 |
224 | Args:
225 | tmdb_id (int): The TMDB ID of the movie.
226 |
227 | Returns:
228 | tuple: A tuple containing the backdrop path (str) and an error message (str).
229 | If the backdrop path is found, the error message will be None.
230 | If the backdrop path is not found, the backdrop path will be None and the error message will contain the movie ID.
231 | """
232 | movie_details, err_info = get_movie_details(tmdb_id)
233 | if movie_details:
234 | backdrop_path = movie_details.get("backdrop_path")
235 | if backdrop_path:
236 | return f"{TMDB_IMAGE_DOMAIN}/t/p/w500{backdrop_path}", None
237 | return None, f"No backdrop path found for movie {tmdb_id}."
238 | return None, err_info
239 |
240 |
241 | def get_tv_episode_still_paths(tmdb_id, season_number, episode_number):
242 | episode_details, err = get_tv_episode_details(tmdb_id, season_number, episode_number)
243 | if episode_details:
244 | still = episode_details.get("still_path")
245 | if still:
246 | return f"{TMDB_IMAGE_DOMAIN}/t/p/w500{still}", None
247 | return None, f"No stills found for TV {tmdb_id} S{season_number}E{episode_number}."
--------------------------------------------------------------------------------
/tvdb_api.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/python3
2 | # -*- coding: UTF-8 -*-
3 |
4 | import requests, os
5 | import log
6 |
7 | TVDB_API = "https://api4.thetvdb.com/v4"
8 |
9 | TVDB_API_KEY = os.getenv("TVDB_API_KEY")
10 |
11 | TVDB_API_TOKEN = ""
12 |
13 | TVDB_API_HEADERS = {
14 | "accept": "application/json",
15 | "Authorization": "Bearer {TVDB_API_TOKEN}"
16 | }
17 |
18 |
19 | def login():
20 | """
21 | Log in to the TVDB API and get an access token.
22 |
23 | Returns:
24 | str: The access token.
25 | str: An error message if the login fails.
26 | """
27 | login_url = f"{TVDB_API}/login"
28 | login_data = {"apikey": TVDB_API_KEY}
29 | try:
30 | response = requests.post(login_url, json=login_data, headers=TVDB_API_HEADERS)
31 | response.raise_for_status()
32 | log.logger.info("TVDB login successful.")
33 | global TVDB_API_TOKEN
34 | TVDB_API_TOKEN = response.json().get("data", {}).get("token", "")
35 | log.logger.debug(response.json())
36 | TVDB_API_HEADERS["Authorization"] = f"Bearer {TVDB_API_TOKEN}"
37 | return TVDB_API_TOKEN, None
38 | except requests.exceptions.RequestException as e:
39 | if response.status_code == 401:
40 | log.logger.error(f"TVDB login failed. {response.json()['message']} Current API key: {TVDB_API_KEY}")
41 | return None, f"TVDB login failed. Check network connection or API key: {e}"
42 |
43 |
44 | def get_seriesid_by_episodeid(episode_id):
45 | """
46 | Get the series ID for a given episode ID.
47 |
48 | Args:
49 | episode_id (int): The TVDB episode ID.
50 |
51 | Returns:
52 | int: The TVDB series ID.
53 | str: An error message if the request fails.
54 | """
55 | global TVDB_API_TOKEN
56 | log.logger.debug(f"TVDB_API_TOKEN: {TVDB_API_TOKEN}, TVDB_API_KEY: {TVDB_API_KEY}")
57 | for _ in range(2):
58 | if TVDB_API_TOKEN == "" or TVDB_API_TOKEN is None:
59 | TVDB_API_TOKEN, err = login()
60 | if err:
61 | return None, err
62 | episode_url = f"{TVDB_API}/episodes/{episode_id}"
63 | try:
64 | response = requests.get(episode_url, headers=TVDB_API_HEADERS)
65 | response.raise_for_status()
66 | return response.json().get("data", {}).get("seriesId"), None
67 | except requests.exceptions.RequestException as e:
68 | if e.response.status_code == 401:
69 | TVDB_API_TOKEN = ""
70 | continue
71 | else:
72 | return None, f"TVDB episode ID {episode_id} not found: {e}"
73 | return None, f"TVDB episode ID {episode_id} not found"
74 |
75 | def search_series(series_name, year):
76 | """
77 | Search for a TV series by name and year.
78 |
79 | Args:
80 | series_name (str): The name of the TV series.
81 | year (int): The year the series was released.
82 |
83 | Returns:
84 | int: The TVDB series ID.
85 | str: An error message if the request fails.
86 | """
87 | global TVDB_API_TOKEN
88 | for _ in range(2):
89 | if TVDB_API_TOKEN == "" or TVDB_API_TOKEN is None:
90 | TVDB_API_TOKEN, err = login()
91 | if err:
92 | return None, err
93 | search_url = f"{TVDB_API}/search?query={series_name}&type=series&year={year}"
94 | try:
95 | response = requests.get(search_url, headers=TVDB_API_HEADERS)
96 | response.raise_for_status()
97 | for series in response.json().get("data", []):
98 | if series.get("name") == series_name:
99 | return series.get("tvdb_id"), None
100 | return None, f"No complete match for TVDB series {series_name} found."
101 | except requests.exceptions.RequestException as e:
102 | if e.response.status_code == 401:
103 | TVDB_API_TOKEN = ""
104 | continue
105 | else:
106 | break
107 | return None, f"TVDB series {series_name} not found: {e}"
108 |
109 |
--------------------------------------------------------------------------------
/wxapp.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/python3
2 | # -*- coding: UTF-8 -*-
3 |
4 | import requests, json, os
5 | import time
6 | import log
7 |
8 |
9 | # 企业 id
10 | CORP_ID = os.getenv("WECHAT_CORP_ID")
11 | # 应用的凭证密钥
12 | CORP_SECRET = os.getenv("WECHAT_CORP_SECRET")
13 | # 应用的 agentid
14 | AGENT_ID = int(os.getenv("WECHAT_AGENT_ID", "0"))
15 |
16 | # 用户 id,推送给所有人时,设置为 "@all"
17 | USER_ID = os.getenv("WECHAT_USER_ID", "@all")
18 |
19 | # TOKEN
20 | TOKEN = {
21 | "access_token": None,
22 | "expires_in": 7200,
23 | "expires_time": None,
24 | }
25 |
26 | TOKEN_FILE = "_tmp_wechat.json"
27 |
28 | # 获取应用 token 的 url
29 | GET_TOKEN_URL = (
30 | "https://qyapi.weixin.qq.com/cgi-bin/gettoken?"
31 | + f"corpid={CORP_ID}&corpsecret={CORP_SECRET}"
32 | )
33 |
34 | # 消息推送 url
35 | SEND_MSG_URL = "https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token="
36 |
37 |
38 | def get_access_token():
39 | """
40 | Retrieves the access token required for authentication.
41 |
42 | Returns:
43 | str: The access token.
44 |
45 | Raises:
46 | requests.exceptions.ConnectionError: If there is a connection error.
47 | Exception: If there is an error retrieving the access token.
48 | """
49 | global TOKEN
50 | current_time = time.time()
51 | if TOKEN["access_token"] and TOKEN["expires_time"] > current_time:
52 | return TOKEN["access_token"]
53 |
54 | if os.path.exists(TOKEN_FILE):
55 | with open(TOKEN_FILE, "r") as f:
56 | TOKEN = json.load(f)
57 | if TOKEN["expires_time"] > current_time:
58 | return TOKEN["access_token"]
59 |
60 | try:
61 | res = requests.get(GET_TOKEN_URL)
62 | res.raise_for_status()
63 | if res.json()["errcode"] != 0:
64 | raise Exception(f"{res.text}")
65 | log.logger.debug(log.SensitiveData(res.text))
66 | # Update token
67 | TOKEN["access_token"] = res.json()["access_token"]
68 | TOKEN["expires_in"] = res.json()["expires_in"]
69 | TOKEN["expires_time"] = current_time + TOKEN["expires_in"]
70 | log.logger.info(
71 | f"Update access token successful. Token: {TOKEN['access_token']}"
72 | )
73 |
74 | with open(TOKEN_FILE, "w") as f:
75 | json.dump(TOKEN, f)
76 |
77 | return TOKEN["access_token"]
78 | except requests.exceptions.ConnectionError as e:
79 | log.logger.error(f"Get access token failed. Check network connection: {e}")
80 | raise e
81 | except Exception as e:
82 | log.logger.error(f"Get access token failed. Error: {e}")
83 | raise e
84 |
85 |
86 | def send_text(content):
87 | payload = {
88 | "touser": USER_ID,
89 | "agentid": AGENT_ID,
90 | "safe": 0,
91 | "msgtype": "text",
92 | "text": {
93 | "content": content,
94 | },
95 | }
96 | log.logger.debug(log.SensitiveData(json.dumps(payload, ensure_ascii=False)))
97 |
98 | send_msg_url = SEND_MSG_URL + get_access_token()
99 | try:
100 | res = requests.post(send_msg_url, json=payload)
101 | res.raise_for_status()
102 | if res.json()["errcode"] != 0:
103 | raise Exception(res.text)
104 | log.logger.debug(f"Send text message successful. Response: {res.json()}")
105 | except requests.exceptions.ConnectionError as e:
106 | log.logger.error(f"Send text message failed. Check network connection: {e}")
107 | raise e
108 | except Exception as e:
109 | log.logger.error(f"Send text message failed. Error: {e}")
110 | raise e
111 |
112 |
113 |
114 | def send_markdown(content):
115 | payload = {
116 | "touser": USER_ID,
117 | "agentid": AGENT_ID,
118 | "safe": 0,
119 | "msgtype": "markdown",
120 | "markdown": {
121 | "content": content,
122 | },
123 | }
124 | log.logger.debug(log.SensitiveData(json.dumps(payload, ensure_ascii=False)))
125 |
126 | send_msg_url = SEND_MSG_URL + get_access_token()
127 | try:
128 | res = requests.post(send_msg_url, json=payload)
129 | res.raise_for_status()
130 | if res.json()["errcode"] != 0:
131 | raise Exception(res.text)
132 | log.logger.debug(f"Send markdown message successful. Response: {res.json()}")
133 | except requests.exceptions.ConnectionError as e:
134 | log.logger.error(f"Send markdown message failed. Check network connection: {e}")
135 | raise e
136 | except Exception as e:
137 | log.logger.error(f"Send markdown message failed. Error: {e}")
138 | raise e
139 |
140 |
141 | def send_news(article):
142 | payload = {
143 | "touser": USER_ID,
144 | "agentid": AGENT_ID,
145 | "msgtype": "news",
146 | "news": {
147 | "articles": [article]
148 | }
149 | }
150 | log.logger.debug(log.SensitiveData(json.dumps(payload, ensure_ascii=False)))
151 |
152 | send_msg_url = SEND_MSG_URL + get_access_token()
153 | try:
154 | res = requests.post(send_msg_url, json=payload)
155 | res.raise_for_status()
156 | if res.json()["errcode"] != 0:
157 | raise Exception(f"Send news failed. {res.text}")
158 | log.logger.debug(f"Send news successful. Response: {res.json()}")
159 | except requests.exceptions.ConnectionError as e:
160 | log.logger.error(f"Send news failed. Check network connection: {e}")
161 | raise e
162 | except Exception as e:
163 | log.logger.error(f"Send news failed. Error: {e}")
164 | raise e
165 |
166 |
167 | def send_news_notice(card_detail):
168 | payload = {
169 | "touser": USER_ID,
170 | "agentid": AGENT_ID,
171 | "safe": 0,
172 | "msgtype": "template_card",
173 | "template_card": card_detail,
174 | }
175 | log.logger.debug(log.SensitiveData(json.dumps(payload, ensure_ascii=False)))
176 |
177 | send_msg_url = SEND_MSG_URL + get_access_token()
178 | try:
179 | res = requests.post(send_msg_url, json=payload)
180 | res.raise_for_status()
181 | if res.json()["errcode"] != 0:
182 | raise Exception(f"Send news notice failed. {res.text}")
183 | log.logger.debug(f"Send news notice successful. Response: {res.json()}")
184 | except requests.exceptions.ConnectionError as e:
185 | log.logger.error(f"Send news notice failed. Check network connection: {e}")
186 | raise e
187 | except Exception as e:
188 | log.logger.error(f"Send news notice failed. Error: {e}")
189 | raise e
190 |
191 |
192 | def send_welcome_card(welcome):
193 | payload = {
194 | "touser": USER_ID,
195 | "agentid": AGENT_ID,
196 | "safe": 0,
197 | "msgtype": "template_card",
198 | "template_card": {
199 | "card_type": "text_notice",
200 | "source": {
201 | "desc": "🚀 Emby Notifier",
202 | "desc_color": 0,
203 | },
204 | "main_title": {
205 | "title": f"🎉 {welcome['content']}",
206 | },
207 | "quote_area": {
208 | "quote_text": f"{welcome['intro']}",
209 | },
210 | "horizontal_content_list": [
211 | {"keyname": "Author", "value": f"{welcome['author']}"},
212 | {"keyname": "Version", "value": f"{welcome['version']}"},
213 | {"keyname": "Update Time", "value": f"{welcome['update_time']}"},
214 | ],
215 | "jump_list": [
216 | {
217 | "type": 1,
218 | "url": f"{welcome['repo']}",
219 | "title": "👾 github",
220 | },
221 | ],
222 | "card_action": {
223 | "type": 1,
224 | "url": f"{welcome['repo']}",
225 | },
226 | },
227 | }
228 | log.logger.debug(log.SensitiveData(json.dumps(payload, ensure_ascii=False)))
229 |
230 | send_msg_url = SEND_MSG_URL + get_access_token()
231 | try:
232 | res = requests.post(send_msg_url, json=payload)
233 | res.raise_for_status()
234 | if res.json()["errcode"] != 0:
235 | raise Exception(f"{res.text}")
236 | log.logger.debug(f"Send news notice successful. Response: {res.json()}")
237 | except requests.exceptions.ConnectionError as e:
238 | log.logger.error(f"Send news notice failed. Check network connection: {e}")
239 | raise e
240 | except Exception as e:
241 | log.logger.error(f"Send news notice failed. Error: {e}")
242 | raise e
243 |
--------------------------------------------------------------------------------