├── .gitignore ├── .vscode └── settings.json ├── LICENSE ├── Makefile ├── README.md ├── README.zh-Hant.md ├── Subtitle-Downloader (clash).bat ├── Subtitle-Downloader.bat ├── configs ├── AppleTVPlus.toml ├── CatchPlay.toml ├── Crunchyroll.toml ├── DisneyPlus.toml ├── FridayVideo.toml ├── HBOGOAsia.toml ├── KKTV.toml ├── LineTV.toml ├── MyVideo.toml ├── NowE.toml ├── NowPlayer.toml ├── Viki.toml ├── Viu.toml ├── WeTV.toml ├── config.py ├── iQIYI.toml ├── iTunes.toml └── meWATCH.toml ├── constants.py ├── cookies └── Put.the.cookies.txt.file.here.txt ├── guide.png ├── install_requirements.bat ├── locales └── zh-Hant │ └── LC_MESSAGES │ ├── main.mo │ ├── main.po │ ├── services.appletvplus.mo │ ├── services.appletvplus.po │ ├── services.catchplay.mo │ ├── services.catchplay.po │ ├── services.crunchyroll.mo │ ├── services.crunchyroll.po │ ├── services.disneyplus.disneyplus.mo │ ├── services.disneyplus.disneyplus.po │ ├── services.disneyplus.disneyplus_login.mo │ ├── services.disneyplus.disneyplus_login.po │ ├── services.fridayvideo.mo │ ├── services.fridayvideo.po │ ├── services.hbogoasia.mo │ ├── services.hbogoasia.po │ ├── services.iqiyi.iqiyi.mo │ ├── services.iqiyi.iqiyi.po │ ├── services.itunes.mo │ ├── services.itunes.po │ ├── services.kktv.mo │ ├── services.kktv.po │ ├── services.linetv.mo │ ├── services.linetv.po │ ├── services.mewatch.mo │ ├── services.mewatch.po │ ├── services.myvideo.mo │ ├── services.myvideo.po │ ├── services.nowe.mo │ ├── services.nowe.po │ ├── services.nowplayer.mo │ ├── services.nowplayer.po │ ├── services.service.mo │ ├── services.service.po │ ├── services.viki.mo │ ├── services.viki.po │ ├── services.viu.mo │ ├── services.viu.po │ ├── services.wetv.wetv.mo │ ├── services.wetv.wetv.po │ ├── services.youtube.mo │ ├── services.youtube.po │ ├── utils.helper.mo │ ├── utils.helper.po │ ├── utils.io.mo │ ├── utils.io.po │ ├── utils.subtitle.mo │ └── utils.subtitle.po ├── requirements.txt ├── services ├── __init__.py ├── appletvplus.py ├── baseservice.py ├── catchplay.py ├── crunchyroll.py ├── disneyplus │ ├── __init__.py │ ├── disneyplus.py │ └── disneyplus_login.py ├── fridayvideo.py ├── hbogoasia.py ├── iqiyi │ ├── cmd5x.js │ └── iqiyi.py ├── itunes.py ├── kktv.py ├── linetv.py ├── mewatch.py ├── myvideo.py ├── nowe.py ├── nowplayer.py ├── viki.py ├── viu.py ├── wetv │ ├── __init__.py │ ├── ckey.py │ └── wetv.py └── youtube.py ├── subtitle_downloader.py ├── subtitle_downloader.sh ├── tools ├── XstreamDL_CLI │ ├── __init__.py │ ├── __main__.py │ ├── cli.py │ ├── cmdargs.py │ ├── daemon.py │ ├── downloader.py │ ├── extractor.py │ ├── extractors │ │ ├── __init__.py │ │ ├── base.py │ │ ├── dash │ │ │ ├── __init__.py │ │ │ ├── childs │ │ │ │ ├── __init__.py │ │ │ │ ├── adaptationset.py │ │ │ │ ├── baseurl.py │ │ │ │ ├── cencpssh.py │ │ │ │ ├── contentprotection.py │ │ │ │ ├── initialization.py │ │ │ │ ├── location.py │ │ │ │ ├── period.py │ │ │ │ ├── representation.py │ │ │ │ ├── role.py │ │ │ │ ├── s.py │ │ │ │ ├── segmentbase.py │ │ │ │ ├── segmentlist.py │ │ │ │ ├── segmenttemplate.py │ │ │ │ ├── segmenttimeline.py │ │ │ │ └── segmenturl.py │ │ │ ├── funcs.py │ │ │ ├── handler.py │ │ │ ├── key.py │ │ │ ├── maps │ │ │ │ ├── __init__.py │ │ │ │ └── audiomap.py │ │ │ ├── mpd.py │ │ │ ├── mpditem.py │ │ │ ├── parser.py │ │ │ ├── segment.py │ │ │ └── stream.py │ │ ├── hls │ │ │ ├── ext │ │ │ │ ├── x.py │ │ │ │ ├── xdaterange.py │ │ │ │ ├── xkey.py │ │ │ │ ├── xmedia.py │ │ │ │ ├── xprivinf.py │ │ │ │ ├── xprogram_date_time.py │ │ │ │ └── xstream_inf.py │ │ │ ├── parser.py │ │ │ ├── segment.py │ │ │ └── stream.py │ │ ├── metaitem.py │ │ └── mss │ │ │ ├── box_util.py │ │ │ ├── childs │ │ │ ├── __init__.py │ │ │ ├── c.py │ │ │ ├── protection.py │ │ │ ├── protectionheader.py │ │ │ ├── qualitylevel.py │ │ │ └── streamindex.py │ │ │ ├── handler.py │ │ │ ├── ism.py │ │ │ ├── ismitem.py │ │ │ ├── key.py │ │ │ ├── parser.py │ │ │ ├── segment.py │ │ │ └── stream.py │ ├── headers │ │ └── default.py │ ├── log.py │ ├── models │ │ ├── base.py │ │ ├── key.py │ │ ├── segment.py │ │ └── stream.py │ ├── util │ │ ├── concat.py │ │ ├── decryptors │ │ │ └── aes.py │ │ ├── maps │ │ │ └── codecs.py │ │ └── texts.py │ └── version.py ├── __init__.py └── pyshaka │ ├── __init__.py │ ├── log.py │ ├── main.py │ ├── text │ ├── Cue.py │ ├── Mp4TtmlParser.py │ ├── Mp4VttParser.py │ ├── TextEngine.py │ ├── TtmlTextParser.py │ └── VttTextParser.py │ └── util │ ├── DataViewReader.py │ ├── Functional.py │ ├── Mp4BoxParsers.py │ ├── Mp4Parser.py │ ├── TextParser.py │ └── exceptions.py ├── user_config.toml └── utils ├── __init__.py ├── helper.py ├── io.py ├── proxy.py ├── ripprocess.py └── subtitle.py /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | __pycache__/ 3 | __tmp__/ 4 | logs/ 5 | *.pyc 6 | *.pot 7 | *.srt 8 | *.vtt 9 | *.ass 10 | *.log 11 | cookies/*_cookies.txt 12 | *.zip 13 | *.mp4 14 | *.mpd 15 | *.m3u8 16 | *.html 17 | test.py 18 | .vscode/ 19 | *.json 20 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "makefile.extensionOutputFolder": "./.vscode", 3 | "[python]": { 4 | "editor.defaultFormatter": "ms-python.autopep8" 5 | }, 6 | "python.formatting.provider": "none" 7 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Wayne Club 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 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | Subtitle-Downloader: 2 | msgfmt -o locales/zh-Hant/LC_MESSAGES/main.mo locales/zh-Hant/LC_MESSAGES/main 3 | msgfmt -o locales/zh-Hant/LC_MESSAGES/utils.helper.mo locales/zh-Hant/LC_MESSAGES/utils.helper 4 | msgfmt -o locales/zh-Hant/LC_MESSAGES/utils.io.mo locales/zh-Hant/LC_MESSAGES/utils.io 5 | msgfmt -o locales/zh-Hant/LC_MESSAGES/utils.subtitle.mo locales/zh-Hant/LC_MESSAGES/utils.subtitle 6 | msgfmt -o locales/zh-Hant/LC_MESSAGES/services.crunchyroll.mo locales/zh-Hant/LC_MESSAGES/services.crunchyroll 7 | msgfmt -o locales/zh-Hant/LC_MESSAGES/services.disneyplus.disneyplus.mo locales/zh-Hant/LC_MESSAGES/services.disneyplus.disneyplus 8 | msgfmt -o locales/zh-Hant/LC_MESSAGES/services.disneyplus.disneyplus_login.mo locales/zh-Hant/LC_MESSAGES/services.disneyplus.disneyplus_login 9 | msgfmt -o locales/zh-Hant/LC_MESSAGES/services.hbogoasia.mo locales/zh-Hant/LC_MESSAGES/services.hbogoasia 10 | msgfmt -o locales/zh-Hant/LC_MESSAGES/services.viu.mo locales/zh-Hant/LC_MESSAGES/services.viu 11 | msgfmt -o locales/zh-Hant/LC_MESSAGES/services.iqiyi.iqiyi.mo locales/zh-Hant/LC_MESSAGES/services.iqiyi.iqiyi 12 | msgfmt -o locales/zh-Hant/LC_MESSAGES/services.kktv.mo locales/zh-Hant/LC_MESSAGES/services.kktv 13 | msgfmt -o locales/zh-Hant/LC_MESSAGES/services.linetv.mo locales/zh-Hant/LC_MESSAGES/services.linetv 14 | msgfmt -o locales/zh-Hant/LC_MESSAGES/services.fridayvideo.mo locales/zh-Hant/LC_MESSAGES/services.fridayvideo 15 | msgfmt -o locales/zh-Hant/LC_MESSAGES/services.myvideo.mo locales/zh-Hant/LC_MESSAGES/services.myvideo 16 | msgfmt -o locales/zh-Hant/LC_MESSAGES/services.itunes.mo locales/zh-Hant/LC_MESSAGES/services.itunes 17 | msgfmt -o locales/zh-Hant/LC_MESSAGES/services.catchplay.mo locales/zh-Hant/LC_MESSAGES/services.catchplay 18 | msgfmt -o locales/zh-Hant/LC_MESSAGES/services.wetv.wetv.mo locales/zh-Hant/LC_MESSAGES/services.wetv.wetv 19 | msgfmt -o locales/zh-Hant/LC_MESSAGES/services.appletvplus.mo locales/zh-Hant/LC_MESSAGES/services.appletvplus 20 | msgfmt -o locales/zh-Hant/LC_MESSAGES/services.nowe.mo locales/zh-Hant/LC_MESSAGES/services.nowe 21 | msgfmt -o locales/zh-Hant/LC_MESSAGES/services.nowplayer.mo locales/zh-Hant/LC_MESSAGES/services.nowplayer 22 | msgfmt -o locales/zh-Hant/LC_MESSAGES/services.viki.mo locales/zh-Hant/LC_MESSAGES/services.viki 23 | msgfmt -o locales/zh-Hant/LC_MESSAGES/services.mewatch.mo locales/zh-Hant/LC_MESSAGES/services.mewatch 24 | msgfmt -o locales/zh-Hant/LC_MESSAGES/services.youtube.mo locales/zh-Hant/LC_MESSAGES/services.youtube 25 | -------------------------------------------------------------------------------- /Subtitle-Downloader (clash).bat: -------------------------------------------------------------------------------- 1 | @@ECHO OFF 2 | :start 3 | cls 4 | set/p url="url: " 5 | 6 | set proxy="http://127.0.0.1:7890" 7 | 8 | python subtitle_downloader.py %url% -p %proxy% 9 | pause 10 | 11 | goto start -------------------------------------------------------------------------------- /Subtitle-Downloader.bat: -------------------------------------------------------------------------------- 1 | @@ECHO OFF 2 | :start 3 | cls 4 | set/p url="url: " 5 | 6 | python subtitle_downloader.py %url% 7 | pause 8 | 9 | goto start -------------------------------------------------------------------------------- /configs/AppleTVPlus.toml: -------------------------------------------------------------------------------- 1 | credentials = 'cookies' 2 | required = 'media-user-token' 3 | user_agent = 'AppleTV6,2/11.1' # AppleTV6,2/11.1 | ATVE/1.1 FireOS/6.2.6.8 build/4A93 maker/Amazon model/FireTVStick4K FW/NS6268/2315 4 | 5 | [api] 6 | title = 'https://tv.apple.com/api/uts/v3/{content_type}/{id}' 7 | shows = 'https://tv.apple.com/api/uts/v3/shows/{id}/episodes' 8 | episode = 'https://tv.apple.com/api/uts/v3/episodes/{id}' 9 | configurations = 'https://tv.apple.com/api/uts/v3/configurations' 10 | storefront = 'https://buy.tv.apple.com/account/web/info' 11 | 12 | [device] 13 | utscf = 'OjAAAAAAAAA~' 14 | utsk = '6e3013c6d6fae3c2::::::ca09fd2bb1996546' # 6e3013c6d6fae3c2::::::e08f7cfb96228836 (?), 6e3013c6d6fae3c2::::::235656c069bb0efb (web) 15 | caller = 'web' # ?, vz, web 16 | sf = '143441' # "storefront", country | 143441: US, 143444: GB, 143470:TW 17 | v = '68' # "version" | latest: 68 18 | pfm = 'web' # "platform" | appletv, vz, web 19 | locale = 'en-US' # 'zh-Hant' 20 | -------------------------------------------------------------------------------- /configs/CatchPlay.toml: -------------------------------------------------------------------------------- 1 | credentials = 'cookies' 2 | required = 'connect.sid' 3 | 4 | [api] 5 | media_info = 'https://vcmsapi.catchplay.com/video/v3/mediaInfo/{video_id}' 6 | auth = 'https://www.catchplay.com/ssr-oauth/getOauth' 7 | play = 'https://hp2-api.catchplay.com/me/play' 8 | -------------------------------------------------------------------------------- /configs/Crunchyroll.toml: -------------------------------------------------------------------------------- 1 | credentials = 'cookies' 2 | 3 | [api] 4 | titles = 'https://www.crunchyroll.com/content/v2/cms/series/{content_id}/seasons' 5 | token = 'https://www.crunchyroll.com/auth/v1/token' 6 | -------------------------------------------------------------------------------- /configs/DisneyPlus.toml: -------------------------------------------------------------------------------- 1 | credentials = 'email' 2 | 3 | [api] 4 | explore = 'https://disney.api.edge.bamgrid.com/explore/v1.2/page/{series_id}' 5 | DmcSeriesBundle = 'https://disney.content.edge.bamgrid.com/svc/content/DmcSeriesBundle/version/5.1/region/{region}/audience/false/maturity/1850/language/{language}/encodedSeriesId/{series_id}' 6 | DmcEpisodes = 'https://disney.content.edge.bamgrid.com/svc/content/DmcEpisodes/version/5.1/region/{region}/audience/false/maturity/1850/language/{language}/seasonId/{season_id}/pageSize/30/page/{page}' 7 | DmcVideo = 'https://disney.content.edge.bamgrid.com/svc/content/DmcVideoBundle/version/5.1/region/{region}/audience/false/maturity/1850/language/{language}/encodedFamilyId/{family_id}' 8 | playback = 'https://disney.playback.edge.bamgrid.com/media/{media_id}/scenarios/tvs-drm-cbcs' 9 | 10 | # DisneyPlus Login 11 | login_page = 'https://www.disneyplus.com/login' 12 | devices = 'https://global.edge.bamgrid.com/devices' 13 | login = 'https://global.edge.bamgrid.com/idp/login' 14 | token = 'https://global.edge.bamgrid.com/token' 15 | grant = 'https://global.edge.bamgrid.com/accounts/grant' 16 | current_account = 'https://global.edge.bamgrid.com/accounts/me' 17 | session = 'https://disney.api.edge.bamgrid.com/session' 18 | -------------------------------------------------------------------------------- /configs/FridayVideo.toml: -------------------------------------------------------------------------------- 1 | credentials = 'cookies' 2 | required = 'uid' 3 | 4 | [api] 5 | title = 'https://video.friday.tw/api2/content/get?contentId={content_id}&contentType={content_type}&srcRecommendId=-1&recommendId=&eventPageId=&offset=0&length=1' 6 | episode_list = 'https://video.friday.tw/api2/episode/list?contentId={content_id}&contentType={content_type}&offset=0&length=100&mode=2' 7 | media_info = 'https://video.friday.tw/api2/streaming/get?streamingId={streaming_id}&streamingType={streaming_type}&contentType={content_type}&contentId={content_id}&clientId={client_id}&haveSubtitle={subtitle}&isEst=false&_={time_stamp}' 8 | fet_monitor = '{url}/FETMonitor' 9 | -------------------------------------------------------------------------------- /configs/HBOGOAsia.toml: -------------------------------------------------------------------------------- 1 | credentials = 'email' 2 | 3 | [api] 4 | 'geo' = 'https://api2.hbogoasia.com/v1/geog?lang=zh-Hant&version=0&bundleId={bundle_id}' 5 | login = 'https://api2.hbogoasia.com/v1/hbouser/login?lang=zh-Hant' 6 | device = 'https://api2.hbogoasia.com/v1/hbouser/device?lang=zh-Hant' 7 | tvseason = 'https://api2.hbogoasia.com/v1/tvseason/list?parentId={parent_id}&territory={territory}' 8 | tvepisode = 'https://api2.hbogoasia.com/v1/tvepisode/list?parentId={parent_id}&territory={territory}' 9 | movie = 'https://api2.hbogoasia.com/v1/movie?contentId={content_id}&territory={territory}' 10 | playback = 'https://api2.hbogoasia.com/v1/asset/playbackurl?territory={territory}&contentId={content_id}&sessionToken={session_token}&channelPartnerID={channel_partner_id}&operatorId=SIN&lang=zh-Hant' 11 | -------------------------------------------------------------------------------- /configs/KKTV.toml: -------------------------------------------------------------------------------- 1 | credentials = '' 2 | required = '' 3 | 4 | [api] 5 | titles = 'https://api.kktv.me/v3/titles/{title_id}' 6 | play = 'https://www.kktv.me/play/{title_id}010001' 7 | -------------------------------------------------------------------------------- /configs/LineTV.toml: -------------------------------------------------------------------------------- 1 | credentials = '' 2 | required = '' 3 | 4 | app_id = '062097f1b1f34e11e7f82aag22000aee' 5 | 6 | [api] 7 | manifest = 'https://www.linetv.tw/api/part/{drama_id}/eps/{episode_index}/part?appId={app_id}&device=desktop_web&chocomemberId={member_id}&productType=VOD' 8 | sub_1 = 'https://s3-ap-northeast-1.amazonaws.com/tv-aws-media-convert-input-tokyo/subtitles/{drama_id}/{drama_id}-eps-{episode_name}.vtt' 9 | sub_2 = 'https://choco-tv.s3.amazonaws.com/subtitle/{drama_id}-{drama_name}/{drama_id}-eps-{episode_name}.vtt' 10 | -------------------------------------------------------------------------------- /configs/MyVideo.toml: -------------------------------------------------------------------------------- 1 | credentials = 'cookies' 2 | 3 | [api] 4 | media_info = 'https://www.myvideo.net.tw/ajax/ajaxGetVideoData?contentId={content_id}&engine=html5&isSeries=0&isPreview=false&excludeAVOD=N' 5 | check_session = 'https://www.myvideo.net.tw/ajax/ajaxCheckSession.do?_={time}' 6 | -------------------------------------------------------------------------------- /configs/NowE.toml: -------------------------------------------------------------------------------- 1 | credentials = 'cookies' 2 | required = 'OTTSESSIONID' 3 | 4 | [api] 5 | product_detail = 'https://bridge.nowe.com/BridgeEngine/getProductDetail' 6 | update_session = 'https://webtvapi.nowe.com/16/1/updateSession' 7 | get_vod = 'https://webtvapi.nowe.com/16/1/getVodURL' 8 | -------------------------------------------------------------------------------- /configs/NowPlayer.toml: -------------------------------------------------------------------------------- 1 | credentials = 'cookies' 2 | required = 'NOWSESSIONID' 3 | 4 | [api] 5 | series = 'https://nowplayer.now.com/vodplayer/getSeriesJson/?seriesId={series_id}' 6 | movie = 'https://nowplayer.now.com/vodplayer/getProductJson/?productId={product_id}' 7 | play = 'https://nowplayer.now.com/vodplayer/play/' 8 | license = 'https://fwp.now.com/wrapperWV' 9 | -------------------------------------------------------------------------------- /configs/Viki.toml: -------------------------------------------------------------------------------- 1 | credentials = 'cookies' 2 | required = 'session__id' 3 | 4 | [api] 5 | episodes = '{url}?token={token}&direction=asc&with_upcoming=true&sort=number&blocked=true&page=1&per_page=100&app=100000a' 6 | videos = 'https://www.viki.com/api/videos/{video_id}' 7 | 8 | [vmplayer] 9 | version = '14.10.0' # x-viki-app-ver 10 | -------------------------------------------------------------------------------- /configs/Viu.toml: -------------------------------------------------------------------------------- 1 | credentials = '' 2 | required = '' 3 | 4 | app_version = '3.10.0' 5 | 6 | [api] 7 | # ott = 'https://www.viu.com/ott/{region}/index.php?area_id={area_id}&language_flag_id={language_flag_id}&r=vod/ajax-detail&platform_flag_label=web&area_id={area_id}&language_flag_id={language_flag_id}&product_id={product_id}' 8 | # load = 'https://viu.com/ott/web/api/container/load?ver=1.0&fmt=json&aver=5.0&appver=2.0&appid=viu_desktop&platform=desktop&id=playlist-{playlist_id}&start=0&limit=20&filter=mixed&contentCountry={region}&contentFlavour=all®ionid=all&languageid=en&ccode={region}&geo={geo}&iid=41be67db-a75a-4525-9d25-f41034cc578c' 9 | # token = 'https://um.viuapi.io/user/identity?ver=1.0&fmt=json&aver=5.0&appver=2.0&appid=viu_desktop&platform=desktop&iid=c1757177-ad9e-4cbb-8025-7f11569645d6' 10 | titles = 'https://api-gateway-global.viu.com/api/mobile' 11 | manifest = 'https://api-gateway-global.viu.com/api/playback/distribute' 12 | token = 'https://api-gateway-global.viu.com/api/auth/token' 13 | 14 | [language_flag_id] 15 | zh_hk = 1 16 | zh_cn = 2 17 | en = 3 18 | th = 4 19 | ms = 7 20 | 21 | [area_id] 22 | hk = 1 23 | sg = 2 24 | th = 4 25 | my = 1001 26 | -------------------------------------------------------------------------------- /configs/WeTV.toml: -------------------------------------------------------------------------------- 1 | credentials = 'cookies' 2 | required = 'guid' 3 | 4 | [api] 5 | play = 'https://wetv.vip/id/play/{series_id}/{episode_id}' 6 | getvinfo = 'https://play.wetv.vip/getvinfo' 7 | -------------------------------------------------------------------------------- /configs/config.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | # coding: utf-8 3 | 4 | """ 5 | This module is for default setting. 6 | """ 7 | from __future__ import annotations 8 | from pathlib import Path 9 | from typing import Any 10 | import pytomlpp 11 | 12 | app_name = "Subtitle-Downloader" 13 | __version__ = "2.0.0" 14 | 15 | 16 | class Config: 17 | """ 18 | Config module 19 | """ 20 | 21 | def __init__(self, **kwargs: Any): 22 | self.locale: str = kwargs.get('locale') or '' 23 | self.subtitles: dict = kwargs.get('subtitles') or {} 24 | self.credentials: dict = kwargs.get('credentials') or {} 25 | self.directories: dict = kwargs.get('directories') or {} 26 | self.headers: dict = kwargs.get('headers') or {} 27 | self.nordvpn: dict = kwargs.get('nordvpn') or {} 28 | self.proxies: dict = kwargs.get('proxies') or {} 29 | 30 | @classmethod 31 | def from_toml(cls, path: Path) -> Config: 32 | """Load toml""" 33 | if not path.exists(): 34 | raise FileNotFoundError(f"Config file path ({path}) was not found") 35 | if not path.is_file(): 36 | raise FileNotFoundError( 37 | f"Config file path ({path}) is not to a file.") 38 | return cls(**pytomlpp.load(path)) 39 | 40 | 41 | class Directories: 42 | """ 43 | Directories module 44 | """ 45 | 46 | def __init__(self) -> None: 47 | self.package_root = Path(__file__).resolve().parent.parent 48 | self.configuration = self.package_root / 'configs' 49 | self.downloads = self.package_root / 'downloads' 50 | self.cookies = self.package_root / 'cookies' 51 | self.logs = self.package_root / 'logs' 52 | 53 | 54 | class Filenames: 55 | """ 56 | Filenames module 57 | """ 58 | 59 | def __init__(self) -> None: 60 | self.log = directories.logs / "{app_name}_{log_time}.log" 61 | self.config = directories.configuration / "{service}.toml" 62 | self.root_config: Path = directories.package_root / "user_config.toml" 63 | 64 | 65 | directories = Directories() 66 | filenames = Filenames() 67 | 68 | config = Config.from_toml(filenames.root_config) 69 | if not config.directories.get('cookies'): 70 | config.directories['cookies'] = directories.cookies 71 | if not config.directories.get('downloads'): 72 | config.directories['downloads'] = directories.downloads 73 | config.directories['logs'] = directories.logs 74 | credentials = config.credentials 75 | user_agent = config.headers['User-Agent'] 76 | -------------------------------------------------------------------------------- /configs/iQIYI.toml: -------------------------------------------------------------------------------- 1 | credentials = 'cookies' 2 | required = '__dfp' 3 | 4 | [api] 5 | episode_list = 'https://pcw-api.iq.com/api/v2/episodeListSource/{album_id}?platformId=3&modeCode={mode_code}&langCode={lang_code}&deviceId=21fcb553c8e206bb515b497bb6376aa4&endOrder={end_order}&startOrder={start_order}' 6 | meta = 'https://meta.video.iqiyi.com' 7 | -------------------------------------------------------------------------------- /configs/iTunes.toml: -------------------------------------------------------------------------------- 1 | credentials = '' 2 | required = '' 3 | 4 | [api] 5 | configurations = 'https://tv.apple.com/api/uts/v3/configurations?caller=web&sfh=143470&v=56&pfm=web&locale=zh-tw&ts={time}' 6 | -------------------------------------------------------------------------------- /configs/meWATCH.toml: -------------------------------------------------------------------------------- 1 | credentials = '' 2 | device_id = '8cda5412-4864-404d-b5c3-59a8dbd8b6f1' 3 | 4 | [api] 5 | token = 'https://www.mewatch.sg/api/authorization/anonymous?ff=idp,ldp,rpt,cd&lang=en' 6 | recaptcha = 'https://www.google.com/recaptcha/api2/anchor?ar=1&k=6LdHwD0kAAAAACN6qCWwOfOKjcLSaCQJbA7WPWr1&co=aHR0cHM6Ly93d3cubWV3YXRjaC5zZzo0NDM.&hl=zh-TW&v=lLirU0na9roYU3wDDisGJEVT&size=invisible&cb=1fqmj6nlfng7' 7 | login = 'https://www.mewatch.sg/mcs-signin' 8 | authorization = 'https://www.mewatch.sg/api/authorization/sso/anonymous?ff=idp,ldp,rpt,cd&lang=en' 9 | 10 | movies = 'https://cdn.mewatch.sg/api/items/{content_id}?expand=all&ff=idp,ldp,rpt,cd&lang=en&segments=all&sub=Registered' 11 | series = 'https://cdn.mewatch.sg/api/items/{content_id}?expand=all&ff=idp,ldp,rpt,cd&lang=en&order=asc&page=1&page_size=100&segments=all&sub=Registered' 12 | episodes = 'https://cdn.mewatch.sg/api/items/{season_id}/children?ff=idp,ldp,rpt,cd&lang=en&order=asc&page=1&page_size=100&segments=all&sub=Registered' 13 | videos = 'https://www.mewatch.sg/api/account/items/{video_id}/videos?delivery=stream&ff=idp,ldp,rpt,cd&lang=en&resolution=External&segments=all&sub=Registered' 14 | # videos = 'https://cdn.mewatch.sg/api/items/{video_id}/videos?delivery=stream,progressive&ff=idp,ldp,rpt,cd&lang=en&resolution=External&segments=all' 15 | -------------------------------------------------------------------------------- /cookies/Put.the.cookies.txt.file.here.txt: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /guide.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wayneclub/Subtitle-Downloader/00804b4e017c1c8f8adc7e914514d73e774816b9/guide.png -------------------------------------------------------------------------------- /install_requirements.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | pip install -r requirements.txt 3 | pause -------------------------------------------------------------------------------- /locales/zh-Hant/LC_MESSAGES/main.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wayneclub/Subtitle-Downloader/00804b4e017c1c8f8adc7e914514d73e774816b9/locales/zh-Hant/LC_MESSAGES/main.mo -------------------------------------------------------------------------------- /locales/zh-Hant/LC_MESSAGES/main.po: -------------------------------------------------------------------------------- 1 | # Subtitle-Downloader 2 | # Copyright (C) 2021 3 | # 4 | msgid "" 5 | msgstr "" 6 | "Project-Id-Version: 1.0\n" 7 | "Report-Msgid-Bugs-To: \n" 8 | "POT-Creation-Date: 2021-12-30 23:11+0800\n" 9 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 10 | "Last-Translator: Wayne \n" 11 | "Language-Team: wayneclub \n" 12 | "Language: zh-Hant\n" 13 | "MIME-Version: 1.0\n" 14 | "Content-Type: text/plain; charset=UTF-8\n" 15 | "Content-Transfer-Encoding: 8bit\n" 16 | 17 | #: subtitle_downloader.py:22 18 | msgid "Support downloading subtitles from multiple streaming services, such as {} ,and etc." 19 | msgstr "支持從各大串流平台下載字幕,例如:{} 等等。" 20 | 21 | #: subtitle_downloader.py:24 22 | msgid "series's/movie's url" 23 | msgstr "電影、影集的網址" 24 | 25 | #: subtitle_downloader.py:29 26 | msgid "download season [0-9]" 27 | msgstr "下載 第[0-9]季" 28 | 29 | #: subtitle_downloader.py:29 30 | msgid "download episode [0-9]" 31 | msgstr "下載 第[0-9]集" 32 | 33 | #: subtitle_downloader.py:34 34 | msgid "download the latest episode" 35 | msgstr "下載 最新一集" 36 | 37 | #: subtitle_downloader.py:38 38 | msgid "output directory" 39 | msgstr "下載路徑" 40 | 41 | #: subtitle_downloader.py:46 42 | msgid "subtitles format: .srt or .ass" 43 | msgstr "字幕格式:.srt 或 .ass" 44 | 45 | #: subtitle_downloader.py:50 46 | msgid "" 47 | "languages of subtitles; use commas to separate multiple languages" 48 | msgstr "字幕語言,用','分隔" 49 | 50 | #: subtitle_downloader.py:54 51 | msgid "" 52 | "languages of audio-tracks; use commas to separate multiple languages" 53 | msgstr "音軌語言,用','分隔" 54 | 55 | #: subtitle_Downloader.py:62 56 | msgid "streaming service's region" 57 | msgstr "串流平台地區" 58 | 59 | #: subtitle_Downloader.py:68 60 | msgid "interface language" 61 | msgstr "界面語言" 62 | 63 | #: subtitle_Downloader.py:68 64 | msgid "proxy" 65 | msgstr "代理" 66 | 67 | #: subtitle_Downloader.py:74 68 | msgid "enable debug logging" 69 | msgstr "除錯日誌" 70 | 71 | #: subtitle_Downloader.py:74 72 | msgid "app's version" 73 | msgstr "程式版本" 74 | 75 | #: subtitle_Downloader.py:74 76 | msgid "show this help message and exit" 77 | msgstr "參數說明" 78 | 79 | #: subtitle_Downloader.py:74 80 | msgid "" 81 | "\n" 82 | "Please input correct url!" 83 | msgstr "\n輸入網址有誤,請檢查網址是否正確!" 84 | 85 | #: subtitle_Downloader.py:136 86 | msgid "" 87 | "\n" 88 | "Only support downloading subtitles from %s ,and etc." 89 | msgstr "\n目前僅支持從 %s 下載字幕,請確認電影、影集網址無誤" -------------------------------------------------------------------------------- /locales/zh-Hant/LC_MESSAGES/services.appletvplus.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wayneclub/Subtitle-Downloader/00804b4e017c1c8f8adc7e914514d73e774816b9/locales/zh-Hant/LC_MESSAGES/services.appletvplus.mo -------------------------------------------------------------------------------- /locales/zh-Hant/LC_MESSAGES/services.appletvplus.po: -------------------------------------------------------------------------------- 1 | # Subtitle-Downloader 2 | # Copyright (C) 2021 3 | # 4 | msgid "" 5 | msgstr "" 6 | "Project-Id-Version: 1.0\n" 7 | "Report-Msgid-Bugs-To: \n" 8 | "POT-Creation-Date: 2021-12-30 23:11+0800\n" 9 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 10 | "Last-Translator: Wayne \n" 11 | "Language-Team: wayneclub \n" 12 | "Language: zh-Hant\n" 13 | "MIME-Version: 1.0\n" 14 | "Content-Type: text/plain; charset=UTF-8\n" 15 | "Content-Transfer-Encoding: 8bit\n" 16 | 17 | #: services/appletvplus:63 services/appletvplus:68 18 | #, python-format 19 | msgid "" 20 | "\n" 21 | "Unsupport %s subtitle, available languages: %s" 22 | msgstr "\n未提供%s的字幕,支援語言:%s" 23 | 24 | 25 | #: services/appletvplus.py:60 26 | #, python-format 27 | msgid "" 28 | "\n" 29 | "%s total: %s season(s)" 30 | msgstr "\n%s 共有:%s 季" 31 | 32 | #: services/appletvplus.py:85 33 | msgid "" 34 | "\n" 35 | "Download:\n" 36 | "---------------------------------------------------------------" 37 | msgstr "\n下載:\n---------------------------------------------------------------" 38 | 39 | #: services/appletvplus.py:89 40 | #, python-format 41 | msgid "" 42 | "\n" 43 | "Season %s total: %s episode(s)\tdownload season %s last episode\n" 44 | "---------------------------------------------------------------" 45 | msgstr "\n第 %s 季 共有:%s 集\t下載第 %s 季 最後一集\n---------------------------------------------------------------" 46 | 47 | #: services/appletvplus.py:99 48 | #, python-format 49 | msgid "" 50 | "\n" 51 | "Total: %s episode(s)\tdownload all episodes\n" 52 | "---------------------------------------------------------------" 53 | msgstr "\n共有:%s 集\t下載全集\n---------------------------------------------------------------" 54 | 55 | #: services/appletvplus.py:104 56 | #, python-format 57 | msgid "" 58 | "\n" 59 | "Season %s total: %s episode(s)\tdownload all episodes\n" 60 | "---------------------------------------------------------------" 61 | msgstr "\n第 %s 季 共有:%s 集\t下載全集\n---------------------------------------------------------------" 62 | 63 | #: services/appletvplus.py:155 64 | #, python-format 65 | msgid "" 66 | "\n" 67 | "Season %s total: %s episode(s)\tupdate to episode %s\tdownload all episodes\n" 68 | "---------------------------------------------------------------" 69 | msgstr "\n第 %s 季 共有:%s 集\t更新至 第 %s 集\t下載全集\n---------------------------------------------------------------" 70 | 71 | #: services/appletvplus.py:90 72 | msgid "" 73 | "\n" 74 | "Sorry, there's no embedded subtitles in this video!" 75 | msgstr "\n無提供可下載的字幕" 76 | 77 | #: services/appletvplus.py:96 78 | #, python-format 79 | msgid "" 80 | "\n" 81 | "Download: %s\n" 82 | "---------------------------------------------------------------" 83 | msgstr "\n下載:%s\n---------------------------------------------------------------" 84 | 85 | #: services/appletvplus.py:95 86 | msgid "" 87 | "\n" 88 | "Sorry, you haven't purchased this movie!" 89 | msgstr "\n抱歉,您尚未購買此部電影" 90 | 91 | #: services/appletvplus.py:90 92 | msgid "" 93 | "\n" 94 | "Failed to get AppleTV+ WEB TV App Environment Configuration..." 95 | msgstr "\n無法取得 AppleTV+ WEB TV App 環境設定..." -------------------------------------------------------------------------------- /locales/zh-Hant/LC_MESSAGES/services.catchplay.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wayneclub/Subtitle-Downloader/00804b4e017c1c8f8adc7e914514d73e774816b9/locales/zh-Hant/LC_MESSAGES/services.catchplay.mo -------------------------------------------------------------------------------- /locales/zh-Hant/LC_MESSAGES/services.catchplay.po: -------------------------------------------------------------------------------- 1 | # Subtitle-Downloader 2 | # Copyright (C) 2021 3 | # 4 | msgid "" 5 | msgstr "" 6 | "Project-Id-Version: 1.0\n" 7 | "Report-Msgid-Bugs-To: \n" 8 | "POT-Creation-Date: 2021-12-30 23:11+0800\n" 9 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 10 | "Last-Translator: Wayne \n" 11 | "Language-Team: wayneclub \n" 12 | "Language: zh-Hant\n" 13 | "MIME-Version: 1.0\n" 14 | "Content-Type: text/plain; charset=UTF-8\n" 15 | "Content-Transfer-Encoding: 8bit\n" 16 | 17 | #: services/catchplay.py:118 18 | #, python-format 19 | msgid "" 20 | "\n" 21 | "Download: %s\n" 22 | "---------------------------------------------------------------" 23 | msgstr "\n下載:%s\n---------------------------------------------------------------" 24 | 25 | #: services/catchplay.py:133 26 | #, python-format 27 | msgid "" 28 | "\n" 29 | "%s (%s) total: %s season(s)" 30 | msgstr "\n%s (%s) 共有:%s 季" 31 | 32 | #: services/catchplay.py:149 33 | #, python-format 34 | msgid "" 35 | "\n" 36 | "Season %s total: %s episode(s)\tdownload all episodes\n" 37 | "---------------------------------------------------------------" 38 | msgstr "\n第 %s 季 共有:%s 集\t下載全集\n---------------------------------------------------------------" 39 | 40 | #: services/catchplay.py:153 41 | #, python-format 42 | msgid "Finding %s ..." 43 | msgstr "尋找 %s ..." 44 | 45 | #: services/catchplay.py:262 46 | msgid "" 47 | "\n" 48 | "Sorry, there's no embedded subtitles in this video!" 49 | msgstr "\n抱歉,此劇只有硬字幕,可去其他串流平台查看" 50 | 51 | #: services/catchplay.py:262 52 | msgid "" 53 | "\n" 54 | "Out of services! Please use proxy to bypass." 55 | msgstr "\n抱歉,未在此區提供服務!請使用代理繞過" 56 | 57 | #: services/catchplay.py:262 58 | msgid "" 59 | "\n" 60 | "Please check your subscription plan, and make sure you are able to watch it online!" 61 | msgstr "\n抱歉,請檢查您的訂閱,確保您能夠線上觀看!" 62 | 63 | #: services/catchplay.py:262 64 | msgid "" 65 | "\n" 66 | "Please renew the cookies!" 67 | msgstr "\n請更新Cookies!" 68 | -------------------------------------------------------------------------------- /locales/zh-Hant/LC_MESSAGES/services.crunchyroll.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wayneclub/Subtitle-Downloader/00804b4e017c1c8f8adc7e914514d73e774816b9/locales/zh-Hant/LC_MESSAGES/services.crunchyroll.mo -------------------------------------------------------------------------------- /locales/zh-Hant/LC_MESSAGES/services.crunchyroll.po: -------------------------------------------------------------------------------- 1 | # Subtitle-Downloader 2 | # Copyright (C) 2021 3 | # 4 | msgid "" 5 | msgstr "" 6 | "Project-Id-Version: 1.0\n" 7 | "Report-Msgid-Bugs-To: \n" 8 | "POT-Creation-Date: 2021-12-30 23:11+0800\n" 9 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 10 | "Last-Translator: Wayne \n" 11 | "Language-Team: wayneclub \n" 12 | "Language: zh-Hant\n" 13 | "MIME-Version: 1.0\n" 14 | "Content-Type: text/plain; charset=UTF-8\n" 15 | "Content-Transfer-Encoding: 8bit\n" 16 | 17 | #: services/youtube.py:125 18 | #, python-format 19 | msgid "" 20 | "\n" 21 | "%s total: %s season(s)" 22 | msgstr "\n%s 共有:%s 季" 23 | 24 | #: services/youtube.py:130 25 | msgid "(Provide bilingual subtitles)" 26 | msgstr "(提供雙語字幕)" 27 | 28 | #: services/youtube.py:132 29 | msgid "(Provide Japanese subtitles)" 30 | msgstr "(提供日語字幕)" 31 | 32 | #: services/youtube.py:135 33 | #, python-format 34 | msgid "" 35 | "\n" 36 | "Season %s total: %s episode(s)\tdownload season %s last episode\n" 37 | "---------------------------------------------------------------" 38 | msgstr "\n第 %s 季 共有:%s 集\t下載最後一集\n---------------------------------------------------------------" 39 | 40 | #: services/youtube.py:142 services/youtube.py:155 41 | #, python-format 42 | msgid "" 43 | "\n" 44 | "Season %s total: %s episode(s)\tdownload all episodes\n" 45 | "---------------------------------------------------------------" 46 | msgstr "\n第 %s 季 共有:%s 集\t下載全集\n---------------------------------------------------------------" 47 | 48 | #: services/youtube.py:150 49 | #, python-format 50 | msgid "" 51 | "\n" 52 | "Total: %s episode(s)\tdownload all episodes\n" 53 | "---------------------------------------------------------------" 54 | msgstr "\n共有:%s 集\t下載全集\n---------------------------------------------------------------" 55 | 56 | 57 | #: services/youtube.py:88 58 | #, python-format 59 | msgid "" 60 | "\n" 61 | "Season %s total: %s episode(s)\tupdate to episode %s\tdownload all episodes\n" 62 | "---------------------------------------------------------------" 63 | msgstr "\n第 %s 季 共有:%s 集\t更新至 第 %s 集\t下載全集\n---------------------------------------------------------------" 64 | 65 | #: services/youtube.py:167 66 | #, python-format 67 | msgid "Finding %s ..." 68 | msgstr "尋找 %s ..." 69 | 70 | #: services/youtube.py:225 71 | #, python-format 72 | msgid "" 73 | "\n" 74 | "Download: %s\n" 75 | "---------------------------------------------------------------" 76 | msgstr "\n下載:%s\n---------------------------------------------------------------" 77 | 78 | #: services/youtube.py:234 services/youtube.py:261 79 | msgid "" 80 | "\n" 81 | "Sorry, there's no embedded subtitles in this video!" 82 | msgstr "\n抱歉,此劇只有硬字幕,可去其他串流平台查看" 83 | 84 | #: services/youtube.py:95 85 | msgid "" 86 | "\n" 87 | "Please check your subscription plan, and make sure you are able to watch it online!" 88 | msgstr "\n抱歉,請檢查您的訂閱,確保您能夠線上觀看!" 89 | 90 | #: services/youtube.py:95 91 | msgid "" 92 | "\n" 93 | "Too Many Requests! Please clear browser cookies and re-download cookies!" 94 | msgstr "\n連線太頻繁,請清除瀏覽器cookies並重新下載!" 95 | -------------------------------------------------------------------------------- /locales/zh-Hant/LC_MESSAGES/services.disneyplus.disneyplus.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wayneclub/Subtitle-Downloader/00804b4e017c1c8f8adc7e914514d73e774816b9/locales/zh-Hant/LC_MESSAGES/services.disneyplus.disneyplus.mo -------------------------------------------------------------------------------- /locales/zh-Hant/LC_MESSAGES/services.disneyplus.disneyplus.po: -------------------------------------------------------------------------------- 1 | # Subtitle-Downloader 2 | # Copyright (C) 2021 3 | # 4 | msgid "" 5 | msgstr "" 6 | "Project-Id-Version: 1.0\n" 7 | "Report-Msgid-Bugs-To: \n" 8 | "POT-Creation-Date: 2021-12-30 23:11+0800\n" 9 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 10 | "Last-Translator: Wayne \n" 11 | "Language-Team: wayneclub \n" 12 | "Language: zh-Hant\n" 13 | "MIME-Version: 1.0\n" 14 | "Content-Type: text/plain; charset=UTF-8\n" 15 | "Content-Transfer-Encoding: 8bit\n" 16 | 17 | #: services/disneyplus/disneyplus.py:76 18 | #, python-format 19 | msgid "" 20 | "\n" 21 | "%s total: %s season(s)" 22 | msgstr "\n%s 共有:%s 季" 23 | 24 | #: services/disneyplus/disneyplus.py:92 25 | #, python-format 26 | msgid "" 27 | "\n" 28 | "Season %s total: %s episode(s)\tdownload all episodes\n" 29 | "---------------------------------------------------------------" 30 | msgstr "\n第 %s 季 共有:%s 集\t下載全集\n---------------------------------------------------------------" 31 | 32 | #: services/disneyplus/disneyplus.py:56 services/disneyplus/disneyplus.py:61 33 | #, python-format 34 | msgid "" 35 | "\n" 36 | "Unsupport %s subtitle, available languages: %s" 37 | msgstr "\n未提供%s的字幕,支援語言:%s" 38 | 39 | #: services/disneyplus/disneyplus.py:93 services/disneyplus/disneyplus.py:175 40 | #, python-format 41 | msgid "" 42 | "\n" 43 | "No subtitles found!" 44 | msgstr "\n未找到字幕!" 45 | 46 | #: services/disneyplus/disneyplus.py:96 services/disneyplus/disneyplus.py:178 services/disneyplus/disneyplus.py:343 47 | #, python-format 48 | msgid "" 49 | "\n" 50 | "Download: %s\n" 51 | "---------------------------------------------------------------" 52 | msgstr "\n下載:%s\n---------------------------------------------------------------" 53 | -------------------------------------------------------------------------------- /locales/zh-Hant/LC_MESSAGES/services.disneyplus.disneyplus_login.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wayneclub/Subtitle-Downloader/00804b4e017c1c8f8adc7e914514d73e774816b9/locales/zh-Hant/LC_MESSAGES/services.disneyplus.disneyplus_login.mo -------------------------------------------------------------------------------- /locales/zh-Hant/LC_MESSAGES/services.disneyplus.disneyplus_login.po: -------------------------------------------------------------------------------- 1 | # Subtitle-Downloader 2 | # Copyright (C) 2021 3 | # 4 | msgid "" 5 | msgstr "" 6 | "Project-Id-Version: 1.0\n" 7 | "Report-Msgid-Bugs-To: \n" 8 | "POT-Creation-Date: 2021-12-30 23:11+0800\n" 9 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 10 | "Last-Translator: Wayne \n" 11 | "Language-Team: wayneclub \n" 12 | "Language: zh-Hant\n" 13 | "MIME-Version: 1.0\n" 14 | "Content-Type: text/plain; charset=UTF-8\n" 15 | "Content-Transfer-Encoding: 8bit\n" 16 | 17 | #: services/disneyplus/disneyplus_login.py:96 18 | msgid "Disney+ email: " 19 | msgstr "Disney+ 帳號:" 20 | 21 | #: services/disneyplus/disneyplus_login.py:97 22 | msgid "Disney+ password: " 23 | msgstr "Disney+ 密碼:" 24 | 25 | #: services/disneyplus/disneyplus_login.py:212 26 | #, python-format 27 | msgid "" 28 | "\n" 29 | "Successfully logged in. Welcome %s!" 30 | msgstr "\n登入成功,歡迎 %s" 31 | -------------------------------------------------------------------------------- /locales/zh-Hant/LC_MESSAGES/services.fridayvideo.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wayneclub/Subtitle-Downloader/00804b4e017c1c8f8adc7e914514d73e774816b9/locales/zh-Hant/LC_MESSAGES/services.fridayvideo.mo -------------------------------------------------------------------------------- /locales/zh-Hant/LC_MESSAGES/services.fridayvideo.po: -------------------------------------------------------------------------------- 1 | # Subtitle-Downloader 2 | # Copyright (C) 2021 3 | # 4 | msgid "" 5 | msgstr "" 6 | "Project-Id-Version: 1.0\n" 7 | "Report-Msgid-Bugs-To: \n" 8 | "POT-Creation-Date: 2021-12-30 23:11+0800\n" 9 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 10 | "Last-Translator: Wayne \n" 11 | "Language-Team: wayneclub \n" 12 | "Language: zh-Hant\n" 13 | "MIME-Version: 1.0\n" 14 | "Content-Type: text/plain; charset=UTF-8\n" 15 | "Content-Transfer-Encoding: 8bit\n" 16 | 17 | #: services/fridayvideo.py:125 18 | #, python-format 19 | msgid "" 20 | "\n" 21 | "%s total: %s season(s)" 22 | msgstr "\n%s 共有:%s 季" 23 | 24 | #: services/fridayvideo.py:130 25 | msgid "(Provide bilingual subtitles)" 26 | msgstr "(提供雙語字幕)" 27 | 28 | #: services/fridayvideo.py:132 29 | msgid "(Provide Japanese subtitles)" 30 | msgstr "(提供日語字幕)" 31 | 32 | #: services/fridayvideo.py:135 33 | #, python-format 34 | msgid "" 35 | "\n" 36 | "Season %s total: %s episode(s)\tdownload season %s last episode\n" 37 | "---------------------------------------------------------------" 38 | msgstr "\n第 %s 季 共有:%s 集\t下載最後一集\n---------------------------------------------------------------" 39 | 40 | #: services/fridayvideo.py:142 services/fridayvideo.py:155 41 | #, python-format 42 | msgid "" 43 | "\n" 44 | "Season %s total: %s episode(s)\tdownload all episodes\n" 45 | "---------------------------------------------------------------" 46 | msgstr "\n第 %s 季 共有:%s 集\t下載全集\n---------------------------------------------------------------" 47 | 48 | #: services/fridayvideo.py:150 49 | #, python-format 50 | msgid "" 51 | "\n" 52 | "Total: %s episode(s)\tdownload all episodes\n" 53 | "---------------------------------------------------------------" 54 | msgstr "\n共有:%s 集\t下載全集\n---------------------------------------------------------------" 55 | 56 | #: services/fridayvideo.py:167 57 | #, python-format 58 | msgid "Finding %s ..." 59 | msgstr "尋找 %s ..." 60 | 61 | #: services/fridayvideo.py:225 62 | #, python-format 63 | msgid "" 64 | "\n" 65 | "Download: %s\n" 66 | "---------------------------------------------------------------" 67 | msgstr "\n下載:%s\n---------------------------------------------------------------" 68 | 69 | #: services/fridayvideo.py:234 services/fridayvideo.py:261 70 | msgid "" 71 | "\n" 72 | "Sorry, there's no embedded subtitles in this video!" 73 | msgstr "\n抱歉,此劇只有硬字幕,可去其他串流平台查看" 74 | 75 | #: services/fridayvideo.py:237 76 | msgid "" 77 | "\n" 78 | "\nLogin access token is expired!\nPlease clear browser cookies and re-download cookies!" 79 | msgstr "\n登入授權已逾期,請清除瀏覽器cookies並重新下載!" 80 | -------------------------------------------------------------------------------- /locales/zh-Hant/LC_MESSAGES/services.hbogoasia.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wayneclub/Subtitle-Downloader/00804b4e017c1c8f8adc7e914514d73e774816b9/locales/zh-Hant/LC_MESSAGES/services.hbogoasia.mo -------------------------------------------------------------------------------- /locales/zh-Hant/LC_MESSAGES/services.hbogoasia.po: -------------------------------------------------------------------------------- 1 | # Subtitle-Downloader 2 | # Copyright (C) 2021 3 | # 4 | msgid "" 5 | msgstr "" 6 | "Project-Id-Version: 1.0\n" 7 | "Report-Msgid-Bugs-To: \n" 8 | "POT-Creation-Date: 2021-12-30 23:11+0800\n" 9 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 10 | "Last-Translator: Wayne \n" 11 | "Language-Team: wayneclub \n" 12 | "Language: zh-Hant\n" 13 | "MIME-Version: 1.0\n" 14 | "Content-Type: text/plain; charset=UTF-8\n" 15 | "Content-Transfer-Encoding: 8bit\n" 16 | 17 | #: services/hbogoasia.py:79 18 | msgid "" 19 | "\n" 20 | "Out of service!" 21 | msgstr "\nHBOGO Asia未在此區提供服務,請用VPN換到別區" 22 | 23 | #: services/hbogoasia.py:88 24 | msgid "HBOGO Asia username: " 25 | msgstr "HBOGO Asia 帳號:" 26 | 27 | #: services/hbogoasia.py:89 28 | msgid "HBOGO Asia password: " 29 | msgstr "HBOGO Asia 密碼:" 30 | 31 | #: services/hbogoasia.py:115 32 | #, python-format 33 | msgid "" 34 | "\n" 35 | "Successfully logged in. Welcome %s!" 36 | msgstr "\n登入成功,歡迎 %s" 37 | 38 | #: services/hbogoasia.py:138 39 | #, python-format 40 | msgid "" 41 | "\n" 42 | "Unsupport %s subtitle, available languages: %s" 43 | msgstr "\n未提供%s的字幕,支援語言:%s" 44 | 45 | #: services/hbogoasia.py:151 46 | msgid "" 47 | "\n" 48 | "Series not found!" 49 | msgstr "\n找不到影集,請輸入正確網址" 50 | 51 | #: services/hbogoasia.py:165 52 | msgid "" 53 | "\n" 54 | "The series isn't available in this region." 55 | msgstr "\n這部影集未在此區上映,請用VPN換到別區" 56 | 57 | #: services/hbogoasia.py:168 58 | #, python-format 59 | msgid "" 60 | "\n" 61 | "%s total: %s season(s)" 62 | msgstr "\n%s 共有:%s 季" 63 | 64 | #: services/hbogoasia.py:189 65 | #, python-format 66 | msgid "" 67 | "\n" 68 | "Season %s total: %s episode(s)\tdownload all episodes\n" 69 | "---------------------------------------------------------------" 70 | msgstr "\n第 %s 季 共有:%s 集\t下載全集\n---------------------------------------------------------------" 71 | 72 | #: services/hbogoasia.py:199 73 | #, python-format 74 | msgid "Finding %s ..." 75 | msgstr "尋找 %s ..." 76 | 77 | #: services/hbogoasia.py:225 78 | msgid "" 79 | "\n" 80 | "Download: %s\n" 81 | "---------------------------------------------------------------" 82 | msgstr "\n下載:%s\n---------------------------------------------------------------" 83 | -------------------------------------------------------------------------------- /locales/zh-Hant/LC_MESSAGES/services.iqiyi.iqiyi.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wayneclub/Subtitle-Downloader/00804b4e017c1c8f8adc7e914514d73e774816b9/locales/zh-Hant/LC_MESSAGES/services.iqiyi.iqiyi.mo -------------------------------------------------------------------------------- /locales/zh-Hant/LC_MESSAGES/services.iqiyi.iqiyi.po: -------------------------------------------------------------------------------- 1 | # Subtitle-Downloader 2 | # Copyright (C) 2021 3 | # 4 | msgid "" 5 | msgstr "" 6 | "Project-Id-Version: 1.0\n" 7 | "Report-Msgid-Bugs-To: \n" 8 | "POT-Creation-Date: 2021-12-30 23:11+0800\n" 9 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 10 | "Last-Translator: Wayne \n" 11 | "Language-Team: wayneclub \n" 12 | "Language: zh-Hant\n" 13 | "MIME-Version: 1.0\n" 14 | "Content-Type: text/plain; charset=UTF-8\n" 15 | "Content-Transfer-Encoding: 8bit\n" 16 | 17 | #: services/iqiyi/iqiyi.py:61 18 | msgid "" 19 | "\n" 20 | "Sorry, there's no embedded subtitles in this video!" 21 | msgstr "\n抱歉,此劇只有硬字幕,可去其他串流平台查看" 22 | 23 | #: services/iqiyi/iqiyi.py:72 24 | #, python-format 25 | msgid "" 26 | "\n" 27 | "Unsupport %s subtitle, available languages: %s" 28 | msgstr "\n未提供%s的字幕,支援語言:%s" 29 | 30 | #: services/iqiyi/iqiyi.py:90 31 | #, python-format 32 | msgid "" 33 | "\n" 34 | "This video is only allows in:\n" 35 | "%s" 36 | msgstr "\n你所在的地區無法下載,可用VPN換區到以下地區:\n%s" 37 | 38 | #: services/iqiyi/iqiyi.py:116 39 | #, python-format 40 | msgid "" 41 | "\n" 42 | "Season %s total: %s episode(s)\tdownload season %s last episode\n" 43 | "---------------------------------------------------------------" 44 | msgstr "\n第 %s 季 共有:%s 集\t下載第 %s 季 最後一集\n---------------------------------------------------------------" 45 | 46 | #: services/iqiyi/iqiyi.py:120 47 | #, python-format 48 | msgid "" 49 | "\n" 50 | "Season %s total: %s episode(s)\tdownload all episodes\n" 51 | "---------------------------------------------------------------" 52 | msgstr "\n第 %s 季 共有:%s 集\t下載全集\n---------------------------------------------------------------" 53 | 54 | #: services/iqiyi/iqiyi.py:126 55 | #, python-format 56 | msgid "" 57 | "\n" 58 | "Season %s total: %s episode(s)\tupdate to episode %s\tdownload all episodes\n" 59 | "---------------------------------------------------------------" 60 | msgstr "\n第 %s 季 共有:%s 集\t更新至 第 %s 集\t下載全集\n---------------------------------------------------------------" 61 | 62 | #: services/iqiyi/iqiyi.py:157 63 | #, python-format 64 | msgid "Finding %s ..." 65 | msgstr "尋找 %s ..." 66 | -------------------------------------------------------------------------------- /locales/zh-Hant/LC_MESSAGES/services.itunes.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wayneclub/Subtitle-Downloader/00804b4e017c1c8f8adc7e914514d73e774816b9/locales/zh-Hant/LC_MESSAGES/services.itunes.mo -------------------------------------------------------------------------------- /locales/zh-Hant/LC_MESSAGES/services.itunes.po: -------------------------------------------------------------------------------- 1 | # Subtitle-Downloader 2 | # Copyright (C) 2021 3 | # 4 | msgid "" 5 | msgstr "" 6 | "Project-Id-Version: 1.0\n" 7 | "Report-Msgid-Bugs-To: \n" 8 | "POT-Creation-Date: 2021-12-30 23:11+0800\n" 9 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 10 | "Last-Translator: Wayne \n" 11 | "Language-Team: wayneclub \n" 12 | "Language: zh-Hant\n" 13 | "MIME-Version: 1.0\n" 14 | "Content-Type: text/plain; charset=UTF-8\n" 15 | "Content-Transfer-Encoding: 8bit\n" 16 | 17 | #: services/itunes.py:42 18 | #, python-format 19 | msgid "" 20 | "\n" 21 | "Subtitle available languages: %s" 22 | msgstr "" 23 | 24 | #: services/itunes.py:70 25 | #, python-format 26 | msgid "" 27 | "\n" 28 | "Download: %s\n" 29 | "---------------------------------------------------------------" 30 | msgstr "\n下載:%s\n---------------------------------------------------------------" 31 | -------------------------------------------------------------------------------- /locales/zh-Hant/LC_MESSAGES/services.kktv.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wayneclub/Subtitle-Downloader/00804b4e017c1c8f8adc7e914514d73e774816b9/locales/zh-Hant/LC_MESSAGES/services.kktv.mo -------------------------------------------------------------------------------- /locales/zh-Hant/LC_MESSAGES/services.kktv.po: -------------------------------------------------------------------------------- 1 | # Subtitle-Downloader 2 | # Copyright (C) 2021 3 | # 4 | msgid "" 5 | msgstr "" 6 | "Project-Id-Version: 1.0\n" 7 | "Report-Msgid-Bugs-To: \n" 8 | "POT-Creation-Date: 2021-12-30 23:11+0800\n" 9 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 10 | "Last-Translator: Wayne \n" 11 | "Language-Team: wayneclub \n" 12 | "Language: zh-Hant\n" 13 | "MIME-Version: 1.0\n" 14 | "Content-Type: text/plain; charset=UTF-8\n" 15 | "Content-Transfer-Encoding: 8bit\n" 16 | 17 | #: services/kktv.py:43 18 | msgid "" 19 | "\n" 20 | "Series not found!" 21 | msgstr "\n找不到該劇,請確認網址重試一次" 22 | 23 | #: services/kktv.py:68 24 | #, python-format 25 | msgid "" 26 | "\n" 27 | "%s total: %s season(s) (Provide bilingual subtitles)" 28 | msgstr "\n%s 共有:%s 季(提供雙語字幕)" 29 | 30 | #: services/kktv.py:72 31 | #, python-format 32 | msgid "" 33 | "\n" 34 | "%s total: %s season(s)" 35 | msgstr "\n%s 共有:%s 季" 36 | 37 | #: services/kktv.py:85 38 | msgid "" 39 | "\n" 40 | "Download:\n" 41 | "---------------------------------------------------------------" 42 | msgstr "\n下載:\n---------------------------------------------------------------" 43 | 44 | #: services/kktv.py:89 45 | #, python-format 46 | msgid "" 47 | "\n" 48 | "Season %s total: %s episode(s)\tdownload season %s last episode\n" 49 | "---------------------------------------------------------------" 50 | msgstr "\n第 %s 季 共有:%s 集\t下載第 %s 季 最後一集\n---------------------------------------------------------------" 51 | 52 | #: services/kktv.py:99 53 | #, python-format 54 | msgid "" 55 | "\n" 56 | "Total: %s episode(s)\tdownload all episodes\n" 57 | "---------------------------------------------------------------" 58 | msgstr "\n共有:%s 集\t下載全集\n---------------------------------------------------------------" 59 | 60 | #: services/kktv.py:104 61 | #, python-format 62 | msgid "" 63 | "\n" 64 | "Season %s total: %s episode(s)\tdownload all episodes\n" 65 | "---------------------------------------------------------------" 66 | msgstr "\n第 %s 季 共有:%s 集\t下載全集\n---------------------------------------------------------------" 67 | 68 | #: services/kktv.py:130 69 | msgid "" 70 | "\n" 71 | "Sorry, there's no embedded subtitles in this video!" 72 | msgstr "\n無提供可下載的字幕" 73 | -------------------------------------------------------------------------------- /locales/zh-Hant/LC_MESSAGES/services.linetv.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wayneclub/Subtitle-Downloader/00804b4e017c1c8f8adc7e914514d73e774816b9/locales/zh-Hant/LC_MESSAGES/services.linetv.mo -------------------------------------------------------------------------------- /locales/zh-Hant/LC_MESSAGES/services.linetv.po: -------------------------------------------------------------------------------- 1 | # Subtitle-Downloader 2 | # Copyright (C) 2021 3 | # 4 | msgid "" 5 | msgstr "" 6 | "Project-Id-Version: 1.0\n" 7 | "Report-Msgid-Bugs-To: \n" 8 | "POT-Creation-Date: 2021-12-30 23:11+0800\n" 9 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 10 | "Last-Translator: Wayne \n" 11 | "Language-Team: wayneclub \n" 12 | "Language: zh-Hant\n" 13 | "MIME-Version: 1.0\n" 14 | "Content-Type: text/plain; charset=UTF-8\n" 15 | "Content-Transfer-Encoding: 8bit\n" 16 | 17 | #: services/linetv.py:72 18 | #, python-format 19 | msgid "" 20 | "\n" 21 | "%s Season %s" 22 | msgstr "\n%s 第 %s 季" 23 | 24 | #: services/linetv.py:82 25 | #, python-format 26 | msgid "" 27 | "\n" 28 | "Season %s total: %s episode(s)\tdownload season %s last episode\n" 29 | "---------------------------------------------------------------" 30 | msgstr "\n第 %s 季 共有:%s 集\t下載第 %s 季 最後一集\n---------------------------------------------------------------" 31 | 32 | #: services/linetv.py:88 33 | #, python-format 34 | msgid "" 35 | "\n" 36 | "Season %s total: %s episode(s)\tupdate to episode %s\tdownload all episodes\n" 37 | "---------------------------------------------------------------" 38 | msgstr "\n第 %s 季 共有:%s 集\t更新至 第 %s 集\t下載全集\n---------------------------------------------------------------" 39 | 40 | #: services/linetv.py:93 41 | #, python-format 42 | msgid "" 43 | "\n" 44 | "Season %s total: %s episode(s)\tdownload all episodes\n" 45 | "---------------------------------------------------------------" 46 | msgstr "\n第 %s 季 共有:%s 集\t下載全集\n---------------------------------------------------------------" 47 | 48 | #: services/linetv.py:127 49 | #, python-format 50 | msgid "%s\t...free user will be available on %s" 51 | msgstr "%s\t...一般用戶於%s開啟" 52 | 53 | #: services/linetv.py:234 54 | msgid "" 55 | "\n" 56 | "Sorry, there's no embedded subtitles in this video!" 57 | msgstr "\n抱歉,此劇只有硬字幕,可去其他串流平台查看" 58 | -------------------------------------------------------------------------------- /locales/zh-Hant/LC_MESSAGES/services.mewatch.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wayneclub/Subtitle-Downloader/00804b4e017c1c8f8adc7e914514d73e774816b9/locales/zh-Hant/LC_MESSAGES/services.mewatch.mo -------------------------------------------------------------------------------- /locales/zh-Hant/LC_MESSAGES/services.mewatch.po: -------------------------------------------------------------------------------- 1 | # Subtitle-Downloader 2 | # Copyright (C) 2021 3 | # 4 | msgid "" 5 | msgstr "" 6 | "Project-Id-Version: 1.0\n" 7 | "Report-Msgid-Bugs-To: \n" 8 | "POT-Creation-Date: 2021-12-30 23:11+0800\n" 9 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 10 | "Last-Translator: Wayne \n" 11 | "Language-Team: wayneclub \n" 12 | "Language: zh-Hant\n" 13 | "MIME-Version: 1.0\n" 14 | "Content-Type: text/plain; charset=UTF-8\n" 15 | "Content-Transfer-Encoding: 8bit\n" 16 | 17 | #: services/mewatch.py:60 18 | #, python-format 19 | msgid "" 20 | "\n" 21 | "%s total: %s season(s)" 22 | msgstr "\n%s 共有:%s 季" 23 | 24 | #: services/mewatch.py:108 25 | #, python-format 26 | msgid "" 27 | "\n" 28 | "Season %s total: %s episode(s)\tdownload season %s last episode\n" 29 | "---------------------------------------------------------------" 30 | msgstr "\n第 %s 季 共有:%s 集\t下載第 %s 季 最後一集\n---------------------------------------------------------------" 31 | 32 | #: services/mewatch.py:112 33 | #, python-format 34 | msgid "" 35 | "\n" 36 | "Season %s total: %s episode(s)\tupdate to episode %s\tdownload all episodes\n" 37 | "---------------------------------------------------------------" 38 | msgstr "\n第 %s 季 共有:%s 集\t更新至 第 %s 集\t下載全集\n---------------------------------------------------------------" 39 | 40 | #: services/mewatch.py:115 41 | #, python-format 42 | msgid "" 43 | "\n" 44 | "Season %s total: %s episode(s)\tdownload all episodes\n" 45 | "---------------------------------------------------------------" 46 | msgstr "\n第 %s 季 共有:%s 集\t下載全集\n---------------------------------------------------------------" 47 | 48 | #: services/mewatch.py:133 49 | #, python-format 50 | msgid "Finding %s ..." 51 | msgstr "尋找 %s ..." 52 | 53 | #: services/mewatch.py:140 54 | #, python-format 55 | msgid "" 56 | "\n" 57 | "Unsupport %s subtitle, available languages: %s" 58 | msgstr "\n未提供%s的字幕,支援語言:%s" 59 | 60 | #: services/disneyplus/mewatch.py:212 61 | #, python-format 62 | msgid "" 63 | "\n" 64 | "Successfully logged in. Welcome!" 65 | msgstr "\n登入成功,歡迎!" 66 | -------------------------------------------------------------------------------- /locales/zh-Hant/LC_MESSAGES/services.myvideo.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wayneclub/Subtitle-Downloader/00804b4e017c1c8f8adc7e914514d73e774816b9/locales/zh-Hant/LC_MESSAGES/services.myvideo.mo -------------------------------------------------------------------------------- /locales/zh-Hant/LC_MESSAGES/services.myvideo.po: -------------------------------------------------------------------------------- 1 | # Subtitle-Downloader 2 | # Copyright (C) 2021 3 | # 4 | msgid "" 5 | msgstr "" 6 | "Project-Id-Version: 1.0\n" 7 | "Report-Msgid-Bugs-To: \n" 8 | "POT-Creation-Date: 2021-12-30 23:11+0800\n" 9 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 10 | "Last-Translator: Wayne \n" 11 | "Language-Team: wayneclub \n" 12 | "Language: zh-Hant\n" 13 | "MIME-Version: 1.0\n" 14 | "Content-Type: text/plain; charset=UTF-8\n" 15 | "Content-Transfer-Encoding: 8bit\n" 16 | 17 | #: services/myvideo.py:125 18 | #, python-format 19 | msgid "" 20 | "\n" 21 | "%s total: %s season(s)" 22 | msgstr "\n%s 共有:%s 季" 23 | 24 | #: services/myvideo.py:130 25 | msgid "(Provide bilingual subtitles)" 26 | msgstr "(提供雙語字幕)" 27 | 28 | #: services/myvideo.py:132 29 | msgid "(Provide Japanese subtitles)" 30 | msgstr "(提供日語字幕)" 31 | 32 | #: services/myvideo.py:135 33 | #, python-format 34 | msgid "" 35 | "\n" 36 | "Season %s total: %s episode(s)\tdownload season %s last episode\n" 37 | "---------------------------------------------------------------" 38 | msgstr "\n第 %s 季 共有:%s 集\t下載最後一集\n---------------------------------------------------------------" 39 | 40 | #: services/myvideo.py:142 services/myvideo.py:155 41 | #, python-format 42 | msgid "" 43 | "\n" 44 | "Season %s total: %s episode(s)\tdownload all episodes\n" 45 | "---------------------------------------------------------------" 46 | msgstr "\n第 %s 季 共有:%s 集\t下載全集\n---------------------------------------------------------------" 47 | 48 | #: services/myvideo.py:150 49 | #, python-format 50 | msgid "" 51 | "\n" 52 | "Total: %s episode(s)\tdownload all episodes\n" 53 | "---------------------------------------------------------------" 54 | msgstr "\n共有:%s 集\t下載全集\n---------------------------------------------------------------" 55 | 56 | #: services/myvideo.py:167 57 | #, python-format 58 | msgid "Finding %s ..." 59 | msgstr "尋找 %s ..." 60 | 61 | #: services/myvideo.py:225 62 | #, python-format 63 | msgid "" 64 | "\n" 65 | "Download: %s\n" 66 | "---------------------------------------------------------------" 67 | msgstr "\n下載:%s\n---------------------------------------------------------------" 68 | 69 | #: services/myvideo.py:234 services/myvideo.py:261 70 | msgid "" 71 | "\n" 72 | "Sorry, there's no embedded subtitles in this video!" 73 | msgstr "\n抱歉,此劇只有硬字幕,可去其他串流平台查看" 74 | 75 | #: services/myvideo.py:237 76 | msgid "" 77 | "\n" 78 | "The film isn't released." 79 | msgstr "\n此部電影尚未上映" 80 | -------------------------------------------------------------------------------- /locales/zh-Hant/LC_MESSAGES/services.nowe.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wayneclub/Subtitle-Downloader/00804b4e017c1c8f8adc7e914514d73e774816b9/locales/zh-Hant/LC_MESSAGES/services.nowe.mo -------------------------------------------------------------------------------- /locales/zh-Hant/LC_MESSAGES/services.nowe.po: -------------------------------------------------------------------------------- 1 | # Subtitle-Downloader 2 | # Copyright (C) 2021 3 | # 4 | msgid "" 5 | msgstr "" 6 | "Project-Id-Version: 1.0\n" 7 | "Report-Msgid-Bugs-To: \n" 8 | "POT-Creation-Date: 2021-12-30 23:11+0800\n" 9 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 10 | "Last-Translator: Wayne \n" 11 | "Language-Team: wayneclub \n" 12 | "Language: zh-Hant\n" 13 | "MIME-Version: 1.0\n" 14 | "Content-Type: text/plain; charset=UTF-8\n" 15 | "Content-Transfer-Encoding: 8bit\n" 16 | 17 | #: services/nowe:63 services/nowe:68 18 | #, python-format 19 | msgid "" 20 | "\n" 21 | "Unsupport %s subtitle, available languages: %s" 22 | msgstr "\n未提供%s的字幕,支援語言:%s" 23 | 24 | 25 | #: services/nowe.py:60 26 | #, python-format 27 | msgid "" 28 | "\n" 29 | "%s Season %s" 30 | msgstr "\n%s 第 %s 季" 31 | 32 | #: services/nowe.py:85 33 | msgid "" 34 | "\n" 35 | "Download:\n" 36 | "---------------------------------------------------------------" 37 | msgstr "\n下載:\n---------------------------------------------------------------" 38 | 39 | #: services/nowe.py:89 40 | #, python-format 41 | msgid "" 42 | "\n" 43 | "Season %s total: %s episode(s)\tdownload season %s last episode\n" 44 | "---------------------------------------------------------------" 45 | msgstr "\n第 %s 季 共有:%s 集\t下載第 %s 季 最後一集\n---------------------------------------------------------------" 46 | 47 | #: services/nowe.py:99 48 | #, python-format 49 | msgid "" 50 | "\n" 51 | "Total: %s episode(s)\tdownload all episodes\n" 52 | "---------------------------------------------------------------" 53 | msgstr "\n共有:%s 集\t下載全集\n---------------------------------------------------------------" 54 | 55 | #: services/nowe.py:104 56 | #, python-format 57 | msgid "" 58 | "\n" 59 | "Season %s total: %s episode(s)\tdownload all episodes\n" 60 | "---------------------------------------------------------------" 61 | msgstr "\n第 %s 季 共有:%s 集\t下載全集\n---------------------------------------------------------------" 62 | 63 | #: services/nowe.py:155 64 | #, python-format 65 | msgid "" 66 | "\n" 67 | "Season %s total: %s episode(s)\tupdate to episode %s\tdownload all episodes\n" 68 | "---------------------------------------------------------------" 69 | msgstr "\n第 %s 季 共有:%s 集\t更新至 第 %s 集\t下載全集\n---------------------------------------------------------------" 70 | 71 | #: services/nowe.py:90 72 | msgid "" 73 | "\n" 74 | "Sorry, there's no embedded subtitles in this video!" 75 | msgstr "\n無提供可下載的字幕" 76 | 77 | #: services/nowe.py:96 78 | #, python-format 79 | msgid "" 80 | "\n" 81 | "Download: %s\n" 82 | "---------------------------------------------------------------" 83 | msgstr "\n下載:%s\n---------------------------------------------------------------" 84 | 85 | #: services/nowe.py:95 86 | msgid "" 87 | "\n" 88 | "Unable to find content id, Please check the url is valid." 89 | msgstr "\n找不到id,請檢查網址是否正確!" 90 | 91 | #: services/nowe.py:95 92 | msgid "" 93 | "\n" 94 | "Please check your subscription plan, and make sure you are able to watch it online!" 95 | msgstr "\n抱歉,請檢查您的訂閱,確保您能夠線上觀看!" 96 | 97 | #: services/nowe.py:90 98 | msgid "" 99 | "\n" 100 | "Please renew the cookies, and make sure user_config.toml's User-Agent is same as %s in the browser!" 101 | msgstr "\n請更新Cookies,並確認user_config.toml裡的User-Agent與%s一致" -------------------------------------------------------------------------------- /locales/zh-Hant/LC_MESSAGES/services.nowplayer.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wayneclub/Subtitle-Downloader/00804b4e017c1c8f8adc7e914514d73e774816b9/locales/zh-Hant/LC_MESSAGES/services.nowplayer.mo -------------------------------------------------------------------------------- /locales/zh-Hant/LC_MESSAGES/services.nowplayer.po: -------------------------------------------------------------------------------- 1 | # Subtitle-Downloader 2 | # Copyright (C) 2021 3 | # 4 | msgid "" 5 | msgstr "" 6 | "Project-Id-Version: 1.0\n" 7 | "Report-Msgid-Bugs-To: \n" 8 | "POT-Creation-Date: 2021-12-30 23:11+0800\n" 9 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 10 | "Last-Translator: Wayne \n" 11 | "Language-Team: wayneclub \n" 12 | "Language: zh-Hant\n" 13 | "MIME-Version: 1.0\n" 14 | "Content-Type: text/plain; charset=UTF-8\n" 15 | "Content-Transfer-Encoding: 8bit\n" 16 | 17 | #: services/nowplayer:63 services/nowplayer:68 18 | #, python-format 19 | msgid "" 20 | "\n" 21 | "Unsupport %s subtitle, available languages: %s" 22 | msgstr "\n未提供%s的字幕,支援語言:%s" 23 | 24 | 25 | #: services/nowplayer.py:60 26 | #, python-format 27 | msgid "" 28 | "\n" 29 | "%s Season %s" 30 | msgstr "\n%s 第 %s 季" 31 | 32 | #: services/nowplayer.py:85 33 | msgid "" 34 | "\n" 35 | "Download:\n" 36 | "---------------------------------------------------------------" 37 | msgstr "\n下載:\n---------------------------------------------------------------" 38 | 39 | #: services/nowplayer.py:89 40 | #, python-format 41 | msgid "" 42 | "\n" 43 | "Season %s total: %s episode(s)\tdownload season %s last episode\n" 44 | "---------------------------------------------------------------" 45 | msgstr "\n第 %s 季 共有:%s 集\t下載第 %s 季 最後一集\n---------------------------------------------------------------" 46 | 47 | #: services/nowplayer.py:99 48 | #, python-format 49 | msgid "" 50 | "\n" 51 | "Total: %s episode(s)\tdownload all episodes\n" 52 | "---------------------------------------------------------------" 53 | msgstr "\n共有:%s 集\t下載全集\n---------------------------------------------------------------" 54 | 55 | #: services/nowplayer.py:104 56 | #, python-format 57 | msgid "" 58 | "\n" 59 | "Season %s total: %s episode(s)\tdownload all episodes\n" 60 | "---------------------------------------------------------------" 61 | msgstr "\n第 %s 季 共有:%s 集\t下載全集\n---------------------------------------------------------------" 62 | 63 | #: services/nowplayer.py:155 64 | #, python-format 65 | msgid "" 66 | "\n" 67 | "Season %s total: %s episode(s)\tupdate to episode %s\tdownload all episodes\n" 68 | "---------------------------------------------------------------" 69 | msgstr "\n第 %s 季 共有:%s 集\t更新至 第 %s 集\t下載全集\n---------------------------------------------------------------" 70 | 71 | #: services/nowplayer.py:90 72 | msgid "" 73 | "\n" 74 | "Sorry, there's no embedded subtitles in this video!" 75 | msgstr "\n無提供可下載的字幕" 76 | 77 | #: services/nowplayer.py:96 78 | #, python-format 79 | msgid "" 80 | "\n" 81 | "Download: %s\n" 82 | "---------------------------------------------------------------" 83 | msgstr "\n下載:%s\n---------------------------------------------------------------" 84 | 85 | #: services/nowplayer.py:95 86 | msgid "" 87 | "\n" 88 | "Unable to find content id, Please check the url is valid." 89 | msgstr "\n找不到id,請檢查網址是否正確!" 90 | 91 | #: services/nowplayer.py:95 92 | msgid "" 93 | "\n" 94 | "Please check your subscription plan, and make sure you are able to watch it online!" 95 | msgstr "\n抱歉,請檢查您的訂閱,確保您能夠線上觀看!" 96 | 97 | #: services/nowplayer.py:90 98 | msgid "" 99 | "\n" 100 | "Please renew the cookies, and make sure user_config.toml's User-Agent is same as %s in the browser!" 101 | msgstr "\n請更新Cookies,並確認user_config.toml裡的User-Agent與%s一致" -------------------------------------------------------------------------------- /locales/zh-Hant/LC_MESSAGES/services.service.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wayneclub/Subtitle-Downloader/00804b4e017c1c8f8adc7e914514d73e774816b9/locales/zh-Hant/LC_MESSAGES/services.service.mo -------------------------------------------------------------------------------- /locales/zh-Hant/LC_MESSAGES/services.service.po: -------------------------------------------------------------------------------- 1 | # Subtitle-Downloader 2 | # Copyright (C) 2021 3 | # 4 | msgid "" 5 | msgstr "" 6 | "Project-Id-Version: 1.0\n" 7 | "Report-Msgid-Bugs-To: \n" 8 | "POT-Creation-Date: 2021-12-30 23:11+0800\n" 9 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 10 | "Last-Translator: Wayne \n" 11 | "Language-Team: wayneclub \n" 12 | "Language: zh-Hant\n" 13 | "MIME-Version: 1.0\n" 14 | "Content-Type: text/plain; charset=UTF-8\n" 15 | "Content-Transfer-Encoding: 8bit\n" 16 | -------------------------------------------------------------------------------- /locales/zh-Hant/LC_MESSAGES/services.viki.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wayneclub/Subtitle-Downloader/00804b4e017c1c8f8adc7e914514d73e774816b9/locales/zh-Hant/LC_MESSAGES/services.viki.mo -------------------------------------------------------------------------------- /locales/zh-Hant/LC_MESSAGES/services.viki.po: -------------------------------------------------------------------------------- 1 | # Subtitle-Downloader 2 | # Copyright (C) 2021 3 | # 4 | msgid "" 5 | msgstr "" 6 | "Project-Id-Version: 1.0\n" 7 | "Report-Msgid-Bugs-To: \n" 8 | "POT-Creation-Date: 2021-12-30 23:11+0800\n" 9 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 10 | "Last-Translator: Wayne \n" 11 | "Language-Team: wayneclub \n" 12 | "Language: zh-Hant\n" 13 | "MIME-Version: 1.0\n" 14 | "Content-Type: text/plain; charset=UTF-8\n" 15 | "Content-Transfer-Encoding: 8bit\n" 16 | 17 | #: services/viki.py:125 18 | #, python-format 19 | msgid "" 20 | "\n" 21 | "%s total: %s season(s)" 22 | msgstr "\n%s 共有:%s 季" 23 | 24 | #: services/viki.py:130 25 | msgid "(Provide bilingual subtitles)" 26 | msgstr "(提供雙語字幕)" 27 | 28 | #: services/viki.py:132 29 | msgid "(Provide Japanese subtitles)" 30 | msgstr "(提供日語字幕)" 31 | 32 | #: services/viki.py:135 33 | #, python-format 34 | msgid "" 35 | "\n" 36 | "Season %s total: %s episode(s)\tdownload season %s last episode\n" 37 | "---------------------------------------------------------------" 38 | msgstr "\n第 %s 季 共有:%s 集\t下載最後一集\n---------------------------------------------------------------" 39 | 40 | #: services/viki.py:142 services/viki.py:155 41 | #, python-format 42 | msgid "" 43 | "\n" 44 | "Season %s total: %s episode(s)\tdownload all episodes\n" 45 | "---------------------------------------------------------------" 46 | msgstr "\n第 %s 季 共有:%s 集\t下載全集\n---------------------------------------------------------------" 47 | 48 | #: services/viki.py:150 49 | #, python-format 50 | msgid "" 51 | "\n" 52 | "Total: %s episode(s)\tdownload all episodes\n" 53 | "---------------------------------------------------------------" 54 | msgstr "\n共有:%s 集\t下載全集\n---------------------------------------------------------------" 55 | 56 | 57 | #: services/viki.py:88 58 | #, python-format 59 | msgid "" 60 | "\n" 61 | "Season %s total: %s episode(s)\tupdate to episode %s\tdownload all episodes\n" 62 | "---------------------------------------------------------------" 63 | msgstr "\n第 %s 季 共有:%s 集\t更新至 第 %s 集\t下載全集\n---------------------------------------------------------------" 64 | 65 | #: services/viki.py:167 66 | #, python-format 67 | msgid "Finding %s ..." 68 | msgstr "尋找 %s ..." 69 | 70 | #: services/viki.py:225 71 | #, python-format 72 | msgid "" 73 | "\n" 74 | "Download: %s\n" 75 | "---------------------------------------------------------------" 76 | msgstr "\n下載:%s\n---------------------------------------------------------------" 77 | 78 | #: services/viki.py:234 services/viki.py:261 79 | msgid "" 80 | "\n" 81 | "Sorry, there's no embedded subtitles in this video!" 82 | msgstr "\n抱歉,此劇只有硬字幕,可去其他串流平台查看" 83 | 84 | #: services/viki.py:95 85 | msgid "" 86 | "\n" 87 | "Please check your subscription plan, and make sure you are able to watch it online!" 88 | msgstr "\n抱歉,請檢查您的訂閱,確保您能夠線上觀看!" 89 | 90 | #: services/viki.py:95 91 | msgid "" 92 | "\n" 93 | "Too Many Requests! Please clear browser cookies and re-download cookies!" 94 | msgstr "\n連線太頻繁,請清除瀏覽器cookies並重新下載!" 95 | -------------------------------------------------------------------------------- /locales/zh-Hant/LC_MESSAGES/services.viu.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wayneclub/Subtitle-Downloader/00804b4e017c1c8f8adc7e914514d73e774816b9/locales/zh-Hant/LC_MESSAGES/services.viu.mo -------------------------------------------------------------------------------- /locales/zh-Hant/LC_MESSAGES/services.viu.po: -------------------------------------------------------------------------------- 1 | # Subtitle-Downloader 2 | # Copyright (C) 2021 3 | # 4 | msgid "" 5 | msgstr "" 6 | "Project-Id-Version: 1.0\n" 7 | "Report-Msgid-Bugs-To: \n" 8 | "POT-Creation-Date: 2021-12-30 23:11+0800\n" 9 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 10 | "Last-Translator: Wayne \n" 11 | "Language-Team: wayneclub \n" 12 | "Language: zh-Hant\n" 13 | "MIME-Version: 1.0\n" 14 | "Content-Type: text/plain; charset=UTF-8\n" 15 | "Content-Transfer-Encoding: 8bit\n" 16 | 17 | #: services/viu.py:92 18 | #, python-format 19 | msgid "" 20 | "\n" 21 | "%s Season %s" 22 | msgstr "\n%s 第 %s 季" 23 | 24 | #: services/viu.py:108 25 | #, python-format 26 | msgid "" 27 | "\n" 28 | "Season %s total: %s episode(s)\tdownload season %s last episode\n" 29 | "---------------------------------------------------------------" 30 | msgstr "\n第 %s 季 共有:%s 集\t下載第 %s 季 最後一集\n---------------------------------------------------------------" 31 | 32 | #: services/viu.py:112 33 | #, python-format 34 | msgid "" 35 | "\n" 36 | "Season %s total: %s episode(s)\tupdate to episode %s\tdownload all episodes\n" 37 | "---------------------------------------------------------------" 38 | msgstr "\n第 %s 季 共有:%s 集\t更新至 第 %s 集\t下載全集\n---------------------------------------------------------------" 39 | 40 | #: services/viu.py:115 41 | #, python-format 42 | msgid "" 43 | "\n" 44 | "Season %s total: %s episode(s)\tdownload all episodes\n" 45 | "---------------------------------------------------------------" 46 | msgstr "\n第 %s 季 共有:%s 集\t下載全集\n---------------------------------------------------------------" 47 | 48 | #: services/viu.py:133 49 | #, python-format 50 | msgid "Finding %s ..." 51 | msgstr "尋找 %s ..." 52 | 53 | #: services/viu.py:140 54 | #, python-format 55 | msgid "" 56 | "\n" 57 | "Unsupport %s subtitle, available languages: %s" 58 | msgstr "\n未提供%s的字幕,支援語言:%s" 59 | -------------------------------------------------------------------------------- /locales/zh-Hant/LC_MESSAGES/services.wetv.wetv.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wayneclub/Subtitle-Downloader/00804b4e017c1c8f8adc7e914514d73e774816b9/locales/zh-Hant/LC_MESSAGES/services.wetv.wetv.mo -------------------------------------------------------------------------------- /locales/zh-Hant/LC_MESSAGES/services.wetv.wetv.po: -------------------------------------------------------------------------------- 1 | # Subtitle-Downloader 2 | # Copyright (C) 2021 3 | # 4 | msgid "" 5 | msgstr "" 6 | "Project-Id-Version: 1.0\n" 7 | "Report-Msgid-Bugs-To: \n" 8 | "POT-Creation-Date: 2021-12-30 23:11+0800\n" 9 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 10 | "Last-Translator: Wayne \n" 11 | "Language-Team: wayneclub \n" 12 | "Language: zh-Hant\n" 13 | "MIME-Version: 1.0\n" 14 | "Content-Type: text/plain; charset=UTF-8\n" 15 | "Content-Transfer-Encoding: 8bit\n" 16 | 17 | #: services/wetv/wetv.py:62 18 | msgid "" 19 | "\n" 20 | "Sorry, there's no embedded subtitles in this video!" 21 | msgstr "\n抱歉,此劇只有硬字幕,可去其他串流平台查看" 22 | 23 | #: services/wetv/wetv.py:73 24 | #, python-format 25 | msgid "" 26 | "\n" 27 | "Unsupport %s subtitle, available languages: %s" 28 | msgstr "\n未提供%s的字幕,支援語言:%s" 29 | 30 | #: services/wetv/wetv.py:89 31 | msgid "" 32 | "\n" 33 | "Download: %s\n" 34 | "---------------------------------------------------------------" 35 | msgstr "\n下載:%s\n---------------------------------------------------------------" 36 | 37 | #: services/wetv/wetv.py:258 38 | #, python-format 39 | msgid "" 40 | "\n" 41 | "Sorry, this video is not allow in your region!" 42 | msgstr "\n你所在的地區無法下載,可用VPN切換到其他地區" 43 | 44 | #: services/wetv/wetv.py:107 45 | #, python-format 46 | msgid "" 47 | "\n" 48 | "Season %s total: %s episode(s)\tdownload season %s last episode\n" 49 | "---------------------------------------------------------------" 50 | msgstr "\n第 %s 季 共有:%s 集\t下載第 %s 季 最後一集\n---------------------------------------------------------------" 51 | 52 | #: services/wetv/wetv.py:111 53 | #, python-format 54 | msgid "" 55 | "\n" 56 | "Season %s total: %s episode(s)\tdownload all episodes\n" 57 | "---------------------------------------------------------------" 58 | msgstr "\n第 %s 季 共有:%s 集\t下載全集\n---------------------------------------------------------------" 59 | 60 | #: services/wetv/wetv.py:117 61 | #, python-format 62 | msgid "" 63 | "\n" 64 | "Season %s total: %s episode(s)\tupdate to episode %s\tdownload all episodes\n" 65 | "---------------------------------------------------------------" 66 | msgstr "\n第 %s 季 共有:%s 集\t更新至 第 %s 集\t下載全集\n---------------------------------------------------------------" 67 | 68 | #: services/wetv/wetv.py:140 69 | #, python-format 70 | msgid "Finding %s ..." 71 | msgstr "尋找 %s ..." 72 | -------------------------------------------------------------------------------- /locales/zh-Hant/LC_MESSAGES/services.youtube.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wayneclub/Subtitle-Downloader/00804b4e017c1c8f8adc7e914514d73e774816b9/locales/zh-Hant/LC_MESSAGES/services.youtube.mo -------------------------------------------------------------------------------- /locales/zh-Hant/LC_MESSAGES/services.youtube.po: -------------------------------------------------------------------------------- 1 | # Subtitle-Downloader 2 | # Copyright (C) 2021 3 | # 4 | msgid "" 5 | msgstr "" 6 | "Project-Id-Version: 1.0\n" 7 | "Report-Msgid-Bugs-To: \n" 8 | "POT-Creation-Date: 2021-12-30 23:11+0800\n" 9 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 10 | "Last-Translator: Wayne \n" 11 | "Language-Team: wayneclub \n" 12 | "Language: zh-Hant\n" 13 | "MIME-Version: 1.0\n" 14 | "Content-Type: text/plain; charset=UTF-8\n" 15 | "Content-Transfer-Encoding: 8bit\n" 16 | 17 | #: services/youtube.py:125 18 | #, python-format 19 | msgid "" 20 | "\n" 21 | "%s total: %s season(s)" 22 | msgstr "\n%s 共有:%s 季" 23 | 24 | #: services/youtube.py:130 25 | msgid "(Provide bilingual subtitles)" 26 | msgstr "(提供雙語字幕)" 27 | 28 | #: services/youtube.py:132 29 | msgid "(Provide Japanese subtitles)" 30 | msgstr "(提供日語字幕)" 31 | 32 | #: services/youtube.py:135 33 | #, python-format 34 | msgid "" 35 | "\n" 36 | "Season %s total: %s episode(s)\tdownload season %s last episode\n" 37 | "---------------------------------------------------------------" 38 | msgstr "\n第 %s 季 共有:%s 集\t下載最後一集\n---------------------------------------------------------------" 39 | 40 | #: services/youtube.py:142 services/youtube.py:155 41 | #, python-format 42 | msgid "" 43 | "\n" 44 | "Season %s total: %s episode(s)\tdownload all episodes\n" 45 | "---------------------------------------------------------------" 46 | msgstr "\n第 %s 季 共有:%s 集\t下載全集\n---------------------------------------------------------------" 47 | 48 | #: services/youtube.py:150 49 | #, python-format 50 | msgid "" 51 | "\n" 52 | "Total: %s episode(s)\tdownload all episodes\n" 53 | "---------------------------------------------------------------" 54 | msgstr "\n共有:%s 集\t下載全集\n---------------------------------------------------------------" 55 | 56 | 57 | #: services/youtube.py:88 58 | #, python-format 59 | msgid "" 60 | "\n" 61 | "Season %s total: %s episode(s)\tupdate to episode %s\tdownload all episodes\n" 62 | "---------------------------------------------------------------" 63 | msgstr "\n第 %s 季 共有:%s 集\t更新至 第 %s 集\t下載全集\n---------------------------------------------------------------" 64 | 65 | #: services/youtube.py:167 66 | #, python-format 67 | msgid "Finding %s ..." 68 | msgstr "尋找 %s ..." 69 | 70 | #: services/youtube.py:225 71 | #, python-format 72 | msgid "" 73 | "\n" 74 | "Download: %s\n" 75 | "---------------------------------------------------------------" 76 | msgstr "\n下載:%s\n---------------------------------------------------------------" 77 | 78 | #: services/youtube.py:234 services/youtube.py:261 79 | msgid "" 80 | "\n" 81 | "Sorry, there's no embedded subtitles in this video!" 82 | msgstr "\n抱歉,此劇只有硬字幕,可去其他串流平台查看" 83 | 84 | #: services/youtube.py:95 85 | msgid "" 86 | "\n" 87 | "Please check your subscription plan, and make sure you are able to watch it online!" 88 | msgstr "\n抱歉,請檢查您的訂閱,確保您能夠線上觀看!" 89 | 90 | #: services/youtube.py:95 91 | msgid "" 92 | "\n" 93 | "Too Many Requests! Please clear browser cookies and re-download cookies!" 94 | msgstr "\n連線太頻繁,請清除瀏覽器cookies並重新下載!" 95 | -------------------------------------------------------------------------------- /locales/zh-Hant/LC_MESSAGES/utils.helper.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wayneclub/Subtitle-Downloader/00804b4e017c1c8f8adc7e914514d73e774816b9/locales/zh-Hant/LC_MESSAGES/utils.helper.mo -------------------------------------------------------------------------------- /locales/zh-Hant/LC_MESSAGES/utils.helper.po: -------------------------------------------------------------------------------- 1 | # Subtitle-Downloader 2 | # Copyright (C) 2021 3 | # 4 | msgid "" 5 | msgstr "" 6 | "Project-Id-Version: 1.0\n" 7 | "Report-Msgid-Bugs-To: \n" 8 | "POT-Creation-Date: 2021-12-30 23:11+0800\n" 9 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 10 | "Last-Translator: Wayne \n" 11 | "Language-Team: wayneclub \n" 12 | "Language: zh-Hant\n" 13 | "MIME-Version: 1.0\n" 14 | "Content-Type: text/plain; charset=UTF-8\n" 15 | "Content-Transfer-Encoding: 8bit\n" 16 | 17 | #: utils/helper.py:50 18 | msgid "" 19 | "\n" 20 | "File not found!" 21 | msgstr "\n找不到檔案" 22 | 23 | #: utils/helper.py:182 24 | msgid "" 25 | "\n" 26 | "Timeout, please retry." 27 | msgstr "\n逾時,請再試一次" 28 | 29 | #: utils/helper.py:63 utils/helper.py:68 30 | #, python-format 31 | msgid "" 32 | "\n" 33 | "Unsupport %s subtitle, available languages: %s" 34 | msgstr "\n未提供%s的字幕,支援語言:%s" -------------------------------------------------------------------------------- /locales/zh-Hant/LC_MESSAGES/utils.io.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wayneclub/Subtitle-Downloader/00804b4e017c1c8f8adc7e914514d73e774816b9/locales/zh-Hant/LC_MESSAGES/utils.io.mo -------------------------------------------------------------------------------- /locales/zh-Hant/LC_MESSAGES/utils.io.po: -------------------------------------------------------------------------------- 1 | # Subtitle-Downloader 2 | # Copyright (C) 2021 3 | # 4 | msgid "" 5 | msgstr "" 6 | "Project-Id-Version: 1.0\n" 7 | "Report-Msgid-Bugs-To: \n" 8 | "POT-Creation-Date: 2021-12-30 23:11+0800\n" 9 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 10 | "Last-Translator: Wayne \n" 11 | "Language-Team: wayneclub \n" 12 | "Language: zh-Hant\n" 13 | "MIME-Version: 1.0\n" 14 | "Content-Type: text/plain; charset=UTF-8\n" 15 | "Content-Transfer-Encoding: 8bit\n" 16 | 17 | #: utils/io.py:50 18 | msgid "" 19 | "\n" 20 | "File not found!" 21 | msgstr "\n找不到檔案!" -------------------------------------------------------------------------------- /locales/zh-Hant/LC_MESSAGES/utils.subtitle.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wayneclub/Subtitle-Downloader/00804b4e017c1c8f8adc7e914514d73e774816b9/locales/zh-Hant/LC_MESSAGES/utils.subtitle.mo -------------------------------------------------------------------------------- /locales/zh-Hant/LC_MESSAGES/utils.subtitle.po: -------------------------------------------------------------------------------- 1 | # Subtitle-Downloader 2 | # Copyright (C) 2021 3 | # 4 | msgid "" 5 | msgstr "" 6 | "Project-Id-Version: 1.0\n" 7 | "Report-Msgid-Bugs-To: \n" 8 | "POT-Creation-Date: 2021-12-30 23:11+0800\n" 9 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 10 | "Last-Translator: Wayne \n" 11 | "Language-Team: wayneclub \n" 12 | "Language: zh-Hant\n" 13 | "MIME-Version: 1.0\n" 14 | "Content-Type: text/plain; charset=UTF-8\n" 15 | "Content-Transfer-Encoding: 8bit\n" 16 | 17 | #: utils/subtitle.py:57 18 | #, python-format 19 | msgid "" 20 | "\n" 21 | "Convert %s to %s:\n" 22 | "---------------------------------------------------------------" 23 | msgstr "\n將 %s 轉換成 %s:\n---------------------------------------------------------------" 24 | 25 | #: utils/subtitle.py:65 26 | msgid "" 27 | "\n" 28 | "Archive subtitles:\n" 29 | "---------------------------------------------------------------" 30 | msgstr "\n將字幕壓縮打包:\n---------------------------------------------------------------" 31 | 32 | #: utils/subtitle.py:117 33 | #, python-format 34 | msgid "" 35 | "\n" 36 | "Merge segments:\n" 37 | "---------------------------------------------------------------" 38 | msgstr "\n合併所有字幕片段:\n---------------------------------------------------------------" 39 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | lxml 2 | beautifulsoup4 3 | chardet 4 | m3u8 5 | natsort 6 | orjson 7 | pysubs2 8 | requests 9 | tqdm 10 | cn2an 11 | opencc 12 | pytomlpp 13 | 14 | validators 15 | pwinput 16 | yt-dlp 17 | 18 | # XstreamDL_CLI 19 | 20 | aiohttp 21 | aiohttp_socks 22 | pycryptodome 23 | python-dateutil -------------------------------------------------------------------------------- /services/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | # coding: utf-8 3 | 4 | """ 5 | This module is for service initiation mapping 6 | """ 7 | 8 | from constants import Service 9 | from services.kktv import KKTV 10 | from services.linetv import LineTV 11 | from services.fridayvideo import FridayVideo 12 | from services.catchplay import CatchPlay 13 | from services.crunchyroll import Crunchyroll 14 | from services.iqiyi.iqiyi import IQIYI 15 | from services.mewatch import MeWatch 16 | from services.myvideo import MyVideo 17 | from services.nowplayer import NowPlayer 18 | from services.wetv.wetv import WeTV 19 | from services.viki import Viki 20 | from services.viu import Viu 21 | from services.nowe import NowE 22 | from services.disneyplus.disneyplus import DisneyPlus 23 | from services.hbogoasia import HBOGOAsia 24 | from services.itunes import iTunes 25 | from services.appletvplus import AppleTVPlus 26 | from services.youtube import YouTube 27 | 28 | service_map = [ 29 | { 30 | 'name': Service.APPLETVPLUS, 31 | 'class': AppleTVPlus, 32 | 'domain': 'tv.apple.com', 33 | }, 34 | { 35 | 'name': Service.CATCHPLAY, 36 | 'class': CatchPlay, 37 | 'domain': 'catchplay.com' 38 | }, 39 | { 40 | 'name': Service.CRUNCHYROLL, 41 | 'class': Crunchyroll, 42 | 'domain': 'crunchyroll.com' 43 | }, 44 | { 45 | 'name': Service.DISNEYPLUS, 46 | 'class': DisneyPlus, 47 | 'domain': 'disneyplus.com' 48 | }, 49 | { 50 | 'name': Service.FRIDAYVIDEO, 51 | 'class': FridayVideo, 52 | 'domain': 'video.friday.tw' 53 | }, 54 | { 55 | 'name': Service.HBOGOASIA, 56 | 'class': HBOGOAsia, 57 | 'domain': 'hbogoasia' 58 | }, 59 | { 60 | 'name': Service.IQIYI, 61 | 'class': IQIYI, 62 | 'domain': 'iq.com' 63 | }, 64 | { 65 | 'name': Service.ITUNES, 66 | 'class': iTunes, 67 | 'domain': 'itunes.apple.com', 68 | }, 69 | { 70 | 'name': Service.KKTV, 71 | 'class': KKTV, 72 | 'domain': 'kktv.me' 73 | }, 74 | { 75 | 'name': Service.LINETV, 76 | 'class': LineTV, 77 | 'domain': 'linetv.tw' 78 | }, 79 | { 80 | 'name': Service.MEWATCH, 81 | 'class': MeWatch, 82 | 'domain': 'mewatch.sg' 83 | }, 84 | { 85 | 'name': Service.MYVIDEO, 86 | 'class': MyVideo, 87 | 'domain': 'myvideo.net.tw' 88 | }, 89 | { 90 | 'name': Service.NOWE, 91 | 'class': NowE, 92 | 'domain': 'nowe.com' 93 | }, 94 | { 95 | 'name': Service.NOWPLAYER, 96 | 'class': NowPlayer, 97 | 'domain': 'nowplayer.now.com' 98 | }, 99 | { 100 | 'name': Service.VIKI, 101 | 'class': Viki, 102 | 'domain': 'viki.com' 103 | }, 104 | { 105 | 'name': Service.VIU, 106 | 'class': Viu, 107 | 'domain': 'viu.com' 108 | }, 109 | { 110 | 'name': Service.WETV, 111 | 'class': WeTV, 112 | 'domain': 'wetv.vip' 113 | }, 114 | { 115 | 'name': Service.YOUTUBE, 116 | 'class': YouTube, 117 | 'domain': 'youtube.com' 118 | } 119 | ] 120 | -------------------------------------------------------------------------------- /services/disneyplus/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wayneclub/Subtitle-Downloader/00804b4e017c1c8f8adc7e914514d73e774816b9/services/disneyplus/__init__.py -------------------------------------------------------------------------------- /services/wetv/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wayneclub/Subtitle-Downloader/00804b4e017c1c8f8adc7e914514d73e774816b9/services/wetv/__init__.py -------------------------------------------------------------------------------- /subtitle_downloader.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | # coding: utf-8 3 | 4 | """ 5 | This module is to download subtitle from stream services. 6 | """ 7 | from __future__ import annotations 8 | import argparse 9 | import logging 10 | from logging import INFO, DEBUG 11 | from datetime import datetime 12 | import os 13 | import sys 14 | import validators 15 | from configs.config import app_name, __version__, directories, filenames 16 | from constants import Service 17 | from services import service_map 18 | from utils.helper import get_locale 19 | from utils.io import load_toml 20 | 21 | 22 | def main() -> None: 23 | _ = get_locale('main') 24 | 25 | support_services = ', '.join(sorted([v for k, v in Service.__dict__.items() 26 | if not k.startswith("__")], key=str.lower)) 27 | 28 | parser = argparse.ArgumentParser( 29 | description=_("Support downloading subtitles from multiple streaming services, such as {} ,and etc.").format( 30 | support_services), 31 | add_help=False) 32 | parser.add_argument('url', 33 | help=_("series's/movie's url")) 34 | parser.add_argument('-s', 35 | '--season', 36 | dest='season', 37 | help=_("download season [0-9]")) 38 | parser.add_argument('-e', 39 | '--episode', 40 | dest='episode', 41 | help=_("download episode [0-9]")) 42 | parser.add_argument('-l', 43 | '--last-episode', 44 | dest='last_episode', 45 | action='store_true', 46 | help=_("download the latest episode")) 47 | parser.add_argument('-o', 48 | '--output', 49 | dest='output', 50 | help=_("output directory")) 51 | parser.add_argument('-slang', 52 | '--subtitle-language', 53 | dest='subtitle_language', 54 | help=_("languages of subtitles; use commas to separate multiple languages")) 55 | parser.add_argument('-alang', 56 | '--audio-language', 57 | dest='audio_language', 58 | help=_("languages of audio-tracks; use commas to separate multiple languages")) 59 | parser.add_argument('-sf', 60 | '--subtitle-format', 61 | dest='subtitle_format', 62 | help=_("subtitles format: .srt or .ass")) 63 | parser.add_argument( 64 | '-locale', 65 | '--locale', 66 | dest='locale', 67 | help=_("interface language"), 68 | ) 69 | parser.add_argument('-p', 70 | '--proxy', 71 | dest='proxy', 72 | nargs='?', 73 | help=_("proxy")) 74 | parser.add_argument( 75 | '-d', 76 | '--debug', 77 | action='store_true', 78 | help=_("enable debug logging"), 79 | ) 80 | parser.add_argument( 81 | '-h', 82 | '--help', 83 | action='help', 84 | default=argparse.SUPPRESS, 85 | help=_("show this help message and exit") 86 | ) 87 | parser.add_argument( 88 | '-v', 89 | '--version', 90 | action='version', 91 | version=f'{app_name} {__version__}', 92 | help=_("app's version") 93 | ) 94 | 95 | args = parser.parse_args() 96 | 97 | if args.debug: 98 | os.makedirs(directories.logs, exist_ok=True) 99 | log_time = datetime.now().strftime('%Y-%m-%d_%H-%M-%S') 100 | log_file_path = str(filenames.log).format( 101 | app_name=app_name, log_time=log_time) 102 | logging.basicConfig( 103 | format='%(asctime)s - %(name)s - %(lineno)d - %(message)s', 104 | datefmt='%Y-%m-%d %H:%M:%S', 105 | level=logging.DEBUG, 106 | handlers=[ 107 | logging.FileHandler(log_file_path, encoding='utf-8'), 108 | logging.StreamHandler() 109 | ] 110 | ) 111 | else: 112 | logging.basicConfig( 113 | format='%(message)s', 114 | level=logging.INFO, 115 | ) 116 | 117 | start = datetime.now() 118 | 119 | if not validators.url(args.url): 120 | logging.warning( 121 | _("\nPlease input correct url!")) 122 | sys.exit(0) 123 | 124 | service = next((service for service in service_map 125 | if service['domain'] in args.url), None) 126 | 127 | if service: 128 | log = logging.getLogger(service['class'].__module__) 129 | if args.debug: 130 | log.setLevel(DEBUG) 131 | else: 132 | log.setLevel(INFO) 133 | 134 | service_config = load_toml( 135 | str(filenames.config).format(service=service['name'])) 136 | 137 | args.log = log 138 | args.config = service_config 139 | args.service = service 140 | service['class'](args).main() 141 | else: 142 | logging.warning( 143 | _("\nOnly support downloading subtitles from %s ,and etc."), support_services) 144 | sys.exit(1) 145 | 146 | logging.info("\n%s took %s seconds", app_name, 147 | int(float((datetime.now() - start).total_seconds()))) 148 | 149 | 150 | if __name__ == "__main__": 151 | main() 152 | -------------------------------------------------------------------------------- /subtitle_downloader.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | while true; do 4 | read -p "Url: " url 5 | python3 subtitle_downloader.py $url 6 | done 7 | -------------------------------------------------------------------------------- /tools/XstreamDL_CLI/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wayneclub/Subtitle-Downloader/00804b4e017c1c8f8adc7e914514d73e774816b9/tools/XstreamDL_CLI/__init__.py -------------------------------------------------------------------------------- /tools/XstreamDL_CLI/__main__.py: -------------------------------------------------------------------------------- 1 | from tools.XstreamDL_CLI import cli 2 | 3 | if __name__ == '__main__': 4 | cli.main() 5 | -------------------------------------------------------------------------------- /tools/XstreamDL_CLI/cmdargs.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | 4 | class CmdArgs: 5 | 6 | def __init__(self): 7 | self.speed_up = None # type: bool 8 | self.speed_up_left = None # type: int 9 | self.live = None # type: bool 10 | self.compare_with_url = None # type: bool 11 | self.dont_split_discontinuity = None # type: bool 12 | self.name_from_url = None # type: bool 13 | self.live_duration = None # type: float 14 | self.live_utc_offset = None # type: int 15 | self.live_refresh_interval = None # type: int 16 | self.name = None # type: str 17 | self.base_url = None # type: str 18 | self.ad_keyword = None # type: str 19 | self.resolution = None # type: str 20 | self.best_quality = None # type: bool 21 | self.video_only = None # type: bool 22 | self.audio_only = None # type: bool 23 | self.all_videos = None # type: bool 24 | self.all_audios = None # type: bool 25 | self.all_subtitles = None # type: bool 26 | self.service = None # type: str 27 | self.save_dir = None # type: Path 28 | self.ffmpeg = None # type: str 29 | self.mp4decrypt = None # type: str 30 | self.mp4box = None # type: str 31 | self.select = None # type: bool 32 | self.multi_s = None # type: bool 33 | self.disable_force_close = None # type: bool 34 | self.limit_per_host = None # type: int 35 | self.headers = None # type: str 36 | self.url_patch = None # type: str 37 | self.overwrite = None # type: bool 38 | self.raw_concat = None # type: bool 39 | self.disable_auto_concat = None # type: bool 40 | self.enable_auto_delete = None # type: bool 41 | self.disable_auto_decrypt = None # type: bool 42 | self.key = None # type: str 43 | self.b64key = None # type: str 44 | self.hexiv = None # type: str 45 | self.proxy = None # type: str 46 | self.disable_auto_exit = None # type: bool 47 | self.parse_only = None # type: bool 48 | self.show_init = None # type: bool 49 | self.index_to_name = None # type: bool 50 | self.log_level = None # type: str 51 | self.redl_code = None # type: list 52 | self.hide_load_metadata = None # type: bool 53 | self.no_metadata_file = None # type: bool 54 | self.gen_init_only = None # type: bool 55 | self.skip_gen_init = None # type: bool 56 | self.URI = None # type: list -------------------------------------------------------------------------------- /tools/XstreamDL_CLI/daemon.py: -------------------------------------------------------------------------------- 1 | import time 2 | from typing import List 3 | from pathlib import Path 4 | from tools.XstreamDL_CLI.cmdargs import CmdArgs 5 | from tools.XstreamDL_CLI.extractor import Extractor 6 | from tools.XstreamDL_CLI.downloader import Downloader 7 | from tools.XstreamDL_CLI.models.stream import Stream 8 | from tools.XstreamDL_CLI.extractors.hls.stream import HLSStream 9 | from tools.XstreamDL_CLI.extractors.dash.stream import DASHStream 10 | from tools.XstreamDL_CLI.extractors.dash.parser import DASHParser 11 | from tools.XstreamDL_CLI.extractors.dash.childs.location import Location 12 | from tools.XstreamDL_CLI.log import setup_logger 13 | 14 | logger = setup_logger('XstreamDL', level='INFO') 15 | 16 | 17 | class Daemon: 18 | 19 | def __init__(self, args: CmdArgs): 20 | self.args = args 21 | self.exit = False 22 | 23 | def daemon(self): 24 | ''' 25 | - 解析 26 | - 下载 27 | - 合并 28 | ''' 29 | extractor = Extractor(self.args) 30 | streams = extractor.fetch_metadata(self.args.URI[0]) 31 | if self.args.live is False: 32 | return Downloader(self.args).download_streams(streams) 33 | else: 34 | return self.live_record(extractor, streams) 35 | 36 | def live_record(self, extractor: Extractor, streams: List[Stream]): 37 | ''' 38 | - 第一轮解析 判断类型 39 | - 选择流 交给下一个函数具体下载-刷新-下载... 40 | - TODO 41 | - 提供录制时长设定 42 | ''' 43 | if isinstance(streams[0], DASHStream): 44 | self.live_record_dash(extractor, streams) 45 | if isinstance(streams[0], HLSStream): 46 | self.live_record_hls(extractor, streams) 47 | # assert False, f'unsupported live stream type {type(streams[0])}' 48 | 49 | def live_record_dash(self, extractor: Extractor, streams: List[DASHStream]): 50 | ''' 51 | dash直播流 52 | 重复拉取新的mpd后 53 | 判断是否和之前的重复关键在于url的path部分是不是一样的 54 | 也就是说 文件是不是一个 55 | 那么主要逻辑如下 56 | - 再次解析 这一轮解析的时候 select 复用第一轮的选择 用skey来判断 57 | - 判断合并 下载已经解析好的分段 58 | - 再次解析 再次下载 直到满足结束条件 59 | Q 为何先再次解析而不是先下载完第一轮解析的分段再下载 60 | A 第一轮解析时有手动选择流的过程 而dash流刷新时间一般都很短 往往只有几秒钟 所以最好是尽快拉取一次最新的mpd 61 | Q 为什么不拉取新mpd单独开一个线程 62 | A 下载的时候很可能会占满网速 个人认为循环会好一点 63 | Q 万一下载卡住导致mpd刷新不及时怎么办 64 | A 还没有想好 不过这种情况概率蛮小的吧... 真的发生了说明你的当前网络不适合录制 65 | ''' 66 | # assert False, 'not support dash live stream, wait plz' 67 | # 再次解析 优先使用 Location 作为要刷新的目标链接 68 | # 因为有的直播流 Location 会比用户填写的链接多一些具体标识 比如时间 或者token 69 | parser = extractor.parser # type: DASHParser 70 | locations = parser.root.find('Location') # type: List[Location] 71 | if len(locations) == 1: 72 | next_mpd_url: str = locations[0].innertext.strip() 73 | else: 74 | next_mpd_url = self.args.URI[0] 75 | if '://' not in next_mpd_url: 76 | if Path(next_mpd_url).is_file() or Path(next_mpd_url).is_dir(): 77 | assert False, 'not support dash live stream for file/folder type, because cannot refresh' 78 | logger.info(f'refresh link {next_mpd_url}') 79 | # 初始化下载器 80 | downloader = Downloader(self.args) 81 | # 获取用户选择的流的skey 82 | skeys = downloader.do_select(streams) 83 | if len(skeys) == 0: 84 | return 85 | refresh_interval = self.args.live_refresh_interval 86 | last_time = time.time() 87 | is_first_time = True 88 | while True: 89 | # 刷新间隔时间检查 90 | if time.time() - last_time < refresh_interval: 91 | time.sleep(0.5) 92 | continue 93 | last_time = time.time() 94 | # 复用 extractor 再次解析 95 | # 这里不应该是文件或者文件夹 当然第一轮可以是链接和文件 96 | next_streams = extractor.fetch_metadata(next_mpd_url) 97 | # 合并下载分段信息 98 | if is_first_time: 99 | is_first_time = False 100 | self.streams_extend({}, next_streams, skeys) 101 | else: 102 | self.streams_extend(streams, next_streams, skeys) 103 | # 下载分段 104 | downloader.download_streams(streams, selected=skeys) 105 | # 检查是不是主动退出了 106 | if downloader.terminate: 107 | logger.debug(f'downloader terminated break') 108 | break 109 | # 继续循环 110 | downloader.try_concat_streams(streams, skeys) 111 | 112 | def live_record_hls(self, extractor: Extractor, streams: List[HLSStream]): 113 | ''' 114 | hls直播流 115 | ''' 116 | assert False, 'not support hls live stream, wait plz' 117 | 118 | def streams_extend(self, streams: List[DASHStream], next_streams: List[DASHStream], skeys: List[str]): 119 | _streams = dict((stream.get_skey(), stream) for stream in streams) 120 | _next_streams = dict((stream.get_skey(), stream) 121 | for stream in next_streams) 122 | for skey in skeys: 123 | _stream = _streams.get(skey) # type: DASHStream 124 | _next_stream = _next_streams.get(skey) # type: DASHStream 125 | if _stream is None or _next_stream is None: 126 | continue 127 | # 对于新增的分段 认为默认有init分段 128 | _stream.live_segments_extend(_next_stream.segments, has_init=True, 129 | name_from_url=self.args.name_from_url, compare_with_url=self.args.compare_with_url) 130 | -------------------------------------------------------------------------------- /tools/XstreamDL_CLI/extractors/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wayneclub/Subtitle-Downloader/00804b4e017c1c8f8adc7e914514d73e774816b9/tools/XstreamDL_CLI/extractors/__init__.py -------------------------------------------------------------------------------- /tools/XstreamDL_CLI/extractors/base.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from tools.XstreamDL_CLI.cmdargs import CmdArgs 3 | from tools.XstreamDL_CLI.models.base import BaseUri 4 | from tools.XstreamDL_CLI.log import setup_logger 5 | 6 | logger = setup_logger('XstreamDL', level='INFO') 7 | 8 | 9 | class BaseParser: 10 | def __init__(self, args: CmdArgs, uri_type: str): 11 | self.args = args 12 | self.uri_type = uri_type 13 | self.suffix = '.SUFFIX' 14 | 15 | def fix_name(self, name: str): 16 | ''' 17 | remove illegal char 18 | ''' 19 | # logger.debug(f'fix name before: {name}') 20 | exclude_str = ["\\", "/", ":", ":", "*", "?", 21 | "\"", "<", ">", "|", "\r", "\n", "\t"] 22 | for s in exclude_str: 23 | name = name.replace(s, " ") 24 | name = "_".join(name.split()) 25 | # logger.debug(f'fix name after: {name}') 26 | return name 27 | 28 | def dump_content(self, name: str, content: str, suffix: str): 29 | if self.args.no_metadata_file: 30 | return 31 | dump_path = self.args.save_dir / f'{name}{suffix}' 32 | logger.debug( 33 | f'save content to {dump_path.resolve().as_posix()}, size {len(content)}') 34 | dump_path.write_text(content, encoding='utf-8') 35 | 36 | def parse_uri(self, uri: str) -> BaseUri: 37 | ''' 38 | 进入此处的uri不可能是文件夹 39 | ''' 40 | logger.debug(f'start parse uri for: {uri}') 41 | rm_manifest = False 42 | if '.ism' in self.args.base_url and 'manifest' in self.args.base_url: 43 | rm_manifest = True 44 | if '.ism' in uri and 'manifest' in uri: 45 | rm_manifest = True 46 | name = self.args.name 47 | if self.uri_type == 'path': 48 | name = Path(uri).stem 49 | home_url, base_url = '', '' 50 | if uri.startswith('http://') or uri.startswith('https://') or uri.startswith('ftp://'): 51 | uris = uri.split('?', maxsplit=1) 52 | if name == '': 53 | name = uris[0][::-1].split('/', maxsplit=1)[0][::-1] 54 | if name.endswith(self.suffix): 55 | name = name[:-len(self.suffix)] 56 | home_url = '/'.join(uris[0].split('/', maxsplit=3)[:-1]) 57 | base_url = uris[0][::-1].split('/', maxsplit=1)[-1][::-1] 58 | elif Path(uri).exists(): 59 | if name == '': 60 | name = Path(uri).stem 61 | if base_url == '' and self.args.base_url != '': 62 | if rm_manifest and self.args.base_url.rstrip('/').endswith('/manifest'): 63 | base_url = '/'.join(self.args.base_url.rstrip('/').split('/') 64 | [:-1]) 65 | else: 66 | base_url = self.args.base_url 67 | home_url = '/'.join(base_url.split('/', maxsplit=3)[:-1]) 68 | name = self.fix_name(name) 69 | logger.debug( 70 | f'parse uri result:\n' 71 | f' name {name}\n' 72 | f' home_url {home_url}\n' 73 | f' base_url {base_url}' 74 | ) 75 | # return [name, home_url, base_url] 76 | return BaseUri(name, home_url, base_url) 77 | -------------------------------------------------------------------------------- /tools/XstreamDL_CLI/extractors/dash/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wayneclub/Subtitle-Downloader/00804b4e017c1c8f8adc7e914514d73e774816b9/tools/XstreamDL_CLI/extractors/dash/__init__.py -------------------------------------------------------------------------------- /tools/XstreamDL_CLI/extractors/dash/childs/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wayneclub/Subtitle-Downloader/00804b4e017c1c8f8adc7e914514d73e774816b9/tools/XstreamDL_CLI/extractors/dash/childs/__init__.py -------------------------------------------------------------------------------- /tools/XstreamDL_CLI/extractors/dash/childs/adaptationset.py: -------------------------------------------------------------------------------- 1 | from ..mpditem import MPDItem 2 | 3 | 4 | class AdaptationSet(MPDItem): 5 | def __init__(self, name: str): 6 | super(AdaptationSet, self).__init__(name) 7 | self.id = None 8 | self.contentType = None # type: str 9 | self.lang = None 10 | self.segmentAlignment = None 11 | self.maxWidth = None 12 | self.maxHeight = None 13 | self.frameRate = None 14 | self.par = None 15 | self.width = None 16 | self.height = None 17 | self.mimeType = None 18 | self.codecs = None 19 | 20 | def get_contenttype(self): 21 | if self.contentType is not None: 22 | return self.contentType 23 | if self.mimeType is not None: 24 | return self.mimeType.split('/')[0].title() 25 | 26 | def get_resolution(self): 27 | return f"{self.width}x{self.height}p" 28 | 29 | def get_suffix(self): 30 | return '.' + self.mimeType.split('/')[0].split('-')[-1] -------------------------------------------------------------------------------- /tools/XstreamDL_CLI/extractors/dash/childs/baseurl.py: -------------------------------------------------------------------------------- 1 | from ..mpditem import MPDItem 2 | 3 | 4 | class BaseURL(MPDItem): 5 | def __init__(self, name: str): 6 | super(BaseURL, self).__init__(name) 7 | self.serviceLocation = None # type: str 8 | self.dvb_priority = None # type: str 9 | self.dvb_weight = None # type: str -------------------------------------------------------------------------------- /tools/XstreamDL_CLI/extractors/dash/childs/cencpssh.py: -------------------------------------------------------------------------------- 1 | from ..mpditem import MPDItem 2 | 3 | 4 | class CencPssh(MPDItem): 5 | def __init__(self, name: str): 6 | super(CencPssh, self).__init__(name) -------------------------------------------------------------------------------- /tools/XstreamDL_CLI/extractors/dash/childs/contentprotection.py: -------------------------------------------------------------------------------- 1 | from ..mpditem import MPDItem 2 | 3 | 4 | class ContentProtection(MPDItem): 5 | def __init__(self, name: str): 6 | super(ContentProtection, self).__init__(name) 7 | self.value = None 8 | self.schemeIdUri = None 9 | self.cenc_default_KID = None -------------------------------------------------------------------------------- /tools/XstreamDL_CLI/extractors/dash/childs/initialization.py: -------------------------------------------------------------------------------- 1 | from ..mpditem import MPDItem 2 | 3 | 4 | class Initialization(MPDItem): 5 | 6 | def __init__(self, name: str): 7 | super(Initialization, self).__init__(name) 8 | self.sourceURL = "" -------------------------------------------------------------------------------- /tools/XstreamDL_CLI/extractors/dash/childs/location.py: -------------------------------------------------------------------------------- 1 | from ..mpditem import MPDItem 2 | 3 | 4 | class Location(MPDItem): 5 | def __init__(self, name: str): 6 | super(Location, self).__init__(name) -------------------------------------------------------------------------------- /tools/XstreamDL_CLI/extractors/dash/childs/period.py: -------------------------------------------------------------------------------- 1 | from ..mpditem import MPDItem 2 | 3 | 4 | class Period(MPDItem): 5 | def __init__(self, name: str): 6 | super(Period, self).__init__(name) 7 | self.id = None # type: str 8 | self.start = None # type: float 9 | self.duration = None # type: float 10 | 11 | def generate(self): 12 | if isinstance(self.start, str): 13 | self.start = self.match_duration(self.start) 14 | # else: 15 | # self.start = 0.0 16 | if isinstance(self.duration, str): 17 | self.duration = self.match_duration(self.duration) 18 | # else: 19 | # self.duration = 0.0 -------------------------------------------------------------------------------- /tools/XstreamDL_CLI/extractors/dash/childs/representation.py: -------------------------------------------------------------------------------- 1 | from ..mpditem import MPDItem 2 | 3 | 4 | class Representation(MPDItem): 5 | def __init__(self, name: str): 6 | super(Representation, self).__init__(name) 7 | self.id = None 8 | self.scanType = None 9 | self.frameRate = None 10 | self.bandwidth = None 11 | self.codecs = None 12 | self.mimeType = None 13 | self.sar = None 14 | self.width = None 15 | self.height = None 16 | self.audioSamplingRate = None 17 | 18 | def get_contenttype(self): 19 | if self.mimeType is not None: 20 | return self.mimeType.split('/')[0].title() 21 | 22 | def get_resolution(self): 23 | return f"{self.width}x{self.height}p" 24 | 25 | def get_suffix(self): 26 | return '.' + self.mimeType.split('/')[0].split('-')[-1] -------------------------------------------------------------------------------- /tools/XstreamDL_CLI/extractors/dash/childs/role.py: -------------------------------------------------------------------------------- 1 | from ..mpditem import MPDItem 2 | 3 | 4 | class Role(MPDItem): 5 | def __init__(self, name: str): 6 | super(Role, self).__init__(name) 7 | self.schemeIdUri = None 8 | self.value = None -------------------------------------------------------------------------------- /tools/XstreamDL_CLI/extractors/dash/childs/s.py: -------------------------------------------------------------------------------- 1 | from ..mpditem import MPDItem 2 | 3 | 4 | class S(MPDItem): 5 | ''' 6 | 5.3.9.6 Segment timeline 7 | - t -> presentationTimeOffset 8 | - d -> duration 9 | - r -> repeat 10 | ''' 11 | def __init__(self, name: str): 12 | super(S, self).__init__(name) 13 | self.t = None # type: int 14 | self.d = None # type: int 15 | self.r = None # type: int 16 | 17 | def generate(self): 18 | if self.t is None: 19 | self.t = 0 20 | if self.d is None: 21 | self.d = 0 22 | if self.r is None: 23 | self.r = 0 24 | self.to_int() 25 | if self.r != -1: 26 | self.r += 1 27 | 28 | def to_int(self): 29 | self.t = int(self.t) 30 | self.d = int(self.d) 31 | self.r = int(self.r) -------------------------------------------------------------------------------- /tools/XstreamDL_CLI/extractors/dash/childs/segmentbase.py: -------------------------------------------------------------------------------- 1 | from ..mpditem import MPDItem 2 | 3 | 4 | class SegmentBaee(MPDItem): 5 | 6 | def __init__(self, name: str): 7 | super(SegmentBaee, self).__init__(name) 8 | self.indexRange = "" 9 | self.timescale = None # type: int 10 | self.presentationTimeOffset = None # type: int 11 | 12 | def generate(self): 13 | if self.presentationTimeOffset is None: 14 | self.presentationTimeOffset = 0 15 | if self.timescale is None: 16 | self.timescale = 0 17 | self.to_int() 18 | 19 | def to_int(self): 20 | self.timescale = int(self.timescale) 21 | self.presentationTimeOffset = int(self.presentationTimeOffset) -------------------------------------------------------------------------------- /tools/XstreamDL_CLI/extractors/dash/childs/segmentlist.py: -------------------------------------------------------------------------------- 1 | from ..mpditem import MPDItem 2 | 3 | 4 | class SegmentList(MPDItem): 5 | 6 | def __init__(self, name: str): 7 | super(SegmentList, self).__init__(name) 8 | self.timescale = 0 # type: int 9 | self.duration = 0 # type: int 10 | 11 | def generate(self): 12 | self.to_int() 13 | 14 | def to_int(self): 15 | try: 16 | self.timescale = int(self.timescale) 17 | self.duration = int(self.duration) 18 | except Exception: 19 | pass -------------------------------------------------------------------------------- /tools/XstreamDL_CLI/extractors/dash/childs/segmenttemplate.py: -------------------------------------------------------------------------------- 1 | from ..mpditem import MPDItem 2 | 3 | 4 | class SegmentTemplate(MPDItem): 5 | ''' 6 | SegmentTemplate没有duration的话 timescale好像没什么用 7 | ''' 8 | def __init__(self, name: str): 9 | super(SegmentTemplate, self).__init__(name) 10 | self.timescale = 0 # type: int 11 | self.duration = 0 # type: int 12 | self.presentationTimeOffset = None # type: int 13 | self.initialization = None # type: str 14 | self.media = None # type: str 15 | self.startNumber = None # type: int 16 | 17 | def generate(self): 18 | if self.presentationTimeOffset is None: 19 | self.presentationTimeOffset = 0 20 | if self.startNumber is None: 21 | self.startNumber = 1 22 | self.to_int() 23 | 24 | def to_int(self): 25 | self.timescale = int(self.timescale) 26 | self.duration = int(self.duration) 27 | self.presentationTimeOffset = int(self.presentationTimeOffset) 28 | self.startNumber = int(self.startNumber) 29 | 30 | def get_url(self) -> str: 31 | return self.initialization 32 | # if self.initialization is None: 33 | # return self.initialization 34 | # return self.initialization.replace('..', '') 35 | 36 | def get_media_url(self) -> str: 37 | return self.media 38 | # return self.media.replace('../', '') -------------------------------------------------------------------------------- /tools/XstreamDL_CLI/extractors/dash/childs/segmenttimeline.py: -------------------------------------------------------------------------------- 1 | from ..mpditem import MPDItem 2 | 3 | 4 | class SegmentTimeline(MPDItem): 5 | # 5.3.9.6 Segment timeline 6 | def __init__(self, name: str): 7 | super(SegmentTimeline, self).__init__(name) -------------------------------------------------------------------------------- /tools/XstreamDL_CLI/extractors/dash/childs/segmenturl.py: -------------------------------------------------------------------------------- 1 | from ..mpditem import MPDItem 2 | 3 | 4 | class SegmentURL(MPDItem): 5 | 6 | def __init__(self, name: str): 7 | super(SegmentURL, self).__init__(name) 8 | self.media = "" -------------------------------------------------------------------------------- /tools/XstreamDL_CLI/extractors/dash/funcs.py: -------------------------------------------------------------------------------- 1 | def tree(obj, step: int = 0): 2 | print(f"{step * '--'}>{obj.name}") 3 | step += 1 4 | for child in obj.childs: 5 | step = tree(child, step=step) 6 | step -= 1 7 | print(f"{step * '--'}>{obj.name}") 8 | return step -------------------------------------------------------------------------------- /tools/XstreamDL_CLI/extractors/dash/handler.py: -------------------------------------------------------------------------------- 1 | from xml.parsers.expat import ParserCreate 2 | from .mpd import MPD 3 | from .childs.location import Location 4 | from .childs.adaptationset import AdaptationSet 5 | from .childs.baseurl import BaseURL 6 | from .childs.cencpssh import CencPssh 7 | from .childs.contentprotection import ContentProtection 8 | from .childs.period import Period 9 | from .childs.representation import Representation 10 | from .childs.role import Role 11 | from .childs.s import S 12 | from .childs.segmentlist import SegmentList 13 | from .childs.initialization import Initialization 14 | from .childs.segmenturl import SegmentURL 15 | from .childs.segmentbase import SegmentBaee 16 | from .childs.segmenttemplate import SegmentTemplate 17 | from .childs.segmenttimeline import SegmentTimeline 18 | 19 | 20 | def xml_handler(content: str): 21 | def handle_start_element(tag, attrs): 22 | nonlocal mpd 23 | nonlocal mpd_handlers 24 | if mpd is None: 25 | if tag != 'MPD': 26 | raise Exception('the first tag is not MPD!') 27 | mpd = MPD(tag) 28 | mpd.addattrs(attrs) 29 | mpd.generate() 30 | stack.append(mpd) 31 | else: 32 | if mpd_handlers.get(tag) is None: 33 | return 34 | child = mpd_handlers[tag](tag) 35 | child.addattrs(attrs) 36 | child.generate() 37 | mpd.childs.append(child) 38 | mpd = child 39 | stack.append(child) 40 | 41 | def handle_end_element(tag): 42 | nonlocal mpd 43 | nonlocal mpd_handlers 44 | if mpd_handlers.get(tag) is None: 45 | return 46 | if len(stack) > 1: 47 | _ = stack.pop(-1) 48 | mpd = stack[-1] 49 | 50 | def handle_character_data(texts: str): 51 | if texts.strip() != '': 52 | mpd.innertext += texts.strip() 53 | stack = [] 54 | mpd = None # type: MPD 55 | mpd_handlers = { 56 | 'MPD': MPD, 57 | 'Location': Location, 58 | 'BaseURL': BaseURL, 59 | 'Period': Period, 60 | 'AdaptationSet': AdaptationSet, 61 | 'Representation': Representation, 62 | 'SegmentTemplate': SegmentTemplate, 63 | 'SegmentURL': SegmentURL, 64 | 'SegmentBase': SegmentBaee, 65 | 'Initialization': Initialization, 66 | 'SegmentList': SegmentList, 67 | 'SegmentTimeline': SegmentTimeline, 68 | 'Role': Role, 69 | 'S': S, 70 | 'ContentProtection': ContentProtection, 71 | 'cenc:pssh': CencPssh, 72 | } 73 | parser = ParserCreate() 74 | parser.StartElementHandler = handle_start_element 75 | parser.EndElementHandler = handle_end_element 76 | parser.CharacterDataHandler = handle_character_data 77 | parser.Parse(content) 78 | return mpd -------------------------------------------------------------------------------- /tools/XstreamDL_CLI/extractors/dash/key.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | from tools.XstreamDL_CLI.models.key import StreamKey 3 | from .childs.contentprotection import ContentProtection 4 | from .childs.cencpssh import CencPssh 5 | 6 | 7 | class COMMON_MPEG: 8 | def __init__(self, schemeiduri: str, cenc_default_kid: str, value: str): 9 | self.schemeiduri = schemeiduri 10 | self.cenc_default_kid = cenc_default_kid 11 | self.value = value 12 | 13 | 14 | class COMMON_CENC: 15 | def __init__(self, schemeiduri: str, cenc_pssh: str, value: str): 16 | self.schemeiduri = schemeiduri 17 | self.cenc_pssh = cenc_pssh 18 | self.value = value 19 | 20 | 21 | class MARLIN: 22 | def __init__(self, schemeiduri: str, mas_marlincontentids: list): 23 | self.schemeiduri = schemeiduri 24 | self.mas_marlincontentids = mas_marlincontentids 25 | 26 | 27 | class PLAYREADY: 28 | def __init__(self, schemeiduri: str, mspr_pro: str): 29 | self.schemeiduri = schemeiduri 30 | self.mspr_pro = mspr_pro 31 | 32 | 33 | class WIDEVINE: 34 | def __init__(self, schemeiduri: str, cenc_pssh: str): 35 | self.schemeiduri = schemeiduri 36 | self.cenc_pssh = cenc_pssh 37 | 38 | 39 | class PRIMETIME: 40 | def __init__(self, schemeiduri: str, cenc_pssh: str): 41 | self.schemeiduri = schemeiduri 42 | self.cenc_pssh = cenc_pssh 43 | 44 | 45 | class DASHKey(StreamKey): 46 | def __init__(self, cp: ContentProtection): 47 | super(DASHKey, self).__init__() 48 | key = '' 49 | method = '' 50 | # https://dashif.org/identifiers/content_protection/ 51 | if cp.schemeIdUri == 'urn:mpeg:dash:mp4protection:2011': 52 | key = COMMON_MPEG(cp.schemeIdUri, cp.cenc_default_KID, cp.value) 53 | method = 'COMMON_MPEG' 54 | elif cp.schemeIdUri == 'urn:uuid:1077efec-c0b2-4d02-ace3-3c1e52e2fb4b': 55 | key = COMMON_CENC(cp.schemeIdUri, self.get_pssh(cp), cp.value) 56 | method = 'COMMON_CENC' 57 | elif cp.schemeIdUri == 'urn:uuid:5E629AF5-38DA-4063-8977-97FFBD9902D4': 58 | key = MARLIN(cp.schemeIdUri, []) 59 | method = 'MARLIN' 60 | elif cp.schemeIdUri == 'urn:uuid:9a04f079-9840-4286-ab92-e65be0885f95': 61 | mspr_pros = cp.find('mspr:pros') # type: List[CencPssh] 62 | if len(mspr_pros) > 0: 63 | mspr_pro = mspr_pros[0].innertext 64 | else: 65 | mspr_pro = '' 66 | key = PLAYREADY(cp.schemeIdUri, mspr_pro) 67 | method = 'PLAYREADY' 68 | elif cp.schemeIdUri == 'urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed': 69 | key = WIDEVINE(cp.schemeIdUri, self.get_pssh(cp)) 70 | method = 'WIDEVINE' 71 | elif cp.schemeIdUri == 'urn:uuid:F239E769-EFA3-4850-9C16-A903C6932EFB': 72 | key = '???????' 73 | method = 'PRIMETIME' 74 | self.method = method 75 | self.key = key 76 | 77 | def get_pssh(self, cp: ContentProtection) -> str: 78 | cenc_psshs = cp.find('cenc:pssh') # type: List[CencPssh] 79 | if len(cenc_psshs) > 0: 80 | return cenc_psshs[0].innertext 81 | else: 82 | return '' 83 | -------------------------------------------------------------------------------- /tools/XstreamDL_CLI/extractors/dash/maps/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wayneclub/Subtitle-Downloader/00804b4e017c1c8f8adc7e914514d73e774816b9/tools/XstreamDL_CLI/extractors/dash/maps/__init__.py -------------------------------------------------------------------------------- /tools/XstreamDL_CLI/extractors/dash/maps/audiomap.py: -------------------------------------------------------------------------------- 1 | AUDIOMAP = { 2 | "1": "PCM", 3 | "mp3": "MP3", 4 | "mp4a.66": "MPEG2_AAC", 5 | "mp4a.67": "MPEG2_AAC", 6 | "mp4a.68": "MPEG2_AAC", 7 | "mp4a.69": "MP3", 8 | "mp4a.6B": "MP3", 9 | "mp4a.40.2": "MPEG4_AAC", 10 | "mp4a.40.02": "MPEG4_AAC", 11 | "mp4a.40.5": "MPEG4_AAC", 12 | "mp4a.40.05": "MPEG4_AAC", 13 | "mp4a.40.29": "MPEG4_AAC", 14 | "mp4a.40.42": "MPEG4_XHE_AAC", 15 | "ac-3": "AC3", 16 | "mp4a.a5": "AC3", 17 | "mp4a.A5": "AC3", 18 | "ec-3": "EAC3", 19 | "mp4a.a6": "EAC3", 20 | "mp4a.A6": "EAC3", 21 | "vorbis": "VORBIS", 22 | "opus": "OPUS", 23 | "flac": "FLAC", 24 | "vp8": "VP8", 25 | "vp8.0": "VP8", 26 | "theora": "THEORA", 27 | } -------------------------------------------------------------------------------- /tools/XstreamDL_CLI/extractors/dash/mpd.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from dateutil.parser import parse as parse_datetime 3 | from .mpditem import MPDItem 4 | 5 | 6 | class MPD(MPDItem): 7 | def __init__(self, name: str): 8 | super(MPD, self).__init__(name) 9 | self.maxSegmentDuration = None # type: str 10 | self.mediaPresentationDuration = None # type: str 11 | self.minBufferTime = None # type: str 12 | # live profile 13 | # - urn:mpeg:dash:profile:isoff-live:2011 14 | # - urn:mpeg:dash:profile:isoff-ext-live:2014 15 | self.profiles = None # type: str 16 | # dynamic -> live 17 | # static -> live playback 18 | self.type = None # type: str 19 | # only use when type is 'dynamic' which specifies the smallest period between potential changes to the MPD 20 | self.minimumUpdatePeriod = None # type: float 21 | # time of client to fetch the mpd content 22 | self.publishTime = None # type: datetime 23 | self.availabilityStartTime = None # type: float 24 | self.timeShiftBufferDepth = None # type: str 25 | self.suggestedPresentationDelay = None # type: str 26 | 27 | def generate(self): 28 | if isinstance(self.maxSegmentDuration, str): 29 | self.maxSegmentDuration = self.match_duration(self.maxSegmentDuration) 30 | if isinstance(self.mediaPresentationDuration, str): 31 | self.mediaPresentationDuration = self.match_duration(self.mediaPresentationDuration) 32 | if isinstance(self.minBufferTime, str): 33 | self.minBufferTime = self.match_duration(self.minBufferTime) 34 | if isinstance(self.minimumUpdatePeriod, str): 35 | self.minimumUpdatePeriod = self.match_duration(self.minimumUpdatePeriod) 36 | if isinstance(self.availabilityStartTime, str): 37 | # if self.availabilityStartTime in ['1970-01-01T00:00:00Z', '1970-01-01T00:00:00.000Z']: 38 | if self.availabilityStartTime.startswith('1970-01-01'): 39 | self.availabilityStartTime = 0.0 40 | # 2019-03-05T08:26:06.748000+00:00 41 | if isinstance(self.availabilityStartTime, str) and self.availabilityStartTime[-9:] == '000+00:00': 42 | self.availabilityStartTime = self.availabilityStartTime[:-9] + 'Z' 43 | try: 44 | self.availabilityStartTime = parse_datetime(self.availabilityStartTime).timestamp() 45 | except Exception: 46 | pass 47 | if isinstance(self.publishTime, str): 48 | is_match = False 49 | try: 50 | self.publishTime = parse_datetime(self.publishTime) 51 | is_match = True 52 | except Exception: 53 | pass 54 | if is_match is False: 55 | assert is_match is True, f'match publishTime failed => {self.publishTime}' -------------------------------------------------------------------------------- /tools/XstreamDL_CLI/extractors/dash/mpditem.py: -------------------------------------------------------------------------------- 1 | from tools.XstreamDL_CLI.extractors.metaitem import MetaItem 2 | 3 | 4 | class MPDItem(MetaItem): 5 | def __init__(self, name: str = "MPDItem"): 6 | self.name = name 7 | self.innertext = '' 8 | self.childs = [] 9 | 10 | def addattr(self, name: str, value): 11 | self.__setattr__(name, value) 12 | 13 | def addattrs(self, attrs: dict): 14 | for attr_name, attr_value in attrs.items(): 15 | attr_name: str 16 | attr_name = attr_name.replace(":", "_") 17 | self.addattr(attr_name, attr_value) 18 | 19 | def find(self, name: str): 20 | return [child for child in self.childs if child.name == name] 21 | -------------------------------------------------------------------------------- /tools/XstreamDL_CLI/extractors/dash/segment.py: -------------------------------------------------------------------------------- 1 | import re 2 | from urllib.parse import urlparse 3 | from tools.XstreamDL_CLI.models.segment import Segment 4 | 5 | 6 | class DASHSegment(Segment): 7 | def __init__(self): 8 | super(DASHSegment, self).__init__() 9 | self.suffix = '.mp4' 10 | 11 | def is_encrypt(self): 12 | return True 13 | 14 | def is_supported_encryption(self): 15 | return False 16 | 17 | def set_duration(self, duration: float): 18 | self.duration = duration 19 | 20 | def set_fmt_time(self, fmt_time: float): 21 | self.fmt_time = fmt_time 22 | 23 | def set_subtitle_url(self, subtitle_url: str): 24 | self.name = subtitle_url.split('?')[0].split('/')[-1] 25 | self.index = -1 26 | self.url = subtitle_url 27 | self.segment_type = 'init' 28 | 29 | def set_init_url(self, init_url: str): 30 | parts = init_url.split('?')[0].split('/')[-1].split('.') 31 | if len(parts) > 1: 32 | self.suffix = f'.{parts[-1]}' 33 | self.name = f'init{self.suffix}' 34 | self.index = -1 35 | self.url = init_url 36 | self.segment_type = 'init' 37 | 38 | def get_url_name(self, url: str): 39 | url_name = urlparse(url).path.split('/')[-1] 40 | match = re.match('Fragments\((.+?)\)', url_name) 41 | if match: 42 | url_name = match.group(1).split( 43 | ',')[0].replace('=', '_') + self.suffix 44 | return url_name 45 | 46 | def set_media_url(self, media_url: str, name_from_url: bool = False): 47 | parts = media_url.split('?')[0].split('/')[-1].split('.') 48 | if len(parts) > 1: 49 | # 修正后缀 50 | self.suffix = f'.{parts[-1]}' 51 | self.name = f'{self.index:0>4}.{parts[-1]}' 52 | self.url = media_url 53 | if name_from_url: 54 | self.name = self.get_url_name(self.url) 55 | -------------------------------------------------------------------------------- /tools/XstreamDL_CLI/extractors/hls/ext/x.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | 4 | class X: 5 | ''' 6 | 每一个标签具有的通用性质 7 | - 标签名 8 | - 以期望的形式打印本身信息 9 | - 标签行去除 TAG_NAME: 部分 10 | ''' 11 | def __init__(self, TAG_NAME: str = 'X'): 12 | self.TAG_NAME = TAG_NAME 13 | self.known_attrs = {} 14 | 15 | def __repr__(self): 16 | return f'{self.TAG_NAME}' 17 | 18 | def __strip(self, line: str): 19 | # return line[len(self.TAG_NAME) + 1:] 20 | data = line.split(':', maxsplit=1) 21 | if len(data) == 2: 22 | _, no_tag_line = data 23 | elif len(data) == 0: 24 | raise 'm3u8格式错误 无法处理的异常' 25 | else: 26 | no_tag_line = data[0] 27 | return no_tag_line 28 | 29 | def get_tag_info(self, line: str): 30 | return self.__strip(line) 31 | 32 | def format_key(self, key: str): 33 | return key.replace('-', '_').lower() 34 | 35 | def convert_type(self, name: str, value: str, _type: type): 36 | self.__setattr__(self.format_key(name), _type(value)) 37 | 38 | def regex_attrs(self, info: str) -> list: 39 | if info.endswith(',') is False: 40 | info += ',' 41 | return re.findall('(.*?)=("[^"]*?"|[^,]*?),', info) 42 | 43 | def set_attrs_from_line(self, line: str): 44 | ''' 45 | https://stackoverflow.com/questions/34081567 46 | re.findall('([A-Z]+[0-9]*)=("[^"]*"|[^,]*)', s) 47 | ''' 48 | info = self.get_tag_info(line) 49 | for key, value in self.regex_attrs(info): 50 | value = value.strip('"') 51 | if key in self.known_attrs: 52 | if isinstance(self.known_attrs[key], str): 53 | self.__setattr__(self.known_attrs[key], value) 54 | elif isinstance(self.known_attrs[key], type): 55 | self.convert_type(key, value, self.known_attrs[key]) 56 | else: 57 | self.known_attrs[key](value) 58 | else: 59 | print(f'unknown attr -> {key} <- of {self.TAG_NAME}') 60 | return self -------------------------------------------------------------------------------- /tools/XstreamDL_CLI/extractors/hls/ext/xdaterange.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from .x import X 3 | 4 | 5 | class XDateRange(X): 6 | ''' 7 | #EXT-X-DATERANGE 第一个分段的绝对时间 8 | - 2019-01-01T00:00:00.000Z 9 | ''' 10 | def __init__(self): 11 | super(XDateRange, self).__init__('#EXT-X-DATERANGE') 12 | self._id = None # type: str 13 | self._class = None # type: str 14 | self.start_date = None # type: datetime 15 | self.end_date = None # type: datetime 16 | self.duration = None # type: float 17 | self.planned_duration = None # type: float 18 | self.end_on_next = None # type: str 19 | self.known_attrs = { 20 | 'ID': '_id', 21 | 'CLASS': '_class', 22 | 'START-DATE': self.set_start_date, 23 | 'END-DATE': self.set_end_date, 24 | 'DURATION': self.set_duration, 25 | 'PLANNED-DURATION': self.set_planned_duration, 26 | 'END-ON-NEXT': 'end_on_next', 27 | } 28 | 29 | def set_duration(self, text: str): 30 | self.duration = float(text) 31 | 32 | def set_planned_duration(self, text: str): 33 | self.planned_duration = float(text) 34 | 35 | def get_time(self, text: str): 36 | if text.endswith('Z') is True: 37 | text = f'{text[:-1]}+00:00' 38 | try: 39 | time = datetime.fromisoformat(text) 40 | except Exception: 41 | raise 42 | return time 43 | 44 | def set_start_date(self, text: str): 45 | self.start_date = self.get_time(text) 46 | 47 | def set_end_date(self, text: str): 48 | self.end_date = self.get_time(text) 49 | 50 | def set_attrs_from_line(self, line: str): 51 | # https://stackoverflow.com/questions/127803 52 | # datetime.strptime(line, "%Y-%m-%dT%H:%M:%S.%fZ") 53 | info = self.get_tag_info(line) 54 | for key, value in self.regex_attrs(info): 55 | value = value.strip('"') 56 | if key in self.known_attrs: 57 | if isinstance(self.known_attrs[key], str): 58 | self.__setattr__(self.known_attrs[key], value) 59 | elif isinstance(self.known_attrs[key], type): 60 | self.convert_type(key, value, self.known_attrs[key]) 61 | else: 62 | self.known_attrs[key](value) 63 | elif key.startswith('X-'): 64 | self.__setattr__(self.format_key(key), value) 65 | else: 66 | print(f'unknown attr of {self.TAG_NAME}') 67 | return self -------------------------------------------------------------------------------- /tools/XstreamDL_CLI/extractors/hls/ext/xkey.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import platform 3 | from aiohttp_socks import ProxyConnector 4 | from aiohttp import ClientSession, ClientResponse 5 | from aiohttp.connector import TCPConnector 6 | 7 | from .x import X 8 | from tools.XstreamDL_CLI.cmdargs import CmdArgs 9 | from tools.XstreamDL_CLI.log import setup_logger 10 | 11 | logger = setup_logger('XstreamDL', level='INFO') 12 | 13 | DEFAULT_IV = '0' * 32 14 | 15 | 16 | class XKey(X): 17 | ''' 18 | 一组加密参数 19 | - METHOD 20 | - AES-128 21 | - SAMPLE-AES 22 | - URI 23 | - data:text/plain;base64,... 24 | - skd://... 25 | - IV 26 | - 0x/0X... 27 | - KEYFORMAT 28 | - urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed 29 | - com.apple.streamingkeydelivery 30 | ''' 31 | 32 | def __init__(self): 33 | super(XKey, self).__init__('#EXT-X-KEY') 34 | self.method = 'AES-128' # type: str 35 | self.uri = None # type: str 36 | self.key = b'' # type: bytes 37 | self.keyid = None # type: str 38 | self.iv = DEFAULT_IV # type: str 39 | self.keyformatversions = None # type: str 40 | self.keyformat = None # type: str 41 | self.known_attrs = { 42 | 'METHOD': 'method', 43 | 'URI': 'uri', 44 | 'KEYID': 'keyid', 45 | 'IV': 'iv', 46 | 'KEYFORMATVERSIONS': 'keyformatversions', 47 | 'KEYFORMAT': 'keyformat', 48 | } 49 | 50 | def set_key(self, key: bytes): 51 | self.key = key 52 | return self 53 | 54 | def set_iv(self, iv: str): 55 | if iv is None: 56 | return 57 | self.iv = iv 58 | return self 59 | 60 | def set_attrs_from_line(self, home_url: str, base_url: str, line: str): 61 | ''' 62 | key的链接可能不全 用home_url或base_url进行补齐 具体处理后面做 63 | ''' 64 | line = line.replace('MEATHOD', 'METHOD') 65 | super(XKey, self).set_attrs_from_line(line) 66 | key_type, self.uri = self.gen_hls_key_uri(home_url, base_url) 67 | if self.iv.lower().startswith('0x'): 68 | self.iv = self.iv[2:] 69 | return self 70 | 71 | def gen_hls_key_uri(self, home_url: str, base_url: str): 72 | ''' 73 | 解析时 不具体调用这个函数 需要的地方再转换 74 | data:text/plain;base64,AAAASnBzc2gAAAAA7e+LqXnWSs6jyCfc1R0h7QAAACoSEKg079lX5xeK9g/zZPwXENESEKg079lX5xeK9g/zZPwXENFI88aJmwY= 75 | skd://a834efd957e7178af60ff364fc1710d1 76 | ''' 77 | if self.uri is None: 78 | return '', self.uri 79 | if self.uri.startswith('data:text/plain;base64,'): 80 | return 'base64', self.uri.split(',', maxsplit=1)[-1] 81 | elif self.uri.startswith('skd://'): 82 | return 'skd', self.uri.split('/', maxsplit=1)[-1] 83 | elif self.uri.startswith('http'): 84 | return 'http', self.uri 85 | elif self.uri.startswith('/'): 86 | return 'http', home_url + self.uri 87 | else: 88 | return 'http', base_url + '/' + self.uri 89 | 90 | async def fetch(self, url: str, args: CmdArgs) -> bytes: 91 | if args.proxy != '': 92 | connector = ProxyConnector.from_url(args.proxy, ssl=False) 93 | else: 94 | connector = TCPConnector(ssl=False) 95 | async with ClientSession(connector=connector) as client: # type: ClientSession 96 | async with client.get(url, headers=args.headers) as resp: # type: ClientResponse 97 | return await resp.content.read() 98 | 99 | def load(self, args: CmdArgs, custom_xkey: 'XKey'): 100 | ''' 101 | 如果custom_xkey存在key 那么覆盖解析结果中的key 102 | 并且不进行请求key的动作 同时覆盖iv 如果有自定义iv的话 103 | ''' 104 | if custom_xkey.iv != DEFAULT_IV: 105 | self.iv = custom_xkey.iv 106 | if custom_xkey.key != b'': 107 | self.key = custom_xkey.key 108 | return True 109 | if self.uri.startswith('http://') or self.uri.startswith('https://'): 110 | logger.info(f'key uri => {self.uri}') 111 | if platform.system() == 'Windows': 112 | asyncio.set_event_loop_policy( 113 | asyncio.WindowsSelectorEventLoopPolicy()) 114 | loop = asyncio.get_event_loop() 115 | self.key = loop.run_until_complete(self.fetch(self.uri, args)) 116 | elif self.uri.startswith('ftp://'): 117 | return False 118 | return True 119 | -------------------------------------------------------------------------------- /tools/XstreamDL_CLI/extractors/hls/ext/xmedia.py: -------------------------------------------------------------------------------- 1 | import re 2 | from .x import X 3 | 4 | 5 | class XMedia(X): 6 | ''' 7 | #EXT-X-MEDIA 外挂媒体 8 | - TYPE=AUDIO,URI="",GROUP-ID="default-audio-group",NAME="stream_0",AUTOSELECT=YES,CHANNELS="2" 9 | ''' 10 | def __init__(self): 11 | super(XMedia, self).__init__('#EXT-X-MEDIA') 12 | self.type = None # type: str 13 | self.uri = None # type: str 14 | self.group_id = None # type: str 15 | self.language = None # type: str 16 | self.assoc_language = None # type: str 17 | self.name = None # type: str 18 | self.default = None # type: str 19 | self.autoselect = None # type: str 20 | self.forced = None # type: str 21 | self.instream_id = None # type: str 22 | self.subtitles = None # type: str 23 | self.channels = None # type: int 24 | self.known_attrs = { 25 | 'TYPE': 'type', 26 | 'URI': 'uri', 27 | 'GROUP-ID': 'group_id', 28 | 'LANGUAGE': 'language', 29 | 'ASSOC-LANGUAGE': 'assoc_language', 30 | 'NAME': 'name', 31 | 'DEFAULT': 'default', 32 | 'AUTOSELECT': 'autoselect', 33 | 'FORCED': 'forced', 34 | 'INSTREAM-ID': 'instream_id', 35 | 'CHARACTERISTICS': 'subtitles', 36 | 'CHANNELS': int, 37 | } 38 | 39 | def convert_type(self, name: str, value: str, _type: type): 40 | if name == 'CHANNELS': 41 | try: 42 | value = re.findall('(\d+)', value)[0] 43 | except Exception: 44 | pass 45 | self.__setattr__(self.format_key(name), _type(value)) 46 | 47 | def set_attrs_from_line(self, line: str): 48 | ''' 49 | 这里实际上可以不写 50 | ''' 51 | return super(XMedia, self).set_attrs_from_line(line) -------------------------------------------------------------------------------- /tools/XstreamDL_CLI/extractors/hls/ext/xprivinf.py: -------------------------------------------------------------------------------- 1 | from .x import X 2 | 3 | 4 | class XPrivinf(X): 5 | ''' 6 | #EXT-X-PRIVINF YOUKU的自定义标签 7 | - FILESIZE=925655 8 | ''' 9 | def __init__(self): 10 | super(XPrivinf, self).__init__('#EXT-X-PRIVINF') 11 | self.filesize = None # type: int 12 | self.drm_notencrypt = False # type: bool 13 | self.known_attrs = { 14 | 'FILESIZE': int, 15 | } 16 | 17 | def set_attrs_from_line(self, line: str): 18 | if line.endswith('DRM_NOTENCRYPT'): 19 | self.drm_notencrypt = True 20 | line = line.replace('DRM_NOTENCRYPT', '') 21 | return super(XPrivinf, self).set_attrs_from_line(line) -------------------------------------------------------------------------------- /tools/XstreamDL_CLI/extractors/hls/ext/xprogram_date_time.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from .x import X 3 | 4 | 5 | class XProgramDateTime(X): 6 | ''' 7 | #EXT-X-PROGRAM-DATE-TIME 第一个分段的绝对时间 8 | - 2019-01-01T00:00:00.000Z 9 | ''' 10 | def __init__(self): 11 | super(XProgramDateTime, self).__init__('#EXT-X-PROGRAM-DATE-TIME') 12 | self.program_date_time = None # type: datetime 13 | 14 | def set_attrs_from_line(self, line: str): 15 | ''' 16 | 重写父类同名函数 17 | ''' 18 | line = self.get_tag_info(line) 19 | if line.endswith('Z') is True: 20 | line = f'{line[:-1]}+00:00' 21 | try: 22 | self.program_date_time = datetime.fromisoformat(line) 23 | except Exception: 24 | raise 25 | return self -------------------------------------------------------------------------------- /tools/XstreamDL_CLI/extractors/hls/ext/xstream_inf.py: -------------------------------------------------------------------------------- 1 | from .x import X 2 | 3 | 4 | class XStreamInf(X): 5 | ''' 6 | #EXT-X-STREAM-INF 紧接着该标签的实际上是一个Stream 7 | - PROGRAM-ID=1,BANDWIDTH=1470188,SIZE=468254984,FPS=25,RESOLU=1080,CODECS="avc1,mp4a",QUALITY=5,STREAMTYPE="mp4hd3" 8 | #EXT-X-I-FRAME-STREAM-INF 也被归类于此 有一个额外属性 URI 9 | ''' 10 | def __init__(self): 11 | super(XStreamInf, self).__init__('#EXT-X-STREAM-INF') 12 | self.program_id = None # type: int 13 | self.bandwidth = None # type: int 14 | self.average_bandwidth = None # type: int 15 | self.codecs = None # type: str 16 | self.resolution = '' # type: str 17 | self.frame_rate = None # type: float 18 | self.hdcp_level = None # type: str 19 | self.characteristics = None # type: str 20 | self.uri = None # type: str 21 | self.audio = None # type: str 22 | self.video = None # type: str 23 | self.subtitles = None # type: str 24 | self.closed_captions = None # type: str 25 | self.video_range = None # type: str 26 | self.size = None # type: int 27 | self.fps = None # type: float 28 | self.quality = None # type: int 29 | self.streamtype = '' # type: str 30 | # VIDEO-RANGE是苹果的标准 往下的是非标准属性 31 | self.known_attrs = { 32 | 'PROGRAM-ID': int, 33 | 'BANDWIDTH': int, 34 | 'AVERAGE-BANDWIDTH': int, 35 | 'CODECS': 'codecs', 36 | 'RESOLUTION': 'resolution', 37 | 'FRAME-RATE': float, 38 | 'HDCP-LEVEL': 'hdcp_level', 39 | 'CHARACTERISTICS': 'characteristics', 40 | 'URI': 'uri', 41 | 'AUDIO': 'audio', 42 | 'VIDEO': 'video', 43 | 'SUBTITLES': 'subtitles', 44 | 'CLOSED-CAPTIONS': 'closed_captions', 45 | 'VIDEO-RANGE': 'video_range', 46 | 'SIZE': int, 47 | 'FPS': float, 48 | 'RESOLU': 'resolution', 49 | 'QUALITY': int, 50 | 'STREAMTYPE': 'streamtype', 51 | } 52 | 53 | def set_attrs_from_line(self, line: str): 54 | ''' 55 | 这里实际上可以不写 56 | ''' 57 | return super(XStreamInf, self).set_attrs_from_line(line) -------------------------------------------------------------------------------- /tools/XstreamDL_CLI/extractors/hls/segment.py: -------------------------------------------------------------------------------- 1 | import re 2 | from tools.XstreamDL_CLI.models.segment import Segment 3 | from tools.XstreamDL_CLI.extractors.hls.ext.xkey import XKey 4 | from tools.XstreamDL_CLI.extractors.hls.ext.xprivinf import XPrivinf 5 | 6 | 7 | class HLSSegment(Segment): 8 | def __init__(self): 9 | super(HLSSegment, self).__init__() 10 | # 加密信息 11 | self.xkey = None # type: XKey 12 | self.__xprivinf = None # type: XPrivinf 13 | self.has_set_key = False 14 | 15 | def is_encrypt(self): 16 | if self.__xprivinf is not None: 17 | return self.__xprivinf.drm_notencrypt 18 | elif self.xkey is not None: 19 | return True 20 | else: 21 | return False 22 | 23 | def is_supported_encryption(self): 24 | if self.xkey is not None and self.xkey.method.upper() in ['AES-128']: 25 | return True 26 | return False 27 | 28 | def set_duration(self, line: str): 29 | try: 30 | self.duration = float(line.split( 31 | ':', maxsplit=1)[-1].split(',')[0]) 32 | except Exception: 33 | pass 34 | 35 | def set_byterange(self, line: str): 36 | try: 37 | _ = line.split(':', maxsplit=1)[-1].split('@') 38 | total, offset = int(_[0]), int(_[1]) 39 | self.byterange = [total, offset] 40 | except Exception: 41 | pass 42 | 43 | def set_privinf(self, line: str): 44 | ''' 45 | 对于分段来说 标签的属性值 应该归属在标签下面 计算时需要注意 46 | 不过也可以在解析标签信息之后 进行赋值处理 这样便于调用 47 | ''' 48 | self.__xprivinf = XPrivinf().set_attrs_from_line(line) 49 | if self.__xprivinf.filesize is not None: 50 | self.filesize = self.__xprivinf.filesize 51 | 52 | def set_url(self, home_url: str, base_url: str, line: str): 53 | if line.startswith('http://') or line.startswith('https://') or line.startswith('ftp://'): 54 | self.url = line 55 | elif line.startswith('/'): 56 | self.url = f'{home_url}/{line}' 57 | else: 58 | self.url = f'{base_url}/{line}' 59 | 60 | def set_map_url(self, home_url: str, base_url: str, line: str): 61 | map_uri = re.match('#EXT-X-MAP:URI="(.*?)"', line.strip()) 62 | if map_uri is None: 63 | print('find #EXT-X-MAP tag, however has no uri') 64 | return 65 | map_uri = map_uri.group(1) 66 | if map_uri.startswith('http://') or map_uri.startswith('https://') or map_uri.startswith('ftp://'): 67 | self.url = map_uri 68 | elif map_uri.startswith('/'): 69 | self.url = f'{home_url}{map_uri}' 70 | else: 71 | self.url = f'{base_url}/{map_uri}' 72 | self.segment_type = 'map' 73 | # 每一条流理应只有一个map 74 | self.name = 'map.mp4' 75 | self.index = -1 76 | 77 | def set_key(self, home_url: str, base_url: str, line: str): 78 | self.has_set_key = True 79 | xkey = XKey().set_attrs_from_line(home_url, base_url, line) 80 | if xkey is not None: 81 | self.xkey = xkey 82 | 83 | def get_xkey(self): 84 | return self.xkey 85 | 86 | def set_xkey(self, last_segment_has_xkey: bool, xkey: XKey): 87 | ''' 88 | 如果已经因为#EXT-X-KEY而设置过xkey了 89 | 那就不使用之前分段的xkey了 90 | ''' 91 | if last_segment_has_xkey is False: 92 | return 93 | if self.has_set_key is True: 94 | return 95 | if xkey is None: 96 | return 97 | self.xkey = xkey 98 | -------------------------------------------------------------------------------- /tools/XstreamDL_CLI/extractors/metaitem.py: -------------------------------------------------------------------------------- 1 | class MetaItem: 2 | def generate(self): 3 | pass 4 | 5 | def to_int(self): 6 | pass 7 | 8 | def match_duration(self, _duration: str) -> float: 9 | ''' 10 | test samples 11 | - PT0S 12 | - PT50M0S 13 | - PT1H54.600S 14 | - PT23M59.972S 15 | - P8DT11H6M41.1367016S 16 | - P0Y0M0DT0H3M30.000S 17 | ''' 18 | if isinstance(_duration, str) is False: 19 | return 20 | 21 | def reset_token(): 22 | nonlocal token_unit, token_time 23 | token_unit, token_time = '', '' 24 | offset = 0 25 | duration = 0.0 26 | token_unit = '' 27 | token_time = '' 28 | t_flag = False 29 | while offset < len(_duration): 30 | if _duration[offset].isalpha(): 31 | token_unit += _duration[offset] 32 | elif _duration[offset].isdigit() or _duration[offset] == '.': 33 | token_time += _duration[offset] 34 | else: 35 | assert False, f'not possible be here _duration => {_duration}' 36 | offset += 1 37 | if token_unit == 'P': 38 | reset_token() 39 | elif token_unit == 'Y' or (t_flag is False and token_unit == 'M'): 40 | # 暂时先不计算年和月 有问题再说 41 | reset_token() 42 | elif token_unit == 'D': 43 | duration += 24 * int(token_time) * 60 * 60 44 | reset_token() 45 | elif token_unit == 'T': 46 | t_flag = True 47 | reset_token() 48 | elif token_unit == 'H': 49 | duration += int(token_time) * 60 * 60 50 | reset_token() 51 | elif token_unit == 'M': 52 | duration += int(token_time) * 60 53 | reset_token() 54 | elif token_unit == 'S': 55 | duration += float("0" + token_time) 56 | reset_token() 57 | return duration -------------------------------------------------------------------------------- /tools/XstreamDL_CLI/extractors/mss/box_util.py: -------------------------------------------------------------------------------- 1 | import io 2 | import struct 3 | 4 | u8 = struct.Struct('>B') 5 | u88 = struct.Struct('>Bx') 6 | u16 = struct.Struct('>H') 7 | u1616 = struct.Struct('>Hxx') 8 | u32 = struct.Struct('>I') 9 | u64 = struct.Struct('>Q') 10 | 11 | s88 = struct.Struct('>bx') 12 | s16 = struct.Struct('>h') 13 | s1616 = struct.Struct('>hxx') 14 | s32 = struct.Struct('>i') 15 | unity_matrix = (s32.pack(0x10000) + s32.pack(0) * 3) * 2 + s32.pack(0x40000000) 16 | 17 | 18 | def box(box_type, payload): 19 | return u32.pack(8 + len(payload)) + box_type + payload 20 | 21 | 22 | def full_box(box_type, version, flags, payload): 23 | return box(box_type, u8.pack(version) + u32.pack(flags)[1:] + payload) 24 | 25 | 26 | def extract_box_data(data: bytes, box_sequence: list): 27 | data_reader = io.BytesIO(data) 28 | while True: 29 | box_size = u32.unpack(data_reader.read(4))[0] 30 | box_type = data_reader.read(4) 31 | if box_type == box_sequence[0]: 32 | box_data = data_reader.read(box_size - 8) 33 | if len(box_sequence) == 1: 34 | return box_data 35 | return extract_box_data(box_data, box_sequence[1:]) 36 | data_reader.seek(box_size - 8, 1) -------------------------------------------------------------------------------- /tools/XstreamDL_CLI/extractors/mss/childs/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wayneclub/Subtitle-Downloader/00804b4e017c1c8f8adc7e914514d73e774816b9/tools/XstreamDL_CLI/extractors/mss/childs/__init__.py -------------------------------------------------------------------------------- /tools/XstreamDL_CLI/extractors/mss/childs/c.py: -------------------------------------------------------------------------------- 1 | from ..ismitem import ISMItem 2 | 3 | 4 | class c(ISMItem): 5 | def __init__(self, name: str): 6 | super(c, self).__init__(name) 7 | self.t = None # type: int 8 | self.d = None # type: int 9 | self.r = 1 # type: int 10 | 11 | def generate(self): 12 | if self.t is not None: 13 | self.to_int('t') 14 | self.to_int('d') 15 | self.to_int('r') -------------------------------------------------------------------------------- /tools/XstreamDL_CLI/extractors/mss/childs/protection.py: -------------------------------------------------------------------------------- 1 | from ..ismitem import ISMItem 2 | 3 | 4 | class Protection(ISMItem): 5 | def __init__(self, name: str): 6 | super(Protection, self).__init__(name) -------------------------------------------------------------------------------- /tools/XstreamDL_CLI/extractors/mss/childs/protectionheader.py: -------------------------------------------------------------------------------- 1 | import re 2 | import base64 3 | from ..ismitem import ISMItem 4 | 5 | 6 | class ProtectionHeader(ISMItem): 7 | def __init__(self, name: str): 8 | super(ProtectionHeader, self).__init__(name) 9 | self.SystemID = None # type: str 10 | self.kid = bytes([0] * 16) # type: bytes 11 | 12 | def generate(self): 13 | ''' 14 | get kid from innertext 15 | ''' 16 | # dAIAAAEAAQBqAjwAVwBSAE0ASABFAEEARABFAFIAIAB4AG0AbABuAHMAPQAiAGgAdAB0AHAAOgAvAC8AcwBjAGgAZQBtAGEAcwAuAG0AaQBjAHIAbwBzAG8AZgB0AC4AYwBvAG0ALwBEAFIATQAvADIAMAAwADcALwAwADMALwBQAGwAYQB5AFIAZQBhAGQAeQBIAGUAYQBkAGUAcgAiACAAdgBlAHIAcwBpAG8AbgA9ACIANAAuADAALgAwAC4AMAAiAD4APABEAEEAVABBAD4APABQAFIATwBUAEUAQwBUAEkATgBGAE8APgA8AEsARQBZAEwARQBOAD4AMQA2ADwALwBLAEUAWQBMAEUATgA+ADwAQQBMAEcASQBEAD4AQQBFAFMAQwBUAFIAPAAvAEEATABHAEkARAA+ADwALwBQAFIATwBUAEUAQwBUAEkATgBGAE8APgA8AEsASQBEAD4ATwBXAGoAaAB0AHIAMwB1ADkAawArAHIAZABvADEASQBMAFkAMAByAGEAZwA9AD0APAAvAEsASQBEAD4APABDAEgARQBDAEsAUwBVAE0APgBOADgAVABvAEsASABKADEAZABKAGMAPQA8AC8AQwBIAEUAQwBLAFMAVQBNAD4APABMAEEAXwBVAFIATAA+AGgAdAB0AHAAcwA6AC8ALwBhAHAAaQAuAGIAbABpAG0ALgBjAG8AbQAvAGwAaQBjAGUAbgBzAGUALwBwAGwAYQB5AHIAZQBhAGQAeQA8AC8ATABBAF8AVQBSAEwAPgA8AC8ARABBAFQAQQA+ADwALwBXAFIATQBIAEUAQQBEAEUAUgA+AA== 17 | try: 18 | data = base64.b64decode(self.innertext).replace(b'\x00', b'') 19 | b64_kid = re.findall(b'(.+?)', data)[0].decode('utf-8') 20 | _kid = base64.b64decode(b64_kid) 21 | self.kid = bytes([_kid[3], _kid[2], _kid[1], _kid[0], _kid[5], _kid[4], _kid[7], _kid[6], *list(_kid[8:])]) 22 | except Exception as e: 23 | print(f'ProtectionHeader generate failed, reason:{e}') -------------------------------------------------------------------------------- /tools/XstreamDL_CLI/extractors/mss/childs/qualitylevel.py: -------------------------------------------------------------------------------- 1 | from ..ismitem import ISMItem 2 | 3 | 4 | class QualityLevel(ISMItem): 5 | ''' 6 | https://docs.microsoft.com/en-us/iis/extensions/smooth-streaming-client/qualitylevel-attributes-iis-smooth-streaming 7 | ''' 8 | def __init__(self, name: str): 9 | super(QualityLevel, self).__init__(name) 10 | # <----- 通用 -----> 11 | self.Index = None # type: int 12 | self.Bitrate = None # type: int 13 | self.CodecPrivateData = None # type: str 14 | self.FourCC = None # type: str 15 | self.NALUnitLengthField = None # type: int 16 | # <----- 音频 -----> 17 | self.SamplingRate = None # type: int 18 | self.Channels = None # type: int 19 | self.BitsPerSample = None # type: int 20 | self.PacketSize = None # type: int 21 | self.AudioTag = None # type: str 22 | # <----- 视频 -----> 23 | self.MaxWidth = None # type: str 24 | self.MaxHeight = None # type: str 25 | 26 | def generate(self): 27 | self.to_int('Index') 28 | self.to_int('Bitrate') 29 | self.to_int('SamplingRate') 30 | self.to_int('Channels') 31 | self.to_int('BitsPerSample') 32 | self.to_int('PacketSize') -------------------------------------------------------------------------------- /tools/XstreamDL_CLI/extractors/mss/childs/streamindex.py: -------------------------------------------------------------------------------- 1 | from ..ismitem import ISMItem 2 | 3 | 4 | class StreamIndex(ISMItem): 5 | def __init__(self, name: str): 6 | super(StreamIndex, self).__init__(name) 7 | # <----- 通用 -----> 8 | self.Type = None # type: str 9 | self.QualityLevels = None # type: int 10 | self.TimeScale = None # type: int 11 | self.Name = None # type: str 12 | self.Chunks = None # type: int 13 | self.Url = None # type: str 14 | # <----- 视频 -----> 15 | self.MaxWidth = None # type: str 16 | self.MaxHeight = None # type: str 17 | self.DisplayWidth = None # type: str 18 | self.DisplayHeight = None # type: str 19 | # <----- 字幕 & 音频 -----> 20 | self.Language = None # type: str 21 | # <----- 字幕 -----> 22 | self.Subtype = None # type: str 23 | self.Language = None # type: str 24 | 25 | def generate(self): 26 | self.to_int('QualityLevels') 27 | self.to_int('Chunks') 28 | self.to_int('TimeScale') 29 | 30 | def get_media_url(self) -> str: 31 | return self.Url -------------------------------------------------------------------------------- /tools/XstreamDL_CLI/extractors/mss/handler.py: -------------------------------------------------------------------------------- 1 | from xml.parsers.expat import ParserCreate 2 | from .ism import ISM 3 | from .childs.c import c 4 | from .childs.protection import Protection 5 | from .childs.protectionheader import ProtectionHeader 6 | from .childs.qualitylevel import QualityLevel 7 | from .childs.streamindex import StreamIndex 8 | 9 | 10 | def xml_handler(content: str): 11 | def handle_start_element(tag, attrs): 12 | nonlocal ism 13 | nonlocal ism_handlers 14 | if ism is None: 15 | if tag != 'SmoothStreamingMedia': 16 | raise Exception('the first tag is not SmoothStreamingMedia!') 17 | ism = ISM(tag) 18 | ism.addattrs(attrs) 19 | ism.generate() 20 | stack.append(ism) 21 | else: 22 | if ism_handlers.get(tag) is None: 23 | return 24 | child = ism_handlers[tag](tag) 25 | child.addattrs(attrs) 26 | if tag != 'ProtectionHeader': 27 | child.generate() 28 | ism.childs.append(child) 29 | ism = child 30 | stack.append(child) 31 | 32 | def handle_end_element(tag): 33 | nonlocal ism 34 | nonlocal ism_handlers 35 | if ism_handlers.get(tag) is None: 36 | return 37 | if tag == 'ProtectionHeader': 38 | ism.generate() 39 | if len(stack) > 1: 40 | _ = stack.pop(-1) 41 | ism = stack[-1] 42 | 43 | def handle_character_data(texts: str): 44 | if texts.strip() != '': 45 | ism.innertext += texts.strip() 46 | stack = [] 47 | ism = None # type: ISM 48 | ism_handlers = { 49 | 'SmoothStreamingMedia': ISM, 50 | 'StreamIndex': StreamIndex, 51 | 'QualityLevel': QualityLevel, 52 | 'c': c, 53 | 'Protection': Protection, 54 | 'ProtectionHeader': ProtectionHeader, 55 | } 56 | parser = ParserCreate() 57 | parser.StartElementHandler = handle_start_element 58 | parser.EndElementHandler = handle_end_element 59 | parser.CharacterDataHandler = handle_character_data 60 | parser.Parse(content) 61 | return ism -------------------------------------------------------------------------------- /tools/XstreamDL_CLI/extractors/mss/ism.py: -------------------------------------------------------------------------------- 1 | from .ismitem import ISMItem 2 | 3 | 4 | class ISM(ISMItem): 5 | def __init__(self, name: str): 6 | super(ISM, self).__init__(name) 7 | self.MajorVersion = None # type: str 8 | self.MinorVersion = None # type: str 9 | self.TimeScale = None # type: int 10 | self.Duration = None # type: int 11 | 12 | def generate(self): 13 | self.to_int('TimeScale') 14 | self.to_int('Duration') -------------------------------------------------------------------------------- /tools/XstreamDL_CLI/extractors/mss/ismitem.py: -------------------------------------------------------------------------------- 1 | from tools.XstreamDL_CLI.extractors.metaitem import MetaItem 2 | 3 | 4 | class ISMItem(MetaItem): 5 | def __init__(self, name: str = "ISMItem"): 6 | self.name = name 7 | self.innertext = '' 8 | self.childs = [] 9 | 10 | def addattr(self, name: str, value): 11 | self.__setattr__(name, value) 12 | 13 | def addattrs(self, attrs: dict): 14 | for attr_name, attr_value in attrs.items(): 15 | attr_name: str 16 | attr_name = attr_name.replace(":", "_") 17 | self.addattr(attr_name, attr_value) 18 | 19 | def find(self, name: str): 20 | return [child for child in self.childs if child.name == name] 21 | 22 | def to_int(self, attr_name: str): 23 | value = self.__getattribute__(attr_name) # type: str 24 | if isinstance(value, str) and value.isdigit(): 25 | self.__setattr__(attr_name, int(value)) 26 | -------------------------------------------------------------------------------- /tools/XstreamDL_CLI/extractors/mss/key.py: -------------------------------------------------------------------------------- 1 | from tools.XstreamDL_CLI.models.key import StreamKey 2 | from .childs.protectionheader import ProtectionHeader 3 | 4 | 5 | class MSSKey(StreamKey): 6 | def __init__(self, ph: ProtectionHeader): 7 | super(MSSKey, self).__init__() 8 | self.systemid = ph.SystemID 9 | self.key = ph.innertext 10 | -------------------------------------------------------------------------------- /tools/XstreamDL_CLI/extractors/mss/segment.py: -------------------------------------------------------------------------------- 1 | from tools.XstreamDL_CLI.models.segment import Segment 2 | 3 | 4 | class MSSSegment(Segment): 5 | def __init__(self): 6 | super(MSSSegment, self).__init__() 7 | self.suffix = '.mp4' 8 | self.has_protection = False 9 | 10 | def set_protection_flag(self, flag: bool): 11 | self.has_protection = flag 12 | 13 | def is_encrypt(self): 14 | return self.has_protection 15 | 16 | def is_supported_encryption(self): 17 | return False 18 | 19 | def is_ism(self): 20 | return True 21 | 22 | def set_duration(self, duration: float): 23 | self.duration = duration 24 | 25 | def set_subtitle_url(self, subtitle_url: str): 26 | self.name = subtitle_url.split('?')[0].split('/')[-1] 27 | self.index = -1 28 | self.url = subtitle_url 29 | self.segment_type = 'init' 30 | 31 | def set_init_url(self, init_url: str): 32 | parts = init_url.split('?')[0].split('/')[-1].split('.') 33 | if len(parts) > 1: 34 | self.suffix = f'.{parts[-1]}' 35 | self.name = f'init{self.suffix}' 36 | self.index = -1 37 | self.url = init_url 38 | self.segment_type = 'init' 39 | 40 | def set_media_url(self, media_url: str): 41 | parts = media_url.split('?')[0].split('/')[-1].split('.') 42 | if len(parts) > 1: 43 | # 修正后缀 44 | self.suffix = f'.{parts[-1]}' 45 | self.name = f'{self.index:0>4}.{parts[-1]}' 46 | self.url = media_url 47 | -------------------------------------------------------------------------------- /tools/XstreamDL_CLI/headers/default.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import json 3 | from pathlib import Path 4 | from tools.XstreamDL_CLI.cmdargs import CmdArgs 5 | from tools.XstreamDL_CLI.log import setup_logger 6 | 7 | logger = setup_logger('XstreamDL', level='INFO') 8 | 9 | 10 | class Headers: 11 | def __init__(self): 12 | self.headers = {} 13 | 14 | def get(self, args: CmdArgs) -> dict: 15 | if getattr(sys, 'frozen', False): 16 | config_path = Path(sys.executable).parent / args.headers 17 | else: 18 | config_path = Path(__file__).parent.parent.parent / args.headers 19 | if config_path.exists() is False: 20 | logger.warning( 21 | f'{config_path.stem} is not exists, put your config file to {config_path.parent.parent.resolve().as_posix()}') 22 | return 23 | try: 24 | self.headers = json.loads(config_path.read_text(encoding='utf-8')) 25 | except Exception as e: 26 | logger.error( 27 | f'try to load {config_path.resolve().as_posix()} failed', exc_info=e) 28 | logger.debug( 29 | f'use headers:\n{json.dumps(self.headers, ensure_ascii=False, indent=4)}') 30 | return self.headers 31 | -------------------------------------------------------------------------------- /tools/XstreamDL_CLI/log.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import logging 4 | import datetime 5 | from pathlib import Path 6 | 7 | 8 | GLOBAL_LOGGERS = {} 9 | 10 | 11 | def tell_me_path(target_folder_name: str) -> Path: 12 | ''' 13 | 兼容自用脚本版与免安装可执行版本 14 | :param target_folder_name: 目录文件夹路径 15 | :returns 返回request_config路径 16 | ''' 17 | # if getattr(sys, 'frozen', False): 18 | # return Path(sys.executable).parent / target_folder_name 19 | # else: 20 | # return Path(__file__).parent.parent / target_folder_name 21 | return Path(__file__).parent.parent.parent / target_folder_name 22 | 23 | 24 | class PackagePathFilter(logging.Filter): 25 | ''' 26 | 获取文件相对路径而不只是文件名 27 | 配合行号可以快速定位 28 | 参见 How can I include the relative path to a module in a Python logging statement? 29 | - https://stackoverflow.com/questions/52582458 30 | ''' 31 | 32 | def filter(self, record): 33 | record.relativepath = None 34 | abs_sys_paths = map(os.path.abspath, sys.path) 35 | for path in sorted(abs_sys_paths, key=len, reverse=True): 36 | if not path.endswith(os.sep): 37 | path += os.sep 38 | if getattr(sys, 'frozen', False): 39 | record.relativepath = record.pathname 40 | break 41 | if record.pathname.startswith(path): 42 | record.relativepath = os.path.relpath(record.pathname, path) 43 | break 44 | return True 45 | 46 | 47 | def setup_logger(name: str, level: str = 'INFO') -> logging.Logger: 48 | ''' 49 | - 终端只输出日志等级大于等于level的日志 默认是INFO 50 | - 全部日志等级的信息都会记录到文件中 51 | ''' 52 | logger = GLOBAL_LOGGERS.get(name) 53 | if logger: 54 | return logger 55 | # 先把 logger 初始化好 56 | logger = logging.getLogger(f'{name}') 57 | GLOBAL_LOGGERS[name] = logger 58 | # 开始设置 59 | # formatter = logging.Formatter( 60 | # '%(asctime)s.%(msecs)03d %(relativepath)s:%(lineno)d %(levelname)s: %(message)s', datefmt='%H:%M:%S') 61 | # formatter = logging.Formatter('%(asctime)s %(process)d %(relativepath)s:%(lineno)d %(levelname)s: %(message)s') 62 | formatter = logging.Formatter('%(message)s') 63 | log_time = datetime.datetime.now().strftime("%Y-%m-%d_%H%M%S") 64 | # 没有打包的时候 __file__ 就是当前文件路径 65 | # 打包之后通过 sys.executable 获取程序路径 66 | log_folder_path = tell_me_path('logs') 67 | if log_folder_path.exists() is False: 68 | log_folder_path.mkdir() 69 | ch = logging.StreamHandler() 70 | ch.addFilter(PackagePathFilter()) 71 | if level.lower() == 'info': 72 | ch.setLevel(logging.INFO) 73 | elif level.lower() == 'warning': 74 | ch.setLevel(logging.WARNING) 75 | elif level.lower() == 'error': 76 | ch.setLevel(logging.ERROR) 77 | else: 78 | ch.setLevel(logging.DEBUG) 79 | 80 | ch.setFormatter(formatter) 81 | # logger.setLevel(logging.DEBUG) 82 | 83 | # if level.lower() == 'info': 84 | # ch.setFormatter(logging.Formatter('%(message)s')) 85 | # else: 86 | # ch.setFormatter(formatter) 87 | 88 | logger.addHandler(ch) 89 | log_file_path = log_folder_path / f'{name}-{log_time}.log' 90 | fh = logging.FileHandler( 91 | log_file_path.resolve().as_posix(), encoding='utf-8', delay=True) 92 | fh.addFilter(PackagePathFilter()) 93 | 94 | # fh.setLevel(logging.DEBUG) 95 | fh.setFormatter(formatter) 96 | 97 | # if level.lower() == 'info': 98 | # fh.setFormatter(logging.Formatter('%(message)s')) 99 | # else: 100 | # fh.setFormatter(formatter) 101 | 102 | logger.addHandler(fh) 103 | return logger 104 | 105 | 106 | def test_log(): 107 | logger = setup_logger("test", level='INFO') 108 | logger.debug('this is DEBUG level') 109 | logger.info('this is INFO level') 110 | logger.warning('this is WARNING level') 111 | logger.error('this is ERROR level') 112 | 113 | 114 | # test_log() 115 | -------------------------------------------------------------------------------- /tools/XstreamDL_CLI/models/base.py: -------------------------------------------------------------------------------- 1 | class BaseUri: 2 | def __init__(self, name: str = '', home_url: str = '', base_url: str = ''): 3 | self.name = name 4 | self.home_url = home_url 5 | self.base_url = base_url 6 | 7 | def new_name(self, name: str): 8 | return BaseUri(name, self.home_url, self.base_url) 9 | 10 | def new_home_url(self, home_url: str): 11 | return BaseUri(self.name, home_url, self.base_url) 12 | 13 | def new_base_url(self, base_url: str): 14 | return BaseUri(self.name, self.home_url, base_url) -------------------------------------------------------------------------------- /tools/XstreamDL_CLI/models/key.py: -------------------------------------------------------------------------------- 1 | class StreamKey: 2 | ''' 3 | 一条流的加密信息 含有下列属性 4 | - 加密方法 5 | - key的uri 6 | - key内容 7 | - keyid 8 | - 偏移量 9 | - 其他属性 -> 根据流类型而定 10 | 含有下面的方法 11 | - 设置key内容 12 | - 设置iv 13 | - 保存为文本 14 | - 从文本加载 15 | ''' 16 | def __init__(self): 17 | self.method = 'AES-128' # type: str 18 | self.uri = None # type: str 19 | self.key = b'' # type: bytes 20 | self.keyid = None # type: str 21 | self.iv = '0' * 32 # type: str 22 | 23 | def set_key(self, key: bytes): 24 | self.key = key 25 | return self 26 | 27 | def set_iv(self, iv: str): 28 | if iv is None: 29 | return 30 | self.iv = iv 31 | return self 32 | 33 | def dump(self): 34 | pass 35 | 36 | def load(self): 37 | pass -------------------------------------------------------------------------------- /tools/XstreamDL_CLI/models/segment.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | from pathlib import Path 3 | 4 | 5 | class Segment: 6 | ''' 7 | 每一个分段应当具有以下基本属性: 8 | - 名称 9 | - 索引 10 | - 后缀 11 | - 链接 12 | - 文件大小 13 | - 时长 14 | - 二进制内容 15 | - 下载文件夹 16 | - 分段类型 17 | ''' 18 | def __init__(self): 19 | self.name = '' 20 | self.index = 0 21 | self.suffix = '.ts' 22 | self.url = '' 23 | self.filesize = 0 24 | self.duration = 0.0 25 | # dash直播流需要通过比较时间来确定是不是需要下载 26 | self.fmt_time = 0 27 | self.byterange = [] # type: list 28 | # <---临时存放二进制内容---> 29 | self.content = [] # type: List[bytes] 30 | # <---分段临时下载文件夹---> 31 | self.folder = None # type: Path 32 | # <---分段类型---> 33 | self.segment_type = 'normal' 34 | self.skip_concat = False 35 | # 直播流 单个分段 最大404次数 36 | self.max_retry_404 = 5 37 | 38 | def is_ism(self) -> bool: 39 | ''' 请重写 ''' 40 | return False 41 | 42 | def is_encrypt(self) -> bool: 43 | ''' 请重写 ''' 44 | pass 45 | 46 | def is_supported_encryption(self) -> bool: 47 | ''' 请重写 ''' 48 | pass 49 | 50 | def add_offset_for_name(self, offset: int, has_init: bool = False, name_from_url: bool = False): 51 | self.index += offset 52 | if has_init: 53 | self.index -= 1 54 | if name_from_url is False: 55 | self.name = f'{self.index:0>4}{self.suffix}' 56 | 57 | def set_offset_for_name(self, offset: int, has_init: bool = False, name_from_url: bool = False): 58 | self.index = offset 59 | if has_init: 60 | self.index -= 1 61 | if name_from_url is False: 62 | self.name = f'{self.index:0>4}{self.suffix}' 63 | 64 | def set_index(self, index: str): 65 | self.index = index 66 | if index == -1: 67 | self.name = f'init{self.suffix}' 68 | else: 69 | self.name = f'{self.index:0>4}{self.suffix}' 70 | return self 71 | 72 | def set_folder(self, folder: Path): 73 | self.folder = folder 74 | return self 75 | 76 | def get_path(self) -> Path: 77 | return self.folder / self.name 78 | 79 | def dump(self) -> bool: 80 | self.get_path().write_bytes(b''.join(self.content)) 81 | self.content = [] 82 | return True -------------------------------------------------------------------------------- /tools/XstreamDL_CLI/util/concat.py: -------------------------------------------------------------------------------- 1 | import os 2 | import platform 3 | from typing import List 4 | from pathlib import Path 5 | from tools.XstreamDL_CLI.cmdargs import CmdArgs 6 | from tools.XstreamDL_CLI.util.texts import t_msg 7 | 8 | ONCE_MAX_FILES = 500 9 | 10 | 11 | class Concat: 12 | 13 | @staticmethod 14 | def call_mp4decrypt(out_path: Path, args: CmdArgs): 15 | assert out_path.exists() is True, f'File not exists ! -> {out_path}' 16 | assert out_path.stat( 17 | ).st_size > 0, f'File concat failed ! -> {out_path}' 18 | out = out_path.absolute().as_posix() 19 | out_decrypted = ( 20 | out_path.parent / f'{out_path.stem}_decrypted{out_path.suffix}').absolute() 21 | if args.overwrite is False and out_decrypted.exists(): 22 | print(t_msg.decrypted_file_exists_skip) 23 | return 24 | if platform.system() == 'Windows': 25 | _cmd = f'""{args.mp4decrypt}" --show-progress --key {args.key} "{out}" "{out_decrypted.as_posix()}""' 26 | else: 27 | _cmd = f'"{args.mp4decrypt}" --show-progress --key {args.key} "{out}" "{out_decrypted.as_posix()}"' 28 | print(t_msg.start_decrypt) 29 | os.system(_cmd) 30 | if args.enable_auto_delete: 31 | if out_decrypted.exists() and out_decrypted.stat().st_size > 0: 32 | os.remove(out) 33 | 34 | @staticmethod 35 | def gen_new_names(names: list, out: str, tmp_suffix: str = '.tmp'): 36 | work_num = len(names) // ONCE_MAX_FILES + 1 37 | counts = len(names) // work_num 38 | new_names = [] 39 | _tmp_outs = [] 40 | for multi_index in range(work_num): 41 | if multi_index < work_num - 1: 42 | _names = names[multi_index * counts:(multi_index + 1) * counts] 43 | else: 44 | _names = names[multi_index * counts:] 45 | _tmp_outs.append(f'out{multi_index}{tmp_suffix}') 46 | new_names.append([_names, f'out{multi_index}{tmp_suffix}']) 47 | new_names.append([_tmp_outs, out]) 48 | return new_names, _tmp_outs 49 | 50 | @staticmethod 51 | def gen_cmds_outs(out_path: Path, names: list, args: CmdArgs) -> List[str]: 52 | out = out_path.absolute().as_posix() 53 | cmds = [] # type: List[str] 54 | if args.raw_concat is False: 55 | if len(names) > ONCE_MAX_FILES: 56 | new_names, _tmp_outs = Concat.gen_new_names( 57 | names, out, tmp_suffix=".ts") 58 | for _names, _out in new_names: 59 | if platform.system() == 'Windows': 60 | cmds.append( 61 | f'""{args.ffmpeg}" -i concat:"{"|".join(_names)}" -c copy -y "{_out}" > nul"') 62 | else: 63 | cmds.append( 64 | f'"{args.ffmpeg}" -i concat:"{"|".join(_names)}" -c copy -y "{_out}" > nul') 65 | return cmds, _tmp_outs 66 | if platform.system() == 'Windows': 67 | return [f'""{args.ffmpeg}" -i concat:"{"|".join(names)}" -c copy -y "{out}" > nul"'], [] 68 | else: 69 | return [f'"{args.ffmpeg}" -i concat:"{"|".join(names)}" -c copy -y "{out}" > nul'], [] 70 | if len(names) > ONCE_MAX_FILES: 71 | new_names, _tmp_outs = Concat.gen_new_names(names, out) 72 | if platform.system() == 'Windows': 73 | for _names, _out in new_names: 74 | cmds.append(f'copy /b {"+".join(_names)} "{_out}" > nul') 75 | return cmds, _tmp_outs 76 | else: 77 | for _names, _out in new_names: 78 | cmds.append(f'cat {" ".join(_names)} > "{_out}"') 79 | return cmds, _tmp_outs 80 | if platform.system() == 'Windows': 81 | return [f'copy /b {"+".join(names)} "{out}"'], [] 82 | else: 83 | return [f'cat {" ".join(names)} > "{out}"'], [] 84 | -------------------------------------------------------------------------------- /tools/XstreamDL_CLI/util/decryptors/aes.py: -------------------------------------------------------------------------------- 1 | from Crypto.Cipher import AES 2 | from tools.XstreamDL_CLI.models.segment import Segment 3 | 4 | 5 | class CommonAES: 6 | ''' 7 | 这是一个常规的AES-128-CBC解密类 8 | 或许应该将这个类注册为分段的一个属性? 9 | ''' 10 | 11 | def __init__(self, aes_key: bytes, aes_iv: bytes = None): 12 | self.aes_key = aes_key # type: bytes 13 | self.aes_iv = aes_iv # type: bytes 14 | if self.aes_iv is None: 15 | self.aes_iv = bytes([0] * 16) 16 | 17 | def decrypt(self, segment: Segment) -> bool: 18 | ''' 19 | 解密 落盘 20 | ''' 21 | try: 22 | cipher = AES.new(self.aes_key, AES.MODE_CBC, iv=self.aes_iv) 23 | content = cipher.decrypt(b''.join(segment.content)) 24 | segment.content = [] 25 | except Exception as e: 26 | print(f'decrypt {segment.name} error -> {e}') 27 | return False 28 | else: 29 | segment.get_path().write_bytes(content) 30 | return True 31 | -------------------------------------------------------------------------------- /tools/XstreamDL_CLI/util/maps/codecs.py: -------------------------------------------------------------------------------- 1 | AUDIO_CODECS = { 2 | "1": "PCM", 3 | "mp3": "MP3", 4 | "mp4a.66": "MPEG2_AAC", 5 | "mp4a.67": "MPEG2_AAC", 6 | "mp4a.68": "MPEG2_AAC", 7 | "mp4a.69": "MP3", 8 | "mp4a.6B": "MP3", 9 | "mp4a.40.2": "MPEG4_AAC", 10 | "mp4a.40.02": "MPEG4_AAC", 11 | "mp4a.40.5": "MPEG4_AAC", 12 | "mp4a.40.05": "MPEG4_AAC", 13 | "mp4a.40.29": "MPEG4_AAC", 14 | "mp4a.40.42": "MPEG4_XHE_AAC", 15 | "ac-3": "AC3", 16 | "mp4a.a5": "AC3", 17 | "mp4a.A5": "AC3", 18 | "ec-3": "EAC3", 19 | "mp4a.a6": "EAC3", 20 | "mp4a.A6": "EAC3", 21 | "vorbis": "VORBIS", 22 | "opus": "OPUS", 23 | "flac": "FLAC", 24 | "vp8": "VP8", 25 | "vp8.0": "VP8", 26 | "theora": "THEORA", 27 | } -------------------------------------------------------------------------------- /tools/XstreamDL_CLI/util/texts.py: -------------------------------------------------------------------------------- 1 | import locale 2 | 3 | 4 | class Texts: 5 | def __init__(self): 6 | if locale.getdefaultlocale()[0] == 'zh_CN': 7 | self.setup_zh_CN() 8 | else: 9 | self.setup_en_US() 10 | 11 | def setup_zh_CN(self): 12 | self.input_stream_number = '请输入要下载流的序号:\n' 13 | self.select_without_any_stream = '未选择流,退出' 14 | self.download_start = '下载开始' 15 | self.segment_cannot_download = '无法下载的m3u8 取消全部下载任务\n' 16 | self.segment_cannot_download_unknown_status = '出现未知status 取消全部下载任务\n' 17 | self.segment_cannot_download_unknown_exc = '出现未知异常 强制退出全部task' 18 | self.cannot_get_stream_metadata = '无法获取视频流信息' 19 | self.decrypted_file_exists_skip = '解密文件已存在 跳过' 20 | self.start_decrypt = '开始解密' 21 | self.total_segments_info_1 = '共计' 22 | self.total_segments_info_2 = '个分段' 23 | self.try_to_concat = '尝试合并' 24 | self.cancel_concat_reason_1 = '但是已经存在合并文件' 25 | self.cancel_concat_reason_2 = '但是未下载完成' 26 | self.force_use_raw_concat_for_sample_aes = '发现SAMPLE-AES 将使用二进制合并' 27 | 28 | def setup_en_US(self): 29 | self.input_stream_number = 'input stream number(s):\n' 30 | self.select_without_any_stream = 'haven\'t select any stream, exiting' 31 | self.download_start = 'download start' 32 | self.segment_cannot_download = 'can not download segment, cancel all downloading task\n' 33 | self.segment_cannot_download_unknown_status = 'appear unknown status, cancel all downloading task\n' 34 | self.segment_cannot_download_unknown_exc = 'appear unknown status, force to cancel all downloading task' 35 | self.cannot_get_stream_metadata = 'cannot get stream metadata' 36 | self.decrypted_file_exists_skip = 'decrypted file exists, skip it' 37 | self.start_decrypt = 'start decrypt' 38 | self.total_segments_info_1 = 'total' 39 | self.total_segments_info_2 = ' segments' 40 | self.try_to_concat = 'try to concat' 41 | self.cancel_concat_reason_1 = 'but file already exists' 42 | self.cancel_concat_reason_2 = 'buf download not completely' 43 | self.force_use_raw_concat_for_sample_aes = 'force use --raw-concat for SAMPLE-AES(-CTR)' 44 | 45 | 46 | t_msg = Texts() 47 | 48 | 49 | # if __name__ == '__main__': 50 | # t_msg = Texts() 51 | # print(t_msg.try_to_concat) -------------------------------------------------------------------------------- /tools/XstreamDL_CLI/version.py: -------------------------------------------------------------------------------- 1 | script_name = 'XstreamDL-CLI' 2 | __version__ = '1.4.3' -------------------------------------------------------------------------------- /tools/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wayneclub/Subtitle-Downloader/00804b4e017c1c8f8adc7e914514d73e774816b9/tools/__init__.py -------------------------------------------------------------------------------- /tools/pyshaka/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wayneclub/Subtitle-Downloader/00804b4e017c1c8f8adc7e914514d73e774816b9/tools/pyshaka/__init__.py -------------------------------------------------------------------------------- /tools/pyshaka/log.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import logging 3 | import datetime 4 | from pathlib import Path 5 | 6 | 7 | def setup_logger(name: str, write_to_file: bool = False) -> logging.Logger: 8 | formatter = logging.Formatter('%(message)s') 9 | log_time = datetime.datetime.now().strftime("%Y-%m-%d_%H%M%S") 10 | app_path = Path(__file__).parent.parent.parent 11 | log_folder_path = app_path / 'logs' 12 | if log_folder_path.exists() is False: 13 | log_folder_path.mkdir() 14 | 15 | ch = logging.StreamHandler() 16 | ch.setLevel(logging.INFO) 17 | ch.setFormatter(formatter) 18 | lt = logging.getLogger(f'{name}') 19 | lt.setLevel(logging.INFO) 20 | lt.addHandler(ch) 21 | if write_to_file: 22 | log_file_path = log_folder_path / f'{name}-{log_time}.log' 23 | fh = logging.FileHandler( 24 | log_file_path.resolve().as_posix(), encoding='utf-8') 25 | fh.setLevel(logging.DEBUG) 26 | fh.setFormatter(formatter) 27 | lt.addHandler(fh) 28 | lt.info(f'log file -> {log_file_path}') 29 | return lt 30 | 31 | 32 | log = setup_logger('pyshaka') 33 | -------------------------------------------------------------------------------- /tools/pyshaka/text/Cue.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class positionAlign(Enum): 5 | LEFT = 'line-left' 6 | RIGHT = 'line-right' 7 | CENTER = 'center' 8 | AUTO = 'auto' 9 | 10 | 11 | class textAlign(Enum): 12 | LEFT = 'left' 13 | RIGHT = 'right' 14 | CENTER = 'center' 15 | START = 'start' 16 | END = 'end' 17 | 18 | 19 | class displayAlign(Enum): 20 | BEFORE = 'before' 21 | CENTER = 'center' 22 | AFTER = 'after' 23 | 24 | 25 | class direction(Enum): 26 | HORIZONTAL_LEFT_TO_RIGHT = 'ltr' 27 | HORIZONTAL_RIGHT_TO_LEFT = 'rtl' 28 | 29 | 30 | class writingMode(Enum): 31 | HORIZONTAL_TOP_TO_BOTTOM = 'horizontal-tb' 32 | VERTICAL_LEFT_TO_RIGHT = 'vertical-lr' 33 | VERTICAL_RIGHT_TO_LEFT = 'vertical-rl' 34 | 35 | 36 | class lineInterpretation(Enum): 37 | LINE_NUMBER = 0 38 | PERCENTAGE = 1 39 | 40 | 41 | class lineAlign(Enum): 42 | CENTER = 'center' 43 | START = 'start' 44 | END = 'end' 45 | 46 | 47 | class defaultTextColor(Enum): 48 | white = '#FFF' 49 | lime = '#0F0' 50 | cyan = '#0FF' 51 | red = '#F00' 52 | yellow = '#FF0' 53 | magenta = '#F0F' 54 | blue = '#00F' 55 | black = '#000' 56 | 57 | 58 | class defaultTextBackgroundColor(Enum): 59 | bg_white = '#FFF' 60 | bg_lime = '#0F0' 61 | bg_cyan = '#0FF' 62 | bg_red = '#F00' 63 | bg_yellow = '#FF0' 64 | bg_magenta = '#F0F' 65 | bg_blue = '#00F' 66 | bg_black = '#000' 67 | 68 | 69 | class fontWeight(Enum): 70 | NORMAL = 400 71 | BOLD = 700 72 | 73 | 74 | class fontStyle(Enum): 75 | NORMAL = 'normal' 76 | ITALIC = 'italic' 77 | OBLIQUE = 'oblique' 78 | 79 | 80 | class textDecoration(Enum): 81 | UNDERLINE = 'underline' 82 | LINE_THROUGH = 'lineThrough' 83 | OVERLINE = 'overline' 84 | 85 | 86 | class Cue: 87 | 88 | def __init__(self, startTime: float, endTime: float, payload: str, _settings: str = ''): 89 | self.startTime = startTime 90 | self.direction = direction.HORIZONTAL_LEFT_TO_RIGHT 91 | self.endTime = endTime 92 | self.payload = payload 93 | self.region = CueRegion() 94 | self.position = None 95 | self.positionAlign = positionAlign.AUTO 96 | self.size = 0 97 | self.textAlign = textAlign.CENTER 98 | self.writingMode = writingMode.HORIZONTAL_TOP_TO_BOTTOM 99 | self.lineInterpretation = lineInterpretation.LINE_NUMBER 100 | self.line = None 101 | self.lineHeight = '' 102 | self.lineAlign = lineAlign.START 103 | self.displayAlign = displayAlign.AFTER 104 | self.color = '' 105 | self.backgroundColor = '' 106 | self.backgroundImage = '' 107 | self.border = '' 108 | self.fontSize = '' 109 | self.fontWeight = fontWeight.NORMAL 110 | self.fontStyle = fontStyle.NORMAL 111 | self.fontFamily = '' 112 | self.letterSpacing = '' 113 | self.linePadding = '' 114 | self.opacity = 1 115 | self.textDecoration = [] 116 | self.wrapLine = True 117 | self.id = '' 118 | self.nestedCues = [] 119 | self.lineBreak = False 120 | self.spacer = False 121 | self.cellResolution = {'columns': 32, 'rows': 15} 122 | self._settings = _settings 123 | 124 | @staticmethod 125 | def lineBreak(start: float, end: float) -> 'Cue': 126 | cue = Cue(start, end, '') 127 | cue.lineBreak = True 128 | return cue 129 | 130 | def clone(self): 131 | cue = Cue(0, 0, '') 132 | for k, v in self.__dict__.items(): 133 | if isinstance(v, list): 134 | v = v.copy() 135 | cue.__setattr__(k, v) 136 | return cue 137 | 138 | @staticmethod 139 | def equal(cue1: 'Cue', cue2: 'Cue') -> bool: 140 | if cue1.startTime != cue2.startTime or cue1.endTime != cue2.endTime or cue1.payload != cue2.payload: 141 | return False 142 | for k, v in cue1.__dict__.items(): 143 | if k == 'startTime' or k == 'endTime' or k == 'payload': 144 | pass 145 | elif k == 'nestedCues': 146 | if not Cue.equal(cue1.nestedCues, cue2.nestedCues): 147 | return False 148 | elif k == 'region' or k == 'cellResolution': 149 | for k2 in cue1.__getattribute__(k): 150 | if cue1.__getattribute__(k)[k2] != cue2.__getattribute__(k)[k2]: 151 | return False 152 | elif isinstance(cue1.__getattribute__(k), list): 153 | if cue1.__getattribute__(k) != cue2.__getattribute__(k): 154 | return False 155 | else: 156 | if cue1.__getattribute__(k) != cue1.__getattribute__(k): 157 | return False 158 | return True 159 | 160 | 161 | class units(Enum): 162 | PX = 0 163 | PERCENTAGE = 1 164 | LINES = 2 165 | 166 | 167 | class scrollMode(Enum): 168 | NONE = '' 169 | UP = 'up' 170 | 171 | 172 | class CueRegion: 173 | 174 | def __init__(self, **kwargs): 175 | self.id = '' 176 | self.viewportAnchorX = 0 177 | self.viewportAnchorY = 0 178 | self.regionAnchorX = 0 179 | self.regionAnchorY = 0 180 | self.width = 100 181 | self.height = 100 182 | self.heightUnits = units.PERCENTAGE 183 | self.widthUnits = units.PERCENTAGE 184 | self.viewportAnchorUnits = units.PERCENTAGE 185 | self.scroll = scrollMode.NONE -------------------------------------------------------------------------------- /tools/pyshaka/text/Mp4TtmlParser.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from tools.pyshaka.text.Cue import Cue 4 | from tools.pyshaka.text.TtmlTextParser import TtmlTextParser 5 | from tools.pyshaka.util.Mp4Parser import Mp4Parser, ParsedBox 6 | from tools.pyshaka.util.exceptions import InvalidMp4TTML 7 | from tools.pyshaka.util.TextParser import TimeContext 8 | 9 | 10 | class Mp4TtmlParser: 11 | 12 | def __init__(self): 13 | self.parser_ = TtmlTextParser() 14 | 15 | def set_timescale(self, timescale: int): 16 | pass 17 | 18 | def parseInit(self, data: memoryview): 19 | ''' 20 | 这个函数不调用也没什么问题 21 | ''' 22 | def stpp_callback(box: ParsedBox): 23 | nonlocal sawSTPP 24 | sawSTPP = True 25 | box.parser.stop() 26 | 27 | sawSTPP = False 28 | # 初始化解析器 29 | mp4parser = Mp4Parser() 30 | # 给要准备解析的box添加对应的解析函数 后面回调 31 | mp4parser = mp4parser.box('moov', Mp4Parser.children) 32 | mp4parser = mp4parser.box('trak', Mp4Parser.children) 33 | mp4parser = mp4parser.box('mdia', Mp4Parser.children) 34 | mp4parser = mp4parser.box('minf', Mp4Parser.children) 35 | mp4parser = mp4parser.box('stbl', Mp4Parser.children) 36 | mp4parser = mp4parser.fullBox('stsd', Mp4Parser.sampleDescription) 37 | mp4parser = mp4parser.box('stpp', stpp_callback) 38 | # 解析数据 39 | mp4parser = mp4parser.parse(data) 40 | 41 | if not sawSTPP: 42 | raise InvalidMp4TTML(f'is sawSTPP? {sawSTPP}') 43 | 44 | def parseMedia(self, data: memoryview, time: TimeContext, dont_raise: bool = True) -> List[Cue]: 45 | 46 | def mdat_callback(data: bytes): 47 | nonlocal payload 48 | nonlocal sawMDAT 49 | sawMDAT = True 50 | payload.extend(self.parser_.parseMedia(data, time)) 51 | 52 | sawMDAT = False 53 | payload = [] 54 | 55 | mp4parser = Mp4Parser() 56 | mp4parser = mp4parser.box('mdat', Mp4Parser.allData(mdat_callback)) 57 | mp4parser = mp4parser.parse(data, partialOkay=False) 58 | 59 | if not sawMDAT: 60 | if dont_raise: 61 | return payload 62 | else: 63 | raise InvalidMp4TTML(f'is sawMDAT? {sawMDAT}') 64 | return payload 65 | -------------------------------------------------------------------------------- /tools/pyshaka/text/TextEngine.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wayneclub/Subtitle-Downloader/00804b4e017c1c8f8adc7e914514d73e774816b9/tools/pyshaka/text/TextEngine.py -------------------------------------------------------------------------------- /tools/pyshaka/text/VttTextParser.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import Dict, List, Union 3 | from xml.dom.minidom import parseString, Node, Element, Text 4 | 5 | from tools.pyshaka.text.Cue import Cue, defaultTextColor, fontStyle, fontWeight, textDecoration 6 | from tools.pyshaka.log import log 7 | 8 | 9 | class VttTextParser: 10 | 11 | def __init__(self): 12 | pass 13 | 14 | def parseInit(self, data: bytes): 15 | assert False, 'VTT does not have init segments' 16 | 17 | def parseMedia(self, data: bytes, time: int): 18 | pass 19 | 20 | @staticmethod 21 | def parseCueStyles(payload: str, rootCue: Cue, styles: Dict[str, Cue]): 22 | if len(styles) == 0: 23 | VttTextParser.addDefaultTextColor_(styles) 24 | payload = VttTextParser.replaceColorPayload_(payload) 25 | xmlPayload = '' + payload + '' 26 | elements = parseString(xmlPayload).getElementsByTagName( 27 | 'span') # type: List[Element] 28 | if len(elements) > 0 and elements[0]: 29 | element = elements[0] 30 | cues = [] # type: List[Cue] 31 | childNodes = element.childNodes # type: List[Element] 32 | if len(childNodes) == 1: 33 | childNode = childNodes[0] 34 | if childNode.nodeType == Node.TEXT_NODE or childNode.nodeType == Node.CDATA_SECTION_NODE: 35 | rootCue.payload = payload 36 | return 37 | for childNode in childNodes: 38 | VttTextParser.generateCueFromElement_( 39 | childNode, rootCue, cues, styles) 40 | rootCue.nestedCues = cues 41 | else: 42 | log.warning(f'The cue\'s markup could not be parsed: {payload}') 43 | rootCue.payload = payload 44 | 45 | @staticmethod 46 | def generateCueFromElement_(element: Union[Element, Text], rootCue: Cue, cues: List[Cue], styles: Dict[str, Cue]): 47 | nestedCue = rootCue.clone() 48 | if element.nodeType == Node.ELEMENT_NODE and element.nodeName: 49 | bold = fontWeight.BOLD 50 | italic = fontStyle.ITALIC 51 | underline = textDecoration.UNDERLINE 52 | tags = re.split('[ .]+', element.nodeName) 53 | for tag in tags: 54 | if styles.get(tag): 55 | VttTextParser.mergeStyle_(nestedCue, styles.get(tag)) 56 | if tag == 'b': 57 | nestedCue.fontWeight = bold 58 | elif tag == 'i': 59 | nestedCue.fontStyle = italic 60 | elif tag == 'u': 61 | nestedCue.textDecoration.append(underline) 62 | isTextNode = element.nodeType == Node.TEXT_NODE or element.nodeType == Node.CDATA_SECTION_NODE 63 | if isTextNode: 64 | # element 这里是 Text 类型 js的textContent对应这里的data 65 | textArr = element.data.split('\n') 66 | isFirst = True 67 | for text in textArr: 68 | if not isFirst: 69 | lineBreakCue = rootCue.clone() 70 | lineBreakCue.lineBreak = True 71 | cues.append(lineBreakCue) 72 | if len(text) > 0: 73 | textCue = nestedCue.clone() 74 | textCue.payload = text 75 | cues.append(textCue) 76 | isFirst = False 77 | else: 78 | for childNode in element.childNodes: 79 | VttTextParser.generateCueFromElement_( 80 | childNode, nestedCue, cues, styles) 81 | 82 | @staticmethod 83 | def replaceColorPayload_(payload: str): 84 | ''' 85 | 这里没有找到相关样本测试 可能有bug 86 | ''' 87 | names = [] 88 | nameStart = -1 89 | newPayload = '' 90 | for i in range(len(payload)): 91 | if payload[i] == '/': 92 | if '>' not in payload: 93 | continue 94 | 95 | end = payload.index('>', i) 96 | if end <= i: 97 | return payload 98 | tagEnd = payload[i + 1:end] 99 | tagStart = names.pop(-1) 100 | if not tagEnd or not tagStart: 101 | return payload 102 | elif tagStart == tagEnd: 103 | newPayload += '/' + tagEnd + '>' 104 | i += len(tagEnd) + 1 105 | else: 106 | if not tagStart.startsWith('c.') or tagEnd != 'c': 107 | return payload 108 | newPayload += '/' + tagStart + '>' 109 | i += len(tagEnd) + 1 110 | else: 111 | if payload[i] == '<': 112 | nameStart = i + 1 113 | elif payload[i] == '>': 114 | if nameStart > 0: 115 | names.append(payload[nameStart:i]) 116 | nameStart = -1 117 | newPayload += payload[i] 118 | return newPayload 119 | 120 | @staticmethod 121 | def addDefaultTextColor_(styles: Dict[str, Cue]): 122 | for key, value in defaultTextColor.__members__.items(): 123 | cue = Cue(0, 0, '') 124 | cue.color = value 125 | styles[key] = cue 126 | -------------------------------------------------------------------------------- /tools/pyshaka/util/DataViewReader.py: -------------------------------------------------------------------------------- 1 | import struct 2 | from enum import Enum 3 | 4 | from tools.pyshaka.util.exceptions import OutOfBoundsError 5 | from tools.pyshaka.util.exceptions import IntOverflowError 6 | 7 | 8 | class Endianness(Enum): 9 | BIG_ENDIAN = 0 10 | LITTLE_ENDIAN = 1 11 | 12 | 13 | class DataView: 14 | ''' 15 | shaka/util/buffer_utils.js 16 | ''' 17 | 18 | def __init__(self, data: bytes): 19 | self.buffer = memoryview(bytearray(data)) 20 | # self.buffer = memoryview(bytearray([0x96, 0x87, 0xac])) 21 | self.byteLength = len(self.buffer) # type: int 22 | 23 | def getUint8(self): 24 | pass 25 | 26 | def getUint16(self): 27 | pass 28 | 29 | def getUint32(self, position: int, littleEndian: bool = False): 30 | # 这里记得切片长度要补齐4位 不然unpack会报错 31 | buf = self.buffer[position:position + 4].tobytes() 32 | if len(buf) < 4: 33 | buf = b'\x00' * (4 - len(buf)) + buf 34 | if littleEndian: 35 | return struct.unpack("I", buf)[0] 38 | 39 | def getUint64(self, position: int, littleEndian: bool = False): 40 | # 这里记得切片长度要补齐4位 不然 41 | buf = self.buffer[position:position + 4].tobytes() 42 | if len(buf) < 4: 43 | buf = b'\x00' * (4 - len(buf)) + buf 44 | if littleEndian: 45 | return struct.unpack("I", buf)[0] 48 | 49 | def getInt8(self): 50 | pass 51 | 52 | def getInt16(self): 53 | pass 54 | 55 | def getInt32(self, position: int, littleEndian: bool = False): 56 | buf = self.buffer[position:position + 4].tobytes() 57 | if len(buf) < 4: 58 | buf = b'\x00' * (4 - len(buf)) + buf 59 | if littleEndian: 60 | return struct.unpack("i", buf)[0] 63 | 64 | def getInt64(self): 65 | pass 66 | 67 | def readUint8(self): 68 | pass 69 | 70 | def readUint16(self): 71 | pass 72 | 73 | def readUint32(self): 74 | pass 75 | 76 | def readInt8(self): 77 | pass 78 | 79 | def readInt16(self): 80 | pass 81 | 82 | def readInt32(self): 83 | pass 84 | 85 | def readInt64(self): 86 | pass 87 | 88 | @staticmethod 89 | def toUint8(data: 'DataView', offset: int = 0, length: int = None): 90 | # 由于python中float('inf')表示无穷大 但不能作为索引 91 | # 所以这里直接将最大长度视为byteLength 92 | if length is None: 93 | length = data.byteLength 94 | return data.buffer[offset:offset + length].tobytes() 95 | 96 | 97 | class DataViewReader(DataView): 98 | ''' 99 | shaka/util/data_view_reader.js 100 | ''' 101 | 102 | def __init__(self, data: bytes, endianness: Endianness): 103 | self.dataView_ = DataView(data) # type: DataView 104 | self.littleEndian_ = endianness == Endianness.LITTLE_ENDIAN # type: bool 105 | self.position_ = 0 # type: int 106 | 107 | def getDataView(self) -> DataView: 108 | return self.dataView_ 109 | 110 | def hasMoreData(self) -> bool: 111 | return self.position_ < self.dataView_.byteLength 112 | 113 | def getPosition(self) -> int: 114 | return self.position_ 115 | 116 | def getLength(self) -> int: 117 | return self.dataView_.byteLength 118 | 119 | def readUint8(self): 120 | pass 121 | 122 | def readUint16(self): 123 | pass 124 | 125 | def readUint32(self) -> int: 126 | value = self.dataView_.getUint32(self.position_, self.littleEndian_) 127 | self.position_ += 4 128 | return value 129 | 130 | def readInt32(self): 131 | value = self.dataView_.getInt32(self.position_, self.littleEndian_) 132 | self.position_ += 4 133 | return value 134 | 135 | def readUint64(self) -> int: 136 | if self.littleEndian_: 137 | low = self.dataView_.getUint32(self.position_, True) 138 | high = self.dataView_.getUint32(self.position_ + 4, True) 139 | else: 140 | high = self.dataView_.getUint32(self.position_, False) 141 | low = self.dataView_.getUint32(self.position_ + 4, False) 142 | 143 | if high > 0x1FFFFF: 144 | raise IntOverflowError 145 | 146 | self.position_ += 8 147 | return (high * (2 ** 32)) + low 148 | 149 | def readBytes(self, length: int): 150 | assert length >= 0, 'Bad call to DataViewReader.readBytes' 151 | if self.position_ + length > self.dataView_.byteLength: 152 | raise OutOfBoundsError 153 | data = DataView.toUint8(self.dataView_, self.position_, length) 154 | self.position_ += length 155 | return data 156 | 157 | def skip(self, length: int): 158 | assert length >= 0, 'Bad call to DataViewReader.skip' 159 | if self.position_ + length > self.dataView_.byteLength: 160 | raise OutOfBoundsError 161 | self.position_ += length 162 | 163 | def rewind(self, length: int): 164 | pass 165 | 166 | def seek(self, position: int): 167 | pass 168 | 169 | def readTerminatedString(self): 170 | pass 171 | 172 | def outOfBounds_(self): 173 | pass 174 | -------------------------------------------------------------------------------- /tools/pyshaka/util/Functional.py: -------------------------------------------------------------------------------- 1 | class Functional: 2 | 3 | @staticmethod 4 | def isNotNull(value) -> bool: 5 | return value is not None -------------------------------------------------------------------------------- /tools/pyshaka/util/Mp4BoxParsers.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | from tools.pyshaka.util.DataViewReader import DataViewReader 3 | 4 | 5 | class ParsedTFHDBox: 6 | 7 | def __init__(self, **kwargs): 8 | self.trackId = kwargs['trackId'] # type: int 9 | # type: int 10 | self.defaultSampleDuration = kwargs['defaultSampleDuration'] 11 | self.defaultSampleSize = kwargs['defaultSampleSize'] # type: int 12 | 13 | 14 | class ParsedTFDTBox: 15 | 16 | def __init__(self, **kwargs): 17 | self.baseMediaDecodeTime = kwargs['baseMediaDecodeTime'] # type: int 18 | 19 | 20 | class ParsedMDHDBox: 21 | 22 | def __init__(self, **kwargs): 23 | self.timescale = kwargs['timescale'] # type: int 24 | 25 | 26 | class ParsedTREXBox: 27 | 28 | def __init__(self, **kwargs): 29 | # type: int 30 | self.defaultSampleDuration = kwargs['defaultSampleDuration'] 31 | self.defaultSampleSize = kwargs['defaultSampleSize'] # type: int 32 | 33 | 34 | class ParsedTRUNBox: 35 | 36 | def __init__(self, **kwargs): 37 | self.sampleCount = kwargs['sampleCount'] # type: int 38 | self.sampleData = kwargs['sampleData'] # type: List[ParsedTRUNSample] 39 | 40 | 41 | class ParsedTRUNSample: 42 | 43 | def __init__(self, **kwargs): 44 | self.sampleDuration = kwargs['sampleDuration'] # type: int 45 | self.sampleSize = kwargs['sampleSize'] # type: int 46 | # type: int 47 | self.sampleCompositionTimeOffset = kwargs['sampleCompositionTimeOffset'] 48 | 49 | 50 | class ParsedTKHDBox: 51 | 52 | def __init__(self, **kwargs): 53 | self.trackId = kwargs['trackId'] # type: int 54 | 55 | 56 | class Mp4BoxParsers: 57 | 58 | @staticmethod 59 | def parseTFHD(reader: DataViewReader, flags: int) -> ParsedTFHDBox: 60 | defaultSampleDuration = None 61 | defaultSampleSize = None 62 | 63 | # Read "track_ID" 64 | trackId = reader.readUint32() 65 | 66 | # Skip "base_data_offset" if present. 67 | if flags & 0x000001: 68 | reader.skip(8) 69 | 70 | # Skip "sample_description_index" if present. 71 | if flags & 0x000002: 72 | reader.skip(4) 73 | 74 | # Read "default_sample_duration" if present. 75 | if flags & 0x000008: 76 | defaultSampleDuration = reader.readUint32() 77 | 78 | # Read "default_sample_size" if present. 79 | if flags & 0x000010: 80 | defaultSampleSize = reader.readUint32() 81 | 82 | return ParsedTFHDBox(**{ 83 | 'trackId': trackId, 84 | 'defaultSampleDuration': defaultSampleDuration, 85 | 'defaultSampleSize': defaultSampleSize, 86 | }) 87 | 88 | @staticmethod 89 | def parseTFDT(reader: DataViewReader, version: int) -> ParsedTFDTBox: 90 | if version == 1: 91 | baseMediaDecodeTime = reader.readUint64() 92 | else: 93 | baseMediaDecodeTime = reader.readUint32() 94 | return ParsedTFDTBox(**{'baseMediaDecodeTime': baseMediaDecodeTime}) 95 | 96 | @staticmethod 97 | def parseMDHD(reader: DataViewReader, version: int) -> ParsedMDHDBox: 98 | if version == 1: 99 | # Skip "creation_time" 100 | reader.skip(8) 101 | # Skip "modification_time" 102 | reader.skip(8) 103 | else: 104 | # Skip "creation_time" 105 | reader.skip(4) 106 | # Skip "modification_time" 107 | reader.skip(4) 108 | timescale = reader.readUint32() 109 | return ParsedMDHDBox(**{'timescale': timescale}) 110 | 111 | @staticmethod 112 | def parseTREX(reader: DataViewReader) -> ParsedTREXBox: 113 | pass 114 | 115 | @staticmethod 116 | def parseTRUN(reader: DataViewReader, version: int, flags: int) -> ParsedTRUNBox: 117 | sampleCount = reader.readUint32() 118 | sampleData = [] 119 | 120 | # Skip "data_offset" if present. 121 | if flags & 0x000001: 122 | reader.skip(4) 123 | 124 | # Skip "first_sample_flags" if present. 125 | if flags & 0x000004: 126 | reader.skip(4) 127 | 128 | for _ in range(sampleCount): 129 | sample = ParsedTRUNSample(**{ 130 | 'sampleDuration': None, 131 | 'sampleSize': None, 132 | 'sampleCompositionTimeOffset': None, 133 | }) 134 | 135 | # Read "sample duration" if present. 136 | if flags & 0x000100: 137 | sample.sampleDuration = reader.readUint32() 138 | 139 | # Read "sample_size" if present. 140 | if flags & 0x000200: 141 | sample.sampleSize = reader.readUint32() 142 | 143 | # Skip "sample_flags" if present. 144 | if flags & 0x000400: 145 | reader.skip(4) 146 | 147 | # Read "sample_time_offset" if present. 148 | if flags & 0x000800: 149 | if version == 0: 150 | sample.sampleCompositionTimeOffset = reader.readUint32() 151 | else: 152 | sample.sampleCompositionTimeOffset = reader.readInt32() 153 | sampleData.append(sample) 154 | 155 | return ParsedTRUNBox(**{'sampleCount': sampleCount, 'sampleData': sampleData}) 156 | 157 | @staticmethod 158 | def parseTKHD(reader: DataViewReader, version: int) -> ParsedTKHDBox: 159 | pass 160 | -------------------------------------------------------------------------------- /tools/pyshaka/util/TextParser.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | 4 | class TimeContext: 5 | def __init__(self, **kwargs): 6 | self.periodStart = kwargs['periodStart'] # tpye: float 7 | self.segmentStart = kwargs['segmentStart'] # tpye: float 8 | self.segmentEnd = kwargs['segmentEnd'] # tpye: float 9 | 10 | 11 | class TextParser: 12 | 13 | def __init__(self, data: str): 14 | self.data_ = data 15 | self.position_ = 0 16 | 17 | def atEnd(self): 18 | return self.position_ == len(self.data_) 19 | 20 | def readLine(self): 21 | # assert 1 == 0, 'not implemented yet' 22 | return self.readRegexReturnCapture_('(.*?)(\n|$)', 1) 23 | 24 | def readWord(self): 25 | # assert 1 == 0, 'not implemented yet' 26 | return self.readRegexReturnCapture_('[^ \t\n]*', 0) 27 | 28 | def readRegexReturnCapture_(self, regex: str, index: int): 29 | if self.atEnd(): 30 | return None 31 | ret = self.readRegex(regex) 32 | if not ret: 33 | return None 34 | else: 35 | return ret[index] 36 | 37 | def readRegex(self, regex: str): 38 | index = self.indexOf_(regex) 39 | if self.atEnd() or index is None or index.position != self.position_: 40 | return None 41 | 42 | self.position_ += index.length 43 | return index.results 44 | 45 | def indexOf_(self, regex: str): 46 | # assert 1 == 0, 'not implemented yet' 47 | results = re.search(regex, self.data_[self.position_:]) 48 | if not results: 49 | return 50 | else: 51 | return IndexOf(results) 52 | 53 | 54 | class IndexOf: 55 | def __init__(self, results: re.Match): 56 | self.position = results.regs[0][0] 57 | self.length = len(results[0]) 58 | self.results = results -------------------------------------------------------------------------------- /tools/pyshaka/util/exceptions.py: -------------------------------------------------------------------------------- 1 | class Error(Exception): 2 | '''Base class for shaka errors.''' 3 | 4 | 5 | class SeverityError(Error): 6 | '''Severity Error.''' 7 | 8 | 9 | class CategoryError(Error): 10 | '''Category Error.''' 11 | 12 | 13 | class InvalidMp4VTT(Error): 14 | '''Code INVALID_MP4_VTT Error.''' 15 | def __init__(self, reason: str): 16 | self.reason = reason 17 | 18 | def __str__(self): 19 | return self.reason 20 | 21 | 22 | class InvalidMp4TTML(Error): 23 | '''Code INVALID_MP4_TTML Error.''' 24 | def __init__(self, reason: str): 25 | self.reason = reason 26 | 27 | def __str__(self): 28 | return self.reason 29 | 30 | 31 | class InvalidXML(Error): 32 | '''Code INVALID_XML Error.''' 33 | def __init__(self, reason: str): 34 | self.reason = reason 35 | 36 | def __str__(self): 37 | return self.reason 38 | 39 | 40 | class InvalidTextCue(Error): 41 | '''Code INVALID_TEXT_CUE Error.''' 42 | def __init__(self, reason: str): 43 | self.reason = reason 44 | 45 | def __str__(self): 46 | return self.reason 47 | 48 | 49 | class OutOfBoundsError(Error): 50 | '''Code BUFFER_READ_OUT_OF_BOUNDS Error.''' 51 | 52 | 53 | class IntOverflowError(Error): 54 | '''Code JS_INTEGER_OVERFLOW Error.''' -------------------------------------------------------------------------------- /user_config.toml: -------------------------------------------------------------------------------- 1 | # This is an example Subtitle-Downloader's config file. 2 | locale = '' # en/zh-Hant; default: system locale 3 | 4 | [subtitles] 5 | default-language = 'all' # all/en/zh-Hant/zh-Hans/zh-HK/ja/ko 6 | default-format = '.srt' # .srt/.ass/.vtt 7 | archive = true # true/false 8 | fix-subtitle = true # true/false 9 | 10 | # Default cookies dir: Subtitle-Downloader/cookies 11 | # downloads dir: Subtitle-Downloader/downloads 12 | [directories] 13 | cookies = '' 14 | downloads = '' 15 | 16 | # Copy user-agent from login browser (https://www.whatsmyua.info/) 17 | [headers] 18 | User-Agent = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36' 19 | 20 | [credentials] 21 | 22 | [credentials.TMDB] 23 | api_key = '305cd2702459761c82756596508900d7' 24 | 25 | [credentials.AppleTVPlus] 26 | cookies = 'tv.apple.com_cookies.txt' 27 | 28 | [credentials.CatchPlay] 29 | cookies = 'www.catchplay.com_cookies.txt' 30 | 31 | [credentials.Crunchyroll] 32 | cookies = 'www.crunchyroll.com_cookies.txt' 33 | 34 | [credentials.DisneyPlus] 35 | email = '' 36 | password = '' 37 | 38 | [credentials.FridayVideo] 39 | cookies = 'video.friday.tw_cookies.txt' 40 | 41 | [credentials.HBOGOAsia] 42 | email = '' 43 | password = '' 44 | 45 | [credentials.iQIYI] 46 | cookies = 'www.iq.com_cookies.txt' 47 | 48 | [credentials.meWATCH] 49 | profile_token = '' 50 | 51 | [credentials.MyVideo] 52 | cookies = 'www.myvideo.net.tw_cookies.txt' 53 | 54 | [credentials.NowE] 55 | cookies = 'www.nowe.com_cookies.txt' 56 | 57 | [credentials.NowPlayer] 58 | cookies = 'nowplayer.now.com_cookies.txt' 59 | 60 | [credentials.Viki] 61 | cookies = 'www.viki.com_cookies.txt' 62 | 63 | [credentials.WeTV] 64 | cookies = 'wetv.vip_cookies.txt' 65 | 66 | [credentials.YouTube] 67 | cookies = 'www.youtube.com_cookies.txt' 68 | 69 | [proxies] 70 | # This is a dictionary of proxies you wish to use on services that require a specific country IP. 71 | # The dictionary key needs to be an alpha 2 country code (2-letter code, e.g. `us`, `gb`, `jp`, `de`). 72 | # The value needs to be the proxy string which should be recognizable by python-requests and curl. 73 | # e.g.: https://username:password123@subdomain.hostname.ext:89 74 | # If you don't want any, just have this section blank or remove the [proxies] entirely 75 | # us = 'http://127.0.0.1:7890' # Clash 76 | 77 | [nordvpn] 78 | # Like `proxies` above, this does the same thing except it automatically generates a proxy string 79 | # for a nordvpn server automatically. 80 | # The credentials should be `Service credentials` NOT your Nord account details! 81 | # https://my.nordaccount.com/dashboard/nordvpn/ (Under advanced configuration). 82 | # ex: https://support.nordvpn.com/Connectivity/Proxy/1087802472/Proxy-setup-on-qBittorrent.htm 83 | username = '' 84 | password = '' 85 | -------------------------------------------------------------------------------- /utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wayneclub/Subtitle-Downloader/00804b4e017c1c8f8adc7e914514d73e774816b9/utils/__init__.py -------------------------------------------------------------------------------- /utils/helper.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | # coding: utf-8 3 | 4 | """ 5 | This module is for common tool. 6 | """ 7 | import locale 8 | import logging 9 | import gettext 10 | from natsort import natsorted 11 | import requests 12 | import validators 13 | from configs.config import config, user_agent 14 | from constants import ISO_6391 15 | 16 | 17 | class EpisodesNumbersHandler(object): 18 | """ 19 | Convert user-input episode range to list of int numbers 20 | """ 21 | 22 | def __init__(self, episodes): 23 | self.episodes = episodes 24 | 25 | def number_range(self, start: int, end: int): 26 | if list(range(start, end + 1)) != []: 27 | return list(range(start, end + 1)) 28 | 29 | if list(range(end, start + 1)) != []: 30 | return list(range(end, start + 1)) 31 | 32 | return [start] 33 | 34 | def list_number(self, number: str): 35 | if number.isdigit(): 36 | return [int(number)] 37 | 38 | if number.strip() == "~" or number.strip() == "": 39 | return self.number_range(1, 999) 40 | 41 | if "-" in number: 42 | start, end = number.split("-") 43 | if start.strip() == "" or end.strip() == "": 44 | raise ValueError(f"wrong number: {number}") 45 | return self.number_range(int(start), int(end)) 46 | 47 | if "~" in number: 48 | start, _ = number.split("~") 49 | if start.strip() == "": 50 | raise ValueError(f"wrong number: {number}") 51 | return self.number_range(int(start), 999) 52 | 53 | return 54 | 55 | def sort_numbers(self, numbers): 56 | sorted_numbers = [] 57 | for number in numbers.split(","): 58 | sorted_numbers += self.list_number(number.strip()) 59 | 60 | return natsorted(list(set(sorted_numbers))) 61 | 62 | def get_episodes(self): 63 | return ( 64 | self.sort_numbers( 65 | str(self.episodes).lstrip("0") 66 | ) 67 | if self.episodes 68 | else self.sort_numbers("~") 69 | ) 70 | 71 | 72 | def get_locale(name, lang=""): 73 | """Get environment locale""" 74 | 75 | if locale.getlocale()[0]: 76 | current_locale = locale.getlocale()[0] 77 | else: 78 | current_locale = 'en' 79 | 80 | if lang and 'zh' in lang: 81 | current_locale = 'zh' 82 | elif config.locale and config.locale in ['en', 'zh-Hant']: 83 | current_locale = config.locale 84 | 85 | if 'zh' in current_locale: 86 | locale_ = gettext.translation( 87 | name, localedir='locales', languages=['zh-Hant']) 88 | locale_.install() 89 | return locale_.gettext 90 | else: 91 | return gettext.gettext 92 | 93 | 94 | def get_language_code(lang: str): 95 | """Convert subtitle language code to ISO_6391 format""" 96 | 97 | uniform = lang.lower().replace('_', '-') 98 | if ISO_6391.get(uniform): 99 | return ISO_6391.get(uniform) 100 | else: 101 | return lang 102 | 103 | 104 | def get_all_languages(available_languages, subtitle_language, locale_): 105 | """Get all subtitles language""" 106 | 107 | _ = get_locale(__name__, locale_) 108 | 109 | if available_languages: 110 | available_languages = sorted(available_languages) 111 | 112 | if 'all' in subtitle_language: 113 | subtitle_language = available_languages 114 | 115 | intersect = set(subtitle_language).intersection( 116 | set(available_languages)) 117 | 118 | if not intersect: 119 | logger.error( 120 | _("\nUnsupport %s subtitle, available languages: %s"), ", ".join(subtitle_language), ", ".join(available_languages)) 121 | return False 122 | 123 | if len(intersect) != len(subtitle_language): 124 | logger.error( 125 | _("\nUnsupport %s subtitle, available languages: %s"), ", ".join(set(subtitle_language).symmetric_difference(intersect)), ", ".join(available_languages)) 126 | return False 127 | 128 | return subtitle_language 129 | 130 | 131 | def check_url_exist(url, headers=None): 132 | """Validate url exist""" 133 | 134 | if validators.url(url): 135 | 136 | if not headers: 137 | headers = {'user-agent': user_agent} 138 | try: 139 | response = requests.head( 140 | url, headers=headers, timeout=10) 141 | if response.ok: 142 | return True 143 | 144 | except (requests.exceptions.HTTPError, requests.exceptions.ConnectionError) as error: 145 | logger.error( 146 | "Failure - Unable to establish connection: %s.", error) 147 | except Exception as error: 148 | logger.error("Failure - Unknown error occurred: %s.", error) 149 | 150 | return False 151 | 152 | 153 | if __name__: 154 | logger = logging.getLogger(__name__) 155 | -------------------------------------------------------------------------------- /utils/io.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | # coding: utf-8 3 | 4 | """ 5 | This module is for I/O. 6 | """ 7 | 8 | from __future__ import annotations 9 | import logging 10 | import multiprocessing 11 | import os 12 | import re 13 | import sys 14 | from operator import itemgetter 15 | from pathlib import Path 16 | from typing import Union 17 | from urllib.parse import quote 18 | 19 | from tqdm import tqdm 20 | import requests 21 | import pytomlpp 22 | from configs.config import user_agent, credentials 23 | from utils.helper import check_url_exist, get_locale 24 | 25 | 26 | def load_toml(path: Union[Path, str]) -> dict: 27 | """Read .toml file""" 28 | 29 | if not isinstance(path, Path): 30 | path = Path(path) 31 | if not path.is_file(): 32 | return {} 33 | return pytomlpp.load(path) 34 | 35 | 36 | def rename_filename(filename): 37 | """Fix invalid character from title""" 38 | 39 | filename = ( 40 | filename.replace(" ", ".") 41 | .replace("'", "") 42 | .replace('"', "") 43 | .replace(",", "") 44 | .replace("-", "") 45 | .replace(":", ".") 46 | .replace("’", "") 47 | .replace('"', '') 48 | .replace("-.", ".") 49 | .replace(".-.", ".") 50 | ) 51 | filename = re.sub(" +", ".", filename) 52 | for _ in range(5): 53 | filename = re.sub(r"(\.\.)", ".", filename) 54 | filename = re.sub(r'[/\\:|<>"?*\0-\x1f]|^(AUX|COM[1-9]|CON|LPT[1-9]|NUL|PRN)(?![^.])|^\s|[\s.]$', 55 | "", filename[:255], flags=re.IGNORECASE) 56 | 57 | return filename 58 | 59 | 60 | def get_tmdb_info(title: str, release_year: str = "", is_movie: bool = False) -> dict: 61 | """Get tmdb information.""" 62 | api_key = credentials['TMDB']['api_key'] 63 | 64 | if not api_key: 65 | logger.error( 66 | "Please get tmdb api key and set in video_downloader.toml!") 67 | sys.exit(1) 68 | 69 | url = f"https://api.themoviedb.org/3/search/{'movie' if is_movie else 'tv'}?query={quote(title)}" 70 | 71 | if release_year: 72 | url += f"&{'primary_release_year' if is_movie else 'first_air_date_year'}={release_year}" 73 | 74 | url += f"&api_key={api_key}" 75 | 76 | res = requests.get( 77 | url, headers={'User-Agent': user_agent}, timeout=1) 78 | if res.ok: 79 | return res.json() 80 | else: 81 | logger.error(res.text) 82 | sys.exit(1) 83 | 84 | 85 | def download_file(url, output_path, headers=None): 86 | """Download file from url and show progress""" 87 | 88 | if check_url_exist(url): 89 | 90 | if not headers: 91 | headers = {'User-Agent': user_agent} 92 | 93 | res = requests.get(url, headers=headers, stream=True, timeout=10) 94 | total = int(res.headers.get('content-length', 0)) 95 | with open(output_path, 'wb') as file, tqdm( 96 | desc=os.path.basename(output_path), 97 | total=total, 98 | unit='B', 99 | unit_scale=True, 100 | unit_divisor=1024 101 | ) as progress_bar: 102 | for data in res.iter_content(chunk_size=1024): 103 | size = file.write(data) 104 | progress_bar.update(size) 105 | 106 | else: 107 | logger.warning(_("\nFile not found!")) 108 | 109 | 110 | def download_files(files, headers=None): 111 | """Multi-processing download files""" 112 | 113 | cpus = multiprocessing.cpu_count() 114 | max_pool_size = 8 115 | pool = multiprocessing.Pool( 116 | cpus if cpus < max_pool_size else max_pool_size) 117 | 118 | lang_paths = [] 119 | for file in sorted(files, key=itemgetter('name')): 120 | if 'url' in file and 'name' in file and 'path' in file: 121 | if 'segment' in file and file['segment']: 122 | extension = Path(file['name']).suffix 123 | sequence = str(lang_paths.count(file['path'])).zfill(3) 124 | if file['segment'] == 'comment': 125 | filename = os.path.join(file['path'], file['name'].replace( 126 | extension, f'-seg_{sequence}_comment{extension}')) 127 | else: 128 | filename = os.path.join(file['path'], file['name'].replace( 129 | extension, f'-seg_{sequence}{extension}')) 130 | lang_paths.append(file['path']) 131 | else: 132 | filename = os.path.join(file['path'], file['name']) 133 | pool.apply_async(download_file, args=( 134 | file['url'], filename, headers)) 135 | pool.close() 136 | pool.join() 137 | 138 | 139 | def download_audio(m3u8_url, output): 140 | """Download audios from m3u8 url""" 141 | 142 | os.system( 143 | f'ffmpeg -protocol_whitelist file,http,https,tcp,tls,crypto -i "{m3u8_url}" -c copy "{output}" -preset ultrafast -loglevel warning -hide_banner -stats') 144 | 145 | 146 | if __name__: 147 | logger = logging.getLogger(__name__) 148 | _ = get_locale(__name__) 149 | -------------------------------------------------------------------------------- /utils/proxy.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | # coding: utf-8 3 | 4 | """ 5 | This module is for proxy service. 6 | """ 7 | from __future__ import annotations 8 | import logging 9 | import sys 10 | from typing import Optional 11 | import orjson 12 | import requests 13 | from configs.config import config 14 | 15 | 16 | def get_ip_info(session: Optional[requests.Session] = None) -> dict: 17 | """Use ipinfo.io to get IP location information.""" 18 | 19 | return (session or requests.Session()).get('https://ipinfo.io/json', timeout=5).json() 20 | 21 | 22 | def get_proxy(region: str, geofence: list, platform: str) -> Optional[str]: 23 | """Get proxy""" 24 | 25 | if not region: 26 | logger.error('Region cannot be empty!') 27 | sys.exit(1) 28 | region = region.lower() 29 | 30 | logger.info('Obtaining a proxy to "%s"', region) 31 | 32 | if get_ip_info()['country'].lower() == ''.join(i for i in region if not i.isdigit()): 33 | return None # no proxy necessary 34 | 35 | if config.proxies.get(region): 36 | proxy = config.proxies[region] 37 | logger.info(' + %s (via config.proxies)', proxy) 38 | elif config.nordvpn.get('username') and config.nordvpn.get('password'): 39 | proxy = get_nordvpn_proxy(region) 40 | logger.info(' + %s (via nordvpn)', proxy) 41 | else: 42 | logger.error(' - Unable to obtain a proxy') 43 | if geofence: 44 | logger.error( 45 | '%s is restricted in %s, please use the proxy to bypass restrictions.', platform, ', '.join(geofence).upper()) 46 | sys.exit(1) 47 | 48 | if '://' not in proxy: 49 | # assume a https proxy port 50 | proxy = f'https://{proxy}' 51 | 52 | return proxy 53 | 54 | 55 | def get_nordvpn_proxy(region: str) -> str: 56 | """Via NordVPN to use proxy""" 57 | 58 | proxy = f"https://{config.nordvpn['username']}:{config.nordvpn['password']}@" 59 | if any(char.isdigit() for char in region): 60 | proxy += f"{region}.nordvpn.com" # direct server id 61 | elif config.nordvpn.get("servers", {}).get(region): 62 | # configured server id 63 | proxy += f"{region}{config.nordvpn['servers'][region]}.nordvpn.com" 64 | else: 65 | # get current recommended server id 66 | hostname = get_nordvpn_server(region) 67 | if not hostname: 68 | logger.error( 69 | " - NordVPN doesn't contain any servers for the country \"{%s}\"", region) 70 | sys.exit(1) 71 | proxy += hostname 72 | return proxy + ":89" # https: 89, http: 80 73 | 74 | 75 | def get_nordvpn_server(country: str) -> Optional[str]: 76 | """ 77 | Get the recommended NordVPN server hostname for a specified Country. 78 | :param country: Country (in alpha 2 format, e.g. 'US' for United States) 79 | :returns: Recommended NordVPN server hostname, e.g. `us123.nordvpn.com` 80 | """ 81 | # Get the Country's NordVPN ID 82 | countries = requests.get( 83 | url="https://nordvpn.com/wp-admin/admin-ajax.php", 84 | params={"action": "servers_countries"}, 85 | timeout=5 86 | ).json() 87 | country_id = [x["id"] 88 | for x in countries if x["code"].lower() == country.lower()] 89 | if not country_id: 90 | return None 91 | country_id = country_id[0] 92 | # Get the most recommended server for the country and return it 93 | recommendations = requests.get( 94 | url="https://nordvpn.com/wp-admin/admin-ajax.php", 95 | params={ 96 | "action": "servers_recommendations", 97 | "filters": orjson.dumps({"country_id": country_id}).decode('utf-8') # pylint: disable=maybe-no-member 98 | }, 99 | timeout=5 100 | ).json() 101 | return recommendations[0]["hostname"] 102 | 103 | 104 | if __name__: 105 | logger = logging.getLogger(__name__) 106 | --------------------------------------------------------------------------------