commit 704d105bb0d139c0b636cfbbf83b55173a15721f Author: javara999 Date: Wed Apr 1 11:04:15 2026 +0900 Add files via upload ff_linkkf diff --git a/README.md b/README.md new file mode 100644 index 0000000..4d9abac --- /dev/null +++ b/README.md @@ -0,0 +1,15 @@ +# linkkf + +FlaskFarm용 `linkkf.tv` 플러그인입니다. + +설치 전 의존 패키지: +`pip install -r requirements.txt` + +플러그인 최초 로딩 시 누락된 패키지는 자동 설치를 시도합니다. + +현재 기준 주요 기능: +- 카테고리 목록 조회 +- 작품 분석 및 회차 목록 조회 +- 재생 URL 추출 및 브라우저 플레이어 프록시 +- ffmpeg 다운로드 대기열 연동 +- 다운로드 이력 목록 표시 diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..a83baeb --- /dev/null +++ b/__init__.py @@ -0,0 +1,2 @@ +from .setup import P + diff --git a/bin/Darwin/chromedriver b/bin/Darwin/chromedriver new file mode 100644 index 0000000..afcc6d2 Binary files /dev/null and b/bin/Darwin/chromedriver differ diff --git a/bin/Linux/chromedriver b/bin/Linux/chromedriver new file mode 100644 index 0000000..696aabc Binary files /dev/null and b/bin/Linux/chromedriver differ diff --git a/info.json b/info.json new file mode 100644 index 0000000..a879bf5 --- /dev/null +++ b/info.json @@ -0,0 +1,11 @@ +{ + "version": "0.3.2.0", + "name": "linkkf", + "category_name": "vod", + "icon": "", + "developer": "projectdx && persuade", + "description": "linkkf 사이트에서 애니 다운로드", + "home": "https://linkkf.tv", + "more": "", + "category": "vod" +} diff --git a/info.yaml b/info.yaml new file mode 100644 index 0000000..40ce8e0 --- /dev/null +++ b/info.yaml @@ -0,0 +1,8 @@ +title: "linkkf" +version: "0.3.2.0" +package_name: "linkkf" +developer: "projectdx && persuade" +description: "linkkf 사이트에서 애니 다운로드" +home: "https://linkkf.tv" +more: "" + diff --git a/lib/__pycache__/utils.cpython-310.pyc b/lib/__pycache__/utils.cpython-310.pyc new file mode 100644 index 0000000..db5b42d Binary files /dev/null and b/lib/__pycache__/utils.cpython-310.pyc differ diff --git a/lib/utils.py b/lib/utils.py new file mode 100644 index 0000000..6d318fe --- /dev/null +++ b/lib/utils.py @@ -0,0 +1,17 @@ +import time +import logging +from functools import wraps + +logger = logging.getLogger("linkkf") + +def linkkf_async_timeit(func): + @wraps(func) + async def wrapper(*args, **kwargs): + start_time = time.perf_counter() + try: + return await func(*args, **kwargs) + finally: + total_time = time.perf_counter() - start_time + logger.debug("%s%r %r took %.4fs", func.__name__, args, kwargs, total_time) + + return wrapper diff --git a/logic.py b/logic.py new file mode 100644 index 0000000..cba6dd1 --- /dev/null +++ b/logic.py @@ -0,0 +1,137 @@ +# -*- coding: utf-8 -*- +import os +import traceback + +from framework import F, Job, db, get_logger, path_data, scheduler +from support import SupportFile + +from .logic_linkkf import LogicLinkkf +from .logic_queue import LogicQueue +from .model import ModelLinkkf, ModelSetting + + +package_name = __name__.split(".")[0] +logger = get_logger(package_name) + + +class Logic(object): + db_default = { + "linkkf_url": "https://linkkf.tv", + "download_path": os.path.join(path_data, "linkkf"), + "linkkf_auto_make_folder": "True", + "linkkf_auto_make_season_folder": "True", + "linkkf_finished_insert": "[완결]", + "include_date": "False", + "date_option": "0", + "auto_make_folder": "True", + "max_ffmpeg_process_count": "4", + "auto_interval": "* 20 * * *", + "auto_start": "False", + "whitelist_program": "", + } + + @staticmethod + def db_init(): + try: + with F.app.app_context(): + logger.debug(Logic.db_default.items()) + for key, value in Logic.db_default.items(): + logger.debug(f"{key}: {value}") + if db.session.query(ModelSetting).filter_by(key=key).count() == 0: + db.session.add(ModelSetting(key, value)) + db.session.commit() + Logic.db_migration() + except Exception as e: + logger.error("Exception:%s", e) + logger.error(traceback.format_exc()) + + @staticmethod + def plugin_load(): + try: + logger.debug("%s plugin_load", package_name) + Logic.db_init() + + if ModelSetting.get("auto_start") == "True": + Logic.scheduler_start() + + from .plugin import plugin_info + + SupportFile.write_json( + os.path.join(os.path.dirname(__file__), "info.json"), + plugin_info, + ) + LogicQueue.queue_start() + except Exception as e: + logger.error("Exception:%s", e) + logger.error(traceback.format_exc()) + + @staticmethod + def plugin_unload(): + try: + logger.debug("%s plugin_unload", package_name) + except Exception as e: + logger.error("Exception:%s", e) + logger.error(traceback.format_exc()) + + @staticmethod + def db_migration(): + logger.debug("db_migration::=======================") + try: + migrated = ModelLinkkf.migrate_existing_rows() + logger.debug("ModelLinkkf migrate_existing_rows: %s", migrated) + except Exception as e: + logger.error("Exception:%s", e) + logger.error(traceback.format_exc()) + + @staticmethod + def scheduler_start(): + try: + interval = ModelSetting.get("auto_interval") + job = Job( + package_name, + package_name, + interval, + Logic.scheduler_function, + "linkkf 다운로드", + True, + ) + scheduler.add_job_instance(job) + except Exception as e: + logger.error("Exception:%s", e) + logger.error(traceback.format_exc()) + + @staticmethod + def scheduler_stop(): + try: + scheduler.remove_job(package_name) + except Exception as e: + logger.error("Exception:%s", e) + logger.error(traceback.format_exc()) + + @staticmethod + def setting_save(req): + try: + for key, value in req.form.items(): + logger.debug("Key:%s Value:%s", key, value) + entity = ( + db.session.query(ModelSetting) + .filter_by(key=key) + .with_for_update() + .first() + ) + entity.value = value + db.session.commit() + return True + except Exception as e: + logger.error("Exception:%s", e) + logger.error(traceback.format_exc()) + logger.error("key:%s value:%s", key, value) + return False + + @staticmethod + def scheduler_function(): + try: + LogicLinkkf.scheduler_function() + except Exception as e: + logger.error("Exception:%s", e) + logger.error(traceback.format_exc()) diff --git a/logic_linkkf.py b/logic_linkkf.py new file mode 100644 index 0000000..527f15a --- /dev/null +++ b/logic_linkkf.py @@ -0,0 +1,1445 @@ +# -*- coding: utf-8 -*- +######################################################### +# python +import asyncio +import os +import sys +import traceback +import time +import re +import random +import urllib + +import json + +import cloudscraper +import requests +from bs4 import BeautifulSoup +from requests_cache import CachedSession +from lxml import html + +from .lib.utils import linkkf_async_timeit + +# import snoop +# from snoop import spy + +from framework import db, get_logger +from framework.util import Util + +# 패키지 +# from .plugin import package_name, logger +# from anime_downloader.logic_ohli24 import ModelOhli24Item +from .model import ModelSetting, ModelLinkkf, ModelLinkkfProgram +from .logic_queue import LogicQueue +from .subtitle_util import convert_vtt_to_srt, write_file + +######################################################### +package_name = __name__.split(".")[0] +logger = get_logger(package_name) +cache_path = os.path.dirname(__file__) + + +def _fallback_change_text_for_use_filename(value): + text = str(value or "").strip() + text = re.sub(r'[\\/:*?"<>|]+', " ", text) + text = re.sub(r"\s+", " ", text).strip() + text = text.rstrip(".") + return text + + +if hasattr(Util, "change_text_for_use_filename") is False: + Util.change_text_for_use_filename = staticmethod(_fallback_change_text_for_use_filename) + + +# requests_cache.install_cache("linkkf_cache", backend="sqlite", expire_after=300) + + +class LogicLinkkf(object): + headers = { + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/104.0.0.0 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", + "Accept-Language": "en-US,en;q=0.5", + "Accept-Encoding": "gzip, deflate", + "Connection": "keep-alive", + "Upgrade-Insecure-Requests": "1", + "Sec-Fetch-Dest": "document", + "Sec-Fetch-Mode": "navigate", + "Sec-Fetch-Site": "none", + "Sec-Fetch-User": "?1", + "Cache-Control": "max-age=0", + "Referer": "https://linkkf.tv", + # "Cookie": "SL_G_WPT_TO=ko; SL_GWPT_Show_Hide_tmp=1; SL_wptGlobTipTmp=1", + } + + session = None + referer = None + current_data = None + + @staticmethod + def _parse_total_page(soup): + max_page = 1 + for link in soup.select('a[href*="/page/"]'): + href = link.get("href", "") + match = re.search(r"/page/(\d+)/?$", href) + if match: + max_page = max(max_page, int(match.group(1))) + return max_page + + @staticmethod + def _parse_vod_items(soup): + data = [] + for item in soup.select("div.vod-item"): + link_tag = item.select_one("a.vod-item-img[href]") or item.select_one(".vod-item-title a[href]") + title_tag = item.select_one(".vod-item-title strong") or item.select_one(".vod-item-title a") + image_tag = item.select_one(".img-wrapper") + + if link_tag is None or title_tag is None: + continue + + href = link_tag.get("href", "").strip() + title = title_tag.get_text(" ", strip=True).strip() + if href == "" or title == "": + continue + + code_match = re.search(r"/ani/(\d+)/?", href) + if code_match is None: + code_match = re.search(r"(\d+)", href) + if code_match is None: + continue + + chapter = "" + for selector in [".vod-item-status", ".vod-item-desc strong", ".vod-item-desc"]: + node = item.select_one(selector) + if node is None: + continue + text = node.get_text(" ", strip=True).replace(" .", "").strip(". ").strip() + if text != "": + chapter = text + break + + image_link = "" + if image_tag is not None: + image_link = image_tag.get("data-original", "").strip() + + data.append( + { + "link": urllib.parse.urljoin(ModelSetting.get("linkkf_url"), href), + "code": code_match.group(1), + "title": title, + "image_link": image_link, + "chapter": chapter, + } + ) + return data + + @staticmethod + def _get_list_page(path, page=1): + if page in [None, 1, "1"]: + return f"{ModelSetting.get('linkkf_url').rstrip('/')}{path}" + return f"{ModelSetting.get('linkkf_url').rstrip('/')}{path}page/{page}/" + + @staticmethod + def _get_list_response(path, page=1): + url = LogicLinkkf._get_list_page(path, page) + html_content = LogicLinkkf.get_html(url, cached=False) + soup = BeautifulSoup(html_content, "html.parser") + items = LogicLinkkf._parse_vod_items(soup) + return { + "ret": "success", + "page": int(page), + "total_page": LogicLinkkf._parse_total_page(soup), + "episode_count": len(items), + "episode": items, + } + + @staticmethod + def _get_home_response(): + url = ModelSetting.get("linkkf_url") + html_content = LogicLinkkf.get_html(url, cached=False) + soup = BeautifulSoup(html_content, "html.parser") + items = LogicLinkkf._parse_vod_items(soup)[:20] + return { + "ret": "success", + "page": 1, + "total_page": 1, + "episode_count": len(items), + "episode": items, + } + + @staticmethod + def _normalize_code(code): + value = str(code).strip() + match = re.search(r"/(?:ani|watch)/(\d+)/", value) + if match: + return match.group(1) + match = re.search(r"(\d{3,})", value) + if match: + return match.group(1) + return value + + @staticmethod + def _parse_program_title(raw_title): + title = (raw_title or "").strip() + match = re.search(r"^(?P.*?)(?:\s+(?P<season>\d+)\s*기)?$", title) + if match is None: + return Util.change_text_for_use_filename(title).strip(), "1" + + season = match.group("season") or "1" + normalized_title = (match.group("title") or title).strip() + normalized_title = normalized_title.replace("()", "").replace("OVA", "").strip() + normalized_title = Util.change_text_for_use_filename(normalized_title).strip() + return normalized_title, season + + @staticmethod + def _parse_detail_rows(soup): + details = [] + for li in soup.select(".detail-info-desc li"): + label_tag = li.select_one("span") + raw_text = li.get_text(" ", strip=True) + if raw_text == "": + continue + + if label_tag is None: + details.append({"info": raw_text}) + continue + + key = label_tag.get_text(" ", strip=True).replace(":", "").replace(":", "").strip() + value = raw_text.replace(label_tag.get_text(" ", strip=True), "", 1).strip(" /") + if key == "": + key = "info" + details.append({key: value}) + + return details if len(details) > 0 else [{"정보없음": ""}] + + @staticmethod + def get_html(url, cached=False): + + try: + if LogicLinkkf.referer is None: + LogicLinkkf.referer = f"{ModelSetting.get('linkkf_url')}" + + # return LogicLinkkf.get_html_requests(url) + return LogicLinkkf.get_html_cloudflare(url) + + except Exception as e: + logger.error("Exception:%s", e) + logger.error(traceback.format_exc()) + + @staticmethod + def get_html_requests(url, cached=False): + if LogicLinkkf.session is None: + if cached: + logger.debug("cached===========++++++++++++") + + LogicLinkkf.session = CachedSession( + os.path.join(cache_path, "linkkf_cache"), + backend="sqlite", + expire_after=300, + cache_control=True, + ) + # print(f"{cache_path}") + # print(f"cache_path:: {LogicLinkkf.session.cache}") + else: + LogicLinkkf.session = requests.Session() + + LogicLinkkf.referer = f"{ModelSetting.get('linkkf_url')}" + + LogicLinkkf.headers["Referer"] = LogicLinkkf.referer + + # logger.debug( + # f"get_html()::LogicLinkkf.referer = {LogicLinkkf.referer}" + # ) + page = LogicLinkkf.session.get(url, headers=LogicLinkkf.headers) + # logger.info(f"page: {page}") + + return page.content.decode("utf8", errors="replace") + + @staticmethod + def get_html_selenium(url, referer=None): + from selenium.webdriver.common.by import By + + from selenium import webdriver + from selenium_stealth import stealth + from webdriver_manager.chrome import ChromeDriverManager + + from seleniumwire import webdriver + import time + import platform + import os + + os_platform = platform.system() + + # print(os_platform) + + options = webdriver.ChromeOptions() + # 크롬드라이버 헤더 옵션추가 (리눅스에서 실행시 필수) + options.add_argument("start-maximized") + options.add_argument("--headless") + options.add_argument("--no-sandbox") + options.add_experimental_option("excludeSwitches", ["enable-automation"]) + options.add_experimental_option("useAutomationExtension", False) + + if os_platform == "Darwin": + # 크롬드라이버 경로 + driver_bin_path = os.path.join( + os.path.dirname(__file__), "bin", f"{os_platform}" + ) + driver_path = f"{driver_bin_path}/chromedriver" + driver = webdriver.Chrome( + executable_path=driver_path, chrome_options=options + ) + # driver = webdriver.Chrome( + # ChromeDriverManager().install(), chrome_options=options + # ) + elif os_platform == "Linux": + driver_bin_path = os.path.join( + os.path.dirname(__file__), "bin", f"{os_platform}" + ) + driver_path = f"{driver_bin_path}/chromedriver" + driver = webdriver.Chrome( + executable_path=driver_path, chrome_options=options + ) + + else: + # driver_bin_path = os.path.join( + # os.path.dirname(__file__), "bin", f"{os_platform}" + # ) + # driver_path = f"{driver_bin_path}/chromedriver" + # driver = webdriver.Chrome(executable_path=driver_path, chrome_options=options) + driver = webdriver.Chrome( + ChromeDriverManager().install(), chrome_options=options + ) + + LogicLinkkf.headers["Referer"] = f"{ModelSetting.get('linkkf_url')}" + + driver.header_overrides = LogicLinkkf.headers + # stealth( + # driver, + # languages=["en-US", "en"], + # vendor="Google Inc.", + # platform="Win32", + # webgl_vendor="Intel Inc.", + # renderer="Intel Iris OpenGL Engine", + # fix_hairline=True, + # ) + driver.get(url) + + # driver.refresh() + print(f"current_url:: {driver.current_url}") + + # time.sleep(1) + elem = driver.find_element(By.XPATH, "//*") + source_code = elem.get_attribute("outerHTML") + + time.sleep(3.0) + + return source_code.encode("utf-8") + + @staticmethod + def get_html_playwright(url): + from playwright.sync_api import sync_playwright + import time + + try: + + start = time.time() + ua = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/69.0.3497.100 Safari/537.36" + ) + # from playwright_stealth import stealth_sync + + with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + context = browser.new_context( + user_agent=ua, + ) + LogicLinkkf.referer = f"{ModelSetting.get('linkkf_url')}" + + LogicLinkkf.headers["Referer"] = LogicLinkkf.referer + + logger.debug(f"headers::: {LogicLinkkf.headers}") + + context.set_extra_http_headers(LogicLinkkf.headers) + + page = context.new_page() + + page.set_extra_http_headers(LogicLinkkf.headers) + # stealth_sync(page) + page.goto(url, wait_until="domcontentloaded") + + # print(page.request.headers) + # print(page.content()) + + print(f"run at {time.time() - start} sec") + + return page.content() + except ModuleNotFoundError: + # os.system(f"pip3 install playwright") + # os.system(f"playwright install") + pass + + @staticmethod + def get_html_cloudflare(url, cached=False): + # scraper = cloudscraper.create_scraper( + # # disableCloudflareV1=True, + # # captcha={"provider": "return_response"}, + # delay=10, + # browser="chrome", + # ) + # scraper = cfscrape.create_scraper( + # browser={"browser": "chrome", "platform": "android", "desktop": False} + # ) + + # scraper = cloudscraper.create_scraper( + # browser={"browser": "chrome", "platform": "windows", "mobile": False}, + # debug=True, + # ) + logger.debug("cloudflare protection bypass ==================") + + user_agents_list = [ + "Mozilla/5.0 (iPad; CPU OS 12_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4844.83 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4844.51 Safari/537.36", + ] + # ua = UserAgent(verify_ssl=False) + + LogicLinkkf.headers["User-Agent"] = random.choice(user_agents_list) + + LogicLinkkf.headers["Referer"] = LogicLinkkf.referer + + # logger.debug(f"headers:: {LogicLinkkf.headers}") + + if LogicLinkkf.session is None: + LogicLinkkf.session = requests.Session() + + # LogicLinkkf.session = requests.Session() + # re_sess = requests.Session() + # logger.debug(LogicLinkkf.session) + + # sess = cloudscraper.create_scraper( + # # browser={"browser": "firefox", "mobile": False}, + # browser={"browser": "chrome", "mobile": False}, + # debug=True, + # sess=LogicLinkkf.session, + # delay=10, + # ) + # scraper = cloudscraper.create_scraper(sess=re_sess) + scraper = cloudscraper.create_scraper( + # debug=True, + delay=10, + sess=LogicLinkkf.session, + browser={ + "custom": "linkkf", + }, + ) + + # print(scraper.get(url, headers=LogicLinkkf.headers).content) + # print(scraper.get(url).content) + # return scraper.get(url, headers=LogicLinkkf.headers).content + # logger.debug(LogicLinkkf.headers) + return scraper.get( + url, + headers=LogicLinkkf.headers, + timeout=10, + ).content.decode("utf8", errors="replace") + + @staticmethod + def get_video_url_from_url(url, url2): + target = str(url2 or "").replace("&", "&").strip() + if target == "": + return [None, None, None] + + if target.startswith("/"): + target = urllib.parse.urljoin(url, target) + + try: + player_html = LogicLinkkf.get_html(target) + video_url, vtt_url = LogicLinkkf._extract_stream_config(player_html, target) + if video_url is not None: + return [video_url, target, vtt_url] + + server_urls = re.findall(r'data-url=["\']([^"\']+)["\']', player_html) + for server_url in server_urls: + next_target = server_url.replace("&", "&") + if next_target.startswith("/"): + next_target = urllib.parse.urljoin(target, next_target) + nested_html = LogicLinkkf.get_html(next_target) + video_url, vtt_url = LogicLinkkf._extract_stream_config(nested_html, next_target) + if video_url is not None: + return [video_url, next_target, vtt_url] + except Exception as e: + logger.error("Exception:%s", e) + logger.error(traceback.format_exc()) + + return [None, None, None] + + @staticmethod + def apply_new_title(new_title): + try: + ret = {} + if LogicLinkkf.current_data is not None: + program = ( + db.session.query(ModelLinkkfProgram) + .filter_by(programcode=LogicLinkkf.current_data["code"]) + .first() + ) + new_title = Util.change_text_for_use_filename(new_title) + LogicLinkkf.current_data["save_folder"] = new_title + program.save_folder = new_title + db.session.commit() + total_epi = None + for entity in LogicLinkkf.current_data["episode"]: + entity["save_folder"] = new_title + entity["filename"] = LogicLinkkf.get_filename( + LogicLinkkf.current_data["save_folder"], + LogicLinkkf.current_data["season"], + entity["title"], + total_epi, + ) + + return LogicLinkkf.current_data + else: + ret["ret"] = False + ret["log"] = "No current data!!" + except Exception as e: + logger.error("Exception:%s", e) + logger.error(traceback.format_exc()) + ret["ret"] = False + ret["log"] = str(e) + return ret + + @staticmethod + def apply_new_season(new_season): + try: + ret = {} + season = int(new_season) + if LogicLinkkf.current_data is not None: + program = ( + db.session.query(ModelLinkkfProgram) + .filter_by(programcode=LogicLinkkf.current_data["code"]) + .first() + ) + LogicLinkkf.current_data["season"] = season + program.season = season + db.session.commit() + total_epi = None + for entity in LogicLinkkf.current_data["episode"]: + entity["filename"] = LogicLinkkf.get_filename( + LogicLinkkf.current_data["save_folder"], + LogicLinkkf.current_data["season"], + entity["title"], + total_epi, + ) + return LogicLinkkf.current_data + else: + ret["ret"] = False + ret["log"] = "No current data!!" + except Exception as e: + logger.error("Exception:%s", e) + logger.error(traceback.format_exc()) + ret["ret"] = False + ret["log"] = str(e) + return ret + + @staticmethod + def add_whitelist(*args): + ret = {} + + logger.debug(f"args: {args}") + try: + + if len(args) == 0: + code = str(LogicLinkkf.current_data["code"]) + else: + # code = str(args[0]) + code = str(args[0]["data_code"]) + + whitelist_program = ModelSetting.get("whitelist_program") + whitelist_programs = [ + str(x.strip().replace(" ", "")) + for x in whitelist_program.replace("\n", ",").split(",") + ] + if code not in whitelist_programs: + whitelist_programs.append(code) + whitelist_programs = filter( + lambda x: x != "", whitelist_programs + ) # remove blank code + whitelist_program = ",".join(whitelist_programs) + entity = ( + db.session.query(ModelSetting) + .filter_by(key="whitelist_program") + .with_for_update() + .first() + ) + entity.value = whitelist_program + db.session.commit() + ret["ret"] = True + ret["code"] = code + if len(args) == 0: + return LogicLinkkf.current_data + else: + return ret + else: + ret["ret"] = False + ret["log"] = "이미 추가되어 있습니다." + except Exception as e: + logger.error("Exception:%s", e) + logger.error(traceback.format_exc()) + ret["ret"] = False + ret["log"] = str(e) + return ret + + @staticmethod + async def fetch_url(session, url): + async with session.get(url) as resp: + # print(type(resp.text())) + data = [] + html_content = await resp.text() + tree = html.fromstring(html_content) + tmp_items = tree.xpath('//div[@class="myui-vodlist__box"]') + for item in tmp_items: + entity = {} + entity["link"] = item.xpath(".//a/@href")[0] + entity["code"] = re.search(r"[0-9]+", entity["link"]).group() + data.append(entity["code"]) + return data + + @staticmethod + # def flatten_list(nested_list): + # flat_list = [] + # if isinstance(nested_list, list): + # for sublist in nested_list: + # flat_list.extend(flatten_list(sublist)) + # else: + # flat_list.append(nested_list) + # return flat_list + def flatten_list(nested_list): + flat_list = [] + for sublist in nested_list: + for item in sublist: + flat_list.append(item) + return flat_list + + @staticmethod + @linkkf_async_timeit + async def get_airing_code(): + try: + data = LogicLinkkf._get_home_response() + codes = [item["code"] for item in data.get("episode", []) if item.get("code")] + logger.debug(codes) + return codes + + except Exception as e: + logger.error("Exception:%s", e) + logger.error(traceback.format_exc()) + + @staticmethod + def get_airing_info(): + try: + return LogicLinkkf._get_home_response() + + except Exception as e: + logger.error("Exception:%s", e) + logger.error(traceback.format_exc()) + + @staticmethod + def get_search_result(query): + + try: + _query = urllib.parse.quote(query) + url = f"{ModelSetting.get('linkkf_url').rstrip('/')}/view/?wd={_query}" + logger.debug("search url::> %s", url) + html_content = LogicLinkkf.get_html(url) + soup = BeautifulSoup(html_content, "html.parser") + items = LogicLinkkf._parse_vod_items(soup) + data = { + "ret": "success", + "query": query, + "total_page": LogicLinkkf._parse_total_page(soup), + "episode_count": len(items), + "episode": items, + } + return data + + except Exception as e: + logger.error("Exception:%s", e) + logger.error(traceback.format_exc()) + + @staticmethod + def get_anime_list_info(cate, page): + try: + if cate == "ing": + return LogicLinkkf._get_list_response("/list/2/", page) + elif cate == "movie": + return LogicLinkkf._get_list_response("/list/2/lang/Movie/", page) + elif cate == "complete": + return LogicLinkkf._get_list_response("/list/9/", page) + elif cate == "top_view": + return LogicLinkkf._get_home_response() + return {"ret": "success", "page": int(page), "total_page": 0, "episode_count": 0, "episode": []} + + except Exception as e: + logger.error("Exception:%s", e) + logger.error(traceback.format_exc()) + + @staticmethod + def get_screen_movie_info(page): + try: + return LogicLinkkf._get_list_response("/list/2/lang/Movie/", page) + + except Exception as e: + logger.error("Exception:%s", e) + logger.error(traceback.format_exc()) + + @staticmethod + def get_complete_anilist_info(page): + try: + return LogicLinkkf._get_list_response("/list/9/", page) + + except Exception as e: + logger.error("Exception:%s", e) + logger.error(traceback.format_exc()) + + @staticmethod + def get_title_info(code): + try: + if ( + LogicLinkkf.current_data is not None + and LogicLinkkf.current_data["code"] == code + and LogicLinkkf.current_data["ret"] + ): + return LogicLinkkf.current_data + url = "%s/%s" % (ModelSetting.get("linkkf_url"), code) + logger.info(url) + + # logger.debug(f"LogicLinkkf.headers: {LogicLinkkf.headers}") + + html_content = LogicLinkkf.get_html(url, cached=False) + # html_content = LogicLinkkf.get_html_playwright(url) + # html_content = LogicLinkkf.get_html_cloudflare(url, cached=False) + + sys.setrecursionlimit(10**7) + # logger.info(html_content) + tree = html.fromstring(html_content) + # tree = etree.fromstring( + # html_content, parser=etree.XMLParser(huge_tree=True) + # ) + # tree1 = BeautifulSoup(html_content, "lxml") + + soup = BeautifulSoup(html_content, "html.parser") + # tree = etree.HTML(str(soup)) + # logger.info(tree) + + data = {"code": code, "ret": False} + tmp = soup.select("ul > a") + + # logger.debug(f"tmp1 size:=> {str(len(tmp))}") + + try: + tmp = ( + tree.xpath('//div[@class="hrecipe"]/article/center/strong')[ + 0 + ] + .text_content() + .strip() + ) + except IndexError: + tmp = ( + tree.xpath("//article/center/strong")[0] + .text_content() + .strip() + ) + match = re.compile(r"(?P<season>\d+)기").search(tmp) + if match: + data["season"] = match.group("season") + else: + data["season"] = "1" + + # replace_str = f'({data["season"]}기)' + # logger.info(replace_str) + data["_id"] = str(code) + data["title"] = tmp.replace(data["season"] + "기", "").strip() + data["title"] = data["title"].replace("()", "").strip() + data["title"] = ( + Util.change_text_for_use_filename(data["title"]) + .replace("OVA", "") + .strip() + ) + # logger.info(f"title:: {data['title']}") + try: + data["poster_url"] = tree.xpath( + '//div[@class="myui-content__thumb"]/a/@data-original' + ) + # print(tree.xpath('//div[@class="myui-content__detail"]/text()')) + if ( + len( + tree.xpath( + '//div[@class="myui-content__detail"]/text()' + ) + ) + > 3 + ): + data["detail"] = [ + { + "info": tree.xpath( + '//div[@class="myui-content__detail"]/text()' + )[3] + } + ] + else: + data["detail"] = [{"정보없음": ""}] + except Exception as e: + logger.error(e) + data["detail"] = [{"정보없음": ""}] + data["poster_url"] = None + + data["rate"] = tree.xpath('span[@class="tag-score"]') + # tag_score = tree.xpath('//span[@class="taq-score"]').text_content().strip() + tag_score = tree.xpath('//span[@class="taq-score"]')[ + 0 + ].text_content() + # logger.debug(tag_score) + tag_count = ( + tree.xpath('//span[contains(@class, "taq-count")]')[0] + .text_content() + .strip() + ) + data_rate = tree.xpath('//div[@class="rating"]/div/@data-rate') + # logger.debug("data_rate::> %s", data_rate) + # tmp = tree.xpath('//*[@id="relatedpost"]/ul/li') + # tmp = tree.xpath('//article/a') + # 수정된 + # tmp = tree.xpath("//ul/a") + tmp = soup.select("ul > a") + + # logger.debug(f"tmp size:=> {str(len(tmp))}") + # logger.info(tmp) + if tmp is not None: + data["episode_count"] = str(len(tmp)) + else: + data["episode_count"] = "0" + + data["episode"] = [] + # tags = tree.xpath( + # '//*[@id="syno-nsc-ext-gen3"]/article/div[1]/article/a') + # tags = tree.xpath("//ul/a") + tags = soup.select("ul > u > a") + if len(tags) > 0: + pass + else: + tags = soup.select("ul > a") + total_epi_no = len(tags) + logger.debug(len(tags)) + + # logger.info("tags", tags) + # re1 = re.compile(r'\/(?P<code>\d+)') + re1 = re.compile(r"\-([^-])+\.") + + data["save_folder"] = data["title"] + # logger.debug(f"save_folder::> {data['save_folder']}") + + program = ( + db.session.query(ModelLinkkfProgram) + .filter_by(programcode=code) + .first() + ) + + if program is None: + program = ModelLinkkfProgram(data) + db.session.add(program) + db.session.commit() + else: + data["save_folder"] = program.save_folder + data["season"] = program.season + + idx = 1 + for t in tags: + entity = { + "_id": data["code"], + "program_code": data["code"], + "program_title": data["title"], + "save_folder": Util.change_text_for_use_filename( + data["save_folder"] + ), + "title": t.text.strip(), + # "title": t.text_content().strip(), + } + # entity['code'] = re1.search(t.attrib['href']).group('code') + + # logger.debug(f"title ::>{entity['title']}") + + # 고유id임을 알수 없는 말도 안됨.. + # 에피소드 코드가 고유해야 상태값 갱신이 제대로 된 값에 넣어짐 + p = re.compile(r"([0-9.]+)화?") + try: + m_obj = p.match(entity["title"]) + except: + m_obj = None + logger.debug(entity["title"]) + # entity['code'] = data['code'] + '_' +str(idx) + + episode_code = None + try: + logger.debug( + f"m_obj::> {m_obj.group(0)} {data['title']} {entity['title']}" + ) + logger.debug( + f"m_obj::> {m_obj.group(1)} {data['title']} {entity['title']}" + ) + except: + pass + if m_obj is not None: + episode_code = m_obj.group(1) + entity["code"] = data["code"] + episode_code.zfill(4) + else: + entity["code"] = data["code"] + + logger.debug("episode_code", entity["code"]) + # entity["url"] = t.attrib["href"] + check_url = t["href"] + if check_url.startswith("http"): + entity["url"] = t["href"] + else: + entity["url"] = ( + f"{ModelSetting.get('linkkf_url')}{t['href']}" + ) + entity["season"] = data["season"] + + # 저장경로 저장 + tmp_save_path = ModelSetting.get("download_path") + if ModelSetting.get("auto_make_folder") == "True": + program_path = os.path.join( + tmp_save_path, entity["save_folder"] + ) + entity["save_path"] = program_path + if ModelSetting.get("linkkf_auto_make_season_folder") == "True": + entity["save_path"] = os.path.join( + entity["save_path"], + "Season %s" % int(entity["season"]), + ) + + data["episode"].append(entity) + entity["image"] = data["poster_url"] + + # entity['title'] = t.text_content().strip().encode('utf8') + + # entity['season'] = data['season'] + # logger.debug(f"save_folder::2> {data['save_folder']}") + entity["filename"] = LogicLinkkf.get_filename( + data["save_folder"], + data["season"], + entity["title"], + total_epi_no, + ) + idx = idx + 1 + total_epi_no -= 1 + data["ret"] = True + # logger.info('data', data) + LogicLinkkf.current_data = data + + # srt 파일 처리 + + return data + except Exception as e: + logger.error("Exception:%s", e) + logger.error(traceback.format_exc()) + data["log"] = str(e) + data["ret"] = "error" + return data + except IndexError as e: + logger.error("Exception:%s", e) + logger.error(traceback.format_exc()) + data["log"] = str(e) + data["ret"] = "error" + return data + + @staticmethod + def get_title_info(code): + try: + code = LogicLinkkf._normalize_code(code) + if ( + LogicLinkkf.current_data is not None + and LogicLinkkf.current_data["code"] == code + and LogicLinkkf.current_data["ret"] + ): + return LogicLinkkf.current_data + + url = f"{ModelSetting.get('linkkf_url').rstrip('/')}/ani/{code}/" + logger.info(url) + html_content = LogicLinkkf.get_html(url, cached=False) + soup = BeautifulSoup(html_content, "html.parser") + + data = {"code": code, "_id": str(code), "ret": False} + + title_node = soup.select_one(".detail-info-title") or soup.select_one("title") + raw_title = title_node.get_text(" ", strip=True) if title_node is not None else code + data["title"], data["season"] = LogicLinkkf._parse_program_title(raw_title) + + poster_node = soup.select_one("img[data-original]") + data["poster_url"] = ( + poster_node.get("data-original", "").strip() if poster_node is not None else None + ) + data["detail"] = LogicLinkkf._parse_detail_rows(soup) + + tags = soup.select(".episode-box a[href^='/watch/']") + if len(tags) == 0: + tags = soup.select("a.text-overflow.ep[href^='/watch/']") + + data["episode_count"] = str(len(tags)) + data["episode"] = [] + data["save_folder"] = data["title"] + + program = ( + db.session.query(ModelLinkkfProgram) + .filter_by(programcode=code) + .first() + ) + + if program is None: + program = ModelLinkkfProgram(data) + db.session.add(program) + db.session.commit() + else: + data["save_folder"] = program.save_folder + data["season"] = program.season + + total_epi_no = len(tags) + for idx, tag in enumerate(tags, start=1): + episode_title = tag.get_text(" ", strip=True).strip() + href = tag.get("href", "").strip() + if href == "": + continue + + entity = { + "_id": data["code"], + "program_code": data["code"], + "program_title": data["title"], + "save_folder": Util.change_text_for_use_filename(data["save_folder"]), + "title": episode_title, + } + + match = re.search(r"([0-9]+(?:\.[0-9]+)?)", episode_title) + if match is not None: + episode_code = match.group(1).replace(".", "") + entity["code"] = data["code"] + episode_code.zfill(4) + else: + entity["code"] = f"{data['code']}_{idx:04d}" + + if href.startswith("http"): + entity["url"] = href + else: + entity["url"] = urllib.parse.urljoin(ModelSetting.get("linkkf_url"), href) + entity["season"] = data["season"] + + tmp_save_path = ModelSetting.get("download_path") + if ModelSetting.get("auto_make_folder") == "True": + program_path = os.path.join(tmp_save_path, entity["save_folder"]) + entity["save_path"] = program_path + if ModelSetting.get("linkkf_auto_make_season_folder") == "True": + entity["save_path"] = os.path.join( + entity["save_path"], + "Season %s" % int(entity["season"]), + ) + + entity["image"] = data["poster_url"] + entity["filename"] = LogicLinkkf.get_filename( + data["save_folder"], + data["season"], + entity["title"], + total_epi_no, + ) + data["episode"].append(entity) + total_epi_no -= 1 + + logger.debug("request analysis parsed episodes: %s", len(data["episode"])) + data["ret"] = True + LogicLinkkf.current_data = data + return data + except Exception as e: + logger.error("Exception:%s", e) + logger.error(traceback.format_exc()) + data = {"code": str(code), "ret": "error", "log": str(e)} + return data + + @staticmethod + def get_filename(maintitle, season, title, total_epi): + try: + logger.debug( + "get_filename()= %s %s %s %s", + maintitle, + season, + title, + total_epi, + ) + match = re.compile( + r"(?P<title>.*?)\s?((?P<season>\d+)기)?\s?((?P<epi_no>\d+)화?)" + ).search(title) + if match: + # epi_no_ckeck = match.group("epi_no") + # logger.debug('EP 문자 %s', epi_no_ckeck) + # if ' ' in title: + # tes = title.find(' ') + # epi_no = int(title[0:tes]) + # title = epi_no + # logger.debug('EP 포함 문자(공백) %s', epi_no) + # elif 'OVA' in title: + # tes = title.find('OVA') + # check = int(tes) + # if check == 0: + # epi_no = total_epi + # else: + # epi_no = int(title[0:tes]) + # title = epi_no + # logger.debug('EP 포함 문자(OVA) %s', epi_no) + # elif 'SP' in title: + # tes = title.find('SP') + # epi_no = int(title[0:tes]) + # title = epi_no + # logger.debug('EP 포함 문자 (SP) %s', epi_no) + # elif '-' in title: + # tes = title.find('-') + # epi_no = int(title[0:tes]) + # title = epi_no + # logger.debug('EP 포함 문자(-) %s', epi_no) + # else: + # epi_no = int(match.group("epi_no")) + # logger.debug('EP 문자 %s', epi_no) + # try: + # logger.debug("epi_no: %s %s", int(epi_no), int(title)) + # if epi_no == int(title): + # if epi_no < 10: + # epi_no = "0%s" % epi_no + # else: + # epi_no = "%s" % epi_no + # except: + # logger.debug("epi_no: %s %s", int(epi_no), float(title)) + # if epi_no < 10: + # epi_no = '0%.1f'%float(title) + # epi_no = "0%s-pt1" % epi_no + # else: + # epi_no = '%.1f'%float(title) + # epi_no = "%s-pt1" % epi_no + epi_no = total_epi + if epi_no < 10: + epi_no = "0%s" % epi_no + else: + epi_no = "%s" % epi_no + + if int(season) < 10: + season = "0%s" % season + else: + season = "%s" % season + + # title_part = match.group('title').strip() + # ret = '%s.S%sE%s%s.720p-SA.mp4' % (maintitle, season, epi_no, date_str) + ret = "%s.S%sE%s.720p-LK.mp4" % (maintitle, season, epi_no) + else: + logger.debug("NOT MATCH") + ret = "%s.720p-LK.mp4" % maintitle + + return Util.change_text_for_use_filename(ret) + except Exception as e: + logger.error("Exception:%s", e) + logger.error(traceback.format_exc()) + + @staticmethod + def _extract_player_payload(html_text): + marker = "var player_aaaa=" + start = html_text.find(marker) + if start < 0: + return None + + start = html_text.find("{", start) + if start < 0: + return None + + depth = 0 + in_string = False + escape = False + quote = None + end = None + + for idx in range(start, len(html_text)): + char = html_text[idx] + if in_string: + if escape: + escape = False + elif char == "\\": + escape = True + elif char == quote: + in_string = False + else: + if char in ['"', "'"]: + in_string = True + quote = char + elif char == "{": + depth += 1 + elif char == "}": + depth -= 1 + if depth == 0: + end = idx + 1 + break + + if end is None: + return None + + payload_text = html_text[start:end] + try: + return json.loads(payload_text) + except Exception: + try: + return json.loads(payload_text.replace("\\/", "/")) + except Exception as e: + logger.error("Exception:%s", e) + logger.error(traceback.format_exc()) + return None + + @staticmethod + def _extract_stream_config(player_html, base_url): + video_url = None + vtt_url = None + + video_match = re.search(r'videoUrl\s*:\s*["\']([^"\']+)["\']', player_html) + if video_match is None: + video_match = re.search( + r'new\s+Artplayer\(\s*\{.*?\burl\s*:\s*["\']([^"\']+\.(?:m3u8|mp4)[^"\']*)["\']', + player_html, + re.S, + ) + if video_match is None: + video_match = re.search( + r'\burl\s*:\s*["\']([^"\']+\.(?:m3u8|mp4)[^"\']*)["\']', + player_html, + re.S, + ) + if video_match is not None: + video_url = urllib.parse.urljoin(base_url, video_match.group(1)) + + vtt_match = re.search(r'"file"\s*:\s*"([^"]+\.vtt[^"]*)"', player_html) + if vtt_match is None: + vtt_match = re.search( + r'subtitle\s*:\s*\{.*?url\s*:\s*["\']([^"\']+\.vtt[^"\']*)["\']', + player_html, + re.S, + ) + if vtt_match is not None: + vtt_url = urllib.parse.urljoin(base_url, vtt_match.group(1)) + + return video_url, vtt_url + + @staticmethod + def _get_player_candidates(payload): + candidates = [] + + actual_url = str(payload.get("actual_url", "")).replace("\\/", "/").strip() + if actual_url != "": + candidates.append(actual_url) + + stream_code = str(payload.get("url", "")).replace("\\/", "/").strip() + play_from = str(payload.get("from", "")).strip().lower() + + if ( + stream_code != "" + and stream_code.startswith("http") is False + and re.match(r"^[A-Za-z0-9._-]+$", stream_code) is not None + and play_from in ["sub", "dub", ""] + ): + for target in [ + f"https://play.sub3.top/r2/play.php?&id=pp2&url={stream_code}", + f"https://playv2.sub3.top/r2/playhd2.php?&id=n21&url={stream_code}", + ]: + if target not in candidates: + candidates.append(target) + + return candidates + + @staticmethod + def get_video_url(episode_url: str) -> list: + try: + if episode_url.startswith("http"): + url = episode_url + else: + url = urllib.parse.urljoin(ModelSetting.get("linkkf_url"), episode_url) + + logger.info("get_video_url(): url: %s", url) + + if "playhd2.php" in url or "play.php" in url: + player_html = LogicLinkkf.get_html(url) + video_url, vtt_url = LogicLinkkf._extract_stream_config(player_html, url) + if video_url is not None: + return [video_url, url, vtt_url] + + html_text = LogicLinkkf.get_html(url) + payload = LogicLinkkf._extract_player_payload(html_text) + + if payload is not None: + for target in LogicLinkkf._get_player_candidates(payload): + player_html = LogicLinkkf.get_html(target) + video_url, vtt_url = LogicLinkkf._extract_stream_config(player_html, target) + if video_url is not None: + return [video_url, target, vtt_url] + + server_urls = re.findall(r'data-url=["\']([^"\']+)["\']', html_text) + for server_url in server_urls: + target = server_url.replace("&", "&") + if target.startswith("/"): + target = urllib.parse.urljoin(url, target) + player_html = LogicLinkkf.get_html(target) + video_url, vtt_url = LogicLinkkf._extract_stream_config(player_html, target) + if video_url is not None: + return [video_url, target, vtt_url] + + pattern = re.compile(r"player_post\('https:\/\/.*?'\)").findall(html_text) + fallback_urls = [] + for tag in pattern: + target = tag[13:-2] + if "ds" in target or "hls" in target or "subkf" in target: + continue + if target not in fallback_urls: + fallback_urls.append(target) + + for target in fallback_urls: + result = LogicLinkkf.get_video_url_from_url(url, target) + if result[0] is not None: + return result + + except Exception as e: + logger.error("Exception:%s", e) + logger.error(traceback.format_exc()) + + return [None, None, None] + + @staticmethod + def download_subtitle(info): + # logger.debug(info) + ani_url = LogicLinkkf.get_video_url(info["url"]) + # logger.debug(f"ani_url: {ani_url}") + + referer = None + + # vtt file to srt file + from urllib import parse + + if ani_url[1] is not None: + referer = ani_url[1] + else: + referer = ModelSetting.get("linkkf_url") + + logger.debug(f"referer:: {referer}") + + headers = { + "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/71.0.3554.0 Safari/537.36Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/71.0.3554.0 Safari/537.36", + "Referer": f"{referer}", + } + logger.debug(headers) + + save_path = ModelSetting.get("download_path") + if ModelSetting.get("auto_make_folder") == "True": + program_path = os.path.join(save_path, info["save_folder"]) + save_path = program_path + if ModelSetting.get("linkkf_auto_make_season_folder") == "True": + save_path = os.path.join( + save_path, "Season %s" % int(info["season"]) + ) + + ret = re.compile(r"(http(s)?:\/\/)([a-z0-9\w]+\.*)+[a-z0-9]{2,4}") + base_url_vtt = ret.match(referer) + + if ani_url[2] is None: + return + if ani_url[2].startswith("http"): + vtt_url = ani_url[2] + else: + vtt_url = base_url_vtt[0] + ani_url[2] + + logger.debug(f"srt:url => {vtt_url}") + srt_filepath = os.path.join( + save_path, info["filename"].replace(".mp4", ".ko.srt") + ) + if not os.path.exists(save_path): + os.makedirs(save_path) + # logger.info('srt_filepath::: %s', srt_filepath) + if ani_url[2] is not None and not os.path.exists(srt_filepath): + res = requests.get(vtt_url, headers=headers) + vtt_data = res.text + vtt_status = res.status_code + if vtt_status == 200: + srt_data = convert_vtt_to_srt(vtt_data) + write_file(srt_data, srt_filepath) + else: + logger.debug("자막파일 받을수 없슴") + + @staticmethod + def chunks(l, n): + n = max(1, n) + return (l[i : i + n] for i in range(0, len(l), n)) + + @staticmethod + def get_info_by_code(code): + logger.debug("get_info_by_code: %s", code) + + try: + if LogicLinkkf.current_data is not None: + for t in LogicLinkkf.current_data["episode"]: + if t["code"] == code: + logger.debug(t["code"]) + return t + except Exception as e: + logger.error("Exception:%s", e) + logger.error(traceback.format_exc()) + + @staticmethod + def scheduler_function(): + try: + logger.debug("Linkkf scheduler_function start..") + + whitelist_program = ModelSetting.get("whitelist_program") + whitelist_programs = [ + x.strip().replace(" ", "") + for x in whitelist_program.replace("\n", ",").split(",") + ] + + logger.debug(f"whitelist_programs: {whitelist_programs}") + + for code in whitelist_programs: + logger.info("auto download start : %s", code) + downloaded = ( + db.session.query(ModelLinkkf) + .filter(ModelLinkkf.completed.is_(True)) + .filter_by(programcode=code) + .with_for_update() + .all() + ) + logger.debug(f"downloaded:: {downloaded}") + dl_codes = [dl.episodecode for dl in downloaded] + # logger.debug("dl_codes:: %s", dl_codes) + logger.info("downloaded codes :%s", dl_codes) + + # if len(dl_codes) > 0: + data = LogicLinkkf.get_title_info(code) + logger.debug(f"data:: {data}") + + for episode in data["episode"]: + e_code = episode["code"] + if e_code not in dl_codes: + logger.info("Logic Queue added :%s", e_code) + + logger.debug(f"episode:: {episode}") + print("temp==============") + LogicQueue.add_queue(episode) + + logger.debug("========================================") + except Exception as e: + logger.error("Exception:%s", e) + logger.error(traceback.format_exc()) + + @staticmethod + def reset_db() -> bool: + db.session.query(ModelLinkkf).delete() + db.session.commit() + return True diff --git a/logic_queue.py b/logic_queue.py new file mode 100644 index 0000000..276ccaf --- /dev/null +++ b/logic_queue.py @@ -0,0 +1,576 @@ +# -*- coding: utf-8 -*- +import os +import queue +import re +import threading +import time +import traceback +from collections import deque +from datetime import datetime + +import requests + +from framework import F, db, get_logger +from support.expand.ffmpeg import SupportFfmpeg + +from .model import ModelLinkkf, ModelSetting +from .subtitle_util import convert_vtt_to_srt, write_file + + +package_name = __name__.split(".")[0] +logger = get_logger(package_name) + + +FFMPEG_STATUS_KOR = { + -1: "대기중", + 0: "준비", + 1: "URL 오류", + 2: "폴더 오류", + 3: "예외", + 4: "오류", + 5: "다운로드중", + 6: "사용자중지", + 7: "완료", + 8: "시간초과", + 9: "PF중지", + 10: "강제중지", + 11: "403 오류", + 12: "중복 다운로드", + 100: "파일 있음", +} + +ACTIVE_STATUS = {0, 5} +FINAL_STATUS = {1, 2, 3, 4, 6, 7, 8, 9, 10, 11, 12, 100} + + +class QueueEntity: + static_index = 1 + entity_list = [] + + def __init__(self, info): + self.entity_id = QueueEntity.static_index + QueueEntity.static_index += 1 + + self.info = info + self.episodecode = info["code"] + self.url = None + self.ffmpeg_status = -1 + self.ffmpeg_status_kor = FFMPEG_STATUS_KOR[-1] + self.ffmpeg_percent = 0 + self.ffmpeg_arg = None + self.ffmpeg_callback_id = None + self.ffmpeg_idx = None + self.ffmpeg_finalized = False + self.cancel = False + self.created_time = datetime.now().strftime("%m-%d %H:%M:%S") + self.status = -1 + + QueueEntity.entity_list.append(self) + + @staticmethod + def get_entity_by_entity_id(entity_id): + target = str(entity_id) + for item in QueueEntity.entity_list: + if str(item.entity_id) == target: + return item + + +class LogicQueue(object): + download_queue = None + download_thread = None + monitor_thread = None + current_ffmpeg_count = 0 + + @staticmethod + def queue_start(): + try: + if LogicQueue.download_queue is None: + LogicQueue.download_queue = queue.Queue() + if LogicQueue.download_thread is None: + LogicQueue.download_thread = threading.Thread( + target=LogicQueue.download_thread_function, + daemon=True, + ) + LogicQueue.download_thread.start() + if LogicQueue.monitor_thread is None: + LogicQueue.monitor_thread = threading.Thread( + target=LogicQueue.monitor_thread_function, + daemon=True, + ) + LogicQueue.monitor_thread.start() + except Exception as e: + logger.error("Exception:%s", e) + logger.error(traceback.format_exc()) + + @staticmethod + def _make_save_path(info): + save_path = ModelSetting.get("download_path") + if ModelSetting.get("auto_make_folder") == "True": + save_path = os.path.join(save_path, info["save_folder"]) + if ModelSetting.get("linkkf_auto_make_season_folder") == "True": + save_path = os.path.join(save_path, f"Season {int(info['season'])}") + return save_path + + @staticmethod + def _make_headers(video_info): + referer = video_info[1] or f"{ModelSetting.get('linkkf_url').rstrip('/')}/" + return { + "user-agent": ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/104.0.0.0 Safari/537.36" + ), + "Referer": referer, + } + + @staticmethod + def _ensure_db_entity(info, reset_state=False): + with F.app.app_context(): + episode = db.session.query(ModelLinkkf).filter_by(episodecode=info["code"]).with_for_update().first() + if episode is None: + episode = ModelLinkkf("auto", info=info) + db.session.add(episode) + else: + episode.set_info(info) + if reset_state: + episode.completed = False + episode.user_abort = False + episode.pf_abort = False + episode.etc_abort = 0 + episode.ffmpeg_status = -1 + episode.completed_time = None + episode.end_time = None + episode.download_time = None + episode.filesize = None + episode.filesize_str = None + episode.download_speed = None + episode.start_time = datetime.now() + episode.status = "waiting" + episode.filename = info.get("filename", episode.filename) + episode.save_path = LogicQueue._make_save_path(info) + db.session.commit() + return episode + + @staticmethod + def _make_runtime_snapshot(entity): + data = {} + if isinstance(entity.ffmpeg_arg, dict): + data = entity.ffmpeg_arg.get("data") or {} + data = dict(data) + data.setdefault("status", entity.ffmpeg_status) + data.setdefault("filename", entity.info.get("filename")) + data.setdefault("save_path", LogicQueue._make_save_path(entity.info)) + data.setdefault("percent", entity.ffmpeg_percent) + if entity.ffmpeg_callback_id is not None: + data.setdefault("callback_id", entity.ffmpeg_callback_id) + return data + + @staticmethod + def sync_entities_to_db(): + try: + for entity in list(QueueEntity.entity_list): + LogicQueue._ensure_db_entity(entity.info) + LogicQueue._update_db_from_runtime(entity, LogicQueue._make_runtime_snapshot(entity)) + except Exception as e: + logger.error("Exception:%s", e) + logger.error(traceback.format_exc()) + + @staticmethod + def _set_entity_status(entity, status, percent=0, data=None): + entity.ffmpeg_status = int(status) + entity.ffmpeg_status_kor = FFMPEG_STATUS_KOR.get(int(status), str(status)) + entity.ffmpeg_percent = int(percent or 0) + entity.status = int(status) + entity.ffmpeg_arg = {"status": int(status), "data": data or {}} + if data is not None: + entity.ffmpeg_idx = data.get("idx") + if data.get("callback_id") is not None: + entity.ffmpeg_callback_id = str(data.get("callback_id")) + + try: + from . import plugin + + plugin.socketio_callback( + "status", + { + "plugin_id": entity.entity_id, + "status": entity.ffmpeg_status_kor, + "data": { + "percent": entity.ffmpeg_percent, + "current_speed": "" if data is None else data.get("current_speed", ""), + }, + }, + ) + except Exception as e: + logger.error("Exception:%s", e) + logger.error(traceback.format_exc()) + + @staticmethod + def ffmpeg_callback(**arg): + try: + callback_id = str(arg.get("callback_id", "")) + data = arg.get("data") or {} + if callback_id == "": + return + + entity = QueueEntity.get_entity_by_entity_id(callback_id) + if entity is None: + return + + status = int(arg.get("status", data.get("status", entity.ffmpeg_status))) + percent = int(data.get("percent", entity.ffmpeg_percent)) + LogicQueue._set_entity_status(entity, status, percent, data) + LogicQueue._update_db_from_runtime(entity, data) + + if status in FINAL_STATUS and entity.ffmpeg_finalized is False: + entity.ffmpeg_finalized = True + LogicQueue.current_ffmpeg_count = max(0, LogicQueue.current_ffmpeg_count - 1) + LogicQueue._remove_completed_entity(entity, status) + from . import plugin + + plugin.socketio_list_refresh() + except Exception as e: + logger.error("Exception:%s", e) + logger.error(traceback.format_exc()) + + @staticmethod + def _update_db_from_runtime(entity, data): + with F.app.app_context(): + episode = db.session.query(ModelLinkkf).filter_by(episodecode=entity.info["code"]).with_for_update().first() + if episode is None: + return + + status = int(data.get("status", entity.ffmpeg_status)) + episode.ffmpeg_status = status + episode.filename = data.get("filename", entity.info.get("filename")) + episode.save_path = data.get("save_path", LogicQueue._make_save_path(entity.info)) + + if status in ACTIVE_STATUS: + episode.status = "downloading" + + if status == 7: + episode.completed = True + episode.completed_time = datetime.now() + episode.end_time = datetime.now() + if episode.start_time is not None and episode.end_time is not None: + episode.download_time = int((episode.end_time - episode.start_time).total_seconds()) + episode.filesize = data.get("filesize") + episode.filesize_str = data.get("filesize_str") + episode.download_speed = data.get("download_speed") + episode.status = "completed" + elif status == 6: + episode.user_abort = True + episode.status = "canceled" + elif status == 9: + episode.pf_abort = True + episode.pf = int(data.get("current_pf_count", 0)) + episode.status = "error" + elif status in {1, 2, 3, 4, 8, 10, 11, 12}: + episode.etc_abort = status + episode.status = "error" + elif status == 100: + episode.completed = True + episode.completed_time = datetime.now() + episode.status = "completed" + + db.session.commit() + + @staticmethod + def _download_subtitle(video_info, save_path, filename, headers): + try: + subtitle_url = video_info[2] + if subtitle_url in [None, ""]: + return + + if subtitle_url.startswith("http"): + vtt_url = subtitle_url + else: + match = re.match(r"(https?://[^/]+)", str(video_info[1] or "")) + if match is None: + return + vtt_url = match.group(1) + subtitle_url + + srt_filepath = os.path.join(save_path, filename.replace(".mp4", ".ko.srt")) + if os.path.exists(srt_filepath): + return + + response = requests.get(vtt_url, headers=headers, timeout=30) + if response.status_code != 200: + return + + srt_data = convert_vtt_to_srt(response.text) + write_file(srt_data, srt_filepath) + except Exception as e: + logger.error("Exception:%s", e) + logger.error(traceback.format_exc()) + + @staticmethod + def _remove_from_pending_queue(entity_id): + if LogicQueue.download_queue is None: + return + with LogicQueue.download_queue.mutex: + LogicQueue.download_queue.queue = deque( + item + for item in list(LogicQueue.download_queue.queue) + if str(item.entity_id) != str(entity_id) + ) + + @staticmethod + def _remove_entity_only(entity): + if entity is None: + return + QueueEntity.entity_list = [ + item for item in QueueEntity.entity_list if str(item.entity_id) != str(entity.entity_id) + ] + + @staticmethod + def _remove_completed_entity(entity, status): + if entity is None: + return + if int(status) in {7, 100}: + LogicQueue._remove_entity_only(entity) + + @staticmethod + def _prepare_download(entity): + from .logic_linkkf import LogicLinkkf + + LogicQueue._ensure_db_entity(entity.info) + entity.url = LogicLinkkf.get_video_url(entity.info["url"]) + logger.debug("resolved video url: %s", entity.url) + + if entity.url is None or entity.url[0] is None: + LogicQueue._set_entity_status(entity, 1, 0, {}) + LogicQueue._update_db_from_runtime(entity, {"status": 1}) + return None + + save_path = LogicQueue._make_save_path(entity.info) + os.makedirs(save_path, exist_ok=True) + target_path = os.path.join(save_path, entity.info["filename"]) + + if os.path.exists(target_path): + LogicQueue._set_entity_status(entity, 100, 100, {"percent": 100}) + LogicQueue._update_db_from_runtime( + entity, + { + "status": 100, + "filename": entity.info["filename"], + "save_path": save_path, + "percent": 100, + }, + ) + entity.ffmpeg_finalized = True + LogicQueue._remove_completed_entity(entity, 100) + return None + + headers = LogicQueue._make_headers(entity.url) + LogicQueue._download_subtitle(entity.url, save_path, entity.info["filename"], headers) + + return { + "video_url": entity.url[0], + "save_path": save_path, + "headers": headers, + } + + @staticmethod + def download_thread_function(): + while True: + entity = None + try: + while LogicQueue.current_ffmpeg_count >= int(ModelSetting.get("max_ffmpeg_process_count")): + time.sleep(1) + + entity = LogicQueue.download_queue.get() + if entity is None or entity.cancel: + continue + + prepared = LogicQueue._prepare_download(entity) + if prepared is None: + continue + + ffmpeg_instance = SupportFfmpeg( + prepared["video_url"], + entity.info["filename"], + save_path=prepared["save_path"], + headers=prepared["headers"], + callback_id=str(entity.entity_id), + callback_function=LogicQueue.ffmpeg_callback, + ) + data = ffmpeg_instance.start() + logger.debug("ffmpeg direct download start: %s", data) + + LogicQueue.current_ffmpeg_count += 1 + entity.ffmpeg_callback_id = str(data.get("callback_id")) + LogicQueue._set_entity_status( + entity, + data.get("status", 0), + data.get("percent", 0), + data, + ) + LogicQueue._update_db_from_runtime(entity, data) + + from . import plugin + + plugin.socketio_list_refresh() + except Exception as e: + if entity is not None: + LogicQueue._set_entity_status(entity, 4, entity.ffmpeg_percent, {}) + LogicQueue._update_db_from_runtime(entity, {"status": 4}) + logger.error("Exception:%s", e) + logger.error(traceback.format_exc()) + finally: + if entity is not None and LogicQueue.download_queue is not None: + try: + LogicQueue.download_queue.task_done() + except Exception: + pass + + @staticmethod + def monitor_thread_function(): + while True: + try: + for entity in list(QueueEntity.entity_list): + if entity.ffmpeg_callback_id in [None, ""] or entity.ffmpeg_finalized: + continue + + instance = SupportFfmpeg.get_instance_by_callback_id(entity.ffmpeg_callback_id) + if instance is None: + continue + + data = instance.get_data() + status = int(data.get("status", entity.ffmpeg_status)) + percent = int(data.get("percent", entity.ffmpeg_percent)) + + if status != entity.ffmpeg_status or percent != entity.ffmpeg_percent: + LogicQueue._set_entity_status(entity, status, percent, data) + LogicQueue._update_db_from_runtime(entity, data) + + if status in FINAL_STATUS: + entity.ffmpeg_finalized = True + LogicQueue.current_ffmpeg_count = max(0, LogicQueue.current_ffmpeg_count - 1) + LogicQueue._remove_completed_entity(entity, status) + from . import plugin + + plugin.socketio_list_refresh() + except Exception as e: + logger.error("Exception:%s", e) + logger.error(traceback.format_exc()) + + time.sleep(1) + + @staticmethod + def add_queue(info): + try: + db_entity = ModelLinkkf.get_by_linkkf_id(info["code"]) + existing = None + for item in QueueEntity.entity_list: + if item.info["code"] == info["code"] and item.ffmpeg_status not in FINAL_STATUS: + existing = item + break + + if existing is not None: + return "queue_exist" + + if db_entity is not None and db_entity.status == "completed": + return "db_completed" + + LogicQueue._ensure_db_entity(info, reset_state=True) + + entity = QueueEntity(info) + LogicQueue.download_queue.put(entity) + + from . import plugin + + plugin.socketio_list_refresh() + + if db_entity is None: + return "enqueue_db_append" + return "enqueue_db_exist" + except Exception as e: + logger.error("Exception:%s", e) + logger.error(traceback.format_exc()) + return False + + @staticmethod + def _stop_entity(entity): + if entity is None or entity.ffmpeg_callback_id in [None, ""]: + return {"ret": "refresh"} + + SupportFfmpeg.stop_by_callback_id(entity.ffmpeg_callback_id) + instance = SupportFfmpeg.get_instance_by_callback_id(entity.ffmpeg_callback_id) + data = instance.get_data() if instance is not None else {} + if data: + LogicQueue._set_entity_status( + entity, + data.get("status", 6), + data.get("percent", entity.ffmpeg_percent), + data, + ) + LogicQueue._update_db_from_runtime(entity, data) + else: + LogicQueue._set_entity_status(entity, 6, entity.ffmpeg_percent, {}) + LogicQueue._update_db_from_runtime(entity, {"status": 6}) + entity.ffmpeg_finalized = True + LogicQueue.current_ffmpeg_count = max(0, LogicQueue.current_ffmpeg_count - 1) + return {"ret": "refresh"} + + @staticmethod + def program_auto_command(req): + ret = {} + try: + entity_id = req.form.get("entity_id", "-1") + command = req.form["command"] + entity = QueueEntity.get_entity_by_entity_id(entity_id) + + if command == "cancel": + if entity is None: + return {"ret": "refresh"} + if entity.ffmpeg_status == -1: + entity.cancel = True + entity.ffmpeg_finalized = True + entity.ffmpeg_status = 6 + entity.ffmpeg_status_kor = FFMPEG_STATUS_KOR[6] + ret["ret"] = "refresh" + elif entity.ffmpeg_status in ACTIVE_STATUS: + ret = LogicQueue._stop_entity(entity) + else: + ret["ret"] = "notify" + ret["log"] = "다운로드 중인 상태가 아닙니다." + elif command == "delete": + if entity is None: + return {"ret": "refresh"} + if entity.ffmpeg_status == -1: + entity.cancel = True + entity.ffmpeg_finalized = True + LogicQueue._remove_from_pending_queue(entity.entity_id) + elif entity.ffmpeg_status in ACTIVE_STATUS: + LogicQueue._stop_entity(entity) + LogicQueue._remove_entity_only(entity) + ret["ret"] = "refresh" + elif command == "reset": + if LogicQueue.download_queue is not None: + with LogicQueue.download_queue.mutex: + LogicQueue.download_queue.queue.clear() + for item in list(QueueEntity.entity_list): + if item.ffmpeg_status in ACTIVE_STATUS: + LogicQueue._stop_entity(item) + QueueEntity.entity_list = [] + LogicQueue.current_ffmpeg_count = 0 + ret["ret"] = "refresh" + elif command == "delete_completed": + QueueEntity.entity_list = [ + item for item in QueueEntity.entity_list if item.ffmpeg_status not in FINAL_STATUS + ] + ret["ret"] = "refresh" + else: + ret["ret"] = "notify" + ret["log"] = f"지원하지 않는 명령: {command}" + + from . import plugin + + plugin.socketio_list_refresh() + except Exception as e: + logger.error("Exception:%s", e) + logger.error(traceback.format_exc()) + ret["ret"] = "notify" + ret["log"] = str(e) + return ret diff --git a/mod_basic.py b/mod_basic.py new file mode 100644 index 0000000..fb9b413 --- /dev/null +++ b/mod_basic.py @@ -0,0 +1,303 @@ +import asyncio +import json +import re +import threading +import traceback +import urllib.parse + +import requests +from flask import Response, jsonify, render_template, stream_with_context +from plugin import PluginModuleBase, default_route_socketio_module +from framework import F + +from .logic import Logic +from .logic_linkkf import LogicLinkkf +from .logic_queue import LogicQueue, QueueEntity +from .model import ModelLinkkf +from .setup import P + + +class ModuleBasic(PluginModuleBase): + def __init__(self, P): + super(ModuleBasic, self).__init__(P, name="main") + default_route_socketio_module(self) + + @staticmethod + def _make_proxy_url(target, referer): + return ( + f"/{P.package_name}/normal/proxy" + f"?target={urllib.parse.quote(str(target), safe='')}" + f"&referer={urllib.parse.quote(str(referer or ''), safe='')}" + ) + + @staticmethod + def _get_proxy_headers(referer): + return { + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/122.0.0.0 Safari/537.36" + ), + "Referer": referer or P.ModelSetting.get("linkkf_url"), + "Origin": urllib.parse.urlsplit(referer or P.ModelSetting.get("linkkf_url")).scheme + + "://" + + urllib.parse.urlsplit(referer or P.ModelSetting.get("linkkf_url")).netloc, + } + + @staticmethod + def _rewrite_m3u8(content, target_url, referer): + def replace_uri_attr(line): + def repl(match): + absolute = urllib.parse.urljoin(target_url, match.group(1)) + proxied = ModuleBasic._make_proxy_url(absolute, referer) + return f'URI="{proxied}"' + + return re.sub(r'URI="([^"]+)"', repl, line) + + lines = [] + for raw_line in content.splitlines(): + line = raw_line.strip() + if line == "": + lines.append(raw_line) + continue + if line.startswith("#"): + lines.append(replace_uri_attr(raw_line)) + continue + absolute = urllib.parse.urljoin(target_url, line) + lines.append(ModuleBasic._make_proxy_url(absolute, referer)) + return "\n".join(lines) + + def process_menu(self, sub, req): + if sub == "log": + return render_template("log.html", package=self.P.package_name) + + arg = self.P.ModelSetting.to_dict() if self.P.ModelSetting is not None else {} + arg["package_name"] = self.P.package_name + arg["sub"] = sub + arg["template_name"] = f"{self.P.package_name}_{sub}" + + if sub == "setting": + arg["scheduler"] = str(F.scheduler.is_include(self.P.package_name)) + arg["is_running"] = str(F.scheduler.is_running(self.P.package_name)) + elif sub in ["request", "queue", "list"]: + arg["current_code"] = ( + LogicLinkkf.current_data["code"] + if LogicLinkkf.current_data is not None + else "" + ) + + return render_template(f"{self.P.package_name}_{sub}.html", arg=arg) + + def process_ajax(self, sub, req): + try: + if sub == "scheduler_toggle": + go = req.form["scheduler"] + if go == "true": + Logic.scheduler_start() + else: + Logic.scheduler_stop() + return jsonify(go) + if sub == "execute_once": + threading.Thread(target=Logic.scheduler_function, daemon=True).start() + return jsonify({"ret": "success"}) + if sub == "analysis": + code = req.form["code"] + data = LogicLinkkf.get_title_info(code) + if data["ret"] == "error": + return jsonify(data) + return jsonify({"ret": "success", "data": data}) + if sub == "play": + episode_url = req.form["url"] + play_title = req.form.get("title", "LinkKF") + return jsonify( + { + "ret": "success", + "data": { + "play_url": ( + f"/{self.P.package_name}/normal/play" + f"?url={urllib.parse.quote(str(episode_url), safe='')}" + f"&title={urllib.parse.quote(str(play_title), safe='')}" + ) + }, + } + ) + if sub == "play_latest": + code = req.form["code"] + data = LogicLinkkf.get_title_info(code) + if data["ret"] == "error": + return jsonify(data) + if "episode" not in data or len(data["episode"]) == 0: + return jsonify({"ret": "error", "log": "최신 화 정보를 찾지 못했습니다."}) + latest_episode = data["episode"][0] + latest_title = f"{data['title']} - {latest_episode['title']}" + return jsonify( + { + "ret": "success", + "data": { + "play_url": ( + f"/{self.P.package_name}/normal/play" + f"?url={urllib.parse.quote(str(latest_episode['url']), safe='')}" + f"&title={urllib.parse.quote(str(latest_title), safe='')}" + ), + }, + } + ) + if sub == "search": + query = req.form["query"] + return jsonify(LogicLinkkf.get_search_result(str(query))) + if sub == "anime_list": + page = req.form["page"] + cate = req.form["type"] + return jsonify(LogicLinkkf.get_anime_list_info(cate, page)) + if sub == "airing_list": + return jsonify(LogicLinkkf.get_airing_info()) + if sub == "get_airing_code": + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + data = loop.run_until_complete(LogicLinkkf.get_airing_code()) + return jsonify({"ret": "success", "data": data}) + if sub == "screen_movie_list": + page = req.form["page"] + return jsonify(LogicLinkkf.get_screen_movie_info(page)) + if sub == "complete_anilist": + page = req.form["page"] + return jsonify(LogicLinkkf.get_complete_anilist_info(page)) + if sub == "apply_new_title": + return jsonify(LogicLinkkf.apply_new_title(req.form["new_title"])) + if sub == "apply_new_season": + return jsonify(LogicLinkkf.apply_new_season(req.form["new_season"])) + if sub == "add_whitelist": + payload = req.get_json() + ret = LogicLinkkf.add_whitelist(payload if payload is not None else None) + return jsonify(ret) + if sub == "add_queue": + code = req.form["code"] + info = LogicLinkkf.get_info_by_code(code) + if info is not None: + return jsonify({"ret": LogicQueue.add_queue(info)}) + return jsonify({"ret": "no_data"}) + if sub == "add_queue_checked_list": + code_list = req.form["code"].split(",") + count = 0 + for code in code_list: + info = LogicLinkkf.get_info_by_code(code) + if info is not None: + LogicQueue.add_queue(info) + count += 1 + return jsonify({"ret": "success", "log": str(count)}) + if sub == "down_subtitle_list": + code_list = req.form["code"].split(",") + count = 0 + for code in code_list: + info = LogicLinkkf.get_info_by_code(code) + if info is not None: + LogicLinkkf.download_subtitle(info) + count += 1 + return jsonify({"ret": "success", "log": str(count)}) + if sub == "program_auto_command": + return jsonify(LogicQueue.program_auto_command(req)) + if sub == "web_list": + LogicQueue.sync_entities_to_db() + return jsonify(ModelLinkkf.web_list(req)) + if sub == "db_remove": + return jsonify(ModelLinkkf.delete_by_id(req.form["id"])) + if sub == "reset_db": + res = LogicLinkkf.reset_db() + return jsonify({"ret": "success" if res else "error"}) + except Exception as e: + self.P.logger.error(f"Exception:{str(e)}") + self.P.logger.error(traceback.format_exc()) + return jsonify({"ret": "error", "log": str(e)}) + return jsonify({"ret": "error", "log": f"unsupported ajax: {sub}"}) + + def process_normal(self, sub, req): + try: + if sub == "play": + episode_url = req.args.get("url", "").strip() + play_title = req.args.get("title", "LinkKF").strip() + video_info = LogicLinkkf.get_video_url(episode_url) + if video_info is None or video_info[0] in [None, ""]: + return f"재생 URL을 가져오지 못했습니다: {episode_url}", 500 + + referer = video_info[1] or self.P.ModelSetting.get("linkkf_url") + data = { + "play_title": play_title or "LinkKF", + "play_source_src": self._make_proxy_url(video_info[0], referer), + "play_source_type": "application/x-mpegURL" if ".m3u8" in str(video_info[0]).lower() else "video/mp4", + "play_subtitle_src": "", + } + if len(video_info) > 2 and video_info[2] not in [None, ""]: + data["play_subtitle_src"] = self._make_proxy_url(video_info[2], referer) + return render_template("videojs.html", data=data) + + if sub == "proxy": + target = req.args.get("target", "").strip() + referer = req.args.get("referer", self.P.ModelSetting.get("linkkf_url")).strip() + if target == "": + return "missing target", 400 + + headers = self._get_proxy_headers(referer) + if req.headers.get("Range") is not None: + headers["Range"] = req.headers.get("Range") + upstream = requests.get(target, headers=headers, stream=True, timeout=30) + content_type = upstream.headers.get("content-type", "") + + if upstream.status_code >= 400: + return Response( + upstream.content, + status=upstream.status_code, + content_type=content_type or "text/plain", + ) + + if ".m3u8" in target.lower() or "mpegurl" in content_type.lower(): + text = upstream.text + rewritten = self._rewrite_m3u8(text, target, referer) + return Response( + rewritten, + content_type=content_type or "application/vnd.apple.mpegurl", + ) + + def generate(): + try: + for chunk in upstream.iter_content(chunk_size=64 * 1024): + if chunk: + yield chunk + finally: + upstream.close() + + response = Response( + stream_with_context(generate()), + status=upstream.status_code, + content_type=content_type or "application/octet-stream", + ) + if upstream.headers.get("Accept-Ranges") is not None: + response.headers["Accept-Ranges"] = upstream.headers.get("Accept-Ranges") + if upstream.headers.get("Content-Length") is not None: + response.headers["Content-Length"] = upstream.headers.get("Content-Length") + if upstream.headers.get("Content-Range") is not None: + response.headers["Content-Range"] = upstream.headers.get("Content-Range") + return response + except Exception as e: + self.P.logger.error(f"Exception:{str(e)}") + self.P.logger.error(traceback.format_exc()) + return f"playback proxy error: {e}", 500 + return "unsupported normal route", 404 + + def plugin_load(self): + Logic.plugin_load() + + def plugin_unload(self): + Logic.plugin_unload() + + def setting_save_after(self, change_list): + if "linkkf_url" in change_list: + LogicLinkkf.referer = None + LogicLinkkf.headers["Referer"] = self.P.ModelSetting.get("linkkf_url") + + def socketio_connect(self): + data = json.loads(json.dumps([item.__dict__ for item in QueueEntity.entity_list], default=str)) + self.socketio_callback("on_connect", data, encoding=False) + + def socketio_list_refresh(self): + data = json.loads(json.dumps([item.__dict__ for item in QueueEntity.entity_list], default=str)) + self.socketio_callback("list_refresh", data, encoding=False) diff --git a/model.py b/model.py new file mode 100644 index 0000000..4918b99 --- /dev/null +++ b/model.py @@ -0,0 +1,273 @@ +# -*- coding: utf-8 -*- +import json +import os +from datetime import datetime + +from sqlalchemy import desc, or_ + +from .setup import * + + +ModelSetting = P.ModelSetting + + +class ModelLinkkfProgram(ModelBase): + P = P + __tablename__ = "linkkf_program" + __bind_key__ = P.package_name + + id = db.Column(db.Integer, primary_key=True) + contents_json = db.Column(db.JSON) + created_time = db.Column(db.DateTime) + programcode = db.Column(db.String) + save_folder = db.Column(db.String) + season = db.Column(db.Integer) + + def __init__(self, data): + self.created_time = datetime.now() + self.programcode = data["code"] + self.save_folder = data["title"] + self.season = data["season"] + + def set_info(self, data): + self.contents_json = data + self.programcode = data["code"] + self.save_folder = data["save_folder"] + self.season = data["season"] + + +class ModelLinkkf(ModelBase): + P = P + __tablename__ = "linkkf_auto_episode" + __bind_key__ = P.package_name + + id = db.Column(db.Integer, primary_key=True) + contents_json = db.Column(db.JSON) + created_time = db.Column(db.DateTime) + completed_time = db.Column(db.DateTime) + + programcode = db.Column(db.String) + episodecode = db.Column(db.String) + filename = db.Column(db.String) + duration = db.Column(db.Integer) + start_time = db.Column(db.DateTime) + end_time = db.Column(db.DateTime) + download_time = db.Column(db.Integer) + completed = db.Column(db.Boolean) + user_abort = db.Column(db.Boolean) + pf_abort = db.Column(db.Boolean) + etc_abort = db.Column(db.Integer) + ffmpeg_status = db.Column(db.Integer) + temp_path = db.Column(db.String) + save_path = db.Column(db.String) + pf = db.Column(db.Integer) + retry = db.Column(db.Integer) + filesize = db.Column(db.Integer) + filesize_str = db.Column(db.String) + download_speed = db.Column(db.String) + call = db.Column(db.String) + status = db.Column(db.String) + linkkf_info = db.Column(db.JSON) + + def __init__(self, call, info): + self.created_time = datetime.now() + self.completed = False + self.start_time = datetime.now() + self.user_abort = False + self.pf_abort = False + self.etc_abort = 0 + self.ffmpeg_status = -1 + self.pf = 0 + self.retry = 0 + self.call = call + self.set_info(info) + + def as_dict(self): + ret = super().as_dict() + if ret.get("status") in [None, ""]: + if self.completed is True: + ret["status"] = "completed" + elif self.user_abort is True: + ret["status"] = "canceled" + elif self.pf_abort is True or (self.etc_abort is not None and int(self.etc_abort) > 0): + ret["status"] = "error" + elif self.ffmpeg_status in [0, 5]: + ret["status"] = "downloading" + ret["created_time"] = self.created_time.strftime("%Y-%m-%d %H:%M:%S") + ret["completed_time"] = ( + self.completed_time.strftime("%Y-%m-%d %H:%M:%S") + if self.completed_time is not None + else None + ) + return ret + + def set_info(self, data): + self.contents_json = data + self.programcode = data["program_code"] + self.episodecode = data["code"] + self.filename = data.get("filename", self.filename) + self.linkkf_info = data + if self.status in [None, ""]: + self.status = "waiting" + + @staticmethod + def _normalize_json_data(data): + if isinstance(data, dict): + return data + if isinstance(data, str) and data.strip() != "": + try: + return json.loads(data) + except Exception: + return {} + return {} + + @classmethod + def sync_completed_from_filesystem(cls): + with F.app.app_context(): + changed = 0 + rows = F.db.session.query(cls).filter( + or_(cls.status != "completed", cls.status.is_(None), cls.completed.is_(False)) + ).all() + for row in rows: + info = cls._normalize_json_data(row.linkkf_info) or cls._normalize_json_data(row.contents_json) + save_path = row.save_path or info.get("save_path") + filename = row.filename or info.get("filename") + if not save_path or not filename: + continue + fullpath = os.path.join(save_path, filename) + if os.path.exists(fullpath) is False: + continue + row.completed = True + row.user_abort = False + row.pf_abort = False + row.etc_abort = 0 + row.ffmpeg_status = 7 if row.ffmpeg_status in [None, -1, 0, 5] else row.ffmpeg_status + row.status = "completed" + file_time = datetime.fromtimestamp(os.path.getmtime(fullpath)) + if row.end_time is None: + row.end_time = file_time + if row.completed_time is None: + row.completed_time = file_time + changed += 1 + if changed > 0: + F.db.session.commit() + return changed + + @classmethod + def migrate_existing_rows(cls): + with F.app.app_context(): + changed = 0 + rows = F.db.session.query(cls).all() + for row in rows: + info = cls._normalize_json_data(row.contents_json) or cls._normalize_json_data(row.linkkf_info) + updated = False + if row.programcode in [None, ""] and info.get("program_code"): + row.programcode = info.get("program_code") + updated = True + if row.episodecode in [None, ""] and info.get("code"): + row.episodecode = info.get("code") + updated = True + if row.filename in [None, ""] and info.get("filename"): + row.filename = info.get("filename") + updated = True + if row.linkkf_info in [None, {}] and info: + row.linkkf_info = info + updated = True + if row.status in [None, ""]: + if row.completed is True: + row.status = "completed" + elif row.user_abort is True: + row.status = "canceled" + elif row.pf_abort is True or (row.etc_abort is not None and int(row.etc_abort) > 0): + row.status = "error" + elif row.ffmpeg_status in [0, 5]: + row.status = "downloading" + else: + row.status = "waiting" + updated = True + if updated: + changed += 1 + if changed > 0: + F.db.session.commit() + changed += cls.sync_completed_from_filesystem() + return changed + + @classmethod + def web_list(cls, req): + with F.app.app_context(): + ret = {} + cls.sync_completed_from_filesystem() + page = int(req.form["page"]) if "page" in req.form else 1 + page_size = 30 + search = req.form["search_word"] if "search_word" in req.form else req.form.get("keyword", "") + option = req.form["option"] if "option" in req.form else req.form.get("option1", "finished") + order = req.form["order"] if "order" in req.form else "desc" + + query = cls.make_query(search=search, order=order, option=option) + count = query.count() + query = query.limit(page_size).offset((page - 1) * page_size) + lists = query.all() + ret["list"] = [item.as_dict() for item in lists] + ret["paging"] = cls.get_paging_info(count, page, page_size) + return ret + + @classmethod + def get_by_linkkf_id(cls, linkkf_id): + with F.app.app_context(): + return F.db.session.query(cls).filter_by(episodecode=linkkf_id).first() + + @classmethod + def make_query(cls, search="", order="desc", option="all"): + query = F.db.session.query(cls) + if search is not None and search != "": + if "|" in search: + conditions = [] + for token in [x.strip() for x in search.split("|") if x.strip()]: + conditions.append(cls.filename.like(f"%{token}%")) + conditions.append(cls.programcode.like(f"%{token}%")) + if conditions: + query = query.filter(or_(*conditions)) + elif "," in search: + for token in [x.strip() for x in search.split(",") if x.strip()]: + query = query.filter( + or_( + cls.filename.like(f"%{token}%"), + cls.programcode.like(f"%{token}%"), + ) + ) + else: + query = query.filter( + or_( + cls.filename.like(f"%{search}%"), + cls.programcode.like(f"%{search}%"), + ) + ) + if option == "completed": + query = query.filter(or_(cls.status == "completed", cls.completed.is_(True))) + elif option == "canceled": + query = query.filter(or_(cls.status == "canceled", cls.user_abort.is_(True))) + elif option == "error": + query = query.filter( + or_( + cls.status == "error", + cls.pf_abort.is_(True), + cls.etc_abort > 0, + ) + ) + elif option == "finished": + query = query.filter( + or_( + cls.status.in_(["completed", "error", "canceled"]), + cls.completed.is_(True), + cls.user_abort.is_(True), + cls.pf_abort.is_(True), + cls.etc_abort > 0, + ) + ) + elif option == "downloading": + query = query.filter(cls.status == "downloading") + if order == "desc": + query = query.order_by(desc(cls.id)) + else: + query = query.order_by(cls.id) + return query diff --git a/plugin.py b/plugin.py new file mode 100644 index 0000000..8483eb4 --- /dev/null +++ b/plugin.py @@ -0,0 +1,37 @@ +from .setup import P + + +package_name = P.package_name +logger = P.logger +plugin_info = P.plugin_info + + +def _module(): + if P is None or P.module_list is None or len(P.module_list) == 0: + return None + return P.module_list[0] + + +def plugin_load(): + module = _module() + if module is not None: + module.plugin_load() + + +def plugin_unload(): + module = _module() + if module is not None: + module.plugin_unload() + + +def socketio_callback(cmd, data): + module = _module() + if module is not None and hasattr(module, "socketio_callback"): + module.socketio_callback(cmd, data) + + +def socketio_list_refresh(): + module = _module() + if module is not None and hasattr(module, "socketio_list_refresh"): + module.socketio_list_refresh() + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..964c35e --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +cloudscraper +beautifulsoup4 +requests-cache +lxml diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..a2dcb5f --- /dev/null +++ b/setup.py @@ -0,0 +1,58 @@ +import importlib.util +import subprocess +import sys + +from plugin import * + + +REQUIRED_PACKAGES = [ + ("cloudscraper", "cloudscraper"), + ("beautifulsoup4", "bs4"), + ("requests-cache", "requests_cache"), + ("lxml", "lxml"), +] + + +def _ensure_requirements(): + missing = [package for package, module_name in REQUIRED_PACKAGES if importlib.util.find_spec(module_name) is None] + if not missing: + return + + if getattr(P, "logger", None) is not None: + P.logger.info("Installing missing packages: %s", ", ".join(missing)) + + subprocess.check_call([sys.executable, "-m", "pip", "install", *missing]) + + +setting = { + "filepath": __file__, + "use_db": True, + "use_default_setting": True, + "home_module": "category", + "menu": { + "uri": __package__, + "name": "linkkf", + "list": [ + {"uri": "setting", "name": "설정"}, + {"uri": "request", "name": "요청"}, + {"uri": "category", "name": "카테고리"}, + {"uri": "queue", "name": "대기열"}, + {"uri": "list", "name": "목록"}, + {"uri": "log", "name": "로그"}, + ], + }, + "setting_menu": None, + "default_route": "single", +} + + +P = create_plugin_instance(setting) +_ensure_requirements() + +try: + from .mod_basic import ModuleBasic + + P.set_module_list([ModuleBasic]) +except Exception as e: + P.logger.error(f"Exception:{str(e)}") + P.logger.error(traceback.format_exc()) diff --git a/static/css/linkkf_category.css b/static/css/linkkf_category.css new file mode 100644 index 0000000..ab2d3ca --- /dev/null +++ b/static/css/linkkf_category.css @@ -0,0 +1,243 @@ +button.code-button { + min-width: 82px !important; +} +.tooltip { + position: relative; + display: block; +} + +[data-tooltip-text]:hover { + position: relative; +} + +[data-tooltip-text]:after { + -webkit-transition: bottom 0.3s ease-in-out, opacity 0.3s ease-in-out; + -moz-transition: bottom 0.3s ease-in-out, opacity 0.3s ease-in-out; + transition: bottom 0.3s ease-in-out, opacity 0.3s ease-in-out; + + background-color: rgba(0, 0, 0, 0.8); + + -webkit-box-shadow: 0px 0px 3px 1px rgba(50, 50, 50, 0.4); + -moz-box-shadow: 0px 0px 3px 1px rgba(50, 50, 50, 0.4); + box-shadow: 0px 0px 3px 1px rgba(50, 50, 50, 0.4); + + -webkit-border-radius: 5px; + -moz-border-radius: 5px; + border-radius: 5px; + + color: #ffffff; + font-size: 12px; + margin-bottom: 10px; + padding: 7px 12px; + position: absolute; + width: auto; + min-width: 50px; + max-width: 300px; + word-wrap: break-word; + + z-index: 9999; + + opacity: 0; + left: -9999px; + top: 90%; + + content: attr(data-tooltip-text); +} + +[data-tooltip-text]:hover:after { + top: 230%; + left: 0; + opacity: 1; +} +[data-tooltip-text]:hover { + position: relative; +} + +[data-tooltip-text]:after { + -webkit-transition: bottom 0.3s ease-in-out, opacity 0.3s ease-in-out; + -moz-transition: bottom 0.3s ease-in-out, opacity 0.3s ease-in-out; + transition: bottom 0.3s ease-in-out, opacity 0.3s ease-in-out; + + background-color: rgba(0, 0, 0, 0.8); + + -webkit-box-shadow: 0px 0px 3px 1px rgba(50, 50, 50, 0.4); + -moz-box-shadow: 0px 0px 3px 1px rgba(50, 50, 50, 0.4); + box-shadow: 0px 0px 3px 1px rgba(50, 50, 50, 0.4); + + -webkit-border-radius: 5px; + -moz-border-radius: 5px; + border-radius: 5px; + + color: #ffffff; + font-size: 12px; + margin-bottom: 10px; + padding: 7px 12px; + position: absolute; + width: auto; + min-width: 50px; + max-width: 300px; + word-wrap: break-word; + + z-index: 9999; + + opacity: 0; + left: -9999px; + top: -210% !important; + + content: attr(data-tooltip-text); +} + +[data-tooltip-text]:hover:after { + top: 130%; + left: 0; + opacity: 1; +} + +#airing_list { + display: none; +} + +.cut-text { + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + width: 100%; +} + +@media (min-width: 576px) { + .container { + max-width: 100%; + } +} + +@media (min-width: 1280px) { + #linkkf_wrapper { + max-width: 80%; + margin: 0 auto; + } +} + +#screen_movie_list { + margin-top: 10px; +} +/* .spinner {*/ +/* width: 40px;*/ +/* height: 40px;*/ +/* background-color: #333;*/ + +/* margin: 100px auto;*/ +/* -webkit-animation: sk-rotateplane 1.2s infinite ease-in-out;*/ +/* animation: sk-rotateplane 1.2s infinite ease-in-out;*/ +/*}*/ + +/*@-webkit-keyframes sk-rotateplane {*/ +/* 0% { -webkit-transform: perspective(120px) }*/ +/* 50% { -webkit-transform: perspective(120px) rotateY(180deg) }*/ +/* 100% { -webkit-transform: perspective(120px) rotateY(180deg) rotateX(180deg) }*/ +/*}*/ + +/*@keyframes sk-rotateplane {*/ +/* 0% {*/ +/* transform: perspective(120px) rotateX(0deg) rotateY(0deg);*/ +/* -webkit-transform: perspective(120px) rotateX(0deg) rotateY(0deg)*/ +/* } 50% {*/ +/* transform: perspective(120px) rotateX(-180.1deg) rotateY(0deg);*/ +/* -webkit-transform: perspective(120px) rotateX(-180.1deg) rotateY(0deg)*/ +/* } 100% {*/ +/* transform: perspective(120px) rotateX(-180deg) rotateY(-179.9deg);*/ +/* -webkit-transform: perspective(120px) rotateX(-180deg) rotateY(-179.9deg);*/ +/* }*/ + +/*}*/ +.spinner { + width: 40px; + height: 40px; + + position: relative; + margin: 100px auto; +} + +.double-bounce1, +.double-bounce2 { + width: 100%; + height: 100%; + border-radius: 50%; + background-color: #333; + opacity: 0.6; + position: absolute; + top: 0; + left: 0; + + -webkit-animation: sk-bounce 2s infinite ease-in-out; + animation: sk-bounce 2s infinite ease-in-out; +} + +.double-bounce2 { + -webkit-animation-delay: -1s; + animation-delay: -1s; +} + +@-webkit-keyframes sk-bounce { + 0%, + 100% { + -webkit-transform: scale(0); + } + 50% { + -webkit-transform: scale(1); + } +} + +@keyframes sk-bounce { + 0%, + 100% { + transform: scale(0); + -webkit-transform: scale(0); + } + 50% { + transform: scale(1); + -webkit-transform: scale(1); + } +} + +.badge-on-image { + position: absolute; + top: 2px; + /*bottom: 2px; !* position where you want it *!*/ + right: 2px; + padding: 5px 12px; +} + +#inner_screen_movie > div { + margin-bottom: 10px; +} + +.card-body { + padding: 0!important; +} +.new-anime { + border-color: darksalmon; + border-width: 4px; + border-style: dashed; + +} + +.card-title { + padding: 1rem!important; +} + +button#add_whitelist { + float: right; +} + +button.btn-favorite { + background-color: #e0ff42; +} + + +body { + font-family: NanumSquareNeo,system-ui,-apple-system,Segoe UI,Roboto,Helvetica Neue,Noto Sans,Liberation Sans,Arial,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji; +} +body { + background-image: linear-gradient(90deg, #233f48, #6c6fa2, #768dae); +} + diff --git a/static/css/linkkf_list.css b/static/css/linkkf_list.css new file mode 100644 index 0000000..681bb6f --- /dev/null +++ b/static/css/linkkf_list.css @@ -0,0 +1,9 @@ +#list_div img { + max-width: 100%; + height: auto; +} + +#page1, +#page2 { + margin: 12px 0; +} diff --git a/static/css/linkkf_request.css b/static/css/linkkf_request.css new file mode 100644 index 0000000..8a55057 --- /dev/null +++ b/static/css/linkkf_request.css @@ -0,0 +1,162 @@ +button.code-button { + min-width: 82px !important; +} + +.tooltip { + position: relative; + display: block; +} + +[data-tooltip-text]:hover { + position: relative; +} + +[data-tooltip-text]:after { + -webkit-transition: bottom 0.3s ease-in-out, opacity 0.3s ease-in-out; + -moz-transition: bottom 0.3s ease-in-out, opacity 0.3s ease-in-out; + transition: bottom 0.3s ease-in-out, opacity 0.3s ease-in-out; + + background-color: rgba(0, 0, 0, 0.8); + + -webkit-box-shadow: 0px 0px 3px 1px rgba(50, 50, 50, 0.4); + -moz-box-shadow: 0px 0px 3px 1px rgba(50, 50, 50, 0.4); + box-shadow: 0px 0px 3px 1px rgba(50, 50, 50, 0.4); + + -webkit-border-radius: 5px; + -moz-border-radius: 5px; + border-radius: 5px; + + color: #ffffff; + font-size: 12px; + margin-bottom: 10px; + padding: 7px 12px; + position: absolute; + width: auto; + min-width: 50px; + max-width: 300px; + word-wrap: break-word; + + z-index: 9999; + + opacity: 0; + left: -9999px; + top: 90%; + + content: attr(data-tooltip-text); +} + +[data-tooltip-text]:hover:after { + top: 230%; + left: 0; + opacity: 1; +} + +[data-tooltip-text]:hover { + position: relative; +} + +[data-tooltip-text]:after { + -webkit-transition: bottom 0.3s ease-in-out, opacity 0.3s ease-in-out; + -moz-transition: bottom 0.3s ease-in-out, opacity 0.3s ease-in-out; + transition: bottom 0.3s ease-in-out, opacity 0.3s ease-in-out; + + background-color: rgba(0, 0, 0, 0.8); + + -webkit-box-shadow: 0px 0px 3px 1px rgba(50, 50, 50, 0.4); + -moz-box-shadow: 0px 0px 3px 1px rgba(50, 50, 50, 0.4); + box-shadow: 0px 0px 3px 1px rgba(50, 50, 50, 0.4); + + -webkit-border-radius: 5px; + -moz-border-radius: 5px; + border-radius: 5px; + + color: #ffffff; + font-size: 12px; + margin-bottom: 10px; + padding: 7px 12px; + position: absolute; + width: auto; + min-width: 50px; + max-width: 300px; + word-wrap: break-word; + + z-index: 9999; + + opacity: 0; + left: -9999px; + top: -210% !important; + + content: attr(data-tooltip-text); +} + +[data-tooltip-text]:hover:after { + top: 130%; + left: 0; + opacity: 1; +} + +#airing_list { + display: none; +} + +.card { + border: none; + box-shadow: inset 1px 1px hsl(0deg 0% 100% / 20%), inset -1px -1px hsl(0deg 0% 100% / 10%), 1px 3px 24px -1px rgb(0 0 0 / 15%); + background-color: transparent; + background-image: linear-gradient(125deg, hsla(0, 0%, 100%, .3), hsla(0, 0%, 100%, .2) 70%); + backdrop-filter: blur(5px); +} + +.card.border-light { + --bs-border-opacity: 1; + border-color: rgba(var(--bs-light-rgb), var(--bs-border-opacity)) !important; +} + +#request { + color: aliceblue; +} + +body { + font-family: NanumSquareNeo, system-ui, -apple-system, Segoe UI, Roboto, Helvetica Neue, Noto Sans, Liberation Sans, Arial, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji; +} + +body { + background-image: linear-gradient(90deg, #33242c, #263341, #17273a); +} + +@media (min-width: 1280px) { + #request { + max-width: 80%; + margin: 0 auto; + } +} + +@media (min-width: 992px) { + .container { + max-width: 96%; + } +} + +@media (min-width: 768px) { + .container { + max-width: 94%; + } +} + +@media (min-width: 576px) { + .container { + max-width: 96%; + } + + .form-inline .form-control { + width: 98%; + + } +} + +#preloader, +.loader-inner, +.loader-line-wrap, +.loader-line { + display: none !important; +} diff --git a/static/img_loader_x200.svg b/static/img_loader_x200.svg new file mode 100644 index 0000000..05f8d6f --- /dev/null +++ b/static/img_loader_x200.svg @@ -0,0 +1,23 @@ +<?xml version="1.0" encoding="utf-8"?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="margin: auto; background: rgb(241, 242, 243); display: block; shape-rendering: auto;" width="200px" height="200px" viewBox="0 0 100 100" preserveAspectRatio="xMidYMid"> +<g transform="translate(27.166666666666664,27.166666666666664)"> + <rect x="-18.5" y="-18.5" width="37" height="37" fill="#85a2b6"> + <animateTransform attributeName="transform" type="scale" repeatCount="indefinite" dur="1s" keyTimes="0;1" values="1.1;1" begin="-0.3s"></animateTransform> + </rect> +</g> +<g transform="translate(72.83333333333333,27.166666666666664)"> + <rect x="-18.5" y="-18.5" width="37" height="37" fill="#bbcedd"> + <animateTransform attributeName="transform" type="scale" repeatCount="indefinite" dur="1s" keyTimes="0;1" values="1.1;1" begin="-0.2s"></animateTransform> + </rect> +</g> +<g transform="translate(27.166666666666664,72.83333333333333)"> + <rect x="-18.5" y="-18.5" width="37" height="37" fill="#dce4eb"> + <animateTransform attributeName="transform" type="scale" repeatCount="indefinite" dur="1s" keyTimes="0;1" values="1.1;1" begin="0s"></animateTransform> + </rect> +</g> +<g transform="translate(72.83333333333333,72.83333333333333)"> + <rect x="-18.5" y="-18.5" width="37" height="37" fill="#fdfdfd"> + <animateTransform attributeName="transform" type="scale" repeatCount="indefinite" dur="1s" keyTimes="0;1" values="1.1;1" begin="-0.1s"></animateTransform> + </rect> +</g> +<!-- [ldio] generated by https://loading.io/ --></svg> \ No newline at end of file diff --git a/static/js/linkkf_category.js b/static/js/linkkf_category.js new file mode 100644 index 0000000..caa36c3 --- /dev/null +++ b/static/js/linkkf_category.js @@ -0,0 +1,297 @@ +let currentCate = "ing"; +let nextPage = 2; +let totalPage = 1; +let isLoading = false; + +const spinner = document.getElementById("spinner"); +const listContainer = document.getElementById("screen_movie_list"); +const categoryButtons = document.querySelectorAll("#anime_category button"); + +const categoryConfig = { + ing: { + endpoint: "anime_list", + makeData: (page) => ({ page: String(page), type: "ing" }), + title: "애니", + }, + movie: { + endpoint: "screen_movie_list", + makeData: (page) => ({ page: String(page) }), + title: "극장판", + }, + complete: { + endpoint: "complete_anilist", + makeData: (page) => ({ page: String(page) }), + title: "성인", + }, + top_view: { + endpoint: "anime_list", + makeData: (page) => ({ page: String(page), type: "top_view" }), + title: "최신", + }, +}; + +function setLoading(flag) { + isLoading = flag; + spinner.style.display = flag ? "block" : "none"; +} + +function setActiveCategory(cate) { + categoryButtons.forEach((button) => { + button.classList.toggle("active", button.id === cate); + }); +} + +function escapeHtml(value) { + return String(value ?? "") + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function getImageUrl(imageLink) { + const image = String(imageLink || "").trim(); + return image === "" ? placeholder_image : image; +} + +function renderEmpty(message) { + listContainer.innerHTML = `<div class="alert alert-secondary mt-2">${escapeHtml(message)}</div>`; +} + +function openPlayerPage(url) { + if (!url) { + $.notify("<strong>재생 URL을 찾지 못했습니다.</strong>", { + type: "warning", + }); + return; + } + window.open(url, "_blank"); +} + +async function playLatest(code, title) { + try { + const response = await fetch(`/${package_name}/ajax/play_latest`, { + method: "POST", + cache: "no-cache", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + code: String(code || ""), + title: String(title || ""), + }), + }); + const ret = await response.json(); + if (ret.ret !== "success" || ret.data == null) { + $.notify(`<strong>${escapeHtml(ret.log || "재생 정보를 가져오지 못했습니다.")}</strong>`, { + type: "warning", + }); + return; + } + openPlayerPage(ret.data.play_url); + } catch (error) { + console.error(error); + $.notify("<strong>재생 정보를 가져오지 못했습니다.</strong>", { + type: "warning", + }); + } +} + +function renderItems(ret, append = false) { + const items = Array.isArray(ret.episode) ? ret.episode : []; + const page = Number(ret.page || 1); + const title = categoryConfig[currentCate]?.title || "목록"; + + if (items.length === 0 && append === false) { + renderEmpty(`${title} 목록이 없습니다.`); + return; + } + + let html = ""; + if (append === false) { + html += `<div id="page_caption" style="padding-bottom: 3px">`; + html += `<button type="button" class="btn btn-info">${escapeHtml(title)}</button>`; + html += `</div>`; + html += `<div id="inner_screen_movie" class="row infinite-scroll">`; + } + + for (const item of items) { + const code = escapeHtml(item.code); + const titleText = escapeHtml(item.title); + const chapter = escapeHtml(item.chapter || ""); + const imageUrl = escapeHtml(getImageUrl(item.image_link)); + + html += `<div class="col-6 col-sm-4 col-md-3 mb-3">`; + html += `<div class="card h-100">`; + html += `<img class="card-img-top" src="${imageUrl}" alt="${titleText}" loading="lazy" style="cursor: pointer" onclick="location.href='${request_path}?code=${code}'" />`; + if (chapter !== "") { + html += `<span class="badge badge-danger badge-on-image">${chapter}</span>`; + } + html += `<div class="card-body d-flex flex-column">`; + html += `<h5 class="card-title">${titleText}</h5>`; + html += `<a href="${request_path}?code=${code}" class="btn btn-primary btn-sm mb-2">분석</a>`; + html += `<button type="button" class="btn btn-outline-info btn-sm play-latest-btn" data-code="${code}" data-title="${titleText}">최신화 보기</button>`; + html += `</div>`; + html += `</div>`; + html += `</div>`; + } + + if (append === false) { + html += `</div>`; + listContainer.innerHTML = html; + } else { + const wrapper = document.createElement("div"); + wrapper.innerHTML = html; + const target = document.getElementById("inner_screen_movie"); + while (wrapper.firstChild) { + target.appendChild(wrapper.firstChild); + } + } + + if (page >= totalPage) { + nextPage = totalPage + 1; + } else { + nextPage = page + 1; + } +} + +async function fetchCategory(cate, page = 1, append = false) { + const config = categoryConfig[cate]; + if (config === undefined || isLoading) { + return; + } + + currentCate = cate; + setActiveCategory(cate); + setLoading(true); + + try { + const response = await fetch(`/${package_name}/ajax/${config.endpoint}`, { + method: "POST", + cache: "no-cache", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams(config.makeData(page)), + }); + const ret = await response.json(); + if (ret.ret !== "success") { + renderEmpty(ret.log || "목록을 불러오지 못했습니다."); + return; + } + totalPage = Number(ret.total_page || 1); + renderItems(ret, append); + } catch (error) { + console.error(error); + renderEmpty("목록을 불러오지 못했습니다."); + } finally { + setLoading(false); + } +} + +async function runSearch() { + const input = document.getElementById("input_search"); + const query = input.value.trim(); + if (query === "" || isLoading) { + return; + } + + setLoading(true); + try { + const response = await fetch(`/${package_name}/ajax/search`, { + method: "POST", + cache: "no-cache", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ query }), + }); + const ret = await response.json(); + if (ret.ret !== "success") { + renderEmpty(ret.log || "검색 결과를 불러오지 못했습니다."); + return; + } + + currentCate = "search"; + setActiveCategory(""); + totalPage = 1; + nextPage = 2; + const result = { + page: 1, + total_page: 1, + episode: Array.isArray(ret.episode) ? ret.episode : [], + }; + renderItems(result, false); + if (result.episode.length === 0) { + renderEmpty(`"${query}" 검색 결과가 없습니다.`); + } + } catch (error) { + console.error(error); + renderEmpty("검색 결과를 불러오지 못했습니다."); + } finally { + setLoading(false); + } +} + +function loadNextPage() { + if (isLoading) { + return; + } + if (currentCate === "search" || currentCate === "top_view") { + return; + } + if (nextPage > totalPage) { + return; + } + fetchCategory(currentCate, nextPage, true); +} + +function onCategoryClick(event) { + const cate = event.target.id; + if (categoryConfig[cate] === undefined) { + return; + } + nextPage = 2; + totalPage = 1; + fetchCategory(cate, 1, false); +} + +function onScroll() { + const threshold = 50; + const { scrollTop, scrollHeight, clientHeight } = document.documentElement; + if (clientHeight + scrollTop + threshold >= scrollHeight) { + loadNextPage(); + } +} + +function debounce(func, delay) { + let timeoutId = null; + return (...args) => { + clearTimeout(timeoutId); + timeoutId = setTimeout(() => func(...args), delay); + }; +} + +document.getElementById("anime_category").addEventListener("click", onCategoryClick); +document.getElementById("btn_search").addEventListener("click", runSearch); +document.getElementById("input_search").addEventListener("keydown", (event) => { + if (event.key === "Enter") { + event.preventDefault(); + runSearch(); + } +}); +document.addEventListener("scroll", debounce(onScroll, 250)); + +document.body.addEventListener("click", (event) => { + const button = event.target.closest(".play-latest-btn"); + if (!button) { + return; + } + event.preventDefault(); + playLatest(button.dataset.code, button.dataset.title); +}); + +document.addEventListener("DOMContentLoaded", () => { + fetchCategory("ing", 1, false); +}); diff --git a/static/js/linkkf_list.js b/static/js/linkkf_list.js new file mode 100644 index 0000000..edcb6ab --- /dev/null +++ b/static/js/linkkf_list.js @@ -0,0 +1,249 @@ +(function () { +if (window.__linkkfListInitialized === true) { + return; +} +window.__linkkfListInitialized = true; + +let currentData = null; + +const escapeHtml = (value) => + String(value ?? "") + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + +function getFormData() { + return $("#form_search").serialize(); +} + +function normalizeObject(value) { + if (value && typeof value === "object") { + return value; + } + if (typeof value === "string" && value.trim() !== "") { + try { + return JSON.parse(value); + } catch (e) { + return {}; + } + } + return {}; +} + +function openJsonModal(item) { + $("#modal_title").text("JSON"); + $("#modal_body").html(`<pre style="white-space: pre-wrap; word-break: break-all;">${escapeHtml(JSON.stringify(item, null, 2))}</pre>`); + $("#large_modal").modal("show"); +} + +function makeActionButton(id, label, attrs = {}, klass = "btn btn-sm btn-primary mr-1 mb-1") { + const extra = Object.entries(attrs) + .map(([key, value]) => ` data-${key}="${escapeHtml(value)}"`) + .join(""); + return `<button id="${escapeHtml(id)}" class="${klass}"${extra}>${escapeHtml(label)}</button>`; +} + +function getStatusLabel(item) { + if (item.status === "completed") { + return "완료"; + } + if (item.status === "error") { + return "실패"; + } + if (item.status === "canceled") { + return "취소"; + } + if (item.status === "downloading") { + return "진행중"; + } + return "대기"; +} + +function renderPagination(paging) { + if (!paging) { + $("#page1").html(""); + $("#page2").html(""); + return; + } + + let html = ` + <div class="row mb-3"> + <div class="col-sm-12"> + <div class="btn-toolbar justify-content-center" role="toolbar"> + <div class="btn-group btn-group-sm mr-2" role="group"> + `; + + if (paging.prev_page) { + html += `<button id="page" data-page="${paging.start_page - 1}" type="button" class="btn btn-secondary">«</button>`; + } + + for (let i = paging.start_page; i <= paging.last_page; i++) { + const disabled = i === paging.current_page ? " disabled" : ""; + html += `<button id="page" data-page="${i}" type="button" class="btn btn-secondary"${disabled}>${i}</button>`; + } + + if (paging.next_page) { + html += `<button id="page" data-page="${paging.last_page + 1}" type="button" class="btn btn-secondary">»</button>`; + } + + html += ` + </div> + </div> + </div> + </div> + `; + + $("#page1").html(html); + $("#page2").html(html); +} + +function renderList(list) { + if (!Array.isArray(list) || list.length === 0) { + $("#list_div").html('<div class="alert alert-secondary">목록이 없습니다.</div>'); + return; + } + + let html = ""; + for (const item of list) { + const statusLabel = getStatusLabel(item); + const contents = normalizeObject(item.contents_json || item.linkkf_info); + const savePath = item.save_path || contents.save_path || ""; + const filename = item.filename || contents.filename || ""; + const programCode = item.programcode || contents.program_code || ""; + const programTitle = contents.program_title || contents.save_folder || contents.title || filename; + const completedTime = item.completed_time ? `<div>${escapeHtml(item.completed_time)} (${escapeHtml(statusLabel)})</div>` : ""; + + let actions = ""; + actions += makeActionButton("json_btn", "JSON", { id: item.id }, "btn btn-sm btn-info mr-1 mb-1"); + actions += makeActionButton("request_btn", "작품 검색", { content_code: programCode }, "btn btn-sm btn-primary mr-1 mb-1"); + actions += makeActionButton("self_search_btn", "목록 검색", { title: programTitle }, "btn btn-sm btn-secondary mr-1 mb-1"); + actions += makeActionButton("remove_btn", "삭제", { id: item.id }, "btn btn-sm btn-danger mr-1 mb-1"); + + html += ` + <div class="card mb-3"> + <div class="card-body"> + <div class="row"> + <div class="col-md-1"><strong>${escapeHtml(item.id)}</strong></div> + <div class="col-md-2">${escapeHtml(statusLabel)}</div> + <div class="col-md-3"> + <div>${escapeHtml(item.created_time)} (추가)</div> + ${completedTime} + </div> + <div class="col-md-6"> + <div>${escapeHtml(savePath)}</div> + <div>${escapeHtml(filename)}</div> + <div class="mt-2">${actions}</div> + </div> + </div> + </div> + </div> + `; + } + + $("#list_div").html(html); +} + +function loadList(page, moveTop = true) { + let formData = getFormData(); + formData += "&page=" + page; + $.ajax({ + url: "/" + package_name + "/ajax/web_list", + type: "POST", + cache: false, + data: formData, + dataType: "json", + success: (data) => { + currentData = data; + if (data && !data.ret) { + if (moveTop) { + window.scrollTo(0, 0); + } + renderList(data.list); + renderPagination(data.paging); + } else { + $.notify("<strong>목록을 불러오지 못했습니다.</strong>", { + type: "warning", + }); + } + }, + error: () => { + $.notify("<strong>목록 요청 중 오류가 발생했습니다.</strong>", { + type: "warning", + }); + }, + }); +} + +$(document).ready(function () { + loadList(1); +}); + +$("#search").click(function (e) { + e.preventDefault(); + loadList(1); +}); + +$("#reset_btn").click(function (e) { + e.preventDefault(); + document.getElementById("form_search").reset(); + loadList(1); +}); + +$("body").on("click", "#page", function (e) { + e.preventDefault(); + loadList($(this).data("page")); +}); + +$("body").on("click", "#remove_btn", function (e) { + e.preventDefault(); + const id = $(this).data("id"); + $.ajax({ + url: "/" + package_name + "/ajax/db_remove", + type: "POST", + cache: false, + data: { id }, + dataType: "json", + success: function (ret) { + if (ret) { + $.notify("<strong>삭제했습니다.</strong>", { + type: "success", + }); + loadList(currentData?.paging?.current_page || 1, false); + } else { + $.notify("<strong>삭제 실패</strong>", { + type: "warning", + }); + } + }, + }); +}); + +$("body").on("click", "#json_btn", function (e) { + e.preventDefault(); + const id = $(this).data("id"); + const target = currentData?.list?.find((item) => String(item.id) === String(id)); + if (target) { + openJsonModal(target); + } +}); + +$("body").on("click", "#self_search_btn", function (e) { + e.preventDefault(); + document.getElementById("search_word").value = $(this).data("title"); + loadList(1); +}); + +$("body").on("click", "#request_btn", function (e) { + e.preventDefault(); + const contentCode = $(this).data("content_code"); + if (!contentCode) { + $.notify("<strong>작품 코드를 찾지 못했습니다.</strong>", { + type: "warning", + }); + return; + } + window.location.href = "/" + package_name + "/request?code=" + contentCode; +}); +})(); diff --git a/static/js/linkkf_request.js b/static/js/linkkf_request.js new file mode 100644 index 0000000..359dd69 --- /dev/null +++ b/static/js/linkkf_request.js @@ -0,0 +1,477 @@ +(function () { + if (window.__linkkfRequestInitialized === true) { + return; + } + window.__linkkfRequestInitialized = true; + + let currentData = null; + let currentAiringData = null; + let code = ""; + let analysisInProgress = false; + + const normalizeCode = (value) => { + const raw = String(value || "").trim(); + if (raw === "") { + return ""; + } + const match = raw.match(/\/(?:ani|watch)\/(\d+)\//) || raw.match(/(\d{3,})/); + return match ? match[1] : raw; + }; + + const escapeHtml = (value) => + String(value ?? "") + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + + const mButton = (id, text, attrs = [], extraClass = "btn-primary") => { + const extraAttrs = (attrs || []) + .map((item) => ` data-${item.key}="${escapeHtml(item.value)}"`) + .join(""); + return `<button id="${escapeHtml(id)}" class="btn btn-sm ${extraClass} mr-1 mb-1"${extraAttrs}>${escapeHtml(text)}</button>`; + }; + + const mButtonGroup = (inner) => + `<div class="d-flex flex-wrap align-items-center mb-3">${inner}</div>`; + + const mHrBlack = () => '<hr class="my-3" style="border-color: rgba(255,255,255,0.25);">'; + const mHr = () => '<hr class="my-2" style="border-color: rgba(255,255,255,0.15);">'; + const mRowStart = () => '<div class="row">'; + const mRowEnd = () => "</div>"; + const mCol = (size, content, align) => { + const alignClass = align === "right" ? " text-right" : ""; + return `<div class="col-md-${size}${alignClass}">${content ?? ""}</div>`; + }; + + function notifyWarning(message) { + $.notify(`<strong>${message}</strong>`, { type: "warning" }); + } + + function notifySuccess(message) { + $.notify(`<strong>${message}</strong>`, { type: "success" }); + } + + function setAnalysisLoading(flag) { + analysisInProgress = flag === true; + const analysisButton = document.getElementById("analysis_btn"); + if (analysisButton) { + analysisButton.disabled = analysisInProgress; + } + } + + function openPlayerPage(url) { + if (!url) { + notifyWarning("재생 URL을 가져오지 못했습니다."); + return; + } + window.open(url, "_blank"); + } + + function requestPlay(payload) { + $.ajax({ + url: `/${package_name}/ajax/play`, + type: "POST", + cache: false, + data: payload, + dataType: "json", + success: function (ret) { + if (ret.ret === "success" && ret.data != null) { + openPlayerPage(ret.data.play_url); + } else { + notifyWarning(ret.log || "재생 정보를 가져오지 못했습니다."); + } + }, + error: function () { + notifyWarning("재생 정보를 가져오지 못했습니다."); + }, + }); + } + + function getAiringList() { + $.ajax({ + url: `/${package_name}/ajax/airing_list`, + type: "GET", + cache: false, + dataType: "json", + success: (ret) => { + if (ret.ret === "success" && Array.isArray(ret.episode)) { + currentAiringData = ret; + makeAiringList(ret); + } else { + notifyWarning(ret.log || "최신 목록을 불러오지 못했습니다."); + } + }, + error: function () { + notifyWarning("최신 목록을 불러오지 못했습니다."); + }, + }); + } + + function makeAiringList(data) { + let str = ""; + str += mHrBlack(); + str += '<div id="inner_airing" class="d-flex flex-wrap">'; + for (const item of data.episode) { + str += ` + <div class="mx-1 mb-1"> + <button + type="button" + class="btn btn-primary code-button" + data-code="${escapeHtml(item.code)}" + title="${escapeHtml(item.title)}" + > + ${escapeHtml(item.code)} + </button> + </div> + `; + } + str += "</div>"; + str += mHrBlack(); + document.getElementById("airing_list").innerHTML = str; + } + + function renderProgram(data) { + currentData = data; + + let str = ""; + let tmp = '<div class="form-inline w-100">'; + tmp += mButton("check_download_btn", "선택 다운로드 추가"); + tmp += mButton("all_check_on_btn", "전체 선택"); + tmp += mButton("all_check_off_btn", "전체 해제"); + tmp += mButton("down_subtitle_btn", "자막 전체 받기"); + tmp += `   <input id="new_title" name="new_title" class="form-control form-control-sm" value="${escapeHtml(data.title)}">`; + tmp += "</div>"; + tmp += '<div class="form-inline">'; + tmp += mButton("apply_new_title_btn", "폴더명 변경"); + tmp += `   <input id="new_season" name="new_season" class="form-control form-control-sm" value="${escapeHtml(data.season)}">`; + tmp += mButton("apply_new_season_btn", "시즌 변경"); + tmp += mButton("search_tvdb_btn", "TVDB", [], "btn-outline-info"); + tmp += mButton("add_whitelist", "자동 다운로드 추가", [], "btn-outline-success"); + tmp += "</div>"; + str += mButtonGroup(tmp); + + str += "<div class='card p-lg-5 mt-md-3 p-md-3 border-light'>"; + str += mRowStart(); + str += mCol(3, data.poster_url ? `<img src="${escapeHtml(data.poster_url)}" class="img-fluid">` : ""); + + let detailHtml = ""; + detailHtml += mRowStart(); + detailHtml += mCol(3, "제목", "right"); + detailHtml += mCol(9, escapeHtml(data.title)); + detailHtml += mRowEnd(); + detailHtml += mRowStart(); + detailHtml += mCol(3, "시즌", "right"); + detailHtml += mCol(9, escapeHtml(data.season)); + detailHtml += mRowEnd(); + for (const detailRow of data.detail || []) { + const key = Object.keys(detailRow)[0]; + const value = detailRow[key]; + detailHtml += mRowStart(); + detailHtml += mCol(3, escapeHtml(key), "right"); + detailHtml += mCol(9, escapeHtml(value)); + detailHtml += mRowEnd(); + } + str += mCol(9, detailHtml); + str += mRowEnd(); + str += "</div>"; + + for (let i = 0; i < (data.episode || []).length; i += 1) { + const episode = data.episode[i]; + str += mRowStart(); + + tmp = `<strong>${escapeHtml(episode.title)}</strong><br>`; + tmp += `${escapeHtml(episode.filename)}<br><p></p>`; + tmp += '<div class="form-inline">'; + tmp += ` + <input + id="checkbox_${escapeHtml(episode.code)}" + name="checkbox_${escapeHtml(episode.code)}" + type="checkbox" + checked + data-toggle="toggle" + data-on="선택" + data-off="-" + data-onstyle="success" + data-offstyle="danger" + data-size="small" + >     + `; + tmp += mButton("add_queue_btn", "다운로드 추가", [{ key: "idx", value: i }]); + tmp += mButton("play_video_btn", "보기", [{ key: "idx", value: i }], "btn-outline-info"); + tmp += "</div>"; + + str += mCol(12, tmp); + str += mRowEnd(); + if (i !== data.episode.length - 1) { + str += mHr(); + } + } + + document.getElementById("episode_list").innerHTML = str; + $('input[id^="checkbox_"]').bootstrapToggle(); + } + + $("body").on("click", "button.code-button", function (e) { + e.preventDefault(); + document.getElementById("code").value = $(this).data("code"); + $("#airing_list").toggle(); + runAnalysis(); + }); + + function runAnalysis() { + if (analysisInProgress) { + return; + } + const input = document.getElementById("code"); + code = normalizeCode(input.value); + input.value = code; + + if (code === "") { + notifyWarning("code 값을 입력해 주세요."); + return; + } + + setAnalysisLoading(true); + + $.ajax({ + url: `/${package_name}/ajax/analysis`, + type: "POST", + cache: false, + data: { code }, + dataType: "json", + success: function (ret) { + if (ret.ret === "success" && ret.data != null) { + renderProgram(ret.data); + } else { + notifyWarning(ret.log || "분석에 실패했습니다."); + } + }, + error: function () { + notifyWarning("분석 요청에 실패했습니다."); + }, + complete: function () { + setAnalysisLoading(false); + }, + }); + } + + $("body").on("click", "#analysis_btn", function (e) { + e.preventDefault(); + runAnalysis(); + }); + + $("body").on("click", "#go_linkkf_btn", function (e) { + e.preventDefault(); + window.open(linkkf_url, "_blank"); + }); + + $("body").on("click", "#all_check_on_btn", function (e) { + e.preventDefault(); + $('input[id^="checkbox_"]').bootstrapToggle("on"); + }); + + $("body").on("click", "#all_check_off_btn", function (e) { + e.preventDefault(); + $('input[id^="checkbox_"]').bootstrapToggle("off"); + }); + + $("body").on("click", "#search_tvdb_btn", function (e) { + e.preventDefault(); + const newTitle = document.getElementById("new_title").value; + window.open(`https://www.thetvdb.com/search?query=${encodeURIComponent(newTitle)}`, "_blank"); + }); + + $("body").on("click", "#add_whitelist", function (e) { + e.preventDefault(); + $.ajax({ + url: `/${package_name}/ajax/add_whitelist`, + type: "POST", + cache: false, + dataType: "json", + success: function (ret) { + if (ret.ret) { + notifySuccess("추가되었습니다."); + renderProgram(ret); + } else { + notifyWarning(ret.log || "추가에 실패했습니다."); + } + }, + error: function () { + notifyWarning("추가에 실패했습니다."); + }, + }); + }); + + $("body").on("click", "#down_subtitle_btn", function (e) { + e.preventDefault(); + + const all = $('input[id^="checkbox_"]'); + let str = ""; + for (let i = 0; i < all.length; i += 1) { + if (all[i].checked) { + str += `${all[i].id.split("_")[1]},`; + } + } + if (str === "") { + notifyWarning("선택해 주세요."); + return; + } + + $.ajax({ + url: `/${package_name}/ajax/down_subtitle_list`, + type: "POST", + cache: false, + data: { code: str }, + dataType: "json", + success: function (ret) { + if (ret.ret === "success") { + notifySuccess(`${ret.log}개를 처리했습니다.`); + } else { + notifyWarning(ret.log || "자막 다운로드에 실패했습니다."); + } + }, + }); + }); + + $("body").on("click", "#apply_new_title_btn", function (e) { + e.preventDefault(); + const newTitle = document.getElementById("new_title").value; + $.ajax({ + url: `/${package_name}/ajax/apply_new_title`, + type: "POST", + cache: false, + data: { new_title: newTitle }, + dataType: "json", + success: function (ret) { + if (ret.ret) { + notifySuccess("적용되었습니다."); + renderProgram(ret); + } else { + notifyWarning(ret.log || "적용에 실패했습니다."); + } + }, + }); + }); + + $("body").on("click", "#apply_new_season_btn", function (e) { + e.preventDefault(); + const newSeason = document.getElementById("new_season").value; + if ($.isNumeric(newSeason) === false) { + notifyWarning("시즌은 숫자여야 합니다."); + return; + } + $.ajax({ + url: `/${package_name}/ajax/apply_new_season`, + type: "POST", + cache: false, + data: { new_season: newSeason }, + dataType: "json", + success: function (ret) { + if (ret.ret) { + notifySuccess("적용되었습니다."); + renderProgram(ret); + } else { + notifyWarning(ret.log || "적용에 실패했습니다."); + } + }, + }); + }); + + $("body").on("click", "#add_queue_btn", function (e) { + e.preventDefault(); + const idx = Number($(this).data("idx")); + const episode = currentData?.episode?.[idx]; + if (episode == null) { + notifyWarning("에피소드 정보를 찾지 못했습니다."); + return; + } + + $.ajax({ + url: `/${package_name}/ajax/add_queue`, + type: "POST", + cache: false, + data: { code: episode.code, data: JSON.stringify(episode) }, + dataType: "json", + success: function (ret) { + if (ret.ret === "enqueue_db_append") { + notifySuccess("다운로드 작업에 추가했습니다."); + } else if (ret.ret === "enqueue_db_exist") { + notifyWarning("이미 DB에 있는 항목입니다."); + } else if (ret.ret === "db_completed") { + notifyWarning("이미 완료 기록이 있습니다."); + } else if (ret.ret === "queue_exist") { + notifyWarning("이미 대기열에 있습니다."); + } else if (ret.ret === "no_data") { + notifyWarning("에피소드 정보를 찾지 못했습니다."); + } else { + notifyWarning(ret.log || "대기열 추가에 실패했습니다."); + } + }, + }); + }); + + $("body").on("click", "#play_video_btn", function (e) { + e.preventDefault(); + const idx = Number($(this).data("idx")); + const episode = currentData?.episode?.[idx]; + if (episode == null) { + notifyWarning("에피소드 정보를 찾지 못했습니다."); + return; + } + requestPlay({ + url: episode.url, + title: `${currentData.title} - ${episode.title}`, + }); + }); + + $("body").on("click", "#check_download_btn", function (e) { + e.preventDefault(); + const all = $('input[id^="checkbox_"]'); + let str = ""; + for (let i = 0; i < all.length; i += 1) { + if (all[i].checked) { + str += `${all[i].id.split("_")[1]},`; + } + } + if (str === "") { + notifyWarning("선택해 주세요."); + return; + } + + $.ajax({ + url: `/${package_name}/ajax/add_queue_checked_list`, + type: "POST", + cache: false, + data: { code: str }, + dataType: "json", + success: function (ret) { + if (ret.ret === "success") { + notifySuccess(`${ret.log}개를 추가했습니다.`); + } else { + notifyWarning(ret.log || "대기열 추가에 실패했습니다."); + } + }, + }); + }); + + $("#go_modal_airing").click(function (e) { + e.preventDefault(); + if (currentAiringData === null) { + getAiringList(); + } else { + $("#airing_list").toggle(); + } + }); + + $("#go_modal_airing").attr("class", "btn btn-primary"); + + $(function () { + if (typeof initialCodeFromQuery === "string" && initialCodeFromQuery.trim() !== "") { + setTimeout(() => { + runAnalysis(); + }, 0); + } + }); +})(); diff --git a/subtitle_util.py b/subtitle_util.py new file mode 100644 index 0000000..1a80997 --- /dev/null +++ b/subtitle_util.py @@ -0,0 +1,44 @@ +import re + +from support import SupportFile + + +def write_file(data, filepath): + SupportFile.write_file(filepath, data) + + +def convert_vtt_to_srt(vtt_data): + lines = [] + counter = 1 + + for block in re.split(r"\r?\n\r?\n", vtt_data.strip()): + block = block.strip() + if block == "" or block == "WEBVTT": + continue + + block_lines = [line.strip("\ufeff") for line in block.splitlines() if line.strip() != ""] + if not block_lines: + continue + + if block_lines[0].startswith("WEBVTT"): + block_lines = block_lines[1:] + if not block_lines: + continue + + if "-->" not in block_lines[0] and len(block_lines) > 1 and "-->" in block_lines[1]: + block_lines = block_lines[1:] + + if "-->" not in block_lines[0]: + continue + + timing = block_lines[0].replace(".", ",") + payload = block_lines[1:] + + lines.append(str(counter)) + lines.append(timing) + lines.extend(payload) + lines.append("") + counter += 1 + + return "\n".join(lines).strip() + "\n" + diff --git a/templates/linkkf_category.html b/templates/linkkf_category.html new file mode 100644 index 0000000..49fb8b0 --- /dev/null +++ b/templates/linkkf_category.html @@ -0,0 +1,44 @@ +{% extends "base.html" %} +{% block content %} +<div id="linkkf_wrapper"> + <div class="input-group mb-3"> + <input + id="input_search" + type="search" + class="form-control rounded" + placeholder="작품명 검색" + aria-label="작품명 검색" + aria-describedby="search-addon" + /> + <button id="btn_search" type="button" class="btn btn-primary">검색</button> + </div> + + <div id="anime_category" class="btn-group mb-3" role="group" aria-label="linkkf categories"> + <button id="ing" type="button" class="btn btn-success active">애니</button> + <button id="movie" type="button" class="btn btn-primary">극장판</button> + <button id="complete" type="button" class="btn btn-dark">성인</button> + <button id="top_view" type="button" class="btn btn-warning">최신</button> + </div> + + <div id="screen_movie_list" class="container"></div> + + <div class="spinner" id="spinner" style="display: none"> + <div class="double-bounce1"></div> + <div class="double-bounce2"></div> + </div> +</div> + +<link + href="{{ url_for('.static', filename='css/%s.css' % arg['template_name']) }}" + type="text/css" + rel="stylesheet" +/> +<script> + "use strict"; + const package_name = '{{ arg["package_name"] }}'; + const linkkf_url = "{{ arg['linkkf_url'] }}"; + const request_path = "./request"; + const placeholder_image = "./static/img_loader_x200.svg"; +</script> +<script src="{{ url_for('.static', filename='js/%s.js' % arg['template_name']) }}"></script> +{% endblock %} diff --git a/templates/linkkf_list.html b/templates/linkkf_list.html new file mode 100644 index 0000000..4059220 --- /dev/null +++ b/templates/linkkf_list.html @@ -0,0 +1,51 @@ +{% extends "base.html" %} +{% block content %} +<div> + <form id="form_search" class="mb-3"> + <div class="form-row align-items-center"> + <div class="col-md-4 mb-2"> + <select id="order" name="order" class="form-control form-control-sm"> + <option value="desc">최신순</option> + <option value="asc">오래된순</option> + </select> + </div> + <div class="col-md-3 mb-2"> + <select id="option" name="option" class="form-control form-control-sm"> + <option value="finished" selected>완료/실패/취소</option> + <option value="all">전체</option> + <option value="completed">완료</option> + <option value="error">실패</option> + <option value="canceled">취소</option> + <option value="downloading">진행중</option> + </select> + </div> + <div class="col-md-5 mb-2 d-flex"> + <input + id="search_word" + name="search_word" + class="form-control form-control-sm mr-2" + type="text" + placeholder="작품명 검색" + aria-label="Search" + /> + <button id="search" class="btn btn-sm btn-outline-success mr-2">검색</button> + <button id="reset_btn" class="btn btn-sm btn-outline-secondary">리셋</button> + </div> + </div> + </form> + + <div id="page1"></div> + <div id="list_div"></div> + <div id="page2"></div> +</div> + +<link + href="{{ url_for('.static', filename='css/%s.css' % arg['template_name']) }}" + type="text/css" + rel="stylesheet" +/> +<script type="text/javascript"> + const package_name = "{{arg['package_name']}}"; +</script> +<script src="{{ url_for('.static', filename='js/%s.js' % arg['template_name']) }}"></script> +{% endblock %} diff --git a/templates/linkkf_queue.html b/templates/linkkf_queue.html new file mode 100644 index 0000000..3355142 --- /dev/null +++ b/templates/linkkf_queue.html @@ -0,0 +1,161 @@ +{% extends "base.html" %} +{% block content %} +<div class="mb-3 d-flex flex-wrap align-items-center"> + <button id="reset_btn" class="btn btn-sm btn-warning mr-2 mb-2">초기화</button> + <button id="delete_completed_btn" class="btn btn-sm btn-secondary mr-2 mb-2">완료 항목 제거</button> + <button id="go_ffmpeg_btn" class="btn btn-sm btn-info mb-2">Go FFMPEG</button> +</div> + +<div id="queue_empty" class="alert alert-secondary" style="display: none;"> + 대기열이 비어 있습니다. +</div> + +<div class="table-responsive"> + <table class="table table-sm table-hover"> + <thead> + <tr> + <th>코드</th> + <th>생성 시각</th> + <th>파일명</th> + <th>상태</th> + <th>동작</th> + </tr> + </thead> + <tbody id="download_list_div"></tbody> + </table> +</div> + +<script type="text/javascript"> +(() => { + if (window.__linkkfQueueInitialized === true) { + return; + } + window.__linkkfQueueInitialized = true; + + const packageName = "{{ arg['package_name'] }}"; + const socketUrl = `${window.location.protocol}//${window.location.host}/${packageName}/main`; + const socket = io(socketUrl); + const listContainer = document.getElementById("download_list_div"); + const emptyBox = document.getElementById("queue_empty"); + + const escapeHtml = (value) => + String(value ?? "") + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + + function makeProgress(id, percent, label) { + const safePercent = Number(percent || 0); + return ` + <div class="progress"> + <div + id="progress_${escapeHtml(id)}" + class="progress-bar" + role="progressbar" + style="width: ${safePercent}%" + aria-valuenow="${safePercent}" + aria-valuemin="0" + aria-valuemax="100" + > + <span id="progress_${escapeHtml(id)}_label">${escapeHtml(label)}</span> + </div> + </div> + `; + } + + function renderQueue(data) { + if (!Array.isArray(data) || data.length === 0) { + listContainer.innerHTML = ""; + emptyBox.style.display = "block"; + return; + } + + emptyBox.style.display = "none"; + let html = ""; + for (const item of data) { + const label = item.ffmpeg_percent && item.ffmpeg_percent !== 0 + ? `${item.ffmpeg_status_kor} (${item.ffmpeg_percent}%)` + : item.ffmpeg_status_kor; + const fileName = `${escapeHtml(item.info.program_title || "")}<br>${escapeHtml(item.info.filename || "")}`; + + html += ` + <tr> + <td>${escapeHtml(item.entity_id)}</td> + <td>${escapeHtml(item.created_time)}</td> + <td>${fileName}</td> + <td>${makeProgress(item.entity_id, item.ffmpeg_percent, label)}</td> + <td> + <button class="btn btn-sm btn-danger mr-1 mb-1" data-command="cancel" data-id="${escapeHtml(item.entity_id)}">취소</button> + <button class="btn btn-sm btn-outline-secondary mb-1" data-command="delete" data-id="${escapeHtml(item.entity_id)}">삭제</button> + </td> + </tr> + `; + } + listContainer.innerHTML = html; + } + + function updateStatus(data) { + const progress = document.getElementById(`progress_${data.plugin_id}`); + const label = document.getElementById(`progress_${data.plugin_id}_label`); + const percent = data.data && data.data.percent ? data.data.percent : 0; + if (progress) { + progress.style.width = `${percent}%`; + progress.setAttribute("aria-valuenow", percent); + } + if (label) { + const speed = data.data && data.data.current_speed ? ` x${data.data.current_speed}` : ""; + label.textContent = `${data.status} (${percent}%)${speed}`; + } + } + + function programAutoCommand(payload) { + $.ajax({ + url: "/" + packageName + "/ajax/program_auto_command", + type: "POST", + cache: false, + data: payload, + dataType: "json", + success: function (ret) { + if (ret.ret === "notify") { + $.notify(`<strong>${ret.log}</strong>`, { type: "warning" }); + } + }, + }); + } + + socket.on("on_connect", function (data) { + renderQueue(data); + }); + + socket.on("status", function (data) { + updateStatus(data); + }); + + socket.on("list_refresh", function (data) { + renderQueue(data); + }); + + $("body").on("click", "button[data-command='cancel']", function (e) { + e.preventDefault(); + programAutoCommand({ command: "cancel", entity_id: $(this).data("id") }); + }); + + $("body").on("click", "#reset_btn", function (e) { + e.preventDefault(); + programAutoCommand({ command: "reset", entity_id: -1 }); + }); + + $("body").on("click", "#delete_completed_btn", function (e) { + e.preventDefault(); + programAutoCommand({ command: "delete_completed", entity_id: -1 }); + }); + + $("body").on("click", "#go_ffmpeg_btn", function (e) { + e.preventDefault(); + window.location.href = "/ffmpeg"; + }); +})(); +</script> +{% endblock %} diff --git a/templates/linkkf_request.html b/templates/linkkf_request.html new file mode 100644 index 0000000..ac1fe24 --- /dev/null +++ b/templates/linkkf_request.html @@ -0,0 +1,44 @@ +{% extends "base.html" %} +{% block content %} +<div id="request"> + <form id="program_list" style="padding-bottom: 1em;"> + <div class="card p-lg-5 p-md-3 py-2 border-light"> + {{ + macros.setting_input_text_and_buttons( + 'code', + '작품 코드', + [['analysis_btn', '분석'], ['go_linkkf_btn', 'LinkKF 열기'], ['go_modal_airing', '최신']], + desc='예시: https://linkkf.tv/ani/405619/ 또는 405619' + ) + }} + </div> + </form> + + <form id="airing_list_form"> + <div id="airing_list"></div> + </form> + + <form id="program_auto_form"> + <div id="episode_list"></div> + </form> +</div> + +<link href="{{ url_for('.static', filename='css/%s.css' % arg['template_name']) }}" type="text/css" rel="stylesheet"/> +<script type="text/javascript"> +const package_name = "{{ arg['package_name'] }}"; +const linkkf_url = "{{ arg['linkkf_url'] }}"; +const pageParams = new URLSearchParams(window.location.search); +const initialCodeFromQuery = pageParams.get("code"); + +$(document).ready(function () { + if ("{{ arg['current_code'] }}" !== "") { + document.getElementById("code").value = "{{ arg['current_code'] }}"; + } + + if (initialCodeFromQuery !== null && initialCodeFromQuery !== "") { + document.getElementById("code").value = initialCodeFromQuery; + } +}); +</script> +<script src="{{ url_for('.static', filename='js/%s.js' % arg['template_name']) }}"></script> +{% endblock %} diff --git a/templates/linkkf_setting.html b/templates/linkkf_setting.html new file mode 100644 index 0000000..3d83c6c --- /dev/null +++ b/templates/linkkf_setting.html @@ -0,0 +1,97 @@ +{% extends "base.html" %} +{% block content %} +<div> + {{ macros.m_button_group([['globalSettingSaveBtn', '설정 저장']]) }} + <form id="setting"> + {{ macros.setting_input_text_and_buttons('linkkf_url', 'Linkkf 주소', [['go_btn', '열기']], value=arg['linkkf_url']) }} + {{ macros.setting_input_text('download_path', '다운로드 폴더', value=arg['download_path']) }} + {{ macros.setting_checkbox('auto_make_folder', '작품 폴더 생성', value=arg['auto_make_folder']) }} + {{ macros.setting_checkbox('linkkf_auto_make_season_folder', '시즌 폴더 생성', value=arg['linkkf_auto_make_season_folder']) }} + {{ macros.setting_input_text('linkkf_finished_insert', '완결 표시', value=arg['linkkf_finished_insert'], col='3') }} + {{ macros.setting_input_int('max_ffmpeg_process_count', '동시 다운로드 수', value=arg['max_ffmpeg_process_count']) }} + {{ macros.setting_input_text('auto_interval', '자동 실행 주기', value=arg['auto_interval'], col='3', placeholder='*/20 * * * *') }} + {{ macros.setting_checkbox('auto_start', '시작 시 자동 실행', value=arg['auto_start']) }} + {{ macros.setting_input_textarea('whitelist_program', '자동 다운로드 코드', value=arg['whitelist_program']) }} + {{ macros.global_setting_scheduler_button(arg['scheduler'], arg['is_running']) }} + {{ macros.setting_buttons([['linkkf_one_execute_btn', '1회 실행']], left='수동 실행') }} + {{ macros.setting_buttons([['linkkf_reset_db_btn', 'DB 초기화']], left='DB 정리') }} + {{ macros.setting_buttons([['btn_airing_code', '최신 코드 가져오기']], left='보조 기능') }} + </form> +</div> + +<script type="text/javascript"> +const package_name = "{{ arg['package_name'] }}"; + +$(document).ready(function () { + $("body").off("change", "#globalSchedulerSwitchBtn"); +}); + +$("body").on("click", "#go_btn", function (e) { + e.preventDefault(); + window.open($("#linkkf_url").val(), "_blank"); +}); + +$("body").on("change", "#globalSchedulerSwitchBtn", function (e) { + e.preventDefault(); + $.ajax({ + url: "/" + package_name + "/ajax/scheduler_toggle", + type: "POST", + cache: false, + data: { scheduler: $(this).prop("checked") }, + dataType: "json", + }); +}); + +$("body").on("click", "#linkkf_one_execute_btn", function (e) { + e.preventDefault(); + $.ajax({ + url: "/" + package_name + "/ajax/execute_once", + type: "POST", + cache: false, + dataType: "json", + success: function (ret) { + if (ret.ret === "success") { + $.notify("<strong>작업을 시작했습니다.</strong>", { type: "success" }); + } else { + $.notify("<strong>작업 시작에 실패했습니다.</strong>", { type: "warning" }); + } + }, + }); +}); + +$("body").on("click", "#linkkf_reset_db_btn", function (e) { + e.preventDefault(); + $.ajax({ + url: "/" + package_name + "/ajax/reset_db", + type: "POST", + cache: false, + dataType: "json", + success: function (ret) { + if (ret.ret === "success") { + $.notify("<strong>DB를 초기화했습니다.</strong>", { type: "success" }); + } else { + $.notify("<strong>DB 초기화에 실패했습니다.</strong>", { type: "warning" }); + } + }, + }); +}); + +$("body").on("click", "#btn_airing_code", function (e) { + e.preventDefault(); + $.ajax({ + url: "/" + package_name + "/ajax/get_airing_code", + type: "GET", + cache: false, + dataType: "json", + success: function (ret) { + if (ret.ret === "success" && Array.isArray(ret.data)) { + $("#whitelist_program").val(ret.data.join(",")); + $.notify("<strong>최신 코드를 불러왔습니다.</strong>", { type: "success" }); + } else { + $.notify("<strong>코드를 불러오지 못했습니다.</strong>", { type: "warning" }); + } + }, + }); +}); +</script> +{% endblock %}