Add files via upload
ff_linkkf
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
# linkkf
|
||||
|
||||
FlaskFarm용 `linkkf.tv` 플러그인입니다.
|
||||
|
||||
설치 전 의존 패키지:
|
||||
`pip install -r requirements.txt`
|
||||
|
||||
플러그인 최초 로딩 시 누락된 패키지는 자동 설치를 시도합니다.
|
||||
|
||||
현재 기준 주요 기능:
|
||||
- 카테고리 목록 조회
|
||||
- 작품 분석 및 회차 목록 조회
|
||||
- 재생 URL 추출 및 브라우저 플레이어 프록시
|
||||
- ffmpeg 다운로드 대기열 연동
|
||||
- 다운로드 이력 목록 표시
|
||||
@@ -0,0 +1,2 @@
|
||||
from .setup import P
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
title: "linkkf"
|
||||
version: "0.3.2.0"
|
||||
package_name: "linkkf"
|
||||
developer: "projectdx && persuade"
|
||||
description: "linkkf 사이트에서 애니 다운로드"
|
||||
home: "https://linkkf.tv"
|
||||
more: ""
|
||||
|
||||
Binary file not shown.
@@ -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
|
||||
@@ -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())
|
||||
+1445
File diff suppressed because it is too large
Load Diff
+576
@@ -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
|
||||
+303
@@ -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)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
cloudscraper
|
||||
beautifulsoup4
|
||||
requests-cache
|
||||
lxml
|
||||
@@ -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())
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
#list_div img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
#page1,
|
||||
#page2 {
|
||||
margin: 12px 0;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
})();
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -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"
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
Reference in New Issue
Block a user