tools.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620
  1. import datetime
  2. import json
  3. import logging
  4. import os
  5. import re
  6. import shutil
  7. import socket
  8. import sys
  9. import urllib.parse
  10. from collections import defaultdict
  11. from logging.handlers import RotatingFileHandler
  12. from time import time
  13. import pytz
  14. import requests
  15. from bs4 import BeautifulSoup
  16. from flask import send_file, make_response
  17. from opencc import OpenCC
  18. import utils.constants as constants
  19. from utils.config import config
  20. from utils.types import ChannelData
  21. def get_logger(path, level=logging.ERROR, init=False):
  22. """
  23. get the logger
  24. """
  25. if not os.path.exists(constants.output_path):
  26. os.makedirs(constants.output_path)
  27. if init and os.path.exists(path):
  28. os.remove(path)
  29. handler = RotatingFileHandler(path, encoding="utf-8")
  30. logger = logging.getLogger(path)
  31. logger.addHandler(handler)
  32. logger.setLevel(level)
  33. return logger
  34. def format_interval(t):
  35. """
  36. Formats a number of seconds as a clock time, [H:]MM:SS
  37. Parameters
  38. ----------
  39. t : int or float
  40. Number of seconds.
  41. Returns
  42. -------
  43. out : str
  44. [H:]MM:SS
  45. """
  46. mins, s = divmod(int(t), 60)
  47. h, m = divmod(mins, 60)
  48. if h:
  49. return "{0:d}:{1:02d}:{2:02d}".format(h, m, s)
  50. else:
  51. return "{0:02d}:{1:02d}".format(m, s)
  52. def get_pbar_remaining(n=0, total=0, start_time=None):
  53. """
  54. Get the remaining time of the progress bar
  55. """
  56. try:
  57. elapsed = time() - start_time
  58. completed_tasks = n
  59. if completed_tasks > 0:
  60. avg_time_per_task = elapsed / completed_tasks
  61. remaining_tasks = total - completed_tasks
  62. remaining_time = format_interval(avg_time_per_task * remaining_tasks)
  63. else:
  64. remaining_time = "未知"
  65. return remaining_time
  66. except Exception as e:
  67. print(f"Error: {e}")
  68. def update_file(final_file, old_file, copy=False):
  69. """
  70. Update the file
  71. """
  72. old_file_path = resource_path(old_file, persistent=True)
  73. final_file_path = resource_path(final_file, persistent=True)
  74. if os.path.exists(old_file_path):
  75. if copy:
  76. shutil.copyfile(old_file_path, final_file_path)
  77. else:
  78. os.replace(old_file_path, final_file_path)
  79. def filter_by_date(data):
  80. """
  81. Filter by date and limit
  82. """
  83. default_recent_days = 30
  84. use_recent_days = config.recent_days
  85. if not isinstance(use_recent_days, int) or use_recent_days <= 0:
  86. use_recent_days = default_recent_days
  87. start_date = datetime.datetime.now() - datetime.timedelta(days=use_recent_days)
  88. recent_data = []
  89. unrecent_data = []
  90. for info, response_time in data:
  91. item = (info, response_time)
  92. date = info["date"]
  93. if date:
  94. date = datetime.datetime.strptime(date, "%m-%d-%Y")
  95. if date >= start_date:
  96. recent_data.append(item)
  97. else:
  98. unrecent_data.append(item)
  99. else:
  100. unrecent_data.append(item)
  101. recent_data_len = len(recent_data)
  102. if recent_data_len == 0:
  103. recent_data = unrecent_data
  104. elif recent_data_len < config.urls_limit:
  105. recent_data.extend(unrecent_data[: config.urls_limit - len(recent_data)])
  106. return recent_data
  107. def get_soup(source):
  108. """
  109. Get soup from source
  110. """
  111. source = re.sub(
  112. r"<!--.*?-->",
  113. "",
  114. source,
  115. flags=re.DOTALL,
  116. )
  117. soup = BeautifulSoup(source, "html.parser")
  118. return soup
  119. def get_resolution_value(resolution_str):
  120. """
  121. Get resolution value from string
  122. """
  123. try:
  124. if resolution_str:
  125. pattern = r"(\d+)[xX*](\d+)"
  126. match = re.search(pattern, resolution_str)
  127. if match:
  128. width, height = map(int, match.groups())
  129. return width * height
  130. except:
  131. pass
  132. return 0
  133. def get_total_urls(info_list: list[ChannelData], ipv_type_prefer, origin_type_prefer) -> list:
  134. """
  135. Get the total urls from info list
  136. """
  137. ipv_prefer_bool = bool(ipv_type_prefer)
  138. origin_prefer_bool = bool(origin_type_prefer)
  139. if not ipv_prefer_bool:
  140. ipv_type_prefer = ["all"]
  141. if not origin_prefer_bool:
  142. origin_type_prefer = ["all"]
  143. categorized_urls = {origin: {ipv_type: [] for ipv_type in ipv_type_prefer} for origin in origin_type_prefer}
  144. total_urls = []
  145. for info in info_list:
  146. url, origin, resolution, url_ipv_type = info["url"], info["origin"], info["resolution"], info["ipv_type"]
  147. if not origin:
  148. continue
  149. if origin == "whitelist":
  150. w_url, _, w_info = url.partition("$")
  151. w_info_value = w_info.partition("!")[2] or "白名单"
  152. total_urls.append(add_url_info(w_url, w_info_value))
  153. continue
  154. if origin == "subscribe" and "/rtp/" in url:
  155. origin = "multicast"
  156. if origin_prefer_bool and (origin not in origin_type_prefer):
  157. continue
  158. pure_url, _, info = url.partition("$")
  159. if not info:
  160. origin_name = constants.origin_map[origin]
  161. if origin_name:
  162. url = add_url_info(pure_url, origin_name)
  163. if url_ipv_type == 'ipv6':
  164. url = add_url_info(url, "IPv6")
  165. if resolution:
  166. url = add_url_info(url, resolution)
  167. if not origin_prefer_bool:
  168. origin = "all"
  169. if ipv_prefer_bool:
  170. if url_ipv_type in ipv_type_prefer:
  171. categorized_urls[origin][url_ipv_type].append(url)
  172. else:
  173. categorized_urls[origin]["all"].append(url)
  174. ipv_num = {ipv_type: 0 for ipv_type in ipv_type_prefer}
  175. urls_limit = config.urls_limit
  176. for origin in origin_type_prefer:
  177. if len(total_urls) >= urls_limit:
  178. break
  179. for ipv_type in ipv_type_prefer:
  180. if len(total_urls) >= urls_limit:
  181. break
  182. ipv_type_num = ipv_num[ipv_type]
  183. ipv_type_limit = config.ipv_limit[ipv_type] or urls_limit
  184. if ipv_type_num < ipv_type_limit:
  185. urls = categorized_urls[origin][ipv_type]
  186. if not urls:
  187. continue
  188. limit = min(
  189. max(config.source_limits.get(origin, urls_limit) - ipv_type_num, 0),
  190. max(ipv_type_limit - ipv_type_num, 0),
  191. )
  192. limit_urls = urls[:limit]
  193. total_urls.extend(limit_urls)
  194. ipv_num[ipv_type] += len(limit_urls)
  195. else:
  196. continue
  197. total_urls = list(dict.fromkeys(total_urls))[:urls_limit]
  198. if not config.open_url_info:
  199. return [url.partition("$")[0] for url in total_urls]
  200. else:
  201. return total_urls
  202. def get_total_urls_from_sorted_data(data):
  203. """
  204. Get the total urls with filter by date and duplicate from sorted data
  205. """
  206. total_urls = []
  207. if len(data) > config.urls_limit:
  208. total_urls = [channel_data["url"] for channel_data, _ in filter_by_date(data)]
  209. else:
  210. total_urls = [channel_data["url"] for channel_data, _ in data]
  211. return list(dict.fromkeys(total_urls))[: config.urls_limit]
  212. def check_url_ipv6(url):
  213. """
  214. Check if the url is ipv6
  215. """
  216. try:
  217. host = urllib.parse.urlparse(url).hostname
  218. if host:
  219. addr_info = socket.getaddrinfo(host, None, socket.AF_UNSPEC, socket.SOCK_STREAM)
  220. for info in addr_info:
  221. if info[0] == socket.AF_INET6:
  222. return True
  223. return False
  224. except:
  225. return False
  226. def check_ipv6_support():
  227. """
  228. Check if the system network supports ipv6
  229. """
  230. url = "https://ipv6.tokyo.test-ipv6.com/ip/?callback=?&testdomain=test-ipv6.com&testname=test_aaaa"
  231. try:
  232. print("Checking if your network supports IPv6...")
  233. response = requests.get(url, timeout=10)
  234. if response.status_code == 200:
  235. print("Your network supports IPv6")
  236. return True
  237. except Exception:
  238. pass
  239. print("Your network does not support IPv6, don't worry, these results will be saved")
  240. return False
  241. def check_ipv_type_match(ipv_type: str) -> bool:
  242. """
  243. Check if the ipv type matches
  244. """
  245. config_ipv_type = config.ipv_type
  246. return (
  247. config_ipv_type == ipv_type
  248. or config_ipv_type == "全部"
  249. or config_ipv_type == "all"
  250. )
  251. def check_url_by_keywords(url, keywords=None):
  252. """
  253. Check by URL keywords
  254. """
  255. if not keywords:
  256. return True
  257. else:
  258. return any(keyword in url for keyword in keywords)
  259. def merge_objects(*objects):
  260. """
  261. Merge objects
  262. """
  263. def merge_dicts(dict1, dict2):
  264. for key, value in dict2.items():
  265. if key in dict1:
  266. if isinstance(dict1[key], dict) and isinstance(value, dict):
  267. merge_dicts(dict1[key], value)
  268. elif isinstance(dict1[key], set):
  269. dict1[key].update(value)
  270. elif isinstance(dict1[key], list):
  271. if value:
  272. dict1[key].extend(x for x in value if x not in dict1[key])
  273. elif value:
  274. dict1[key] = {dict1[key], value}
  275. else:
  276. dict1[key] = value
  277. merged_dict = {}
  278. for obj in objects:
  279. if not isinstance(obj, dict):
  280. raise TypeError("All input objects must be dictionaries")
  281. merge_dicts(merged_dict, obj)
  282. return merged_dict
  283. def get_ip_address():
  284. """
  285. Get the IP address
  286. """
  287. s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
  288. ip = "127.0.0.1"
  289. try:
  290. s.connect(("10.255.255.255", 1))
  291. ip = s.getsockname()[0]
  292. except:
  293. ip = "127.0.0.1"
  294. finally:
  295. s.close()
  296. return f"http://{ip}:{config.app_port}"
  297. def convert_to_m3u(first_channel_name=None):
  298. """
  299. Convert result txt to m3u format
  300. """
  301. user_final_file = resource_path(config.final_file)
  302. if os.path.exists(user_final_file):
  303. with open(user_final_file, "r", encoding="utf-8") as file:
  304. m3u_output = f'#EXTM3U x-tvg-url="{join_url(config.cdn_url, 'https://raw.githubusercontent.com/fanmingming/live/main/e.xml')}"\n'
  305. current_group = None
  306. for line in file:
  307. trimmed_line = line.strip()
  308. if trimmed_line != "":
  309. if "#genre#" in trimmed_line:
  310. current_group = trimmed_line.replace(",#genre#", "").strip()
  311. else:
  312. try:
  313. original_channel_name, _, channel_link = map(
  314. str.strip, trimmed_line.partition(",")
  315. )
  316. except:
  317. continue
  318. processed_channel_name = re.sub(
  319. r"(CCTV|CETV)-(\d+)(\+.*)?",
  320. lambda m: f"{m.group(1)}{m.group(2)}"
  321. + ("+" if m.group(3) else ""),
  322. first_channel_name if current_group == "🕘️更新时间" else original_channel_name,
  323. )
  324. m3u_output += f'#EXTINF:-1 tvg-name="{processed_channel_name}" tvg-logo="{join_url(config.cdn_url, f'https://raw.githubusercontent.com/fanmingming/live/main/tv/{processed_channel_name}.png')}"'
  325. if current_group:
  326. m3u_output += f' group-title="{current_group}"'
  327. m3u_output += f",{original_channel_name}\n{channel_link}\n"
  328. m3u_file_path = os.path.splitext(user_final_file)[0] + ".m3u"
  329. with open(m3u_file_path, "w", encoding="utf-8") as m3u_file:
  330. m3u_file.write(m3u_output)
  331. print(f"✅ M3U result file generated at: {m3u_file_path}")
  332. def get_result_file_content(show_content=False, file_type=None):
  333. """
  334. Get the content of the result file
  335. """
  336. user_final_file = resource_path(config.final_file)
  337. result_file = (
  338. os.path.splitext(user_final_file)[0] + f".{file_type}"
  339. if file_type
  340. else user_final_file
  341. )
  342. if os.path.exists(result_file):
  343. if config.open_m3u_result:
  344. if file_type == "m3u" or not file_type:
  345. result_file = os.path.splitext(user_final_file)[0] + ".m3u"
  346. if file_type != "txt" and show_content == False:
  347. return send_file(result_file, as_attachment=True)
  348. with open(result_file, "r", encoding="utf-8") as file:
  349. content = file.read()
  350. else:
  351. content = constants.waiting_tip
  352. response = make_response(content)
  353. response.mimetype = 'text/plain'
  354. return response
  355. def remove_duplicates_from_list(data_list, seen, flag=None, force_str=None):
  356. """
  357. Remove duplicates from data list
  358. """
  359. unique_list = []
  360. for item in data_list:
  361. item_first = item["url"]
  362. part = item_first
  363. if force_str:
  364. info = item_first.partition("$")[2]
  365. if info and info.startswith(force_str):
  366. continue
  367. if flag:
  368. matcher = re.search(flag, item_first)
  369. if matcher:
  370. part = matcher.group(1)
  371. seen_num = seen.get(part, 0)
  372. if (seen_num < config.sort_duplicate_limit) or (seen_num == 0 and config.sort_duplicate_limit == 0):
  373. seen[part] = seen_num + 1
  374. unique_list.append(item)
  375. return unique_list
  376. def process_nested_dict(data, seen, flag=None, force_str=None):
  377. """
  378. Process nested dict
  379. """
  380. for key, value in data.items():
  381. if isinstance(value, dict):
  382. process_nested_dict(value, seen, flag, force_str)
  383. elif isinstance(value, list):
  384. data[key] = remove_duplicates_from_list(value, seen, flag, force_str)
  385. def get_url_host(url):
  386. """
  387. Get the url host
  388. """
  389. matcher = constants.url_host_pattern.search(url)
  390. if matcher:
  391. return matcher.group()
  392. return None
  393. def add_url_info(url, info):
  394. """
  395. Add url info to the URL
  396. """
  397. if info:
  398. separator = "-" if "$" in url else "$"
  399. url += f"{separator}{info}"
  400. return url
  401. def format_url_with_cache(url, cache=None):
  402. """
  403. Format the URL with cache
  404. """
  405. cache = cache or get_url_host(url) or ""
  406. return add_url_info(url, f"cache:{cache}") if cache else url
  407. def remove_cache_info(string):
  408. """
  409. Remove the cache info from the string
  410. """
  411. return re.sub(r"[.*]?\$?-?cache:.*", "", string)
  412. def resource_path(relative_path, persistent=False):
  413. """
  414. Get the resource path
  415. """
  416. base_path = os.path.abspath(".")
  417. total_path = os.path.join(base_path, relative_path)
  418. if persistent or os.path.exists(total_path):
  419. return total_path
  420. else:
  421. try:
  422. base_path = sys._MEIPASS
  423. return os.path.join(base_path, relative_path)
  424. except Exception:
  425. return total_path
  426. def write_content_into_txt(content, path=None, position=None, callback=None):
  427. """
  428. Write content into txt file
  429. """
  430. if not path:
  431. return
  432. mode = "r+" if position == "top" else "a"
  433. with open(path, mode, encoding="utf-8") as f:
  434. if position == "top":
  435. existing_content = f.read()
  436. f.seek(0, 0)
  437. f.write(f"{content}\n{existing_content}")
  438. else:
  439. f.write(content)
  440. if callback:
  441. callback()
  442. def format_name(name: str) -> str:
  443. """
  444. Format the name with sub and replace and lower
  445. """
  446. cc = OpenCC("t2s")
  447. name = cc.convert(name)
  448. for region in constants.region_list:
  449. name = name.replace(f"{region}|", "")
  450. name = constants.sub_pattern.sub("", name)
  451. for old, new in constants.replace_dict.items():
  452. name = name.replace(old, new)
  453. return name.lower()
  454. def get_name_url(content, pattern, check_url=True):
  455. """
  456. Get name and url from content
  457. """
  458. matches = pattern.findall(content)
  459. channels = [
  460. {"name": match[0].strip(), "url": match[1].strip()}
  461. for match in matches
  462. if (check_url and match[1].strip()) or not check_url
  463. ]
  464. return channels
  465. def get_real_path(path) -> str:
  466. """
  467. Get the real path
  468. """
  469. dir_path, file = os.path.split(path)
  470. user_real_path = os.path.join(dir_path, 'user_' + file)
  471. real_path = user_real_path if os.path.exists(user_real_path) else path
  472. return real_path
  473. def get_urls_from_file(path: str) -> list:
  474. """
  475. Get the urls from file
  476. """
  477. real_path = get_real_path(resource_path(path))
  478. urls = []
  479. if os.path.exists(real_path):
  480. with open(real_path, "r", encoding="utf-8") as f:
  481. for line in f:
  482. line = line.strip()
  483. if line.startswith("#"):
  484. continue
  485. match = constants.url_pattern.search(line)
  486. if match:
  487. urls.append(match.group().strip())
  488. return urls
  489. def get_name_urls_from_file(path: str, format_name_flag: bool = False) -> dict[str, list]:
  490. """
  491. Get the name and urls from file
  492. """
  493. real_path = get_real_path(resource_path(path))
  494. name_urls = defaultdict(list)
  495. if os.path.exists(real_path):
  496. with open(real_path, "r", encoding="utf-8") as f:
  497. for line in f:
  498. line = line.strip()
  499. if line.startswith("#"):
  500. continue
  501. name_url = get_name_url(line, pattern=constants.txt_pattern)
  502. if name_url and name_url[0]:
  503. name = format_name(name_url[0]["name"]) if format_name_flag else name_url[0]["name"]
  504. url = name_url[0]["url"]
  505. if url not in name_urls[name]:
  506. name_urls[name].append(url)
  507. return name_urls
  508. def get_datetime_now():
  509. """
  510. Get the datetime now
  511. """
  512. now = datetime.datetime.now()
  513. time_zone = pytz.timezone(config.time_zone)
  514. return now.astimezone(time_zone).strftime("%Y-%m-%d %H:%M:%S")
  515. def get_version_info():
  516. """
  517. Get the version info
  518. """
  519. with open(resource_path("version.json"), "r", encoding="utf-8") as f:
  520. return json.load(f)
  521. def join_url(url1: str, url2: str) -> str:
  522. """
  523. Get the join url
  524. :param url1: The first url
  525. :param url2: The second url
  526. :return: The join url
  527. """
  528. if not url1:
  529. return url2
  530. if not url2:
  531. return url1
  532. if not url1.endswith("/"):
  533. url1 += "/"
  534. return url1 + url2