├── .gitignore ├── LICENSE ├── MANIFEST.in ├── README.rst ├── build.sh ├── moviebotapi ├── __init__.py ├── amr.py ├── auth.py ├── common.py ├── config.py ├── core │ ├── __init__.py │ ├── basemodel.py │ ├── country_mapping.txt │ ├── decorators.py │ ├── exceptions.py │ ├── models.py │ ├── session.py │ └── utils.py ├── douban.py ├── downloader.py ├── event.py ├── ext.py ├── library.py ├── mediaserver.py ├── meta.py ├── notify.py ├── plugin.py ├── scraper.py ├── site.py ├── subscribe.py ├── tmdb.py └── user.py ├── requirements.txt ├── setup.py ├── tests ├── __init__.py ├── constant.py ├── image.png ├── requirements.txt ├── test_amr.py ├── test_auth.py ├── test_common.py ├── test_config.py ├── test_douban.py ├── test_event.py ├── test_library.py ├── test_media_server.py ├── test_meta.py ├── test_notify.py ├── test_plugin.py ├── test_scraper.py ├── test_site.py ├── test_subscribe.py ├── test_tmdb.py └── test_user.py └── tools ├── __init__.py └── plugin.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | .idea/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 yipengfei 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | recursive-include moviebotapi/core *.txt -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | MovieBot Python API 2 | ==================== 3 | 4 | .. image:: https://img.shields.io/pypi/v/movie-bot-api.svg 5 | :target: https://img.shields.io/pypi/v/movie-bot-api.svg 6 | :alt: PyPI version info 7 | .. image:: https://img.shields.io/pypi/pyversions/movie-bot-api.svg 8 | :target: https://pypi.python.org/pypi/movie-bot-api 9 | :alt: PyPI supported Python versions 10 | 11 | 易于使用的,灵活的,可扩展的,Movie Bot智能影音机器人官方Python API包装器 12 | 13 | 安装 14 | ------------- 15 | 使用pip安装: 16 | 17 | .. code:: sh 18 | 19 | # Linux/macOS 20 | python3 -m pip install -U movie-bot-api 21 | 22 | # Windows 23 | py -3 -m pip install -U movie-bot-api -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | python setup.py sdist bdist_wheel 2 | python -m twine upload dist/* -------------------------------------------------------------------------------- /moviebotapi/__init__.py: -------------------------------------------------------------------------------- 1 | from moviebotapi.core.session import Session 2 | from moviebotapi.douban import DoubanApi 3 | from moviebotapi.event import EventApi 4 | from moviebotapi.library import LibraryApi 5 | from moviebotapi.mediaserver import MediaServerApi 6 | from moviebotapi.meta import MetaApi 7 | from moviebotapi.notify import NotifyApi 8 | from moviebotapi.plugin import PluginApi 9 | from moviebotapi.scraper import ScraperApi 10 | from moviebotapi.site import SiteApi 11 | from moviebotapi.subscribe import SubscribeApi 12 | from moviebotapi.tmdb import TmdbApi 13 | from moviebotapi.user import UserApi 14 | from moviebotapi.config import ConfigApi 15 | from moviebotapi.common import CommonApi 16 | from moviebotapi.amr import AmrApi 17 | from moviebotapi.auth import AuthApi 18 | 19 | 20 | class MovieBotServer: 21 | session: Session 22 | user: UserApi 23 | subscribe: SubscribeApi 24 | scraper: ScraperApi 25 | douban: DoubanApi 26 | tmdb: TmdbApi 27 | site: SiteApi 28 | notify: NotifyApi 29 | config: ConfigApi 30 | meta: MetaApi 31 | common: CommonApi 32 | amr: AmrApi 33 | library: LibraryApi 34 | auth: AuthApi 35 | event: EventApi 36 | media_server: MediaServerApi 37 | plugin: PluginApi 38 | 39 | def __init__(self, session: Session = None): 40 | if session: 41 | self.set_session(session) 42 | 43 | def set_session(self, session: Session): 44 | self.session = session 45 | self.config = ConfigApi(session) 46 | self.user = UserApi(session) 47 | self.subscribe = SubscribeApi(session) 48 | self.scraper = ScraperApi(session) 49 | self.douban = DoubanApi(session) 50 | self.tmdb = TmdbApi(session) 51 | self.site = SiteApi(session) 52 | self.notify = NotifyApi(session) 53 | self.meta = MetaApi(session) 54 | self.common = CommonApi(session) 55 | self.amr = AmrApi(session) 56 | self.library = LibraryApi(session) 57 | self.auth = AuthApi(session) 58 | self.event = EventApi(session) 59 | self.media_server = MediaServerApi(session) 60 | self.plugin = PluginApi(session) 61 | -------------------------------------------------------------------------------- /moviebotapi/amr.py: -------------------------------------------------------------------------------- 1 | from typing import List, Dict, Optional 2 | 3 | from moviebotapi import Session 4 | from moviebotapi.core import utils 5 | from moviebotapi.core.basemodel import BaseModel 6 | from moviebotapi.core.models import MediaType 7 | 8 | 9 | class MediaNameMeta(BaseModel): 10 | filepath: Optional[str] = None 11 | cn_name: str = None 12 | aka_names: List[str] = [] 13 | en_name: str = None 14 | year: int = None 15 | season_number: List[int] = None 16 | episode_number: List[int] = None 17 | resolution: str = None 18 | media_source: str = None 19 | media_codec: str = None 20 | media_audio: List[str] = None 21 | release_team: str = None 22 | tmdb_id: int = None 23 | media_type: MediaType = None 24 | 25 | def __init__(self, data: Dict = None): 26 | utils.copy_value(data, self) 27 | 28 | 29 | class TVMeta(BaseModel): 30 | season_start: int = None 31 | season_end: int = None 32 | season_full_index: List[int] = [] 33 | ep_start: int = None 34 | ep_end: int = None 35 | ep_full_index: List[int] = [] 36 | season_is_fill: bool = False 37 | ep_is_fill: bool = False 38 | contains_complete_ep: bool = False 39 | contains_complete_season: bool = False 40 | contains_multiple_season: bool = False 41 | 42 | def __init__(self, data: Dict = None): 43 | utils.copy_value(data, self) 44 | 45 | 46 | class MetaSearchResult(BaseModel): 47 | tmdb_id: int = None 48 | douban_id: int = None 49 | cn_name: str = None 50 | original_name: str = None 51 | release_year: int = None 52 | release_date: str = None 53 | media_type: MediaType = None 54 | season_number: int = None 55 | 56 | def __init__(self, data: Dict): 57 | utils.copy_value(data, self) 58 | 59 | 60 | class AmrApi: 61 | """ 62 | Automatic media Recognition 63 | 自动媒体信息分析接口 64 | """ 65 | 66 | def __init__(self, session: Session): 67 | self._session: Session = session 68 | 69 | def parse_name_meta_by_string(self, string: str) -> Optional[MediaNameMeta]: 70 | """ 71 | 根据一个字符串(种子名、文件名等)解析出名称中包含的影片信息 72 | """ 73 | res = self._session.get('amr.parse_name_meta_by_string', { 74 | 'string': string 75 | }) 76 | if not res: 77 | return 78 | return MediaNameMeta(res) 79 | 80 | def parse_name_meta_by_filepath(self, filepath: str) -> Optional[MediaNameMeta]: 81 | """ 82 | 根据一个影片文件路径解析出影片信息 83 | 需要为完整路径,会根据分析上级目录的有效信息进行解析 84 | """ 85 | res = self._session.get('amr.parse_name_meta_by_filepath', { 86 | 'filepath': filepath 87 | }) 88 | if not res: 89 | return 90 | return MediaNameMeta(res) 91 | 92 | def analysis_string(self, string: str): 93 | """ 94 | 根据一个字符串(种子名、文件名等),分析出它对应的影片信息,包括TMDBID 95 | """ 96 | res = self._session.get('amr.analysis_string', { 97 | 'string': string 98 | }) 99 | if not res: 100 | return 101 | return MetaSearchResult(res) 102 | 103 | def analysis_filepath(self, filepath: str): 104 | """ 105 | 根据一个影片文件路径,分析出它对应的影片信息,包括TMDBID 106 | """ 107 | res = self._session.get('amr.analysis_filepath', { 108 | 'string': filepath 109 | }) 110 | if not res: 111 | return 112 | return MetaSearchResult(res) 113 | 114 | def analysis_douban_meta(self, cn_name: Optional[str], en_name: Optional[str] = None, year: Optional[int] = None, 115 | season_number: Optional[int] = None): 116 | """ 117 | 根据提供的信息,分析出豆瓣的关联影片 118 | """ 119 | res = self._session.get('amr.analysis_douban_meta', { 120 | 'cn_name': cn_name, 121 | 'en_name': en_name, 122 | 'year': year, 123 | 'season_number': season_number 124 | }) 125 | if not res: 126 | return 127 | return MetaSearchResult(res) 128 | -------------------------------------------------------------------------------- /moviebotapi/auth.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, List, Optional 2 | 3 | from moviebotapi import Session 4 | 5 | 6 | class AuthApi: 7 | def __init__(self, session: Session): 8 | self._session: Session = session 9 | 10 | def get_default_ak(self, ) -> Dict: 11 | return self._session.get('auth.get_default_ak') 12 | 13 | def add_permission(self, role_code: List[int], uri: str): 14 | """ 15 | 为角色授权可访问的URI 16 | :param role_code: 角色码 1为管理员 2为普通用户,目前固定不变 17 | :param uri: 权限点URI 18 | :return: 19 | """ 20 | self._session.post('auth.add_permission', { 21 | 'role_code': role_code, 22 | 'uri': uri 23 | }) 24 | 25 | def get_cloud_access_token(self) -> Optional[str]: 26 | return self._session.get('auth.get_cloud_access_token') 27 | -------------------------------------------------------------------------------- /moviebotapi/common.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Dict, List 2 | 3 | from moviebotapi import Session 4 | from moviebotapi.core import utils 5 | from moviebotapi.core.utils import json_object 6 | 7 | 8 | @json_object 9 | class MenuSubItem: 10 | href: str 11 | icon: str 12 | title: str 13 | 14 | def __init__(self, data: Dict = None): 15 | if data: 16 | utils.copy_value(data, self) 17 | 18 | 19 | @json_object 20 | class MenuItem: 21 | href: str 22 | """ 23 | icon的值就是MUI icon的名称 24 | https://mui.com/material-ui/material-icons/ 25 | """ 26 | icon: str 27 | title: str 28 | children: List[MenuSubItem] 29 | 30 | def __init__(self, data: Dict = None): 31 | if data: 32 | utils.copy_value(data, self) 33 | 34 | 35 | @json_object 36 | class MenuGroup: 37 | title: str 38 | pages: List[MenuItem] 39 | 40 | def __init__(self, data: Dict): 41 | utils.copy_value(data, self) 42 | 43 | 44 | class CommonApi: 45 | def __init__(self, session: Session): 46 | self._session: Session = session 47 | 48 | def restart_app(self, secs: Optional[int] = 3): 49 | self._session.get('common.restart_app', { 50 | 'secs': secs 51 | }) 52 | 53 | def get_cache(self, namespace: str, key: str) -> Dict: 54 | return self._session.get('common.get_cache', { 55 | 'namespace': namespace, 56 | 'key': key 57 | }) 58 | 59 | def set_cache(self, namespace: str, key: str, data: Dict): 60 | self._session.post('common.set_cache', { 61 | 'namespace': namespace, 62 | 'key': key, 63 | 'data': data 64 | }) 65 | 66 | def get_image_text(self, b64_img: str): 67 | return self._session.post('common.get_image_text', { 68 | 'b64_image': b64_img 69 | }) 70 | 71 | def get_cache_image_filepath(self, img_url: str) -> str: 72 | return self._session.get('common.get_cache_image_filepath', { 73 | 'url': img_url 74 | }) 75 | 76 | def list_menus(self) -> List[MenuGroup]: 77 | items = self._session.get('common.get_menus') 78 | if not items: 79 | return [] 80 | return [MenuGroup(x) for x in items] 81 | 82 | def save_menus(self, menus: List[MenuGroup]): 83 | if not menus: 84 | return 85 | self._session.post('common.save_menus', { 86 | 'menus': [x.to_json() for x in menus] 87 | }) 88 | -------------------------------------------------------------------------------- /moviebotapi/config.py: -------------------------------------------------------------------------------- 1 | from typing import Dict 2 | 3 | import yaml 4 | 5 | from moviebotapi import Session 6 | from moviebotapi.core import utils 7 | from moviebotapi.core.utils import json_object 8 | 9 | 10 | @json_object 11 | class DoubanConfig: 12 | cookie: str 13 | 14 | def __init__(self, data: Dict, session: Session): 15 | utils.copy_value(data, self) 16 | self._ = session 17 | 18 | def save(self): 19 | """ 20 | 保存豆瓣配置,保存过程服务端会检查cookie的有效性,不是简单的保存配置文件 21 | """ 22 | self._.post('setting.save_douban', json={ 23 | 'cookie': self.cookie 24 | }) 25 | 26 | 27 | @json_object 28 | class FreeDownloadConfig: 29 | available_space: int 30 | avg_statistics_period: int 31 | enable: bool 32 | maximum_active_torrent: int 33 | save_path: str 34 | upload_mbps_maximum: int 35 | 36 | def __init__(self, data: Dict, session: Session): 37 | utils.copy_value(data, self) 38 | self._ = session 39 | 40 | def save(self): 41 | self._.post('setting.save_free_download', json={ 42 | 'enable': self.enable, 43 | 'save_path': self.save_path, 44 | 'available_space': self.available_space, 45 | 'avg_statistics_period': self.avg_statistics_period, 46 | 'upload_mbps_maximum': self.upload_mbps_maximum, 47 | 'maximum_active_torrent': self.maximum_active_torrent 48 | }) 49 | 50 | 51 | @json_object 52 | class WebConfig: 53 | """web访问配置""" 54 | host: str 55 | port: int 56 | # 外网访问地址 57 | server_url: str 58 | 59 | def __init__(self, data: Dict, session: Session): 60 | utils.copy_value(data, self) 61 | self._ = session 62 | 63 | def save(self): 64 | self._.post('setting.save_web', json={ 65 | 'host': self.host, 66 | 'port': self.port, 67 | 'server_url': self.server_url 68 | }) 69 | 70 | 71 | @json_object 72 | class Env: 73 | config_dir: str 74 | user_config_dir: str 75 | site_config_dir: str 76 | plugin_dir: str 77 | 78 | def __init__(self, data: Dict): 79 | utils.copy_value(data, self) 80 | 81 | 82 | class ConfigApi: 83 | def __init__(self, session: Session): 84 | self._session: Session = session 85 | 86 | @property 87 | def douban(self): 88 | return DoubanConfig(self._session.get('setting.get_douban'), self._session) 89 | 90 | @property 91 | def free_download(self): 92 | return FreeDownloadConfig(self._session.get('setting.get_free_download'), self._session) 93 | 94 | @property 95 | def web(self): 96 | return WebConfig(self._session.get('setting.get_web'), self._session) 97 | 98 | @property 99 | def env(self): 100 | return Env(self._session.get('setting.get_env')) 101 | 102 | def register_channel_template(self, tmpl_filepath: str): 103 | """ 104 | 注册一个通道模版文件 105 | 模版示范可以参考conf/notify_template 目录内自带模版文件,此模版内包含了所有系统通知的内容格式 106 | :param tmpl_filepath: 模版文件路径 107 | :return: 108 | """ 109 | with open(tmpl_filepath, 'r', encoding='utf-8') as file: 110 | tmpl = yaml.safe_load(file) 111 | self._session.post('setting.register_channel_template', tmpl) 112 | -------------------------------------------------------------------------------- /moviebotapi/core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pofey/movie-bot-api/3deaec99270c35e6e23d4a963383aea4ef25b7d0/moviebotapi/core/__init__.py -------------------------------------------------------------------------------- /moviebotapi/core/basemodel.py: -------------------------------------------------------------------------------- 1 | from moviebotapi.core import utils 2 | 3 | 4 | class BaseModel(object): 5 | 6 | def to_json(self, hidden_fields=None): 7 | """ 8 | Json序列化 9 | :param hidden_fields: 覆盖类属性 hidden_fields 10 | :return: 11 | """ 12 | model_json = {} 13 | if not hidden_fields: 14 | hidden_fields = [] 15 | for column in self.__dict__: 16 | if column in hidden_fields: 17 | continue 18 | if hasattr(self, column): 19 | model_json[column] = utils.parse_field_value(getattr(self, column)) 20 | if '_sa_instance_state' in model_json: 21 | del model_json['_sa_instance_state'] 22 | return model_json 23 | -------------------------------------------------------------------------------- /moviebotapi/core/country_mapping.txt: -------------------------------------------------------------------------------- 1 | AF=其他 2 | AX=欧美 3 | AL=欧美 4 | DZ=其他 5 | AS=美国 6 | AD=欧美 7 | AO=其他 8 | AI=其他 9 | AQ=其他 10 | AG=其他 11 | AR=其他 12 | AM=其他 13 | AW=其他 14 | AU=澳大利亚 15 | AT=欧美 16 | AZ=欧美 17 | BS=其他 18 | BH=其他 19 | BD=其他 20 | BB=其他 21 | BY=其他 22 | BE=欧美 23 | BZ=其他 24 | BJ=其他 25 | BM=其他 26 | BT=其他 27 | BO=其他 28 | BQ=其他 29 | BA=欧美 30 | BW=其他 31 | BV=其他 32 | BR=巴西 33 | IO=其他 34 | BN=其他 35 | BG=欧美 36 | BF=其他 37 | BI=其他 38 | CV=其他 39 | KH=其他 40 | CM=其他 41 | CA=加拿大 42 | KY=欧美 43 | CF=其他 44 | TD=其他 45 | CL=其他 46 | CN=中国大陆 47 | CX=澳大利亚 48 | CC=其他 49 | CO=其他 50 | KM=其他 51 | CG=其他 52 | CD=其他 53 | CK=其他 54 | CR=其他 55 | CI=其他 56 | HR=欧美 57 | CU=其他 58 | CW=其他 59 | CY=欧美 60 | CZ=欧美 61 | DK=丹麦 62 | DJ=其他 63 | DM=其他 64 | DO=其他 65 | EC=其他 66 | EG=其他 67 | SV=其他 68 | GQ=其他 69 | ER=其他 70 | EE=欧美 71 | SZ=其他 72 | ET=其他 73 | FK=其他 74 | FO=欧美 75 | FJ=其他 76 | FI=欧美 77 | FR=法国 78 | GF=其他 79 | PF=其他 80 | TF=其他 81 | GA=其他 82 | GM=其他 83 | GE=欧美 84 | DE=德国 85 | GH=其他 86 | GI=欧美 87 | GR=欧美 88 | GL=其他 89 | GD=其他 90 | GP=其他 91 | GU=其他 92 | GT=其他 93 | GG=其他 94 | GN=其他 95 | GW=其他 96 | GY=其他 97 | HT=其他 98 | HM=其他 99 | VA=欧美 100 | HN=其他 101 | HK=中国香港 102 | HU=欧美 103 | IS=欧美 104 | IN=印度 105 | ID=其他 106 | IR=伊朗 107 | IQ=其他 108 | IE=爱尔兰 109 | IM=其他 110 | IL=其他 111 | IT=意大利 112 | JM=其他 113 | JP=日本 114 | JE=欧洲 115 | JO=其他 116 | KZ=其他 117 | KE=其他 118 | KI=其他 119 | KP=其他 120 | KR=韩国 121 | KW=其他 122 | KG=其他 123 | LA=其他 124 | LV=欧美 125 | LB=其他 126 | LS=其他 127 | LR=其他 128 | LY=其他 129 | LI=其他 130 | LT=欧美 131 | LU=其他 132 | MO=其他 133 | MG=其他 134 | MW=其他 135 | MY=其他 136 | MV=其他 137 | ML=其他 138 | MT=欧美 139 | MH=其他 140 | MQ=其他 141 | MR=其他 142 | MU=其他 143 | YT=其他 144 | MX=其他 145 | FM=其他 146 | MD=欧美 147 | MC=欧美 148 | MN=其他 149 | ME=其他 150 | MS=其他 151 | MA=其他 152 | MZ=其他 153 | MM=其他 154 | NA=其他 155 | NR=其他 156 | NP=其他 157 | NL=其他 158 | NC=其他 159 | NZ=其他 160 | NI=其他 161 | NE=其他 162 | NG=其他 163 | NU=其他 164 | NF=其他 165 | MK=欧美 166 | MP=其他 167 | NO=欧美 168 | OM=其他 169 | PK=其他 170 | PW=其他 171 | PS=其他 172 | PA=其他 173 | PG=其他 174 | PY=其他 175 | PE=其他 176 | PH=其他 177 | PN=其他 178 | PL=欧美 179 | PT=欧美 180 | PR=其他 181 | QA=其他 182 | RE=其他 183 | RO=欧美 184 | RU=其他 185 | RW=其他 186 | BL=其他 187 | SH=其他 188 | KN=其他 189 | LC=其他 190 | MF=其他 191 | PM=其他 192 | VC=其他 193 | WS=其他 194 | SM=欧美 195 | ST=其他 196 | SA=其他 197 | SN=其他 198 | RS=欧美 199 | SC=其他 200 | SL=其他 201 | SG=其他 202 | SX=其他 203 | SK=欧美 204 | SI=其他 205 | SB=其他 206 | SO=其他 207 | ZA=其他 208 | GS=其他 209 | SS=其他 210 | ES=西班牙 211 | LK=其他 212 | SD=其他 213 | SR=其他 214 | SJ=其他 215 | SE=瑞典 216 | CH=欧美 217 | SY=其他 218 | TW=中国台湾 219 | TJ=其他 220 | TZ=其他 221 | TH=泰国 222 | TL=其他 223 | TG=其他 224 | TK=其他 225 | TO=其他 226 | TT=其他 227 | TN=其他 228 | TR=其他 229 | TM=其他 230 | TC=其他 231 | TV=其他 232 | UG=其他 233 | UA=欧美 234 | AE=其他 235 | GB=英国 236 | US=美国 237 | UM=美国 238 | UY=其他 239 | UZ=其他 240 | VU=其他 241 | VE=其他 242 | VN=其他 243 | VG=其他 244 | VI=其他 245 | WF=其他 246 | EH=其他 247 | YE=其他 248 | ZM=其他 249 | ZW=其他 250 | 其他=其他 -------------------------------------------------------------------------------- /moviebotapi/core/decorators.py: -------------------------------------------------------------------------------- 1 | def ignore_attr_not_exists(cls): 2 | """ 3 | 忽略属性不存在的异常,正常返回空结果 4 | """ 5 | orig_getattribute = cls.__getattribute__ 6 | 7 | def getattribute(self, attr): 8 | if not hasattr(cls, attr): 9 | return 10 | return orig_getattribute(self, attr) 11 | 12 | cls.__getattribute__ = getattribute 13 | return cls 14 | -------------------------------------------------------------------------------- /moviebotapi/core/exceptions.py: -------------------------------------------------------------------------------- 1 | class MovieBotApiException(Exception): 2 | pass 3 | 4 | 5 | class NetworkErrorException(MovieBotApiException): 6 | pass 7 | 8 | 9 | class ApiErrorException(MovieBotApiException): 10 | pass 11 | 12 | 13 | class IllegalAuthorization(ApiErrorException): 14 | pass 15 | -------------------------------------------------------------------------------- /moviebotapi/core/models.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class MediaType(str, Enum): 5 | Movie = 'Movie' 6 | TV = 'TV' 7 | MultiMedia = 'MultiMedia' 8 | XX = 'XX' 9 | Other = '其他' 10 | 11 | @staticmethod 12 | def get(value): 13 | l = str(value).lower() 14 | if l == 'movie': 15 | return MediaType.Movie 16 | if l in ['tv', 'series']: 17 | return MediaType.TV 18 | if l == 'collection': 19 | return MediaType.Collection 20 | if l == 'xx': 21 | return MediaType.XX 22 | else: 23 | return MediaType.Other 24 | -------------------------------------------------------------------------------- /moviebotapi/core/session.py: -------------------------------------------------------------------------------- 1 | from abc import ABCMeta, abstractmethod 2 | from typing import Union, Dict, Sequence, Tuple, Optional, Any 3 | 4 | import httpx 5 | from httpx import Timeout 6 | 7 | from moviebotapi.core.exceptions import ApiErrorException, NetworkErrorException, IllegalAuthorization 8 | 9 | UA = 'moviebotapi/0.0.57' 10 | URLTypes = Union["URL", str] 11 | HeaderTypes = Union[ 12 | "Headers", 13 | Dict[str, str], 14 | Dict[bytes, bytes], 15 | Sequence[Tuple[str, str]], 16 | Sequence[Tuple[bytes, bytes]], 17 | ] 18 | 19 | 20 | class Session(metaclass=ABCMeta): 21 | @staticmethod 22 | def un_code(res): 23 | if res: 24 | if res.get('code') == 0: 25 | return res.get('data') 26 | if res.get('code') == 1: 27 | raise ApiErrorException(res.get('message')) 28 | else: 29 | return 30 | 31 | @abstractmethod 32 | def post(self, api_code: str, json: Optional[Any] = None, headers: Optional[HeaderTypes] = None): 33 | pass 34 | 35 | @abstractmethod 36 | def get(self, api_code: str, params: Optional[Dict[str, Any]] = None, headers: Optional[HeaderTypes] = None): 37 | pass 38 | 39 | 40 | class AccessKeySession(Session): 41 | def __init__(self, server_url: URLTypes, access_key: str): 42 | self.server_url: URLTypes = server_url 43 | self.access_key: str = access_key 44 | self.timeout = Timeout(30) 45 | 46 | def _get_headers(self, headers: Optional[HeaderTypes] = None): 47 | if not headers: 48 | headers = {} 49 | headers.update({'AccessKey': self.access_key}) 50 | headers.update({'User-Agent': UA}) 51 | return headers 52 | 53 | @staticmethod 54 | def _get_api_uri(api_code: str): 55 | if not api_code: 56 | return 57 | return '/api/' + api_code.replace('.', '/') 58 | 59 | def get(self, api_code: str, params: Optional[Dict[str, Any]] = None, headers: Optional[HeaderTypes] = None): 60 | r = httpx.get(f'{self.server_url}{self._get_api_uri(api_code)}', params=params, 61 | headers=self._get_headers(headers), 62 | timeout=self.timeout) 63 | if not r: 64 | raise NetworkErrorException() 65 | if r.status_code == 401: 66 | raise IllegalAuthorization() 67 | r.raise_for_status() 68 | return self.un_code(r.json()) 69 | 70 | def post(self, api_code: str, json: Optional[Any] = None, headers: Optional[HeaderTypes] = None): 71 | r = httpx.post(f'{self.server_url}{self._get_api_uri(api_code)}', json=json, headers=self._get_headers(headers), 72 | timeout=self.timeout) 73 | if not r: 74 | raise NetworkErrorException() 75 | r.raise_for_status() 76 | return self.un_code(r.json()) 77 | -------------------------------------------------------------------------------- /moviebotapi/core/utils.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import decimal 3 | import json 4 | import os 5 | import re 6 | from enum import Enum 7 | from typing import Dict, List, _GenericAlias, Optional 8 | 9 | import cn2an 10 | 11 | from moviebotapi.core.models import MediaType 12 | 13 | 14 | def trans_unit_to_mb(size: float, unit: str) -> float: 15 | """ 16 | 按文件大小尺寸规格,转换成MB单位的数字 17 | :param size: 18 | :param unit: 19 | :return: 20 | """ 21 | if unit == 'GB' or unit == 'GiB': 22 | return round(size * 1024, 2) 23 | elif unit == 'MB' or unit == 'MiB': 24 | return round(size, 2) 25 | elif unit == 'KB' or unit == 'KiB': 26 | return round(size / 1024, 2) 27 | elif unit == 'TB' or unit == 'TiB': 28 | return round(size * 1024 * 1024, 2) 29 | elif unit == 'PB' or unit == 'PiB': 30 | return round(size * 1024 * 1024 * 1024, 2) 31 | else: 32 | return size 33 | 34 | 35 | def trans_size_str_to_mb(size: str): 36 | """ 37 | 把一个字符串格式的文件尺寸单位,转换成MB单位的标准数字 38 | :param size: 39 | :return: 40 | """ 41 | if not size: 42 | return 0.0 43 | s = None 44 | u = None 45 | if size.find(' ') != -1: 46 | arr = size.split(' ') 47 | s = arr[0] 48 | u = arr[1] 49 | else: 50 | if size.endswith('GB'): 51 | s = size[0:-2] 52 | u = 'GB' 53 | elif size.endswith('GiB'): 54 | s = size[0:-3] 55 | u = 'GB' 56 | elif size.endswith('MB'): 57 | s = size[0:-2] 58 | u = 'MB' 59 | elif size.endswith('MiB'): 60 | s = size[0:-3] 61 | u = 'MB' 62 | elif size.endswith('KB'): 63 | s = size[0:-2] 64 | u = 'KB' 65 | elif size.endswith('KiB'): 66 | s = size[0:-3] 67 | u = 'KB' 68 | elif size.endswith('TB'): 69 | s = size[0:-2] 70 | u = 'TB' 71 | elif size.endswith('TiB'): 72 | s = size[0:-3] 73 | u = 'TB' 74 | elif size.endswith('PB'): 75 | s = size[0:-2] 76 | u = 'PB' 77 | elif size.endswith('PiB'): 78 | s = size[0:-3] 79 | u = 'PB' 80 | if not s: 81 | return 0.0 82 | if s.find(',') != -1: 83 | s = s.replace(',', '') 84 | return trans_unit_to_mb(float(s), u) 85 | 86 | 87 | def parse_field_value(field_value): 88 | if isinstance(field_value, decimal.Decimal): # Decimal -> float 89 | field_value = round(float(field_value), 2) 90 | elif isinstance(field_value, datetime.datetime): # datetime -> str 91 | field_value = str(field_value) 92 | elif isinstance(field_value, list): 93 | field_value = [parse_field_value(i) for i in field_value] 94 | if hasattr(field_value, 'to_json'): 95 | field_value = field_value.to_json() 96 | elif isinstance(field_value, Enum): 97 | field_value = field_value.name 98 | elif isinstance(field_value, Dict): 99 | val = {} 100 | for key_ in field_value: 101 | val[key_] = parse_field_value(field_value[key_]) 102 | field_value = val 103 | return field_value 104 | 105 | 106 | def json_object(cls): 107 | def to_json(self): 108 | """ 109 | Json序列化 110 | :param hidden_fields: 覆盖类属性 hidden_fields 111 | :return: 112 | """ 113 | 114 | model_json = {} 115 | 116 | for column in self.__dict__: 117 | if hasattr(self, column): 118 | model_json[column] = parse_field_value(getattr(self, column)) 119 | if '_sa_instance_state' in model_json: 120 | del model_json['_sa_instance_state'] 121 | return model_json 122 | 123 | cls.to_json = to_json 124 | 125 | return cls 126 | 127 | 128 | def string_to_number(text): 129 | """ 130 | 文本转化成字符串,支持中文大写数字,如一百二十三 131 | :param text: 132 | :return: 133 | """ 134 | if text is None: 135 | return None 136 | if text.isdigit(): 137 | return int(text) 138 | else: 139 | try: 140 | return cn2an.cn2an(text) 141 | except ValueError as e: 142 | return None 143 | 144 | 145 | def name_convert_to_camel(name: str) -> str: 146 | """下划线转驼峰(小驼峰)""" 147 | return re.sub(r'(_[a-z])', lambda x: x.group(1)[1].upper(), name) 148 | 149 | 150 | def copy_value(source: Dict, target: object, camel_case=False) -> None: 151 | if not source: 152 | return 153 | if not target or not target.__annotations__: 154 | return 155 | for name in target.__annotations__: 156 | anno = target.__annotations__[name] 157 | setattr(target, name, parse_value(anno, source.get(name_convert_to_camel(name) if camel_case else name))) 158 | 159 | 160 | def to_dict(obj: object) -> Optional[Dict]: 161 | if not obj or not obj.__annotations__: 162 | return 163 | result = {} 164 | for name in obj.__annotations__: 165 | anno = obj.__annotations__[name] 166 | result.update({name: parse_value(anno, getattr(obj, name))}) 167 | return result 168 | 169 | 170 | def _list_value(value): 171 | if isinstance(value, str): 172 | if value[0] in ['{', '[']: 173 | return json.loads(value) 174 | else: 175 | return value.split(',') 176 | else: 177 | return list(value) 178 | 179 | 180 | def _dict_value(value): 181 | if isinstance(value, str): 182 | return json.loads(value) 183 | else: 184 | return value 185 | 186 | 187 | def parse_value(func, value, default_value=None): 188 | if value is not None: 189 | if func == bool: 190 | if value in (1, True, "1", "true"): 191 | return True 192 | elif value in (0, False, "0", "false"): 193 | return False 194 | else: 195 | raise ValueError(value) 196 | 197 | elif func in (int, float): 198 | try: 199 | if isinstance(value, str): 200 | value = value.replace(',', '') 201 | return func(value) 202 | except ValueError: 203 | return float('nan') 204 | elif func == datetime.datetime: 205 | if isinstance(value, datetime.datetime): 206 | return value 207 | elif isinstance(value, str): 208 | if value: 209 | return datetime.datetime.strptime(value, '%Y-%m-%d %H:%M:%S') 210 | else: 211 | return None 212 | else: 213 | return None 214 | elif func in [Dict, dict]: 215 | return _dict_value(value) 216 | elif func in [List, list]: 217 | return _list_value(value) 218 | elif func == MediaType: 219 | return MediaType.get(value) 220 | elif isinstance(func, _GenericAlias): 221 | if func.__origin__ in [List, list]: 222 | list_ = _list_value(value) 223 | res = [] 224 | for x in list_: 225 | res.append(parse_value(func.__args__[0], x)) 226 | return res 227 | return func(value) 228 | else: 229 | return default_value 230 | 231 | 232 | """文件后缀""" 233 | EXT_TYPE = { 234 | 'info': ['.nfo', '.txt', '.cue'], 235 | 'subtitle': ['.ass', '.srt', '.smi', '.ssa', '.sub', '.vtt', '.idx'], 236 | 'image': ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.ico', '.tif'], 237 | 'video': ['.mp4', '.mkv', '.avi', '.wmv', '.mpg', '.mpeg', '.mov', '.rm', '.rmvb', '.ram', '.flv', '.ts', 238 | '.iso', '.m2ts', '.bdmv'], 239 | 'audio': ['.mka', '.mp3', '.m3u', '.flac'] 240 | } 241 | 242 | 243 | def get_file_type(filepath): 244 | """ 245 | 获取一个文件的格式类型,基于后缀判断 246 | :param filepath: 247 | :return: info、subtitle、image、video、audio 248 | """ 249 | if not EXT_TYPE: 250 | return 'unknown' 251 | ext = os.path.splitext(filepath)[-1].lower() 252 | for key in EXT_TYPE.keys(): 253 | if ext in EXT_TYPE[key]: 254 | return key 255 | return 'unknown' 256 | 257 | 258 | class _Countries: 259 | """ISO3166国家码处理工具""" 260 | iso3166_mapping: dict = dict() 261 | 262 | def __init__(self): 263 | with open(os.path.join(os.path.split(os.path.realpath(__file__))[0], 'country_mapping.txt'), 'r') as f: 264 | for line in f: 265 | line = line.strip('\n') 266 | if line == '': 267 | continue 268 | arr = line.split('=') 269 | self.iso3166_mapping[arr[0]] = arr[1] 270 | 271 | def get(self, code): 272 | code = str(code).upper() 273 | if code in self.iso3166_mapping.keys(): 274 | return self.iso3166_mapping[code] 275 | else: 276 | if '其他' in self.iso3166_mapping: 277 | return self.iso3166_mapping['其他'] 278 | else: 279 | return '其他' 280 | 281 | 282 | Countries = _Countries() 283 | CN_EP_PATTERN = '[集话回話画期]' 284 | TV_NUM_PATTERN = '[1234567890一二三四五六七八九十]{1,4}' 285 | SEASON_NUM_PATTERN = '[1234567890一二三四五六七八九十]{1,3}' 286 | 287 | SEASON_PATTERNS = [ 288 | re.compile('[sS](%s)?[-—~]{1,3}[sS]?(%s)' % (SEASON_NUM_PATTERN, SEASON_NUM_PATTERN)), 289 | re.compile('第(%s)[季部辑]?分?[-—~]{1,3}第?(%s)[季部辑]分?' % (SEASON_NUM_PATTERN, SEASON_NUM_PATTERN)), 290 | re.compile(r'S(?:eason)?(\d{1,3})', re.IGNORECASE), 291 | re.compile(r'S(?:eason)?[-]{0,3}(\d{1,3})', re.IGNORECASE), 292 | re.compile('第(%s)[季部辑]分?' % SEASON_NUM_PATTERN), 293 | re.compile(r'S(?:eason)?\s(\d{1,3})', re.IGNORECASE) 294 | ] 295 | COMPLETE_SEASON_PATTERNS = [ 296 | re.compile('全(%s)[季部辑]' % SEASON_NUM_PATTERN) 297 | ] 298 | EPISODE_PATTERNS = [ 299 | re.compile(r'[Ee][Pp]?(%s)' % TV_NUM_PATTERN), 300 | re.compile('第?(%s)%s' % (TV_NUM_PATTERN, CN_EP_PATTERN)), 301 | re.compile(r'第\s?(%s)\s?%s' % (TV_NUM_PATTERN, CN_EP_PATTERN)), 302 | re.compile(r'^(%s)\s?$' % TV_NUM_PATTERN), 303 | re.compile(r'^(%s)\.' % TV_NUM_PATTERN), 304 | re.compile(r'(%s)[oO][fF]%s' % (TV_NUM_PATTERN, TV_NUM_PATTERN)), 305 | re.compile(r'[\[【](%s)[】\]]' % TV_NUM_PATTERN), 306 | re.compile(r'\s{1}-\s{1}(%s)' % TV_NUM_PATTERN) 307 | ] 308 | EPISODE_RANGE_PATTERNS = [ 309 | re.compile('第(%s)%s?-第?(%s)%s' % (TV_NUM_PATTERN, CN_EP_PATTERN, TV_NUM_PATTERN, CN_EP_PATTERN)), 310 | re.compile(r'[Ee][Pp]?(\d{1,4})[Ee][Pp]?(\d{1,4})'), 311 | re.compile(r'[Ee][Pp]?(\d{1,4})-[Ee]?[Pp]?(\d{1,4})'), 312 | re.compile(r'^(%s)[-到](%s)$' % (TV_NUM_PATTERN, TV_NUM_PATTERN)), 313 | re.compile(r'^(%s)\s{0,4}-\s{0,4}.+$' % TV_NUM_PATTERN), 314 | re.compile(r'[\[【\(](%s)-(%s)[】\]\)]' % (TV_NUM_PATTERN, TV_NUM_PATTERN)), 315 | re.compile(r'(全)(%s)%s' % (TV_NUM_PATTERN, CN_EP_PATTERN)) 316 | ] 317 | COMPLETE_EPISODE_PATTERNS = [ 318 | re.compile('全(%s)%s' % (TV_NUM_PATTERN, CN_EP_PATTERN)), 319 | re.compile('(%s)%s全' % (TV_NUM_PATTERN, CN_EP_PATTERN)), 320 | re.compile('全%s' % CN_EP_PATTERN), 321 | re.compile('所有%s' % CN_EP_PATTERN) 322 | ] 323 | 324 | 325 | class MediaParser: 326 | """媒体信息格式化工具类,待重构完成""" 327 | 328 | @staticmethod 329 | def parse_episode(text, match_single=True, match_range=True): 330 | if not text: 331 | return 332 | if get_file_type(text) != 'unknown': 333 | text = os.path.splitext(text)[0] 334 | ep_index_start = None 335 | ep_index_end = None 336 | ep_str = None 337 | pts = [] 338 | if match_range: 339 | pts += EPISODE_RANGE_PATTERNS 340 | if match_single: 341 | pts += EPISODE_PATTERNS 342 | for p in pts: 343 | m = p.search(text) 344 | if m: 345 | ep_str = m.group() 346 | if len(m.groups()) == 1: 347 | ep_index_start = string_to_number(m.group(1)) 348 | ep_index_end = None 349 | elif len(m.groups()) == 2: 350 | ep_index_start = string_to_number(m.group(1)) 351 | ep_index_end = string_to_number(m.group(2)) 352 | if ep_index_start and ep_index_start > 2000: 353 | # is year,not episode 354 | ep_index_start = None 355 | ep_index_end = None 356 | continue 357 | break 358 | if ep_index_start is None: 359 | return 360 | return {'start': ep_index_start, 'end': ep_index_end, 'text': ep_str} 361 | 362 | @staticmethod 363 | def parse_season(text): 364 | season_start = None 365 | season_end = None 366 | season_text = None 367 | for p in SEASON_PATTERNS: 368 | m = p.search(text) 369 | if m: 370 | season_text = m.group() 371 | if season_text == text: 372 | season_text = m.group(1) 373 | if len(m.groups()) == 1: 374 | season_start = string_to_number(m.group(1)) 375 | season_end = None 376 | elif len(m.groups()) == 2: 377 | season_start = string_to_number(m.group(1)) 378 | season_end = string_to_number(m.group(2)) 379 | break 380 | return {'start': season_start, 'end': season_end, 'text': season_text} 381 | 382 | @staticmethod 383 | def episode_format(episode, prefix=''): 384 | if not episode: 385 | return 386 | if isinstance(episode, str): 387 | episode = list(filter(None, episode.split(','))) 388 | episode = [int(i) for i in episode] 389 | elif isinstance(episode, int): 390 | episode = [episode] 391 | if episode: 392 | episode.sort() 393 | if len(episode) <= 2: 394 | return ','.join([str(e).zfill(2) for e in episode]) 395 | else: 396 | episode.sort() 397 | return '%s%s-%s%s' % (prefix, str(episode[0]).zfill(2), prefix, str(episode[len(episode) - 1]).zfill(2)) 398 | 399 | @staticmethod 400 | def trim_season(string): 401 | if not string: 402 | return 403 | string = str(string) 404 | season = MediaParser.parse_season(string) 405 | if season and season.get('text'): 406 | simple_name = string.replace(season.get('text'), '').strip() 407 | else: 408 | simple_name = string 409 | return simple_name 410 | -------------------------------------------------------------------------------- /moviebotapi/douban.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from typing import Dict, List, Optional 3 | 4 | from moviebotapi import Session 5 | from moviebotapi.core import utils 6 | from moviebotapi.core.models import MediaType 7 | from moviebotapi.core.utils import json_object 8 | from moviebotapi.subscribe import SubStatus 9 | 10 | 11 | @json_object 12 | class DoubanPeople: 13 | """剧组人员、导演、演员等人员信息""" 14 | douban_id: int 15 | tmdb_id: int 16 | imdb_id: str 17 | name: str 18 | en_name: str 19 | role: str 20 | pic_url: str 21 | type: str 22 | 23 | def __init__(self, data: Dict): 24 | utils.copy_value(data, self) 25 | 26 | 27 | @json_object 28 | class DoubanMedia: 29 | id: int 30 | url: str 31 | release_year: str 32 | local_name: str 33 | en_name: str 34 | cover_image: str 35 | rating: float 36 | user_id: str 37 | intro: str 38 | director: List[DoubanPeople] 39 | actor: List[DoubanPeople] 40 | duration: int 41 | trailer_video_url: str 42 | 43 | def __init__(self, data: Dict): 44 | utils.copy_value(data, self) 45 | # 对历史遗留接口不规范命名做更正 46 | self.media_type: MediaType = utils.parse_value(MediaType, data.get('type')) 47 | self.aka_names: List[str] = utils.parse_value(List[str], data.get('alias')) 48 | self.genres: List[str] = utils.parse_value(List[str], data.get('cates')) 49 | self.country: List[str] = utils.parse_value(List[str], data.get('area')) 50 | self.cn_name: str = utils.parse_value(str, data.get('name')) 51 | self.premiere_date: str = utils.parse_value(str, data.get('release_date')) 52 | self.imdb_id: str = utils.parse_value(str, data.get('imdb')) 53 | self.episode_count: int = utils.parse_value(int, data.get('total_ep_count')) 54 | self.season_index: int = utils.parse_value(int, data.get('season_index')) 55 | 56 | 57 | @json_object 58 | class DoubanSearchResult: 59 | id: int 60 | cn_name: str 61 | rating: float 62 | url: str 63 | app_url: str 64 | sub_id: int 65 | status: SubStatus 66 | 67 | def __init__(self, data: Dict): 68 | utils.copy_value(data, self) 69 | self.poster_url: str = utils.parse_value(str, data.get('poster_path')) 70 | 71 | 72 | @json_object 73 | class DoubanRankingType(Enum): 74 | movie_top250 = '豆瓣电影Top250' 75 | movie_real_time_hotest = '实时热门电影' 76 | movie_weekly_best = '一周口碑电影榜' 77 | ECPE465QY = '近期热门电影榜' 78 | EC7Q5H2QI = '近期高分电影榜' 79 | 80 | tv_chinese_best_weekly = '华语口碑剧集榜' 81 | tv_global_best_weekly = '全球口碑剧集榜' 82 | show_chinese_best_weekly = '国内口碑综艺榜' 83 | show_global_best_weekly = '国外口碑综艺榜' 84 | 85 | ECFA5DI7Q = '近期热门美剧' 86 | EC74443FY = '近期热门大陆剧' 87 | ECNA46YBA = '近期热门日剧' 88 | ECBE5CBEI = '近期热门韩剧' 89 | 90 | ECAYN54KI = '近期热门喜剧' 91 | ECBUOLQGY = '近期热门动作' 92 | ECSAOJFTA = '近期热门爱情' 93 | ECZYOJPLI = '近期热门科幻' 94 | EC3UOBDQY = '近期热门动画' 95 | ECPQOJP5Q = '近期热门悬疑' 96 | 97 | 98 | @json_object 99 | class DoubanRankingItem: 100 | rank: int 101 | id: int 102 | poster_path: str 103 | background_url: str 104 | cn_name: str 105 | release_year: str 106 | media_type: MediaType 107 | rating: float 108 | url: str 109 | app_url: str 110 | desc: str 111 | comment: str 112 | 113 | def __init__(self, data: Dict): 114 | utils.copy_value(data, self) 115 | self.media_type = MediaType.get(data.get('type')) 116 | 117 | 118 | @json_object 119 | class ApiSearchItem: 120 | media_type: MediaType 121 | douban_id: int 122 | rating: float 123 | title: str 124 | app_url: str 125 | small_poster_url: str 126 | year: int 127 | 128 | def __init__(self, data: Dict): 129 | if data.get('target_type'): 130 | self.media_type = MediaType.Movie if data.get('target_type') == 'movie' else MediaType.TV 131 | self.douban_id = utils.parse_value(int, data.get('target_id')) 132 | if data.get('target'): 133 | t = data.get('target') 134 | self.title = utils.parse_value(str, t.get('title')) 135 | self.app_url = utils.parse_value(str, t.get('uri')) 136 | self.small_poster_url = utils.parse_value(str, t.get('cover_url')) 137 | self.year = utils.parse_value(int, t.get('year')) 138 | self.rating = utils.parse_value(float, t.get('rating').get('value') if t.get('rating') else None) 139 | 140 | 141 | @json_object 142 | class DoubanDailyMedia: 143 | show_date: str 144 | media_type: str 145 | media_id: int 146 | douban_id: int 147 | tmdb_id: int 148 | title: str 149 | comment: str 150 | release_year: int 151 | rating: float 152 | poster_url: str 153 | background_url: str 154 | url: str 155 | app_url: str 156 | 157 | def __init__(self, data: Dict): 158 | utils.copy_value(data, self, True) 159 | 160 | 161 | class DoubanApi: 162 | def __init__(self, session: Session): 163 | self._session: Session = session 164 | 165 | def get(self, douban_id: int) -> Optional[DoubanMedia]: 166 | """ 167 | 根据豆瓣编号获取豆瓣详情信息 168 | """ 169 | meta = self._session.get('douban.get', { 170 | 'douban_id': douban_id 171 | }) 172 | if not meta: 173 | return 174 | return DoubanMedia(meta) 175 | 176 | def search(self, keyword: str) -> List[DoubanSearchResult]: 177 | """ 178 | 搜索豆瓣移动端网页解析结果,此搜索结果中没有年份 179 | """ 180 | result = self._session.get('movie.search_douban', { 181 | 'keyword': keyword 182 | }) 183 | if not result: 184 | return [] 185 | return [DoubanSearchResult(x) for x in result] 186 | 187 | def list_ranking(self, ranking_type: DoubanRankingType, proxy_pic: bool = False) -> Optional[ 188 | List[DoubanRankingItem]]: 189 | """ 190 | 获取豆瓣榜单数据 191 | """ 192 | res = self._session.get('douban.list_ranking', { 193 | 'ranking_type': ranking_type.value, 194 | 'proxy_pic': proxy_pic 195 | }) 196 | if not res: 197 | return [] 198 | return [DoubanRankingItem(x) for x in res.get('result')] 199 | 200 | def use_api_search(self, keyword: str, count: Optional[int] = None) -> List[ApiSearchItem]: 201 | """ 202 | 使用豆瓣移动端API进行搜索,此接口会含有年份信息 203 | """ 204 | res = self._session.get('douban.use_api_search', { 205 | 'keyword': keyword, 206 | 'count': count 207 | }) 208 | if not res or not res.get('items'): 209 | return [] 210 | list_ = res.get('items') 211 | result = [] 212 | for item in list_: 213 | result.append(ApiSearchItem(item)) 214 | return result 215 | 216 | def daily_media(self) -> Optional[DoubanDailyMedia]: 217 | """ 218 | 获取豆瓣每日推荐 219 | """ 220 | meta = self._session.get('common.daily_media') 221 | if not meta: 222 | return 223 | return DoubanDailyMedia(meta) 224 | -------------------------------------------------------------------------------- /moviebotapi/downloader.py: -------------------------------------------------------------------------------- 1 | from moviebotapi.core.basemodel import BaseModel 2 | 3 | 4 | class ClientTorrent(BaseModel): 5 | hash: str 6 | name: str 7 | save_path: str 8 | content_path: str 9 | size: int 10 | size_str: str 11 | dl_speed: int 12 | dl_speed_str: str 13 | up_speed: int 14 | up_speed_str: str 15 | progress: float 16 | uploaded: int 17 | uploaded_str: str 18 | downloaded: int 19 | downloaded_str: str 20 | seeding_time: int 21 | ratio: float 22 | tracker: str 23 | -------------------------------------------------------------------------------- /moviebotapi/event.py: -------------------------------------------------------------------------------- 1 | from typing import Dict 2 | 3 | from moviebotapi import Session 4 | 5 | 6 | class EventApi: 7 | def __init__(self, session: Session): 8 | self._session: Session = session 9 | 10 | def publish_event(self, event_name: str, event_data: Dict): 11 | """ 12 | 发布事件,订阅者可以订阅 13 | 事件名称建议采用每个单词首字母大写的方式来命名 14 | """ 15 | self._session.post('event.publish_event', { 16 | 'event_name': event_name, 17 | 'event_data': event_data 18 | }) 19 | -------------------------------------------------------------------------------- /moviebotapi/ext.py: -------------------------------------------------------------------------------- 1 | from typing import Union, Optional, List 2 | 3 | from moviebotapi.core import utils 4 | from moviebotapi.core.models import MediaType 5 | from moviebotapi.douban import DoubanMedia 6 | from moviebotapi.tmdb import TmdbMovie, TmdbTV 7 | 8 | 9 | class MediaMetaSelect: 10 | def __init__(self, douban: Optional[DoubanMedia] = None, tmdb: Optional[Union[TmdbMovie, TmdbTV]] = None): 11 | self.douban = douban 12 | self.tmdb = tmdb 13 | 14 | @property 15 | def country(self): 16 | country: List[str] = list() 17 | if self.tmdb: 18 | if hasattr(self.tmdb, 'production_countries') and not self.tmdb.production_countries and hasattr(self.tmdb, 19 | 'origin_country') and not self.tmdb.origin_country: 20 | return ['其他'] 21 | if hasattr(self.tmdb, 'origin_country') and self.tmdb.origin_country: 22 | for c in self.tmdb.origin_country: 23 | country.append(utils.Countries.get(c)) 24 | if len(country) == 0 and hasattr(self.tmdb, 'production_countries') and self.tmdb.production_countries: 25 | for c in self.tmdb.production_countries: 26 | country.append(utils.Countries.get(c.get('iso_3166_1'))) 27 | if not country and self.douban: 28 | country = self.douban.country 29 | return country 30 | 31 | @property 32 | def genres(self): 33 | genres = None 34 | if not genres and self.tmdb and self.tmdb.genres: 35 | arr = [] 36 | for item in self.tmdb.genres: 37 | if item.name == 'Sci-Fi & Fantasy': 38 | arr.append('科幻') 39 | elif item.name == 'War & Politics': 40 | arr.append('战争') 41 | else: 42 | arr.append(item.name) 43 | genres = arr 44 | if not genres and self.douban: 45 | genres = self.douban.genres 46 | return genres 47 | 48 | @property 49 | def episode_count(self): 50 | if self.douban and self.douban.episode_count: 51 | return self.douban.episode_count 52 | if self.tmdb and isinstance(self.tmdb, TmdbTV): 53 | return self.tmdb.number_of_episodes 54 | return 1 55 | 56 | @property 57 | def title(self): 58 | title = None 59 | if self.douban: 60 | if self.douban.media_type == MediaType.TV: 61 | title = utils.MediaParser.trim_season(self.douban.cn_name) 62 | else: 63 | title = self.douban.cn_name 64 | if title == '未知电视剧' or title == '未知电影': 65 | title = None 66 | if self.tmdb and not title: 67 | if isinstance(self.tmdb, TmdbMovie): 68 | title = self.tmdb.title 69 | elif isinstance(self.tmdb, TmdbTV): 70 | title = self.tmdb.name 71 | return title 72 | 73 | @property 74 | def rating(self): 75 | rating = None 76 | if self.douban: 77 | rating = self.douban.rating 78 | if not rating and self.tmdb: 79 | rating = self.tmdb.vote_average 80 | if rating: 81 | return round(rating, 1) 82 | else: 83 | return rating 84 | 85 | @property 86 | def url(self): 87 | if self.douban: 88 | return 'https://movie.douban.com/subject/%s/' % self.douban.id 89 | if self.tmdb: 90 | if isinstance(self.tmdb, TmdbMovie): 91 | return 'https://themoviedb.org/movie/%s' % self.tmdb.id 92 | else: 93 | return 'https://themoviedb.org/tv/%s' % self.tmdb.id 94 | return 95 | 96 | @property 97 | def intro(self): 98 | intro = None 99 | if self.douban: 100 | intro = self.douban.intro 101 | if self.tmdb and not intro: 102 | intro = self.tmdb.overview 103 | if intro: 104 | return str(intro).strip() 105 | else: 106 | return intro 107 | 108 | @staticmethod 109 | def _get_tmdb_tv_date(tv: TmdbTV) -> Optional[str]: 110 | if tv.first_air_date: 111 | return tv.first_air_date 112 | elif tv and tv.seasons and tv.seasons[0].air_date: 113 | return tv.seasons[0].air_date[0:4] 114 | return 115 | 116 | @property 117 | def release_year(self): 118 | if self.douban: 119 | return self.douban.release_year 120 | if self.tmdb: 121 | if isinstance(self.tmdb, TmdbMovie): 122 | if self.tmdb.release_date: 123 | return self.tmdb.release_date[0:4] 124 | else: 125 | return 126 | else: 127 | tv_data = self._get_tmdb_tv_date(self.tmdb) 128 | return tv_data[0:4] if tv_data else None 129 | 130 | @property 131 | def release_date(self): 132 | date = None 133 | if self.tmdb: 134 | if isinstance(self.tmdb, TmdbMovie): 135 | if self.tmdb.release_date: 136 | date = self.tmdb.release_date 137 | else: 138 | date = self._get_tmdb_tv_date(self.tmdb) 139 | if self.douban and not date: 140 | date = self.douban.premiere_date 141 | return date 142 | -------------------------------------------------------------------------------- /moviebotapi/library.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from typing import List, Optional, Dict 3 | 4 | from moviebotapi import Session 5 | from moviebotapi.core import utils 6 | from moviebotapi.core.models import MediaType 7 | from moviebotapi.core.utils import json_object 8 | 9 | 10 | @json_object 11 | class MediaLibraryPath: 12 | path: str 13 | auto_scan: bool = False 14 | 15 | def __init__(self, data: Dict = None): 16 | if not data: 17 | utils.copy_value(data, self) 18 | 19 | 20 | @json_object 21 | class TransferMode(Enum): 22 | HardLink = 'link' 23 | Copy = 'copy' 24 | Move = 'move' 25 | 26 | 27 | class LibraryApi: 28 | def __init__(self, session: Session): 29 | self._session: Session = session 30 | 31 | def start_scanner(self, library_id: int): 32 | self._session.get('library.start_scanner', { 33 | 'library_id': library_id 34 | }) 35 | 36 | def stop_scanner(self, library_id: int): 37 | self._session.get('library.stop_scanner', { 38 | 'library_id': library_id 39 | }) 40 | 41 | def add_library(self, media_type: MediaType, library_name: str, library_paths: List[MediaLibraryPath]) -> Optional[ 42 | int]: 43 | return self._session.post('library.add_library', { 44 | 'library_name': library_name, 45 | 'media_type': media_type.value, 46 | 'library_paths': [utils.to_dict(x) for x in library_paths] 47 | }) 48 | 49 | def rename_by_path(self, path: str): 50 | return self._session.post('library.rename_by_path', { 51 | 'path': path 52 | }) 53 | 54 | def direct_transfer(self, src: str, dst: str, mode: TransferMode): 55 | self._session.post('library.direct_transfer', { 56 | 'src': src, 57 | 'dst': dst, 58 | 'mode': mode.value 59 | }) 60 | -------------------------------------------------------------------------------- /moviebotapi/mediaserver.py: -------------------------------------------------------------------------------- 1 | from typing import List, Dict 2 | 3 | from moviebotapi import Session 4 | from moviebotapi.core import utils 5 | from moviebotapi.core.basemodel import BaseModel 6 | from moviebotapi.core.models import MediaType 7 | from moviebotapi.core.utils import json_object 8 | 9 | 10 | @json_object 11 | class SubtitleStream: 12 | """字幕流信息""" 13 | codec: str 14 | language: str 15 | display_language: str 16 | display_title: str 17 | # 外部字幕 18 | external: bool 19 | is_default: bool 20 | 21 | def __init__(self, data: Dict): 22 | utils.copy_value(data, self) 23 | 24 | 25 | @json_object 26 | class AudioStream: 27 | """音频流信息""" 28 | codec: str 29 | language: str 30 | display_language: str 31 | display_title: str 32 | is_default: bool 33 | channel_layout: str 34 | 35 | def __init__(self, data: Dict): 36 | utils.copy_value(data, self) 37 | 38 | 39 | @json_object 40 | class MediaItem: 41 | """媒体服务器的影片基础模型""" 42 | tmdb_id: int 43 | imdb_id: str 44 | tvdb_id: str 45 | url: str 46 | id: str 47 | name: str 48 | # 剧集才有,集号 49 | index: int 50 | type: MediaType 51 | poster_url: str 52 | thumb_url: str 53 | backdrop_url: str 54 | 55 | # 视频容器类型 mkv 原盘 56 | video_container: str 57 | # 视频编码 58 | video_codec: str 59 | # 视频分辨率 60 | video_resolution: str 61 | # 字幕流 62 | subtitle_streams: List[SubtitleStream] 63 | # 音频流 64 | audio_streams: List[AudioStream] 65 | sub_items: list 66 | # 播放状态 67 | status: int 68 | 69 | def __init__(self, data: Dict): 70 | utils.copy_value(data, self) 71 | 72 | 73 | class MediaFolder(BaseModel): 74 | """媒体服务器配置的影音库文件夹""" 75 | id: str 76 | name: str 77 | path: str 78 | sub_folders: list 79 | 80 | 81 | ListMediaItem = List[MediaItem] 82 | ListMediaFolder = List[MediaFolder] 83 | 84 | 85 | class MediaServerApi: 86 | def __init__(self, session: Session): 87 | self._session: Session = session 88 | 89 | def list_episodes_from_tmdb(self, tmdb_id: int, season_number: int, fetch_all: bool = False): 90 | items = self._session.get('media_server.list_episodes_from_tmdb', { 91 | 'tmdb_id': tmdb_id, 92 | 'season_number': season_number, 93 | 'fetch_all': fetch_all 94 | }) 95 | if not items: 96 | return [] 97 | return [MediaItem(x) for x in items] 98 | 99 | def search_by_tmdb(self, tmdb_id: int, fetch_all: bool = False): 100 | items = self._session.get('media_server.search_by_tmdb', { 101 | 'tmdb_id': tmdb_id, 102 | 'fetch_all': fetch_all 103 | }) 104 | if not items: 105 | return [] 106 | return [MediaItem(x) for x in items] 107 | -------------------------------------------------------------------------------- /moviebotapi/meta.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from typing import Dict, Optional, List, Union 3 | 4 | from moviebotapi import Session 5 | from moviebotapi.core import utils 6 | from moviebotapi.core.models import MediaType 7 | from moviebotapi.core.utils import json_object 8 | 9 | 10 | @json_object 11 | class Person: 12 | id: int 13 | douban_id: int 14 | imdb_id: str 15 | cn_name: str 16 | en_name: str 17 | aka_cn_names: str 18 | aka_en_names: str 19 | country: str 20 | date_of_birth: str 21 | douban_image_url: str 22 | description: str 23 | role: str 24 | duty: str 25 | order_number: int 26 | 27 | def __init__(self, data: Dict): 28 | utils.copy_value(data, self, True) 29 | 30 | 31 | @json_object 32 | class Season: 33 | name: str 34 | season_number: int 35 | episode_count: int 36 | intro: str 37 | air_date: int 38 | poster_url: str 39 | douban_id: int 40 | 41 | def __init__(self, data: Dict): 42 | utils.copy_value(data, self, True) 43 | 44 | 45 | @json_object 46 | class MediaMeta: 47 | id: int 48 | douban_id: int 49 | tmdb_id: int 50 | imdb_id: str 51 | tvdb_id: int 52 | # 数据最后更新时间 53 | gmt_modified: datetime.datetime 54 | media_type: MediaType 55 | title: str 56 | en_title: str 57 | original_title: str 58 | intro: str 59 | rating: float 60 | release_year: int 61 | # 首映日期 62 | premiere_date: int 63 | # 影片时常,单位分钟 64 | duration: int 65 | # 封面图 66 | poster_url: str 67 | # 背景图 68 | background_url: str 69 | genres: List[str] 70 | country: List[str] 71 | # 剧集时包含 72 | season_list: List[Season] 73 | 74 | def __init__(self, data: Dict): 75 | utils.copy_value(data, self, True) 76 | 77 | 78 | class MetaApi: 79 | """ 80 | 自建数据操作接口 81 | 请勿恶意调用,识别到违规刷数据行为直接封禁License 82 | """ 83 | 84 | def __init__(self, session: Session): 85 | self._session: Session = session 86 | 87 | def get_cast_crew_by_douban(self, media_type: MediaType, douban_id: int): 88 | """ 89 | 根据豆瓣编号获取影片全部演员信息 90 | """ 91 | list_ = self._session.get('meta.get_cast_crew_by_douban', { 92 | 'media_type': media_type.value, 93 | 'douban_id': douban_id 94 | }) 95 | if not list_: 96 | return 97 | return [Person(x) for x in list_] 98 | 99 | def get_cast_crew_by_tmdb(self, media_type: MediaType, tmdb_id: int, season_number: Optional[int] = None) -> \ 100 | Optional[List[Person]]: 101 | """ 102 | 根据tmdb信息获取影片全部演员信息 103 | """ 104 | list_ = self._session.get('meta.get_cast_crew_by_tmdb', { 105 | 'media_type': media_type.value, 106 | 'tmdb_id': tmdb_id, 107 | 'season_number': season_number 108 | }) 109 | if not list_: 110 | return 111 | return [Person(x) for x in list_] 112 | 113 | def get_media_by_tmdb(self, media_type: MediaType, tmdb_id: int) -> Optional[Union[MediaMeta]]: 114 | """ 115 | 根据tmdb编号获取自建影视元数据 116 | """ 117 | res = self._session.get('meta.get_media_by_tmdb_id', { 118 | 'media_type': media_type.value, 119 | 'tmdb_id': tmdb_id 120 | }) 121 | if not res: 122 | return 123 | return MediaMeta(res) 124 | 125 | def get_media_by_douban(self, media_type: MediaType, douban_id: int): 126 | """ 127 | 根据豆瓣编号获取自建影视元数据 128 | """ 129 | res = self._session.get('meta.get_media_by_douban_id', { 130 | 'media_type': media_type.value, 131 | 'douban_id': douban_id 132 | }) 133 | if not res: 134 | return 135 | return MediaMeta(res) 136 | 137 | def share_meta_from_id(self, tmdb_id: int, douban_id: int, season_number: Optional[int] = None): 138 | """ 139 | 根据TMDB和豆瓣的影片编号,共享数据到自建服务器 140 | :param tmdb_id: TMDB编号 141 | :param douban_id: 豆瓣编号 142 | :param season_number: 如果为剧集时,可以选择强制指定季,如果不填,则自动取豆瓣 143 | :return: 144 | """ 145 | return self._session.get('meta.share_meta_from_id', { 146 | 'tmdb_id': tmdb_id, 147 | 'douban_id': douban_id, 148 | 'season_number': season_number 149 | }) 150 | -------------------------------------------------------------------------------- /moviebotapi/notify.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from typing import Dict, Optional, Union, List 3 | 4 | from moviebotapi import Session 5 | from moviebotapi.core.utils import json_object 6 | 7 | 8 | @json_object 9 | class SystemNotifyType(str, Enum): 10 | Login = "登陆" 11 | SmartDownload = "智能下载" 12 | System = "系统消息" 13 | SystemError = "系统错误" 14 | Plugin = "插件消息" 15 | 16 | 17 | class NotifyApi: 18 | def __init__(self, session: Session): 19 | self._session: Session = session 20 | 21 | def send_message_by_tmpl_name(self, template_name: str, context: Dict, to_uid: Optional[int] = None, 22 | to_channel_name: Union[str, List[str]] = None): 23 | self._session.post('notify.send_message_by_tmpl_name', { 24 | 'to_uid': to_uid, 25 | 'template_name': template_name, 26 | 'context': context, 27 | 'to_channel_name': to_channel_name 28 | }) 29 | 30 | def send_message_by_tmpl(self, title: str, body: str, context: Dict, to_uid: Optional[int] = None, 31 | to_channel_name: Union[str, List[str]] = None): 32 | self._session.post('notify.send_message_by_tmpl', { 33 | 'to_uid': to_uid, 34 | 'title': title, 35 | 'body': body, 36 | 'context': context, 37 | 'to_channel_name': to_channel_name 38 | }) 39 | 40 | def send_text_message(self, title: str, body: str, to_uid: Optional[int] = None, 41 | to_channel_name: Union[str, List[str]] = None): 42 | self._session.post('notify.send_text_message', { 43 | 'to_uid': to_uid, 44 | 'title': title, 45 | 'body': body, 46 | 'to_channel_name': to_channel_name 47 | }) 48 | 49 | def send_system_message(self, to_uid: Optional[int], title: str, message: str, 50 | message_type: SystemNotifyType = None): 51 | if not message_type: 52 | message_type = SystemNotifyType.Plugin 53 | self._session.post('notify.send_system_message', { 54 | 'to_uid': to_uid, 55 | 'title': title, 56 | 'message': message, 57 | 'type': message_type.name 58 | }) 59 | 60 | def clear_system_message(self, uid: int): 61 | self._session.get('notify.clear_system_message', { 62 | 'uid': uid 63 | }) 64 | -------------------------------------------------------------------------------- /moviebotapi/plugin.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, List 2 | 3 | from moviebotapi import Session 4 | from moviebotapi.core import utils 5 | 6 | 7 | class PluginMeta: 8 | id: int 9 | plugin_name: str 10 | title: str 11 | author: str 12 | config_field: list 13 | dependencies: dict 14 | description: str 15 | github_url: str 16 | help_doc_url: str 17 | local_version: str 18 | logo_url: str 19 | version: str 20 | plugin_folder: str 21 | 22 | def __init__(self, data: Dict): 23 | utils.copy_value(data, self, True) 24 | 25 | 26 | class PluginApi: 27 | def __init__(self, session: Session): 28 | self._session: Session = session 29 | 30 | def get_installed_list(self) -> List[PluginMeta]: 31 | """ 32 | 获取本地安装的插件列表 33 | """ 34 | r = self._session.get('plugins.get_installed_list') 35 | if not r: 36 | return [] 37 | res = [] 38 | for item in r: 39 | res.append(PluginMeta(item)) 40 | return res 41 | -------------------------------------------------------------------------------- /moviebotapi/scraper.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Dict 2 | 3 | from moviebotapi import Session 4 | from moviebotapi.core import utils 5 | from moviebotapi.core.models import MediaType 6 | from moviebotapi.core.utils import json_object 7 | 8 | 9 | @json_object 10 | class MediaImage: 11 | """影片图片资源""" 12 | source: str 13 | banner: str 14 | poster: str 15 | small_poster: str 16 | clear_logo: str 17 | background: str 18 | small_backdrop: str 19 | thumb: str 20 | main_background: str 21 | main_poster: str 22 | 23 | def __init__(self, data: Dict): 24 | utils.copy_value(data, self) 25 | 26 | 27 | class ScraperApi: 28 | def __init__(self, session: Session): 29 | self._session: Session = session 30 | 31 | def get_images(self, media_type: MediaType, tmdb_id: int, season_number: Optional[int] = None, 32 | episode_number: Optional[int] = None) -> Optional[MediaImage]: 33 | data = self._session.get('scraper.get_media_image', { 34 | 'media_type': media_type.value, 35 | 'tmdb_id': tmdb_id, 36 | 'season_number': season_number, 37 | 'episode_number': episode_number 38 | }) 39 | if not data: 40 | return 41 | return MediaImage(data) 42 | -------------------------------------------------------------------------------- /moviebotapi/site.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from enum import Enum 3 | from typing import Dict, Optional, List, Union 4 | 5 | from moviebotapi import Session 6 | from moviebotapi.core import utils 7 | from moviebotapi.core.basemodel import BaseModel 8 | 9 | 10 | class CateLevel1(str, Enum): 11 | Movie = 'Movie' 12 | TV = 'TV' 13 | Documentary = 'Documentary' 14 | Anime = 'Anime' 15 | Music = 'Music' 16 | Game = 'Game' 17 | AV = 'AV' 18 | Other = 'Other' 19 | 20 | @staticmethod 21 | def get_type(enum_name: str) -> "CateLevel1": 22 | for item in CateLevel1: 23 | if item.name == enum_name: 24 | return item 25 | return 26 | 27 | 28 | class TVSeries(BaseModel): 29 | season_start: int = None 30 | season_end: int = None 31 | season_full_index: List[int] = [] 32 | ep_start: int = None 33 | ep_end: int = None 34 | ep_full_index: List[int] = [] 35 | season_is_fill: bool = False 36 | ep_is_fill: bool = False 37 | contains_complete_ep: bool = False 38 | contains_complete_season: bool = False 39 | contains_multiple_season: bool = False 40 | 41 | def __init__(self, data: Optional[Dict] = None): 42 | utils.copy_value(data, self) 43 | 44 | 45 | class Torrent(BaseModel): 46 | # 种子id 47 | id: str 48 | # 站点编号 49 | site_id: str 50 | gmt_modified: datetime.datetime 51 | # 种子编号 52 | # torrent_id: int = db.Column(db.Integer, nullable=False, primary_key=True, unique=True) 53 | # 种子名称 54 | name: str 55 | # 种子标题 56 | subject: str 57 | # 以及类目 58 | cate_level1: CateLevel1 59 | # 站点类目id 60 | cate_id: str 61 | # 种子详情页地址 62 | details_url: str 63 | # 种子下载链接 64 | download_url: str 65 | # 种子关联的imdbid 66 | imdb_id: str 67 | # 种子发布时间 68 | publish_date: datetime.datetime 69 | # 种子大小,转化为mb尺寸 70 | size_mb: float 71 | # 做种人数 72 | upload_count: int 73 | # 下载中人数 74 | downloading_count: int 75 | # 下载完成人数 76 | download_count: int 77 | # 免费截止时间 78 | free_deadline: datetime.datetime 79 | # 下载折扣,1为不免费 80 | download_volume_factor: float 81 | # 做种上传系数,1为正常 82 | upload_volume_factor: int 83 | minimum_ratio: float 84 | minimum_seed_time: int 85 | # 封面链接 86 | poster_url: str 87 | 88 | def __init__(self, data: Optional[Dict] = None): 89 | utils.copy_value(data, self) 90 | 91 | @staticmethod 92 | def build_by_parse_item(site_config, item): 93 | t = Torrent() 94 | t.site_id = utils.parse_value(str, site_config.get('id')) 95 | t.id = utils.parse_value(int, item.get('id')) 96 | t.name = utils.parse_value(str, item.get('title')) 97 | t.subject = utils.parse_value(str, item.get('description'), '') 98 | if t.subject: 99 | t.subject = t.subject.strip() 100 | t.free_deadline = utils.parse_value(datetime.datetime, item.get('free_deadline')) 101 | t.imdb_id = utils.parse_value(str, item.get('imdbid')) 102 | t.upload_count = utils.parse_value(int, item.get('seeders'), 0) 103 | t.downloading_count = utils.parse_value(int, item.get('leechers'), 0) 104 | t.download_count = utils.parse_value(int, item.get('grabs'), 0) 105 | t.download_url = utils.parse_value(str, item.get('download')) 106 | if t.download_url and not t.download_url.startswith('http'): 107 | t.download_url = site_config.get('domain') + t.download_url 108 | t.publish_date = utils.parse_value(datetime.datetime, item.get('date'), datetime.datetime.now()) 109 | t.cate_id = utils.parse_value(str, item.get('category')) 110 | for c in site_config.get('category_mappings'): 111 | cid = t.cate_id 112 | id_mapping = site_config.get('category_id_mapping') 113 | if id_mapping: 114 | for mid in id_mapping: 115 | if str(mid.get('id')) == str(cid): 116 | if isinstance(mid.get('mapping'), list): 117 | cid = mid.get('mapping')[0] 118 | else: 119 | cid = mid.get('mapping') 120 | if str(c.get('id')) == str(cid): 121 | t.cate_level1 = CateLevel1.get_type(c.get('cate_level1')) 122 | t.details_url = utils.parse_value(str, item.get('details')) 123 | if t.details_url: 124 | t.details_url = site_config.get('domain') + t.details_url 125 | t.download_volume_factor = utils.parse_value(float, item.get('downloadvolumefactor'), 1) 126 | t.upload_volume_factor = utils.parse_value(int, item.get('uploadvolumefactor')) 127 | t.size_mb = utils.trans_size_str_to_mb(utils.parse_value(str, item.get('size'), '0')) 128 | t.poster_url = utils.parse_value(str, item.get('poster')) 129 | t.minimum_ratio = utils.parse_value(float, item.get('minimumratio'), 0.0) 130 | t.minimum_seed_time = utils.parse_value(int, item.get('minimumseedtime'), 0) 131 | if t.poster_url: 132 | if t.poster_url.startswith("./"): 133 | t.poster_url = site_config.get('domain') + t.poster_url[2:] 134 | elif not t.poster_url.startswith("http"): 135 | t.poster_url = site_config.get('domain') + t.poster_url 136 | return t 137 | 138 | 139 | class TorrentDetail(BaseModel): 140 | site_id: str = None 141 | name: str = None 142 | subject: str = None 143 | download_url: str = None 144 | filename: str = None 145 | intro: str = None 146 | publish_date: datetime.datetime = None 147 | 148 | @staticmethod 149 | def build(site_config, item): 150 | if not item: 151 | return 152 | t = TorrentDetail() 153 | t.site_id = site_config.get('id') 154 | t.id = utils.parse_value(int, item.get('id')) 155 | t.name = utils.parse_value(str, item.get('title'), '') 156 | t.subject = utils.parse_value(str, item.get('description'), '') 157 | if t.subject: 158 | t.subject = t.subject.strip() 159 | t.download_url = utils.parse_value(str, item.get('download')) 160 | if t.download_url and not t.download_url.startswith('http'): 161 | t.download_url = site_config.get('domain') + t.download_url 162 | t.filename = utils.parse_value(str, item.get('filename')) 163 | t.intro = utils.parse_value(str, item.get('intro')) 164 | t.publish_date = utils.parse_value(datetime.datetime, item.get('date')) 165 | return t 166 | 167 | 168 | class SearchType(str, Enum): 169 | Keyword = 'keyword' 170 | Imdb = 'imdb_id' 171 | 172 | 173 | class SearchQuery(BaseModel): 174 | key: SearchType 175 | value: str 176 | 177 | def __init__(self, key: SearchType, value: str): 178 | self.key = key 179 | self.value = value 180 | 181 | 182 | class TrafficManagementStatus(int, Enum): 183 | Disabled = 0 184 | Initiative = 1 185 | Passive = 2 186 | 187 | 188 | class SiteStatus(int, Enum): 189 | Normal = 1 190 | Error = 2 191 | 192 | 193 | class Site(BaseModel): 194 | id: int 195 | gmt_modified: datetime.datetime 196 | uid: int 197 | username: str 198 | cookie: str 199 | web_search: bool 200 | smart_download: bool 201 | share_rate: float 202 | upload_size: float 203 | download_size: float 204 | is_vip: bool 205 | status: SiteStatus 206 | traffic_management_status: TrafficManagementStatus 207 | # 主动模式时上传流量目标,单位GB 208 | upload_kpi: int 209 | proxies: str 210 | user_agent: str 211 | domain: str 212 | 213 | def __init__(self, data: Dict, api: "SiteApi"): 214 | utils.copy_value(data, self) 215 | self.site_id: str = utils.parse_value(str, data.get('site_name')) 216 | self.site_name: str = utils.parse_value(str, data.get('alias')) 217 | self._api = api 218 | 219 | def update(self): 220 | self._api.update(self.site_id, self.cookie, self.web_search, self.smart_download, 221 | self.traffic_management_status, self.upload_kpi, self.proxies, self.user_agent) 222 | 223 | def delete(self): 224 | self._api.delete(self.id) 225 | 226 | 227 | class SiteUserinfo(BaseModel): 228 | uid: int 229 | username: str 230 | user_group: str 231 | share_ratio: float 232 | uploaded: float 233 | downloaded: float 234 | seeding: int 235 | leeching: int 236 | vip_group: bool = False 237 | 238 | 239 | class SiteApi: 240 | def __init__(self, session: Session): 241 | self._session: Session = session 242 | 243 | def list(self): 244 | list_ = self._session.get('site.get_sites') 245 | if not list_: 246 | return 247 | return [Site(x, self) for x in list_] 248 | 249 | def update(self, site_id: str, cookie: str, 250 | web_search: Optional[bool] = None, smart_download: bool = None, 251 | traffic_management_status: Optional[TrafficManagementStatus] = None, 252 | upload_kpi: Optional[int] = None, proxies: Optional[str] = None, 253 | user_agent: Optional[str] = None): 254 | self._session.post('site.save_site', json={ 255 | 'site_name': site_id, 256 | 'cookie': cookie, 257 | 'web_search': web_search, 258 | 'smart_download': smart_download, 259 | 'traffic_management_status': traffic_management_status, 260 | 'upload_kpi': upload_kpi, 261 | 'proxies': proxies, 262 | 'user_agent': user_agent 263 | }) 264 | 265 | def delete(self, id_: int): 266 | self._session.post('site.delete', { 267 | 'id': id_ 268 | }) 269 | 270 | def search_local(self, query: Union[SearchQuery, List[SearchQuery]], 271 | cate_level1: Optional[List[CateLevel1]] = None) -> List[Torrent]: 272 | if isinstance(query, SearchQuery): 273 | query = [{'key': query.key.value, 'value': query.value}] 274 | elif isinstance(query, list): 275 | query = [{'key': str(x.key), 'value': x.value} for x in query] 276 | torrents = self._session.post('site.search_local', { 277 | 'query': query, 278 | 'cate_level1': cate_level1 279 | }) 280 | if not torrents: 281 | return [] 282 | return [Torrent(x) for x in torrents] 283 | 284 | def list_local_torrents(self, start_time: Optional[str] = None) -> List[Torrent]: 285 | """ 286 | 获取本地种子列表 287 | :param start_time: 288 | :return: 289 | """ 290 | torrents = self._session.get('site.list_local_torrents', { 291 | 'start_time': start_time 292 | }) 293 | if not torrents: 294 | return [] 295 | return [Torrent(x) for x in torrents] 296 | 297 | def search_remote(self, query: Union[SearchQuery, List[SearchQuery]], 298 | cate_level1: Optional[List[CateLevel1]] = None, timeout: Optional[int] = 15, 299 | site_id: Optional[List[str]] = None) -> List[Torrent]: 300 | if isinstance(query, SearchQuery): 301 | query = [{'key': query.key.value, 'value': query.value}] 302 | elif isinstance(query, list): 303 | query = [{'key': str(x.key), 'value': x.value} for x in query] 304 | torrents = self._session.post('site.search_remote', { 305 | 'query': query, 306 | 'cate_level1': cate_level1, 307 | 'timeout': timeout, 308 | 'site_id': site_id 309 | }) 310 | if not torrents: 311 | return [] 312 | return [Torrent(x) for x in torrents] 313 | 314 | 315 | TorrentList = List[Torrent] 316 | -------------------------------------------------------------------------------- /moviebotapi/subscribe.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from enum import Enum 3 | from typing import Dict, Optional, List 4 | 5 | from moviebotapi import Session 6 | from moviebotapi.core import utils 7 | from moviebotapi.core.models import MediaType 8 | from moviebotapi.core.utils import json_object 9 | 10 | 11 | @json_object 12 | class SubStatus(int, Enum): 13 | """ 14 | 订阅中 15 | """ 16 | Subscribing = 0 17 | """ 18 | 订阅已经完成 19 | """ 20 | Completed = 1 21 | """ 22 | 等待更好的版本 23 | """ 24 | WaitVersion = 2 25 | 26 | 27 | @json_object 28 | class Subscribe: 29 | id: int 30 | uid: int 31 | en_name: str 32 | desc: str 33 | rating: float 34 | status: SubStatus 35 | from_action: str 36 | alias: str 37 | poster_path: str 38 | download_mode: int 39 | episode_count: int 40 | release_year: int 41 | season_year: int 42 | remote_search: bool 43 | is_aired: bool 44 | media_type: MediaType 45 | thumb_image_path: str 46 | douban_id: int 47 | tmdb_id: int 48 | imdb_id: str 49 | genres: str 50 | area: str 51 | choose_rule_name: str 52 | release_date: str 53 | cn_name: str 54 | priority_keywords: str 55 | filter_config: Dict 56 | gmt_create: datetime.datetime 57 | gmt_modified: datetime.datetime 58 | 59 | def __init__(self, data: Dict, api: 'SubscribeApi'): 60 | utils.copy_value(data, self) 61 | self._api = api 62 | self.season_number: int = utils.parse_value(int, data.get('season_index')) 63 | self.media_type = utils.parse_value(MediaType, data.get('type')) 64 | 65 | def delete(self, deep_delete: bool = False): 66 | self._api.delete(self.id, deep_delete) 67 | 68 | 69 | @json_object 70 | class Filter: 71 | filter_name: str 72 | priority: int 73 | download_mode: int 74 | apply_media_type: List[MediaType] 75 | apply_genres: List[str] 76 | apply_country: List[str] 77 | apply_min_year: int 78 | apply_max_year: int 79 | media_source: List[str] 80 | resolution: List[str] 81 | media_codes: List[str] 82 | has_cn: bool 83 | has_special: bool 84 | min_size: int 85 | max_size: int 86 | min_seeders: int 87 | max_seeders: int 88 | free_only: bool 89 | pass_hr: bool 90 | exclude_keyword: str 91 | include_keyword: str 92 | 93 | def __init__(self, data: Dict, api: 'SubscribeApi'): 94 | utils.copy_value(data, self) 95 | self._api = api 96 | self.apply_genres = utils.parse_value(List[str], data.get('apply_cate')) 97 | self.apply_country = utils.parse_value(List[str], data.get('apply_area')) 98 | 99 | 100 | @json_object 101 | class CustomSubscribe: 102 | id: int 103 | auto_delete: bool 104 | desc: str 105 | dl_count: int 106 | douban_id: int 107 | enable: bool 108 | episode_count: int 109 | gmt_create: datetime.datetime 110 | gmt_modified: datetime.datetime 111 | last_run_time: datetime.datetime 112 | last_run_time_format: str 113 | media_type: MediaType 114 | name: str 115 | personal: bool 116 | remote_last_modified: int 117 | remote_sub_rule_id: int 118 | rename_rule: str 119 | save_path: str 120 | score_rule_name: str 121 | season_number: int 122 | skip_exists: bool 123 | skip_unknown: bool 124 | status: str 125 | tmdb_id: int 126 | torrent_filter: str 127 | uid: int 128 | 129 | def __init__(self, data: Dict): 130 | utils.copy_value(data, self) 131 | 132 | 133 | class SubscribeApi: 134 | def __init__(self, session: Session): 135 | self._session: Session = session 136 | 137 | def get(self, subscribe_id: int) -> Optional[Subscribe]: 138 | sub = self._session.get('subscribe.get_sub', params={'id': subscribe_id}) 139 | if not sub: 140 | return 141 | return Subscribe(sub, self) 142 | 143 | def list(self, media_type: Optional[MediaType] = None, status: Optional[SubStatus] = None, img_proxy=False): 144 | params = {'img_proxy': img_proxy} 145 | if media_type: 146 | params.update({'media_type': media_type.value}) 147 | if status is not None: 148 | params.update({'status': status.value}) 149 | list_ = self._session.get('subscribe.list', params=params) 150 | if not list_: 151 | return [] 152 | return [Subscribe(x, self) for x in list_] 153 | 154 | def delete(self, subscribe_id: int, deep_delete: bool = False): 155 | self._session.post('subscribe.delete_sub', json={ 156 | 'id': subscribe_id, 157 | 'deep_delete': deep_delete 158 | }) 159 | 160 | def sub_by_tmdb(self, media_type: MediaType, tmdb_id: int, season_number: Optional[int] = None): 161 | params = {} 162 | if media_type: 163 | params.update({'media_type': media_type.value}) 164 | if tmdb_id: 165 | params.update({'tmdb_id': tmdb_id}) 166 | if season_number is not None: 167 | params.update({'season_number': season_number}) 168 | self._session.get('subscribe.sub_tmdb', params=params) 169 | 170 | def sub_by_douban(self, douban_id: int, filter_name: Optional[str] = None): 171 | params = {'id': douban_id} 172 | if filter_name: 173 | params.update({'filter_name': filter_name}) 174 | self._session.post('subscribe.sub_douban', json=params) 175 | 176 | def get_filters(self): 177 | list_ = self._session.get('subscribe.get_filters') 178 | if not list_: 179 | return [] 180 | return [Filter(x, self) for x in list_] 181 | 182 | def list_custom_sub(self) -> List[CustomSubscribe]: 183 | items = self._session.get('subscribe.get_sub_custom_list') 184 | if not items: 185 | return [] 186 | return [CustomSubscribe(x) for x in items] 187 | -------------------------------------------------------------------------------- /moviebotapi/tmdb.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, List, Dict, Union 2 | 3 | from moviebotapi import Session 4 | from moviebotapi.core import utils 5 | from moviebotapi.core.decorators import ignore_attr_not_exists 6 | from moviebotapi.core.models import MediaType 7 | from moviebotapi.core.utils import json_object 8 | 9 | 10 | @json_object 11 | class TmdbGenres: 12 | id: int 13 | name: str 14 | 15 | def __init__(self, data: Dict): 16 | utils.copy_value(data, self) 17 | 18 | 19 | @json_object 20 | class ProductionCompanies: 21 | id: int 22 | logo_path: str 23 | name: str 24 | origin_country: str 25 | 26 | def __init__(self, data: Dict): 27 | utils.copy_value(data, self) 28 | 29 | 30 | @json_object 31 | class SpokenLanguages: 32 | english_name: str 33 | iso_639_1: str 34 | name: str 35 | 36 | def __init__(self, data: Dict): 37 | utils.copy_value(data, self) 38 | 39 | 40 | @json_object 41 | class SearchResultItem: 42 | adult: bool 43 | backdrop_path: str 44 | genre_ids: List[int] 45 | id: int 46 | media_type: MediaType 47 | original_language: str 48 | original_title: str 49 | overview: str 50 | popularity: float 51 | poster_path: str 52 | release_date: str 53 | title: str 54 | video: bool 55 | vote_average: float 56 | vote_count: int 57 | 58 | def __init__(self, data: Dict): 59 | utils.copy_value(data, self) 60 | if 'first_air_date' in data: 61 | self.release_date = utils.parse_value(str, data.get('first_air_date')) 62 | else: 63 | self.release_date = utils.parse_value(str, data.get('release_date')) 64 | if 'original_name' in data: 65 | self.original_title = utils.parse_value(str, data.get('original_name')) 66 | else: 67 | self.original_title = utils.parse_value(str, data.get('original_title')) 68 | if 'title' in data: 69 | self.title = utils.parse_value(str, data.get('title')) 70 | else: 71 | self.title = utils.parse_value(str, data.get('name')) 72 | 73 | 74 | @json_object 75 | class SearchResult: 76 | page: int 77 | total_pages: int 78 | total_results: int 79 | results: List[Union[SearchResultItem, "TmdbPerson"]] 80 | 81 | def __init__(self, data: Dict): 82 | self.page = utils.parse_value(int, data.get('page')) 83 | self.total_pages = utils.parse_value(int, data.get('total_pages')) 84 | self.total_results = utils.parse_value(int, data.get('total_results')) 85 | if data.get('results'): 86 | res = [] 87 | for item in data.get('results'): 88 | if item.get('media_type') == 'person': 89 | res.append(TmdbPerson(item)) 90 | else: 91 | res.append(SearchResultItem(item)) 92 | self.results = res 93 | else: 94 | self.results = [] 95 | 96 | 97 | @json_object 98 | class TmdbMovie: 99 | id: int 100 | imdb_id: str 101 | adult: bool 102 | backdrop_path: str 103 | belongs_to_collection: str 104 | budget: int 105 | genres: List[TmdbGenres] 106 | homepage: str 107 | original_language: str 108 | title: str 109 | original_title: str 110 | overview: str 111 | popularity: float 112 | poster_path: str 113 | release_date: str 114 | revenue: int 115 | runtime: int 116 | status: str 117 | tagline: str 118 | video: bool 119 | vote_average: float 120 | vote_count: int 121 | production_companies: List[ProductionCompanies] 122 | production_countries: List 123 | spoken_languages: List[SpokenLanguages] 124 | 125 | def __init__(self, data: Dict): 126 | utils.copy_value(data, self) 127 | 128 | 129 | @json_object 130 | class TmdbPerson: 131 | adult: bool 132 | gender: int 133 | id: int 134 | known_for: List[SearchResultItem] 135 | known_for_department: str 136 | name: str 137 | original_name: str 138 | popularity: float 139 | profile_path: str 140 | credit_id: str 141 | department: str 142 | job: str 143 | character: str 144 | order: int 145 | 146 | def __init__(self, data: Dict): 147 | utils.copy_value(data, self) 148 | 149 | 150 | @json_object 151 | class EpisodeMeta: 152 | air_date: str 153 | episode_number: int 154 | id: int 155 | name: str 156 | overview: str 157 | production_code: str 158 | runtime: int 159 | season_number: int 160 | show_id: int 161 | still_path: str 162 | vote_average: float 163 | vote_count: int 164 | crew: List[TmdbPerson] 165 | guest_stars: List[TmdbPerson] 166 | 167 | def __init__(self, data: Dict): 168 | utils.copy_value(data, self) 169 | 170 | 171 | @json_object 172 | class Network: 173 | id: int 174 | name: str 175 | logo_path: str 176 | origin_country: str 177 | 178 | def __init__(self, data: Dict): 179 | utils.copy_value(data, self) 180 | 181 | 182 | @json_object 183 | class Season: 184 | air_date: str 185 | episode_count: int 186 | id: int 187 | name: str 188 | overview: str 189 | poster_path: str 190 | season_number: int 191 | 192 | def __init__(self, data: Dict): 193 | utils.copy_value(data, self) 194 | 195 | 196 | @json_object 197 | class TmdbTV: 198 | name: str 199 | original_name: str 200 | id: int 201 | in_production: bool 202 | adult: bool 203 | backdrop_path: str 204 | first_air_date: str 205 | last_air_date: str 206 | episode_run_time: List[int] 207 | genres: List[TmdbGenres] 208 | homepage: str 209 | languages: List[str] 210 | number_of_episodes: int 211 | number_of_seasons: int 212 | origin_country: List[str] 213 | original_language: List[str] 214 | overview: str 215 | popularity: float 216 | poster_path: str 217 | production_companies: List[ProductionCompanies] 218 | production_countries: List 219 | spoken_languages: List[SpokenLanguages] 220 | status: str 221 | tagline: str 222 | type: str 223 | vote_average: float 224 | vote_count: int 225 | created_by: dict 226 | last_episode_to_air: EpisodeMeta 227 | next_episode_to_air: EpisodeMeta 228 | networks: List[Network] 229 | seasons: List[Season] 230 | 231 | def __init__(self, data: Dict): 232 | utils.copy_value(data, self) 233 | 234 | 235 | @json_object 236 | class TmdbCredits: 237 | id: int 238 | cast: List[TmdbPerson] 239 | crew: List[TmdbPerson] 240 | 241 | def __init__(self, data: Dict): 242 | utils.copy_value(data, self) 243 | 244 | 245 | @json_object 246 | class TmdbAkaName: 247 | iso_3166_1: str 248 | title: str 249 | 250 | def __init__(self, data: Dict): 251 | utils.copy_value(data, self) 252 | 253 | 254 | @json_object 255 | class TmdbExternalIds: 256 | id: int 257 | imdb_id: str 258 | wikidata_id: str 259 | facebook_id: str 260 | instagram_id: str 261 | twitter_id: str 262 | 263 | def __init__(self, data: Dict): 264 | utils.copy_value(data, self) 265 | 266 | 267 | class TmdbApi: 268 | def __init__(self, session: Session): 269 | self._session: Session = session 270 | 271 | def get(self, media_type: MediaType, tmdb_id: int, language: Optional[str] = None) -> Union[ 272 | TmdbMovie, TmdbTV, None]: 273 | """ 274 | 根据编号获取详情 275 | """ 276 | if not language: 277 | language = 'zh-CN' 278 | meta = self._session.get('tmdb.get', { 279 | 'media_type': media_type.value, 280 | 'tmdb_id': tmdb_id, 281 | 'language': language 282 | }) 283 | if not meta: 284 | return 285 | if media_type == MediaType.Movie: 286 | return TmdbMovie(meta) 287 | else: 288 | return TmdbTV(meta) 289 | 290 | def get_external_ids(self, media_type: MediaType, tmdb_id: int): 291 | """ 292 | 根据tmdb id获取其他媒体平台的id 293 | """ 294 | res = self._session.get('tmdb.get_external_ids', { 295 | 'media_type': media_type.value, 296 | 'tmdb_id': tmdb_id 297 | }) 298 | if not res: 299 | return 300 | return TmdbExternalIds(res) 301 | 302 | def get_credits(self, media_type: MediaType, tmdb_id: int, season_number: Optional[int] = None): 303 | """ 304 | 获取演员,剧组成员信息 305 | """ 306 | res = self._session.get('tmdb.get_credits', { 307 | 'media_type': media_type.value, 308 | 'tmdb_id': tmdb_id, 309 | 'season_number': season_number 310 | }) 311 | if not res: 312 | return 313 | return TmdbCredits(res) 314 | 315 | def search(self, media_type: MediaType, query: str, year: Optional[int] = None, language: Optional[str] = None): 316 | """ 317 | 根据特定的媒体类型搜索 318 | """ 319 | res = self._session.get('tmdb.search', { 320 | 'media_type': media_type.value, 321 | 'query': query, 322 | 'language': language, 323 | 'year': year 324 | }) 325 | if not res: 326 | return 327 | result = [] 328 | for item in res: 329 | item['media_type'] = media_type.value 330 | result.append(SearchResultItem(item)) 331 | return result 332 | 333 | def search_multi(self, query: str, language: Optional[str] = None, page: Optional[int] = None) -> Optional[ 334 | SearchResult]: 335 | """ 336 | 混合搜索,电影、剧集、演员都搜 337 | """ 338 | res = self._session.get('tmdb.search_multi', { 339 | 'query': query, 340 | 'language': language, 341 | 'page': page 342 | }) 343 | if not res: 344 | return 345 | return SearchResult(res) 346 | 347 | def get_aka_names(self, media_type: MediaType, tmdb_id: int) -> List[TmdbAkaName]: 348 | list_ = self._session.get('tmdb.get_aka_names', { 349 | 'media_type': media_type.value, 350 | 'tmdb_id': tmdb_id 351 | }) 352 | if not list_: 353 | return [] 354 | return [TmdbAkaName(x) for x in list_] 355 | 356 | def get_tv_episode(self, tmdb_id: int, season_number: int, episode_number: int, language: Optional[str] = None) -> \ 357 | Optional[EpisodeMeta]: 358 | res = self._session.get('tmdb.get_tv_episode', { 359 | 'tmdb_id': tmdb_id, 360 | 'season_number': season_number, 361 | 'episode_number': episode_number, 362 | 'language': language 363 | }) 364 | if not res: 365 | return 366 | return EpisodeMeta(res) 367 | 368 | def request_api(self, uri: str, params: dict): 369 | return self._session.post('tmdb.request_api', { 370 | 'uri': uri, 371 | 'params': params 372 | }) 373 | -------------------------------------------------------------------------------- /moviebotapi/user.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from typing import Dict, List, Optional 3 | 4 | from moviebotapi.core import utils 5 | from moviebotapi.core.session import Session 6 | from moviebotapi.core.utils import json_object 7 | 8 | 9 | @json_object 10 | class User: 11 | gmt_create: datetime.datetime 12 | gmt_modified: datetime.datetime 13 | username: str 14 | nickname: str 15 | douban_user: str 16 | qywx_user: str 17 | telegram_user_id: int 18 | bark_url: str 19 | avatar: str 20 | role: int 21 | pushdeer_key: str 22 | score_rule_name: str 23 | password: str 24 | 25 | def __init__(self, data: Dict, api: 'UserApi'): 26 | utils.copy_value(data, self) 27 | self._api = api 28 | self.uid: int = utils.parse_value(int, data.get('id')) 29 | 30 | def delete(self): 31 | self._api.delete(self.uid) 32 | 33 | def reset_password(self, new_password: str): 34 | self._api.reset_password(self.uid, new_password) 35 | 36 | def update(self): 37 | self._api.update(self) 38 | 39 | 40 | class UserApi: 41 | def __init__(self, session: Session): 42 | self._session: Session = session 43 | 44 | def reset_password(self, uid: int, new_password: str): 45 | self._session.post('user.reset_password', json={'uid': uid, 'password': new_password}) 46 | 47 | def delete(self, uid: int): 48 | self._session.post('user.delete_user', json={'uid': uid}) 49 | 50 | def get(self, uid: int) -> Optional[User]: 51 | user = self._session.get('user.get_user', params={ 52 | 'id': uid 53 | }) 54 | if not user: 55 | return 56 | return User(user, self) 57 | 58 | def update(self, user: User): 59 | self._session.post('user.update_user', json={ 60 | 'uid': user.uid, 61 | 'username': user.username, 62 | 'nickname': user.nickname, 63 | 'new_password': user.password, 64 | 'role': user.role, 65 | 'douban_user': user.douban_user, 66 | 'qywx_user': user.qywx_user, 67 | 'pushdeer_key': user.pushdeer_key, 68 | 'bark_url': user.bark_url, 69 | 'score_rule_name': user.score_rule_name, 70 | 'telegram_user_id': user.telegram_user_id 71 | }) 72 | 73 | def list(self) -> List[User]: 74 | list_ = self._session.get('user.get_user_list') 75 | if not list_: 76 | return [] 77 | return [User(x, self) for x in list_] 78 | 79 | def upload_img_to_cloud_by_filepath(self, filepath: str): 80 | return self._session.get('user.upload_img_to_cloud_by_filepath', { 81 | 'filepath': filepath 82 | }) 83 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | httpx 2 | pyrate-limiter 3 | tenacity 4 | cn2an 5 | click 6 | PyYAML -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from setuptools import setup 3 | 4 | # Get README.rst contents 5 | readme = '' 6 | with open('README.rst') as f: 7 | readme = f.read() 8 | requirements = [] 9 | with open('requirements.txt') as handle: 10 | for line in handle.readlines(): 11 | if not line.startswith('#'): 12 | package = line.strip().split('=', 1)[0] 13 | requirements.append(package) 14 | setup( 15 | name='movie-bot-api', 16 | version='0.0.57', 17 | author='yee', 18 | author_email='yipengfei329@gmail.com', 19 | license='MIT', 20 | url='https://github.com/pofey/movie-bot-api', 21 | description='智能影音机器人MovieBot的接口SDK', 22 | python_requires='>=3.8', 23 | long_description=readme, 24 | long_description_content_type="text/x-rst", 25 | keywords=['movie bot', 'movie robot'], 26 | packages=['moviebotapi', 'moviebotapi.core'], 27 | install_requires=requirements, 28 | include_package_data=True, 29 | classifiers=[ 30 | 'License :: OSI Approved :: MIT License', 31 | 'Intended Audience :: Developers', 32 | 'Natural Language :: Chinese (Simplified)', 33 | 'Operating System :: OS Independent', 34 | 'Programming Language :: Python :: 3.8', 35 | 'Programming Language :: Python :: 3.9', 36 | 'Programming Language :: Python :: 3.10', 37 | 'Topic :: Internet', 38 | 'Topic :: Software Development :: Libraries', 39 | 'Topic :: Software Development :: Libraries :: Python Modules', 40 | 'Topic :: Utilities' 41 | ] 42 | ) 43 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | from moviebotapi import MovieBotServer 2 | from moviebotapi.core.session import AccessKeySession 3 | from tests.constant import SERVER_URL, ACCESS_KEY 4 | 5 | server = MovieBotServer(AccessKeySession(SERVER_URL, ACCESS_KEY)) 6 | -------------------------------------------------------------------------------- /tests/constant.py: -------------------------------------------------------------------------------- 1 | SERVER_URL = 'http://mbot.ycy.homes:1329' 2 | ACCESS_KEY = 'dfyqYWCFntrMBi973w12EsxcQKbehvLX' 3 | -------------------------------------------------------------------------------- /tests/image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pofey/movie-bot-api/3deaec99270c35e6e23d4a963383aea4ef25b7d0/tests/image.png -------------------------------------------------------------------------------- /tests/requirements.txt: -------------------------------------------------------------------------------- 1 | pytest -------------------------------------------------------------------------------- /tests/test_amr.py: -------------------------------------------------------------------------------- 1 | from moviebotapi import MovieBotServer 2 | from moviebotapi.core.session import AccessKeySession 3 | from tests.constant import SERVER_URL, ACCESS_KEY 4 | 5 | server = MovieBotServer(AccessKeySession(SERVER_URL, ACCESS_KEY)) 6 | 7 | 8 | def test_parse_name_meta_by_string(): 9 | print(server.amr.parse_name_meta_by_string( 10 | '人世间.CCTV1.A.Lifelong.Journey.S01E01.2022.1080i.HDTV.H264.AC3-iLoveTV').__dict__) 11 | 12 | 13 | def test_parse_name_meta_by_filepath(): 14 | print(server.amr.parse_name_meta_by_filepath( 15 | '/Users/yee/workspace/test_media/download/this.is.us/Season 6/this.is.us.s06e05.720p.hdtv.x264-syncopy.mkv').__dict__) 16 | 17 | 18 | def test_analysis_string(): 19 | print(server.amr.analysis_string('人世间.CCTV1.A.Lifelong.Journey.S01E01.2022.1080i.HDTV.H264.AC3-iLoveTV').__dict__) 20 | 21 | 22 | def test_analysis_douban_meta(): 23 | print(server.amr.analysis_douban_meta('人世间', year=2022).__dict__) 24 | -------------------------------------------------------------------------------- /tests/test_auth.py: -------------------------------------------------------------------------------- 1 | from moviebotapi import MovieBotServer 2 | from moviebotapi.core.session import AccessKeySession 3 | from tests.constant import SERVER_URL, ACCESS_KEY 4 | 5 | server = MovieBotServer(AccessKeySession(SERVER_URL, ACCESS_KEY)) 6 | 7 | 8 | def test_get_default_ak(): 9 | server.auth.get_default_ak() 10 | 11 | 12 | def test_add_permission(): 13 | server.auth.add_permission([1, 2], '/common/view#/static/tv_calendar.html') 14 | -------------------------------------------------------------------------------- /tests/test_common.py: -------------------------------------------------------------------------------- 1 | import base64 2 | 3 | import httpx 4 | 5 | from moviebotapi import MovieBotServer 6 | from moviebotapi.common import MenuItem 7 | from moviebotapi.core.session import AccessKeySession 8 | from tests.constant import SERVER_URL, ACCESS_KEY 9 | 10 | server = MovieBotServer(AccessKeySession(SERVER_URL, ACCESS_KEY)) 11 | 12 | 13 | def test_cache(): 14 | server.common.set_cache('test', 'a', {'aaa': 123}) 15 | assert server.common.get_cache('test', 'a').get('aaa') == 123 16 | 17 | 18 | def test_get_image_text(): 19 | with open('image.png', "rb") as f: 20 | base64_data = base64.b64encode(f.read()) 21 | print(server.common.get_image_text(base64_data.decode())) 22 | 23 | 24 | def test_get_cache_image_filepath(): 25 | print(server.common.get_cache_image_filepath('https://image.tmdb.org/t/p/original/xEggmiD4WoJBQR2AiVF46yPUUgD.jpg')) 26 | 27 | 28 | def test_list_menus(): 29 | menus = server.common.list_menus() 30 | for item in menus: 31 | if item.title == '我的': 32 | test = MenuItem() 33 | test.title = '追剧日历' 34 | test.href = '/common/view#/static/tv_calendar.html' 35 | test.icon = 'AcUnit' 36 | item.pages.append(test) 37 | break 38 | server.common.save_menus(menus) 39 | -------------------------------------------------------------------------------- /tests/test_config.py: -------------------------------------------------------------------------------- 1 | from moviebotapi import MovieBotServer 2 | from moviebotapi.core.models import MediaType 3 | from moviebotapi.core.session import AccessKeySession 4 | from tests.constant import SERVER_URL, ACCESS_KEY 5 | 6 | server = MovieBotServer(AccessKeySession(SERVER_URL, ACCESS_KEY)) 7 | 8 | 9 | def test_get_and_set(): 10 | free_download = server.config.free_download 11 | free_download.enable = False 12 | free_download.save() 13 | 14 | 15 | def test_get_web_config(): 16 | print(server.config.web.server_url) 17 | 18 | 19 | def test_get_env(): 20 | print(server.config.env.user_config_dir) 21 | 22 | 23 | def test_register_channel_template(): 24 | server.config.register_channel_template('/home/WUYING_yee_1343011038466001/workspace/test/movie-robot/conf/notify_template/telegram (copy).yml') 25 | -------------------------------------------------------------------------------- /tests/test_douban.py: -------------------------------------------------------------------------------- 1 | from moviebotapi import MovieBotServer 2 | from moviebotapi.core.models import MediaType 3 | from moviebotapi.core.session import AccessKeySession 4 | from moviebotapi.douban import DoubanRankingType 5 | from tests.constant import SERVER_URL, ACCESS_KEY 6 | 7 | server = MovieBotServer(AccessKeySession(SERVER_URL, ACCESS_KEY)) 8 | 9 | 10 | def test_get(): 11 | meta = server.douban.get(35196753) 12 | assert meta 13 | assert meta.media_type == MediaType.TV 14 | 15 | 16 | def test_search(): 17 | assert server.douban.search('子弹列车') 18 | 19 | 20 | def test_list_ranking(): 21 | assert server.douban.list_ranking(DoubanRankingType.movie_real_time_hotest) 22 | 23 | 24 | def test_use_api_search(): 25 | assert server.douban.use_api_search('子弹列车') 26 | 27 | 28 | def test_daily_media(): 29 | assert server.douban.daily_media() 30 | -------------------------------------------------------------------------------- /tests/test_event.py: -------------------------------------------------------------------------------- 1 | from moviebotapi import MovieBotServer 2 | from moviebotapi.core.models import MediaType 3 | from moviebotapi.core.session import AccessKeySession 4 | from moviebotapi.douban import DoubanRankingType 5 | from tests.constant import SERVER_URL, ACCESS_KEY 6 | 7 | server = MovieBotServer(AccessKeySession(SERVER_URL, ACCESS_KEY)) 8 | 9 | 10 | def test_publish_event(): 11 | server.event.publish_event('ApiTestEvent', { 12 | 'name': 'zhangsan' 13 | }) 14 | -------------------------------------------------------------------------------- /tests/test_library.py: -------------------------------------------------------------------------------- 1 | from moviebotapi import MovieBotServer 2 | from moviebotapi.core.models import MediaType 3 | from moviebotapi.core.session import AccessKeySession 4 | from moviebotapi.library import MediaLibraryPath, TransferMode 5 | from tests.constant import SERVER_URL, ACCESS_KEY 6 | 7 | server = MovieBotServer(AccessKeySession(SERVER_URL, ACCESS_KEY)) 8 | 9 | 10 | def test_start_scanner(): 11 | """ 12 | 按编号扫描一个媒体库,此过程会分析媒体库内所有视频文件的影片数据、视频流信息 13 | 此过程不破坏物理文件 14 | """ 15 | server.library.start_scanner(1) 16 | 17 | 18 | def test_stop_scanner(): 19 | server.library.stop_scanner(1) 20 | 21 | 22 | def test_add_library(): 23 | """ 24 | 创建一个媒体库,并设置一个媒体库的路径 25 | """ 26 | path = MediaLibraryPath() 27 | path.path = '/Volumes/media/plex/电影' 28 | server.library.add_library(MediaType.Movie, '电影', [path]) 29 | 30 | 31 | def test_rename_by_path(): 32 | """ 33 | 对一个路径做文件名重建,重命名的模版采用识别与整理设置中的模版 34 | 做此测试,可以选择硬连接一个单独的目录,会实际改变物理文件 35 | """ 36 | server.library.rename_by_path('/Volumes/media/plex/电影') 37 | 38 | 39 | def test_direct_transfer(): 40 | server.library.direct_transfer('/media/电影/港台', '/media/plex/电影/港台', TransferMode.HardLink) 41 | -------------------------------------------------------------------------------- /tests/test_media_server.py: -------------------------------------------------------------------------------- 1 | from moviebotapi import MovieBotServer 2 | from moviebotapi.core.session import AccessKeySession 3 | from tests.constant import SERVER_URL, ACCESS_KEY 4 | 5 | server = MovieBotServer(AccessKeySession(SERVER_URL, ACCESS_KEY)) 6 | 7 | 8 | def test_get_episodes_from_tmdb(): 9 | server.media_server.list_episodes_from_tmdb(94997, 1) 10 | 11 | 12 | def test_search_by_tmdb(): 13 | server.media_server.search_by_tmdb(94997) 14 | -------------------------------------------------------------------------------- /tests/test_meta.py: -------------------------------------------------------------------------------- 1 | from moviebotapi import MovieBotServer 2 | from moviebotapi.core.models import MediaType 3 | from moviebotapi.core.session import AccessKeySession 4 | from tests.constant import SERVER_URL, ACCESS_KEY 5 | 6 | server = MovieBotServer(AccessKeySession(SERVER_URL, ACCESS_KEY)) 7 | 8 | 9 | def test_get_cast_crew_by_douban(): 10 | assert server.meta.get_cast_crew_by_douban(MediaType.Movie, 3804891) 11 | 12 | 13 | def test_get_cast_crew_by_tmdb(): 14 | assert server.meta.get_cast_crew_by_tmdb(MediaType.TV, 60059) 15 | 16 | 17 | def test_get_media_by_tmdb_id(): 18 | assert server.meta.get_media_by_tmdb(MediaType.TV, 60059) 19 | 20 | 21 | def test_get_media_by_douban_id(): 22 | assert server.meta.get_media_by_douban(MediaType.Movie, 1889243) 23 | 24 | 25 | def test_share_meta_from_id(): 26 | print(server.meta.share_meta_from_id(872176, 35415401)) 27 | -------------------------------------------------------------------------------- /tests/test_notify.py: -------------------------------------------------------------------------------- 1 | from moviebotapi import MovieBotServer 2 | from moviebotapi.core.session import AccessKeySession 3 | from tests.constant import SERVER_URL, ACCESS_KEY 4 | 5 | server = MovieBotServer(AccessKeySession(SERVER_URL, ACCESS_KEY)) 6 | 7 | 8 | def test_send_text_message(): 9 | server.notify.send_system_message(1, 'test', 'aaaa') 10 | 11 | 12 | def test_send_message_by_tmpl(): 13 | server.notify.send_message_by_tmpl('{{title}}', '{{a}}', { 14 | 'title': '我是标题', 15 | 'a': "hello", 16 | 'link_url': 'http://www.bing.com', 17 | 'pic_url': 'https://www.curvearro.com/wp-content/uploads/sites/1/2020/10/Microsoft-Bing-Banner-1.jpg' 18 | }, to_channel_name='qywx') 19 | -------------------------------------------------------------------------------- /tests/test_plugin.py: -------------------------------------------------------------------------------- 1 | from tests import server 2 | 3 | 4 | def test_get_installed_list(): 5 | r = server.plugin.get_installed_list() 6 | print(r) 7 | -------------------------------------------------------------------------------- /tests/test_scraper.py: -------------------------------------------------------------------------------- 1 | from moviebotapi import MovieBotServer 2 | from moviebotapi.core.models import MediaType 3 | from moviebotapi.core.session import AccessKeySession 4 | from tests.constant import SERVER_URL, ACCESS_KEY 5 | 6 | server = MovieBotServer(AccessKeySession(SERVER_URL, ACCESS_KEY)) 7 | 8 | 9 | def test_get_images(): 10 | image = server.scraper.get_images(MediaType.TV, 153312, 1, 6) 11 | assert image 12 | assert image.poster 13 | -------------------------------------------------------------------------------- /tests/test_site.py: -------------------------------------------------------------------------------- 1 | from moviebotapi import MovieBotServer 2 | from moviebotapi.core.exceptions import ApiErrorException 3 | from moviebotapi.core.session import AccessKeySession 4 | from moviebotapi.site import SearchQuery, SearchType 5 | from tests import server 6 | from tests.constant import SERVER_URL, ACCESS_KEY 7 | 8 | 9 | def test_list(): 10 | list_ = server.site.list() 11 | assert list_ 12 | return list_ 13 | 14 | 15 | def test_set(): 16 | list_ = test_list() 17 | list_[0].cookie = 'test' 18 | try: 19 | list_[0].save() 20 | assert False 21 | except ApiErrorException: 22 | assert True 23 | 24 | 25 | def test_search_local(): 26 | assert server.site.search_local(SearchQuery(SearchType.Keyword, '子弹列车')) 27 | 28 | 29 | def test_search_remote(): 30 | assert server.site.search_remote(SearchQuery(SearchType.Keyword, '子弹列车')) 31 | 32 | 33 | def test_list_local_torrents(): 34 | assert server.site.list_local_torrents() 35 | -------------------------------------------------------------------------------- /tests/test_subscribe.py: -------------------------------------------------------------------------------- 1 | from moviebotapi import MovieBotServer 2 | from moviebotapi.core.models import MediaType 3 | from moviebotapi.core.session import AccessKeySession 4 | from moviebotapi.subscribe import SubStatus 5 | from tests.constant import SERVER_URL, ACCESS_KEY 6 | 7 | server = MovieBotServer(AccessKeySession(SERVER_URL, ACCESS_KEY)) 8 | 9 | 10 | def test_list(): 11 | list_ = server.subscribe.list(MediaType.Movie, SubStatus.Subscribing) 12 | assert list_ 13 | return list_ 14 | 15 | 16 | def test_get(): 17 | list_ = test_list() 18 | assert server.subscribe.get(list_[0].id) 19 | 20 | 21 | def test_sub(): 22 | server.subscribe.sub_by_douban(26654184) 23 | 24 | 25 | def test_get_filters(): 26 | server.subscribe.get_filters() 27 | 28 | 29 | def test_list_custom(): 30 | items = server.subscribe.list_custom_sub() 31 | assert items 32 | -------------------------------------------------------------------------------- /tests/test_tmdb.py: -------------------------------------------------------------------------------- 1 | from moviebotapi import MovieBotServer 2 | from moviebotapi.core.models import MediaType 3 | from moviebotapi.core.session import AccessKeySession 4 | from moviebotapi.ext import MediaMetaSelect 5 | from tests.constant import SERVER_URL, ACCESS_KEY 6 | 7 | server = MovieBotServer(AccessKeySession(SERVER_URL, ACCESS_KEY)) 8 | 9 | 10 | def test_get(): 11 | meta = MediaMetaSelect(tmdb=server.tmdb.get(MediaType.Movie, 436270)) 12 | assert meta 13 | 14 | 15 | def test_search(): 16 | server.tmdb.search(MediaType.Movie, '子弹列车') 17 | 18 | 19 | def test_search_multi(): 20 | server.tmdb.search_multi('权利的堡垒') 21 | 22 | 23 | def test_get_aka_names(): 24 | server.tmdb.get_aka_names(MediaType.Movie, 985939) 25 | 26 | 27 | def test_get_external_ids(): 28 | server.tmdb.get_external_ids(MediaType.TV, 60059) 29 | 30 | 31 | def test_get_credits(): 32 | server.tmdb.get_credits(MediaType.TV, 60059, 6) 33 | 34 | 35 | def test_get_tv_episode(): 36 | server.tmdb.get_tv_episode(60059, 6, 1) 37 | 38 | 39 | def test_request_api(): 40 | r = server.tmdb.request_api('/3/tv/73586/season/5', { 41 | 'language': 'zh-CN' 42 | }) 43 | print(r) 44 | -------------------------------------------------------------------------------- /tests/test_user.py: -------------------------------------------------------------------------------- 1 | from moviebotapi import MovieBotServer 2 | from moviebotapi.core.session import AccessKeySession 3 | from tests.constant import ACCESS_KEY, SERVER_URL 4 | 5 | server = MovieBotServer(AccessKeySession(SERVER_URL, ACCESS_KEY)) 6 | 7 | 8 | def test_get_user_list(): 9 | assert server.user.list() 10 | 11 | 12 | def test_get_user(): 13 | user = server.user.get(1) 14 | assert user 15 | user.nickname = 'test' 16 | user.update() 17 | user = server.user.get(1) 18 | assert user 19 | assert user.nickname == 'test' 20 | 21 | 22 | def test_upload_img_to_cloud_by_filepath(): 23 | r = server.user.upload_img_to_cloud_by_filepath( 24 | '/Users/yee/workspace/test/movie-robot/plugins/annual_report/report.jpg') 25 | print(r) 26 | -------------------------------------------------------------------------------- /tools/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pofey/movie-bot-api/3deaec99270c35e6e23d4a963383aea4ef25b7d0/tools/__init__.py -------------------------------------------------------------------------------- /tools/plugin.py: -------------------------------------------------------------------------------- 1 | import os 2 | import urllib 3 | 4 | import click 5 | import httpx 6 | from httpx import Timeout 7 | 8 | REPO = 'http://api.xmoviebot.com' 9 | USER_AGENT = 'movie-bot-plugin/0.1' 10 | 11 | 12 | @click.command() 13 | @click.option('--license_key', prompt='请提供你的LicenseKey进行密码重置') 14 | def reset_password(license_key): 15 | """ 16 | 通过Licese重置登陆密码 17 | :param license_key: 18 | :return: 19 | """ 20 | r = httpx.get( 21 | url=f'{REPO}/api/user/resetPasswordByLicense?licenseKey={license_key}', 22 | headers={'User-Agent': USER_AGENT} 23 | ) 24 | j = r.json() 25 | print(j.get('message')) 26 | 27 | 28 | def get_ak(email, password): 29 | r = httpx.post( 30 | url=f'{REPO}/api/auth/createAccessTokenByEmail', 31 | json={ 32 | 'email': email, 33 | 'password': password 34 | }, 35 | headers={'User-Agent': USER_AGENT}, 36 | timeout=Timeout(30) 37 | ) 38 | j = r.json() 39 | if j.get('data'): 40 | return j.get('data').get('accessToken') 41 | else: 42 | print(j.get('message')) 43 | return None 44 | 45 | 46 | @click.command() 47 | @click.option('--email', prompt='MovieBot邮件地址') 48 | @click.option('--password', prompt='密码', hide_input=True) 49 | @click.option('--plugin_zip_path', prompt='请输入插件压缩包(zip)完整路径') 50 | @click.option('--change_log', prompt='请输入变更日志') 51 | def publish(email, password, plugin_zip_path, change_log): 52 | if not os.path.exists(plugin_zip_path): 53 | print(f'插件压缩包路径不存在:{plugin_zip_path}') 54 | return 55 | if os.path.splitext(plugin_zip_path)[-1].lower() != '.zip': 56 | print(f'仅支持zip格式插件包发布') 57 | return 58 | ak = get_ak(email, password) 59 | if not ak: 60 | print('登录失败') 61 | return 62 | files = {'file': (os.path.split(plugin_zip_path)[-1], open(plugin_zip_path, 'rb'), 'application/zip')} 63 | r = httpx.post( 64 | f'{REPO}/api/plugins/publish?changeLog={urllib.parse.quote_plus(change_log)}', 65 | files=files, 66 | headers={ 67 | 'token': ak, 68 | 'User-Agent': USER_AGENT 69 | }, 70 | timeout=Timeout(30) 71 | ) 72 | j = r.json() 73 | print(j.get('message')) 74 | 75 | 76 | @click.command() 77 | @click.option('--action', '-a', prompt='选择需要进行的操作 reset 或 publish 分别为重制密码、发布或更新插件', 78 | type=click.Choice(['reset', 'publish']), 79 | help='选择需要进行的操作 reset 或 publish 分别为重制密码、发布或更新插件') 80 | def select_action(action): 81 | if action == 'reset': 82 | reset_password() 83 | elif action == 'publish': 84 | print('需要登录MovieBot官方仓库后进行发布') 85 | publish() 86 | 87 | 88 | if __name__ == '__main__': 89 | print(f'插件中心服务器:{REPO}') 90 | select_action() 91 | --------------------------------------------------------------------------------