From 3887d28d17f2d2e17b6d4f1c5af4c7c3d5812788 Mon Sep 17 00:00:00 2001 From: panitan103 Date: Mon, 30 Mar 2026 18:02:30 +0700 Subject: [PATCH] add flixer service --- FLX/__init__.py | 260 ++++++++++++++++++++++++++++++++++++++++++++++++ FLX/config.yaml | 27 +++++ 2 files changed, 287 insertions(+) create mode 100644 FLX/__init__.py create mode 100644 FLX/config.yaml diff --git a/FLX/__init__.py b/FLX/__init__.py new file mode 100644 index 0000000..946538b --- /dev/null +++ b/FLX/__init__.py @@ -0,0 +1,260 @@ +import re +import click + +from urllib.parse import urlparse ,urljoin +import hashlib + + +from unshackle.core.manifests import HLS +from unshackle.core.service import Service +from unshackle.core.titles import Episode, Movie, Movies, Series, Title_T, Titles_T +from unshackle.core.tracks import Subtitle, Tracks,Chapters,Track +from unshackle.core.downloaders import requests + +import m3u8 + +from langcodes import Language +from hashlib import md5 + + +class FLX(Service): + """ + Service code for Flixer streaming service (https://www.flixerapp.com/). + + \b + Original-Author: [SeFree] + Version: 1.1.0 + Authorization: Credentials + Cookies + Security: FHD@L3 + \b + """ + + TITLE_RE = r"^(?:https?://(?:www\.)?flixerapp\.com/video/)?(?P\d+)" + LANGUAGE_MAP = { + "en": "en", + "th": "th", + "jp": "ja", + "ko": "ko", + "zh": "zh", + } + THAI_SOUND_FILTER={ + "(Thai Sound)": "th" + } + @staticmethod + @click.command(name="Flixer", short_help="https://www.flixerapp.com/") + @click.argument("title", type=str, required=False) + @click.option("-ol", "--original_lang", default='ja', required=False, type=str, + help="If the title is foreigner audio language, specify the original language.") + @click.option("-SE", "--season", default=1, required=False, type=int, + help="BiliBili not provide Season in info, so specify it manually.") + @click.option("-m", "--movie", is_flag=True, default=False, + help="Download series as movie if listed as a series.)") + @click.option("-d", "--device_type", default='android', required=False, type=str, + help="Device type to use for API requests. Default is 'android'.") + + @click.option("-c", "--cdn", default=None, required=False, type=str, + help="Overwrite CDN settings. Default is 'vps1'.") + @click.pass_context + def cli(ctx, **kwargs): + return FLX(ctx, **kwargs) + + def parse_series_id(self, title_input: str) -> str: + """Parse series ID from URL or direct ID input.""" + match = re.match(self.TITLE_RE, title_input, re.IGNORECASE) + if not match: + raise ValueError(f"Could not parse series ID from: {title_input}") + series_id = match.group("id") + return series_id + + def __init__(self, ctx, title, original_lang, season,movie,device_type,cdn): + super().__init__(ctx) + self.title=self.parse_series_id(title) + + self.original_lang = original_lang + self.season = season + + self.is_movie=movie + self.device_type = device_type + self.cdn=cdn + + # def get_session(self): + + # # Create a session using curl_cffi as it can impersonate browsers and avoid bot detection by Crunchyroll + # return session("chrome124") + + def authenticate(self, cookies=None, credential=None) -> None: + """Authenticate using username and password credentials, with refresh token support.""" + super().authenticate(cookies, credential) + + if self.device_type == "android": + + data = f'screen-density=xhdpi&password={credential.password}&email={credential.username}' + + # self.session.headers.update(self.config['headers']) + response = self.session.post(self.config['endpoints']['login'], headers=self.config['headers_android'], data=data) + + elif self.device_type == "web": + + + files = { + 'email': (None, credential.username), + 'password': (None, credential.password), + 'devicie_id': (None, 'web'), + 'screen_density': (None, 'xxhdpi'), + 'mac_address': (None, ''), + } + + response=self.session.post(self.config['endpoints']['login'], files=files) + + if response.status_code == 200 and response.json()['error_code'] == 110: + self.log.info("Login successful") + else: + self.log.error("Login failed. Please check credential") + exit(1) + + def get_titles(self) -> Titles_T: + response=self.session.get(self.config['endpoints']['info'].format(id=self.title)).json() + if not response.get('error', True): + self.log.info("Success") + else: + self.log.error(f"Error fetching title info: {response.get('message', 'Unknown error')}") + exit(1) + media_title:str=response.get('result', {}).get('title', '').get('name', '') + media_title=media_title.replace("👑", "").strip() + episodes=response.get('result', {}).get('episodes', '') + titles=[] + if not self.is_movie: + + for ep in episodes: + titles.append( + Episode( + id_=hashlib.md5(ep["player_api"].encode()).hexdigest()[0:6], + service=self.__class__, + title=media_title, + season=self.season, + number=int(ep['episode']), + data=ep, + language=self.original_lang + ) + ) + return Series(titles) + else: + return Movies( + [Movie( + id_=hashlib.md5(media_title.encode()).hexdigest()[0:6], + service=self.__class__, + name=media_title, + data=episodes[0], + language=self.original_lang + )] + ) + + + def get_tracks(self, title: Title_T) -> Tracks: + tracks = Tracks() + media_info=self.session.get(self.config['endpoints']['episode'].format(id=self.title,ep=title.data['episode'])).json() + if media_info.get('error_code', None) == "": + self.log.info("Success") + else: + self.log.error(f"Error fetching title info: {media_info.get('error_message', 'Unknown error')}") + exit(1) + # print(media_info['result']) + # with open("test.json", 'w') as f: + # json.dump(media_info['result'], f,indent=4, ensure_ascii=False) + if self.cdn: + # Regex pattern to match vpsX or iX (where X is a number) + pattern = r"(https://)(vps[0-9]+|i[0-9]+)(\.flixerapp\.com)" + media_info['result']['playlist'] = re.sub(pattern, r"\1" + self.cdn + r"\3", media_info['result']['playlist']) + r = self.session.get(url=media_info['result']['playlist']) + res = r.text + + parsed_url = urlparse(media_info['result']['playlist_android']) + for _, resolution in media_info['result']['source']['video'].items(): + if _ == "auto": + continue + for lang, url in resolution.items(): + if self.cdn: + # Regex pattern to match vpsX or iX (where X is a number) + pattern = r"(https://)(vps[0-9]+|i[0-9]+)(\.flixerapp\.com)" + url = re.sub(pattern, r"\1" + self.cdn + r"\3", url) + r = self.session.get(url=url) + res = r.text + if r.status_code != 200: + continue + tracks.add(HLS.from_text(res, url).to_tracks(Language.get(self.LANGUAGE_MAP[lang]))) + + for lang, url in media_info['result']['source'].get('audio', {}).items(): + if self.cdn: + # Regex pattern to match vpsX or iX (where X is a number) + pattern = r"(https://)(vps[0-9]+|i[0-9]+)(\.flixerapp\.com)" + url = re.sub(pattern, r"\1" + self.cdn + r"\3", url) + r = self.session.get(url=url) + + res = r.text + if r.status_code != 200 : + continue + tracks.add(HLS.from_text(res, url).to_tracks(Language.get(self.LANGUAGE_MAP[lang]))) + tracks.subtitles.clear() + for lang, url in media_info['result']['source'].get('subtitle', {}).items(): + url= urljoin(media_info['result']['playlist_android'], parsed_url.path.removesuffix("playlist.m3u8"))+urlparse(url).path.split('/')[-1].replace('_','__')+"_subtitle.m3u8?"+parsed_url.query + if self.cdn: + # Regex pattern to match vpsX or iX (where X is a number) + pattern = r"(https://)(vps[0-9]+|i[0-9]+)(\.flixerapp\.com)" + url = re.sub(pattern, r"\1" + self.cdn + r"\3", url) + + r = self.session.get(url=url) + res = r.text + + if 'placeholder' in res or r.status_code != 200: + continue + sub_load=m3u8.loads(res, url) + base_url= urljoin(media_info['result']['playlist_android'], parsed_url.path.removesuffix("playlist.m3u8")) + subtitle_urls=[] + + for x in (sub_load.segments): + sub_url=urljoin(base_url,x.uri) + rr=self.session.get(url=sub_url) + if rr.status_code != 200: + continue + subtitle_urls.append(sub_url) + # print(subtitle_urls) + tracks.add(Subtitle( + id_=md5("_".join(subtitle_urls).encode()).hexdigest()[0:6], + url=subtitle_urls, + codec=Subtitle.Codec.from_mime(media_info['result'].get('subtitle_type',"srt")), + language=Language.get(self.LANGUAGE_MAP.get(lang, lang)), + )) + # for track in tracks: + + # if track.__class__.__name__ == "Subtitle": + # continue + # base_url= urljoin(media_info['result']['playlist'], parsed_url.path.removesuffix("playlist.m3u8")) + # res = self.session.get(track.url).text + # url_load=m3u8.loads(res, track.url) + # track_url=[] + # for url in (url_load.segments): + # track_url.append(url.absolute_uri) + # track.url=track_url + # track.descriptor=Track.Descriptor.URL + # # track.downloader=requests + + return tracks + + def get_chapters(self, title: Title_T) -> Chapters: + """ + Get Chapters for the Title. + + Parameters: + title: The current Title from `get_titles` that is being processed. + + You must return a Chapters object containing 0 or more Chapter objects. + + You do not need to set a Chapter number or sort/order the chapters in any way as + the Chapters class automatically handles all of that for you. If there's no + descriptive name for a Chapter then do not set a name at all. + + You must not set Chapter names to "Chapter {n}" or such. If you (or the user) + wants "Chapter {n}" style Chapter names (or similar) then they can use the config + option `chapter_fallback_name`. For example, `"Chapter {i:02}"` for "Chapter 01". + """ + return Chapters() \ No newline at end of file diff --git a/FLX/config.yaml b/FLX/config.yaml new file mode 100644 index 0000000..5de4c19 --- /dev/null +++ b/FLX/config.yaml @@ -0,0 +1,27 @@ + +headers_android: + connection: Keep-Alive + content-type: application/x-www-form-urlencoded + host: api.flixerapp.com + user-agent: Dalvik/2.1.0 (Linux; U; Android 16; sdk_gphone64_x86_64 Build/BP22.250325.006) +headers_web: + Accept: 'application/json, text/plain, */*' + Accept-Language: 'en-US,en;q=0.9,th;q=0.8' + Connection: 'keep-alive' + Content-Type: 'multipart/form-data; boundary=----WebKitFormBoundaryJNVQjhS5f52L23Gc' + Origin: 'https://www.flixerapp.com' + Referer: 'https://www.flixerapp.com/' + Sec-Fetch-Dest: 'empty' + Sec-Fetch-Mode: 'cors' + Sec-Fetch-Site: 'same-site' + User-Agent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 Edg/146.0.0.0' + sec-ch-ua: '"Chromium";v="146", "Not-A.Brand";v="24", "Microsoft Edge";v="146"' + sec-ch-ua-mobile: '?0' + sec-ch-ua-platform: '"Windows"' + +endpoints: + login: https://api.flixerapp.com/1.12/emaillogin2 + guestlogin: https://api.flixerapp.com/1.12/guestlogin + info: https://api.flixerapp.com/1.12/title/{id} + episode: https://api.flixerapp.com/1.12/episode/{id}/{ep} +