diff --git a/info.yaml b/info.yaml index 65604b2..c484c84 100644 --- a/info.yaml +++ b/info.yaml @@ -1,5 +1,5 @@ title: "애니 다운로더" -version: "0.4.1" +version: "0.4.2" package_name: "anime_downloader" developer: "projectdx" description: "anime downloader" diff --git a/lib/util.py b/lib/util.py index c327264..410cb0f 100644 --- a/lib/util.py +++ b/lib/util.py @@ -110,7 +110,7 @@ class Util(object): i = 0 while i < len(lines): line = lines[i].strip() - # WEBVTT, NOTE, STYLE 등 메타데이터 스킵 + # WEBWTT, NOTE, STYLE 등 메타데이터 스킵 if line.startswith("WEBVTT") or line.startswith("NOTE") or line.startswith("STYLE"): i += 1 continue @@ -135,3 +135,50 @@ class Util(object): # 캡션 텍스트가 바로 나오는 경우 등을 대비 i += 1 return "\n".join(srt_lines) + + @staticmethod + def merge_subtitle(P, db_item): + """ + ffmpeg를 사용하여 SRT 자막을 MP4에 삽입 (soft embed) + """ + try: + import subprocess + mp4_path = db_item.filepath + if not mp4_path or not os.path.exists(mp4_path): + logger.error(f"MP4 file not found: {mp4_path}") + return + + srt_path = os.path.splitext(mp4_path)[0] + ".srt" + if not os.path.exists(srt_path): + logger.error(f"SRT file not found: {srt_path}") + return + + # 출력 파일: *_subed.mp4 + base_name = os.path.splitext(mp4_path)[0] + output_path = f"{base_name}_subed.mp4" + + if os.path.exists(output_path): + os.remove(output_path) + + ffmpeg_cmd = [ + "ffmpeg", "-y", + "-i", mp4_path, + "-i", srt_path, + "-c:v", "copy", + "-c:a", "copy", + "-c:s", "mov_text", + "-metadata:s:s:0", "language=kor", + output_path + ] + + logger.info(f"[Merge Subtitle] Running ffmpeg: {' '.join(ffmpeg_cmd)}") + result = subprocess.run(ffmpeg_cmd, capture_output=True, text=True, timeout=600) + + if result.returncode == 0 and os.path.exists(output_path): + logger.info(f"[Merge Subtitle] Success: {output_path}") + # 원본 삭제 옵션 등이 필요할 수 있으나 여기서는 생성만 함 + else: + logger.error(f"ffmpeg failed: {result.stderr}") + except Exception as e: + logger.error(f"merge_subtitle error: {e}") + logger.error(traceback.format_exc()) diff --git a/mod_anilife.py b/mod_anilife.py index dd94ff5..cd86c4d 100644 --- a/mod_anilife.py +++ b/mod_anilife.py @@ -57,7 +57,7 @@ from .lib.crawler import Crawler # 패키지 # from .plugin import P -from .lib.util import Util, yommi_timeit +from .lib.util import Util as AniUtil, yommi_timeit from typing import Awaitable, TypeVar T = TypeVar("T") @@ -78,6 +78,7 @@ class LogicAniLife(AnimeModuleBase): "anilife_finished_insert": "[완결]", "anilife_max_ffmpeg_process_count": "1", "anilife_download_method": "ffmpeg", # ffmpeg or ytdlp + "anilife_download_threads": "16", # yt-dlp/aria2c 병렬 쓰레드 수 "anilife_order_desc": "False", "anilife_auto_start": "False", "anilife_interval": "* 5 * * *", @@ -164,9 +165,42 @@ class LogicAniLife(AnimeModuleBase): def __init__(self, P): super(LogicAniLife, self).__init__(P, setup_default=self.db_default, name=name, first_menu='setting', scheduler_desc="애니라이프 자동 다운로드") self.queue = None + self.web_list_model = ModelAniLifeItem self.OS_PLATFORM = platform.system() default_route_socketio_module(self, attach="/search") + def process_command(self, command, arg1, arg2, arg3, req): + try: + if command == "list": + ret = self.queue.get_entity_list() if self.queue else [] + return jsonify(ret) + elif command == "stop": + entity_id = int(arg1) if arg1 else -1 + result = self.queue.command("cancel", entity_id) if self.queue else {"ret": "error"} + return jsonify(result) + elif command == "remove": + entity_id = int(arg1) if arg1 else -1 + result = self.queue.command("remove", entity_id) if self.queue else {"ret": "error"} + return jsonify(result) + elif command in ["reset", "delete_completed"]: + result = self.queue.command(command, 0) if self.queue else {"ret": "error"} + return jsonify(result) + elif command == "merge_subtitle": + # AniUtil already imported at module level + db_id = int(arg1) + db_item = ModelAniLifeItem.get_by_id(db_id) + if db_item and db_item.status == 'completed': + import threading + threading.Thread(target=AniUtil.merge_subtitle, args=(self.P, db_item)).start() + return jsonify({"ret": "success", "log": "자막 합칩을 시작합니다."}) + return jsonify({"ret": "fail", "log": "파일을 찾을 수 없거나 완료된 상태가 아닙니다."}) + + return jsonify({"ret": "fail", "log": f"Unknown command: {command}"}) + except Exception as e: + self.P.logger.error(f"process_command Error: {e}") + self.P.logger.error(traceback.format_exc()) + return jsonify({'ret': 'fail', 'log': str(e)}) + # @staticmethod def get_html( self, @@ -578,11 +612,18 @@ class LogicAniLife(AnimeModuleBase): socketio.emit( "notify", notify, namespace="/framework", broadcast=True ) - thread = threading.Thread(target=func, args=()) thread.daemon = True thread.start() return jsonify("") + elif sub == "proxy_image": + image_url = request.args.get("url") or request.args.get("image_url") + return self.proxy_image(image_url) + elif sub == "entity_list": + if self.queue is not None: + return jsonify(self.queue.get_entity_list()) + else: + return jsonify([]) elif sub == "web_list": return jsonify(ModelAniLifeItem.web_list(request)) elif sub == "db_remove": @@ -657,48 +698,14 @@ class LogicAniLife(AnimeModuleBase): except Exception as e: logger.error(f"browse_dir error: {e}") return jsonify({"ret": "error", "error": str(e)}), 500 + + return jsonify({"ret": "fail", "log": f"Unknown sub: {sub}"}) + except Exception as e: - P.logger.error("Exception:%s", e) + P.logger.error("AniLife process_ajax Exception:%s", e) P.logger.error(traceback.format_exc()) + return jsonify({"ret": "exception", "log": str(e)}) - def process_command(self, command, arg1, arg2, arg3, req): - ret = {"ret": "success"} - logger.debug("queue_list") - if command == "queue_list": - logger.debug( - f"self.queue.get_entity_list():: {self.queue.get_entity_list()}" - ) - ret = [x for x in self.queue.get_entity_list()] - - return ret - elif command == "download_program": - _pass = arg2 - db_item = ModelOhli24Program.get(arg1) - if _pass == "false" and db_item != None: - ret["ret"] = "warning" - ret["msg"] = "이미 DB에 있는 항목 입니다." - elif ( - _pass == "true" - and db_item != None - and ModelOhli24Program.get_by_id_in_queue(db_item.id) != None - ): - ret["ret"] = "warning" - ret["msg"] = "이미 큐에 있는 항목 입니다." - else: - if db_item == None: - db_item = ModelOhli24Program(arg1, self.get_episode(arg1)) - db_item.save() - db_item.init_for_queue() - self.download_queue.put(db_item) - ret["msg"] = "다운로드를 추가 하였습니다." - - elif command == "list": - # Anilife 큐의 entity_list 반환 (이전: SupportFfmpeg.get_list() - 잘못된 소스) - ret = [] - for entity in self.queue.entity_list: - ret.append(entity.as_dict()) - - return jsonify(ret) @staticmethod def add_whitelist(*args): @@ -765,16 +772,50 @@ class LogicAniLife(AnimeModuleBase): self.queue = FfmpegQueue( P, P.ModelSetting.get_int("anilife_max_ffmpeg_process_count"), name, self ) + self.queue.queue_start() + + # 데이터 마이그레이션/동기화: 파일명이 비어있는 항목들 처리 + from framework import app + with app.app_context(): + try: + items = ModelAniLifeItem.get_list_uncompleted() + for item in items: + if not item.filename or item.filename == item.title: + # 임시로 Entity를 만들어 파일명 생성 로직 활용 + tmp_info = item.anilife_info if item.anilife_info else {} + # dict가 아닐 경우 처리 (문자열 등) + if isinstance(tmp_info, str): + try: tmp_info = json.loads(tmp_info) + except: tmp_info = {} + + tmp_entity = AniLifeQueueEntity(P, self, tmp_info) + if tmp_entity.filename: + item.filename = tmp_entity.filename + item.save() + logger.info(f"Synced filename for item {item.id}: {item.filename}") + except Exception as e: + logger.error(f"Data sync error: {e}") + logger.error(traceback.format_exc()) + self.current_data = None self.queue.queue_start() # Camoufox 미리 준비 (백그라운드에서 설치 및 바이너리 다운로드) threading.Thread(target=self.ensure_camoufox_installed, daemon=True).start() + def db_delete(self, day): + try: + # 전체 삭제 (일수 기준 또는 전체) + return ModelAniLifeItem.delete_all() + except Exception as e: + logger.error(f"Exception: {str(e)}") + logger.error(traceback.format_exc()) + return False + def scheduler_function(self): logger.debug(f"ohli24 scheduler_function::=========================") - content_code_list = P.ModelSetting.get_list("ohli24_auto_code_list", "|") + content_code_list = P.ModelSetting.get_list("anilife_auto_code_list", "|") url = f'{P.ModelSetting.get("anilife_url")}/dailyani' if "all" in content_code_list: ret_data = LogicAniLife.get_auto_anime_info(self, url=url) @@ -1158,9 +1199,39 @@ class LogicAniLife(AnimeModuleBase): return data except Exception as e: - P.logger.error(f"Exception: {str(e)}") + P.logger.error(f"AniLife process_ajax Error: {str(e)}") P.logger.error(traceback.format_exc()) - return {"ret": "exception", "log": str(e)} + return jsonify({"ret": "exception", "log": str(e)}) + + def proxy_image(self, image_url): + try: + if not image_url or image_url == "None": + return "" + import requests + headers = { + 'Referer': 'https://anilife.live/', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' + } + res = requests.get(image_url, headers=headers, stream=True, timeout=10) + from flask import Response + return Response(res.content, mimetype=res.headers.get('content-type', 'image/jpeg')) + except Exception as e: + P.logger.error(f"AniLife proxy_image error: {e}") + return "" + + def vtt_proxy(self, vtt_url): + try: + import requests + headers = { + 'Referer': 'https://anilife.live/', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' + } + res = requests.get(vtt_url, headers=headers, timeout=10) + from flask import Response + return Response(res.text, mimetype='text/vtt') + except Exception as e: + P.logger.error(f"AniLife vtt_proxy error: {e}") + return "" ######################################################### def add(self, episode_info): @@ -1210,8 +1281,27 @@ class AniLifeQueueEntity(FfmpegQueueEntity): self.content_title = None self.srt_url = None self.headers = None - # [Lazy Extraction] __init__에서는 무거운 분석을 하지 않습니다. - # self.make_episode_info() + self.filename = info.get("title") + self.epi_queue = info.get("ep_num") + self.content_title = info.get("title") + + def get_downloader(self, video_url, output_file, callback=None, callback_function=None): + from .lib.downloader_factory import DownloaderFactory + # Anilife는 설정이 따로 없으면 기본 ytdlp 사용하거나 ffmpeg + method = self.P.ModelSetting.get("anilife_download_method") or "ffmpeg" + threads = self.P.ModelSetting.get_int("anilife_download_threads") or 16 + logger.info(f"AniLife get_downloader using method: {method}, threads: {threads}") + + return DownloaderFactory.get_downloader( + method=method, + video_url=video_url, + output_file=output_file, + headers=self.headers, + callback=callback, + callback_id="anilife", + threads=threads, + callback_function=callback_function + ) def refresh_status(self): self.module_logic.socketio_callback("status", self.as_dict()) @@ -1223,16 +1313,29 @@ class AniLifeQueueEntity(FfmpegQueueEntity): tmp["vtt"] = self.vtt tmp["season"] = self.season tmp["content_title"] = self.content_title + # 큐 리스트에서 '에피소드 제목'으로 명확히 인지되도록 함 + tmp["episode_title"] = self.info.get("title") tmp["anilife_info"] = self.info tmp["epi_queue"] = self.epi_queue + tmp["filename"] = self.filename return tmp def donwload_completed(self): db_entity = ModelAniLifeItem.get_by_anilife_id(self.info["_id"]) if db_entity is not None: db_entity.status = "completed" - db_entity.complated_time = datetime.now() + db_entity.completed_time = datetime.now() + # 메타데이터 동기화 + db_entity.filename = self.filename + db_entity.save_fullpath = self.save_fullpath + db_entity.filesize = self.filesize + db_entity.duration = self.duration + db_entity.quality = self.quality db_entity.save() + + # Discord 알림 (이미 메인에서 처리될 수도 있으나 명시적으로 필요한 경우) + # if self.P.ModelSetting.get_bool('anilife_discord_notification'): + # ... def prepare_extra(self): """ @@ -1305,7 +1408,11 @@ class AniLifeQueueEntity(FfmpegQueueEntity): def log_stderr(pipe): for line in iter(pipe.readline, ''): if line.strip(): - logger.info(f"[Camoufox] {line.strip()}") + # tqdm 진행바나 불필요한 로그는 debug 레벨로 출력하여 로그 도배 방지 + if '%' in line or '|' in line or 'addon' in line.lower(): + logger.debug(f"[Camoufox-Progress] {line.strip()}") + else: + logger.info(f"[Camoufox] {line.strip()}") stderr_thread = threading.Thread(target=log_stderr, args=(process.stderr,)) stderr_thread.start() @@ -1314,7 +1421,13 @@ class AniLifeQueueEntity(FfmpegQueueEntity): for line in iter(process.stdout.readline, ''): stdout_data.append(line) - process.wait(timeout=120) + try: + process.wait(timeout=120) + except subprocess.TimeoutExpired: + logger.error("Camoufox subprocess timed out (120s)") + process.kill() + return + stderr_thread.join(timeout=5) stdout_full = "".join(stdout_data) @@ -1468,26 +1581,25 @@ class AniLifeQueueEntity(FfmpegQueueEntity): self.epi_queue = epi_no - self.filename = Util.change_text_for_use_filename(ret) + self.filename = AniUtil.change_text_for_use_filename(ret) logger.info(f"Filename: {self.filename}") - # anilife 전용 다운로드 경로 설정 (ohli24_download_path 대신 anilife_download_path 사용) + # anilife 전용 다운로드 경로 설정 self.savepath = P.ModelSetting.get("anilife_download_path") - if not self.savepath: - self.savepath = P.ModelSetting.get("ohli24_download_path") logger.info(f"Savepath: {self.savepath}") - if P.ModelSetting.get_bool("ohli24_auto_make_folder"): + if P.ModelSetting.get_bool("anilife_auto_make_folder"): if self.info.get("day", "").find("완결") != -1: folder_name = "%s %s" % ( - P.ModelSetting.get("ohli24_finished_insert"), + P.ModelSetting.get("anilife_finished_insert"), self.content_title, ) else: folder_name = self.content_title - folder_name = Util.change_text_for_use_filename(folder_name.strip()) + folder_name = AniUtil.change_text_for_use_filename(folder_name.strip()) self.savepath = os.path.join(self.savepath, folder_name) - if P.ModelSetting.get_bool("ohli24_auto_make_season_folder"): + + if P.ModelSetting.get_bool("anilife_auto_make_season_folder"): self.savepath = os.path.join( self.savepath, "Season %s" % int(self.season) ) @@ -1547,12 +1659,17 @@ class ModelAniLifeItem(db.Model): def as_dict(self): ret = {x.name: getattr(self, x.name) for x in self.__table__.columns} - ret["created_time"] = self.created_time.strftime("%Y-%m-%d %H:%M:%S") + ret["created_time"] = self.created_time.strftime("%Y-%m-%d %H:%M:%S") if self.created_time is not None else None ret["completed_time"] = ( self.completed_time.strftime("%Y-%m-%d %H:%M:%S") if self.completed_time is not None else None ) + # 템플릿 호환용 (anilife_list.html) + ret["image_link"] = self.thumbnail + ret["ep_num"] = self.episode_no + # content_title이 없으면 제목(시리즈명)으로 활용 + ret["content_title"] = self.anilife_info.get("content_title") if self.anilife_info else self.title return ret def save(self): @@ -1569,9 +1686,33 @@ class ModelAniLifeItem(db.Model): @classmethod def delete_by_id(cls, idx): - db.session.query(cls).filter_by(id=idx).delete() - db.session.commit() - return True + try: + logger.debug(f"delete_by_id: {idx} (type: {type(idx)})") + if isinstance(idx, str) and ',' in idx: + id_list = [int(x.strip()) for x in idx.split(',') if x.strip()] + logger.debug(f"Batch delete: {id_list}") + count = db.session.query(cls).filter(cls.id.in_(id_list)).delete(synchronize_session='fetch') + logger.debug(f"Deleted count: {count}") + else: + db.session.query(cls).filter_by(id=int(idx)).delete() + logger.debug(f"Single delete: {idx}") + db.session.commit() + return True + except Exception as e: + logger.error(f"Exception: {str(e)}") + logger.error(traceback.format_exc()) + return False + + @classmethod + def delete_all(cls): + try: + db.session.query(cls).delete() + db.session.commit() + return True + except Exception as e: + logger.error(f"Exception: {str(e)}") + logger.error(traceback.format_exc()) + return False @classmethod def web_list(cls, req): @@ -1622,22 +1763,28 @@ class ModelAniLifeItem(db.Model): @classmethod def append(cls, q): + # 중복 체크 + existing = cls.get_by_anilife_id(q["_id"]) + if existing: + logger.debug(f"Item already exists in DB: {q['_id']}") + return existing + item = ModelAniLifeItem() item.content_code = q["content_code"] item.season = q["season"] - item.episode_no = q["epi_queue"] + item.episode_no = q.get("epi_queue") item.title = q["content_title"] item.episode_title = q["title"] - item.ohli24_va = q["va"] - item.ohli24_vi = q["_vi"] - item.ohli24_id = q["_id"] + item.anilife_va = q.get("va") + item.anilife_vi = q.get("_vi") + item.anilife_id = q["_id"] item.quality = q["quality"] - item.filepath = q["filepath"] - item.filename = q["filename"] - item.savepath = q["savepath"] - item.video_url = q["url"] - item.vtt_url = q["vtt"] - item.thumbnail = q["thumbnail"] + item.filepath = q.get("filepath") + item.filename = q.get("filename") + item.savepath = q.get("savepath") + item.video_url = q.get("url") + item.vtt_url = q.get("vtt") + item.thumbnail = q.get("thumbnail") item.status = "wait" - item.ohli24_info = q["anilife_info"] + item.anilife_info = q.get("anilife_info") item.save() diff --git a/mod_base.py b/mod_base.py index 77bb6a5..5697d85 100644 --- a/mod_base.py +++ b/mod_base.py @@ -131,6 +131,8 @@ class AnimeModuleBase(PluginModuleBase): arg3 = request.form.get('arg3') or request.args.get('arg3') return self.process_command(command, arg1, arg2, arg3, req) + return jsonify({'ret': 'fail', 'log': f"Unknown sub: {sub}"}) + except Exception as e: self.P.logger.error(f"AJAX Error: {e}") self.P.logger.error(traceback.format_exc()) diff --git a/mod_linkkf.py b/mod_linkkf.py index d43f9c0..8c461c7 100644 --- a/mod_linkkf.py +++ b/mod_linkkf.py @@ -1691,9 +1691,10 @@ class LinkkfQueueEntity(FfmpegQueueEntity): """ from .lib.downloader_factory import DownloaderFactory - # 설정에서 다운로드 방식 읽기 + # 설정에서 다운로드 방식 및 쓰레드 수 읽기 method = self.P.ModelSetting.get("linkkf_download_method") or "ytdlp" - logger.info(f"Linkkf get_downloader using method: {method}") + threads = self.P.ModelSetting.get_int("linkkf_download_threads") or 16 + logger.info(f"Linkkf get_downloader using method: {method}, threads: {threads}") return DownloaderFactory.get_downloader( method=method, @@ -1702,6 +1703,7 @@ class LinkkfQueueEntity(FfmpegQueueEntity): headers=self.headers, callback=callback, callback_id="linkkf", + threads=threads, callback_function=callback_function ) diff --git a/templates/anime_downloader_anilife_category.html b/templates/anime_downloader_anilife_category.html index 78bd2e8..274e5ca 100644 --- a/templates/anime_downloader_anilife_category.html +++ b/templates/anime_downloader_anilife_category.html @@ -681,12 +681,119 @@ background-color: #e0ff42; } + /* ========== Cosmic Violet Theme (Anilife Exclusive) ========== */ 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; + font-family: 'Inter', 'Noto Sans KR', system-ui, sans-serif; + background: linear-gradient(135deg, #1e1b4b 0%, #312e81 40%, #4c1d95 100%) !important; + background-attachment: fixed; + color: #e0e7ff; + min-height: 100vh; } - body { - background-image: linear-gradient(90deg, #233f48, #6c6fa2, #768dae); + /* Search Bar Styling */ + .input-group { + background: rgba(49, 46, 129, 0.5); + border-radius: 12px; + padding: 6px; + border: 1px solid rgba(167, 139, 250, 0.25); + backdrop-filter: blur(10px); + margin-bottom: 15px; + } + + #input_search { + background: rgba(30, 27, 75, 0.7) !important; + border: 1px solid rgba(167, 139, 250, 0.2) !important; + color: #e0e7ff !important; + border-radius: 8px !important; + } + + #input_search::placeholder { + color: #c4b5fd; + opacity: 0.7; + } + + #btn_search { + background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%) !important; + border: none !important; + border-radius: 8px !important; + } + + /* Category Buttons */ + #anime_category { + margin: 15px 0; + display: flex; + flex-wrap: wrap; + gap: 8px; + } + + #anime_category .btn { + background: rgba(49, 46, 129, 0.5) !important; + border: 1px solid rgba(167, 139, 250, 0.3) !important; + color: #c4b5fd !important; + border-radius: 20px !important; + padding: 8px 20px !important; + } + + #anime_category .btn:hover { + background: rgba(139, 92, 246, 0.3) !important; + color: #fff !important; + } + + #anime_category .btn-success { + background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%) !important; + color: white !important; + } + + #anime_category .btn-primary { + background: linear-gradient(135deg, #6366f1 0%, #4f46e5 100%) !important; + color: white !important; + } + + #anime_category .btn-grey { + background: linear-gradient(135deg, #f472b6 0%, #ec4899 100%) !important; + color: white !important; + } + + /* Card Styling */ + .card { + background: rgba(49, 46, 129, 0.5) !important; + border: 1px solid rgba(167, 139, 250, 0.15) !important; + border-radius: 12px !important; + overflow: hidden; + } + + .card:hover { + border-color: rgba(167, 139, 250, 0.4) !important; + box-shadow: 0 8px 30px rgba(139, 92, 246, 0.2) !important; + } + + .card-body { + background: rgba(30, 27, 75, 0.85) !important; + } + + .card-title { + color: #a78bfa !important; + } + + .card-text { + color: #c4b5fd !important; + } + + .card .btn-primary { + background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%) !important; + border: none !important; + } + + /* Page Badge */ + .btn-info { + background: linear-gradient(135deg, #a78bfa 0%, #8b5cf6 100%) !important; + border: none !important; + border-radius: 8px !important; + } + + /* Spinner */ + #spinner { + color: #a78bfa; } .demo { @@ -809,5 +916,21 @@ z-index: 99999; opacity: 0.5; } + + /* Mobile Responsive */ + @media (max-width: 768px) { + body { padding-top: 10px !important; } + ul.nav.nav-pills.bg-light { + margin-top: 50px !important; + margin-bottom: 10px !important; + width: 100% !important; + display: flex !important; + border-radius: 12px !important; + } + ul.nav.nav-pills .nav-link { + padding: 6px 12px !important; + font-size: 13px; + } + } {% endblock %} diff --git a/templates/anime_downloader_anilife_list.html b/templates/anime_downloader_anilife_list.html index 3845fd0..f8b4aca 100644 --- a/templates/anime_downloader_anilife_list.html +++ b/templates/anime_downloader_anilife_list.html @@ -1,305 +1,662 @@ {% extends "base.html" %} {% block content %} - + + + + +
- - - - - + + - + {% endblock %} \ No newline at end of file diff --git a/templates/anime_downloader_anilife_queue.html b/templates/anime_downloader_anilife_queue.html index 3b8be8f..115b7f8 100644 --- a/templates/anime_downloader_anilife_queue.html +++ b/templates/anime_downloader_anilife_queue.html @@ -2,26 +2,39 @@ {% block content %}
- {{ macros.m_button_group([['reset_btn', '초기화'], ['delete_completed_btn', '완료 목록 삭제'], ['go_ffmpeg_btn', 'Go FFMPEG']]) }} +
+ + + +
- - - - - - - - - - - - - - - - - -
IDXPlugin시작시간파일명상태진행률길이PF배속진행시간Action
+
+ +
+ + + + + + + + + + + + + + + + + +
IDXPlugin시작시간파일명상태진행률길이PF배속진행시간Action
+
+ + +
+ +
+ - - -
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
- + +
+
+
+ +
+ +
+ + +
+
+
+ + 예) "https://anilife.live/g/l?id=xxx" 또는 "f6e83ec6-bd25-4d6c-9428-c10522687604" +
+
+
+
+ +
+
+
@@ -40,7 +70,7 @@ const package_name = "{{arg['package_name'] }}"; const sub = "{{arg['sub'] }}"; const anilife_url = "{{arg['anilife_url']}}"; - {#let current_data = null;#} + // let current_data = null; const params = new Proxy(new URLSearchParams(window.location.search), { get: (searchParams, prop) => searchParams.get(prop), @@ -133,33 +163,21 @@ episodeList.style.transition = 'opacity 0.3s ease-in'; str = ''; - tmp = '
' - tmp += m_button('check_download_btn', '선택 다운로드 추가', []); - tmp += m_button('all_check_on_btn', '전체 선택', []); - tmp += m_button('all_check_off_btn', '전체 해제', []); - /* - tmp += '    ' - tmp += '
' - tmp += m_button('apply_new_title_btn', '저장폴더명, 파일명 제목 변경', []); - tmp += m_button('search_tvdb_btn', 'TVDB', []); - tmp = m_button_group(tmp) - */ - str += tmp + // program - str += m_hr_black(); str += m_row_start(0); - tmp = '' + + let posterHtml = ''; if (data.image != null) { - // CDN 이미지 프록시 적용 let proxyImgSrc = data.image; if (data.image && data.image.includes('cdn.anilife.live')) { - proxyImgSrc = '/' + package_name + '/ajax/' + sub + '/proxy_image?image_url=' + encodeURIComponent(data.image); + proxyImgSrc = '/' + package_name + '/ajax/' + sub + '/proxy_image?url=' + encodeURIComponent(data.image); } - tmp = ''; + posterHtml = '
'; } - str += m_col(3, tmp) - tmp = '' - tmp += m_row_start(2) + m_col(3, '제목', 'right') + m_col(9, '' + data.title + '') + m_row_end(); + + tmp = ''; + tmp += '
' + data.title + '
'; // des1 데이터를 각 항목별로 파싱하여 표시 if (data.des1) { @@ -175,9 +193,22 @@ // 첫 번째 br 태그 제거 (첫 줄에는 필요없음) formattedDes = formattedDes.replace(/^
/, ''); - tmp += '
' + formattedDes + '
'; + tmp += '
'; + if (posterHtml) { + tmp += posterHtml; + } + tmp += '
' + formattedDes + '
'; + tmp += '
'; + + // Integrated Actions Toolbar (Linkkf Style) + tmp += '
'; + tmp += ''; + tmp += ''; + tmp += ''; + tmp += '
'; } - str += m_col(9, tmp) + + str += m_col(12, tmp) str += m_row_end(); str += '
'; @@ -185,7 +216,7 @@ // CDN 이미지 프록시 적용 let epThumbSrc = data.episode[i].thumbnail || ''; if (epThumbSrc && epThumbSrc.includes('cdn.anilife.live')) { - epThumbSrc = '/' + package_name + '/ajax/' + sub + '/proxy_image?image_url=' + encodeURIComponent(epThumbSrc); + epThumbSrc = '/' + package_name + '/ajax/' + sub + '/proxy_image?url=' + encodeURIComponent(epThumbSrc); } str += '
'; @@ -291,25 +322,19 @@ }) $(document).ready(function () { - // DOM 로딩 완료 후 콘텐츠 표시 - const mainContent = document.getElementById('main_content'); + // DOM 로딩 완료 후 preloader 숨기기 const preloader = document.getElementById('preloader'); - // 메인 콘텐츠 보이기 (fade-in 효과) - mainContent.style.display = 'block'; setTimeout(function() { - mainContent.style.opacity = '1'; - // preloader 숨기기 if (preloader) { preloader.style.opacity = '0'; setTimeout(function() { preloader.style.display = 'none'; }, 300); } - }, 100); + }, 500); $("#loader").css("display", 'none'); - // console.log({{ arg['code'] }}) }); $("#analysis_btn").unbind("click").bind('click', function (e) { @@ -452,22 +477,95 @@ box-shadow: 0 4px 12px rgba(37, 99, 235, 0.4); } + /* ========== Cosmic Violet Theme (Anilife Exclusive) ========== */ + body { + background: linear-gradient(135deg, #1e1b4b 0%, #312e81 40%, #4c1d95 100%) !important; + background-attachment: fixed; + color: #e0e7ff; + min-height: 100vh; + font-family: 'Inter', 'Noto Sans KR', system-ui, sans-serif; + } + + + + .action-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 10px 18px; + border-radius: 8px; + font-size: 14px; + font-weight: 600; + border: none; + cursor: pointer; + transition: all 0.2s ease; + } + + .action-btn-primary { + background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%); + color: white; + box-shadow: 0 4px 12px rgba(139, 92, 246, 0.3); + } + + .action-btn-primary:hover { + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(139, 92, 246, 0.4); + } + + .action-btn-secondary { + background: rgba(167, 139, 250, 0.2); + color: #c4b5fd; + border: 1px solid rgba(167, 139, 250, 0.3); + } + + .action-btn-secondary:hover { + background: rgba(167, 139, 250, 0.35); + color: white; + } + + .action-btn-outline { + background: transparent; + color: #a78bfa; + border: 1px solid rgba(167, 139, 250, 0.3); + } + + .action-btn-outline:hover { + background: rgba(167, 139, 250, 0.15); + color: #c4b5fd; + } + /* 시리즈 정보 박스 스타일 */ .series-info-box { - background: linear-gradient(135deg, rgba(30, 41, 59, 0.95) 0%, rgba(15, 23, 42, 0.95) 100%); + background: linear-gradient(135deg, rgba(49, 46, 129, 0.95) 0%, rgba(30, 27, 75, 0.95) 100%); border-radius: 12px; - padding: 20px 25px; + padding: 25px; margin-top: 15px; - line-height: 2.2; - color: #e2e8f0; - border: 1px solid rgba(148, 163, 184, 0.2); + color: #e0e7ff; + border: 1px solid rgba(167, 139, 250, 0.2); box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3); + display: flex; + gap: 20px; + } + + .series-poster-side { + width: 180px; + min-width: 180px; + border-radius: 10px; + overflow: hidden; + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.5); + border: 1px solid rgba(255, 255, 255, 0.1); + align-self: flex-start; + } + + .series-info-side { + flex: 1; + line-height: 2.2; } .series-info-box strong { - color: #60a5fa; + color: #a78bfa; font-weight: 600; - min-width: 100px; + min-width: 90px; display: inline-block; } @@ -485,18 +583,18 @@ align-items: center; gap: 12px; padding: 10px 12px; - background: linear-gradient(135deg, rgba(30, 41, 59, 0.85) 0%, rgba(15, 23, 42, 0.85) 100%); + background: linear-gradient(135deg, rgba(49, 46, 129, 0.85) 0%, rgba(30, 27, 75, 0.85) 100%); border-radius: 8px; - border: 1px solid rgba(148, 163, 184, 0.12); + border: 1px solid rgba(167, 139, 250, 0.15); transition: all 0.2s ease; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); } .episode-card:hover { - background: linear-gradient(135deg, rgba(51, 65, 85, 0.95) 0%, rgba(30, 41, 59, 0.95) 100%); - border-color: rgba(96, 165, 250, 0.5); + background: linear-gradient(135deg, rgba(76, 29, 149, 0.9) 0%, rgba(49, 46, 129, 0.9) 100%); + border-color: rgba(167, 139, 250, 0.5); transform: translateY(-2px); - box-shadow: 0 4px 15px rgba(96, 165, 250, 0.2); + box-shadow: 0 4px 15px rgba(139, 92, 246, 0.25); } /* 에피소드 썸네일 */ @@ -507,7 +605,7 @@ height: 42px; border-radius: 5px; overflow: hidden; - background: linear-gradient(135deg, rgba(0, 0, 0, 0.3) 0%, rgba(30, 41, 59, 0.5) 100%); + background: linear-gradient(135deg, rgba(0, 0, 0, 0.3) 0%, rgba(49, 46, 129, 0.5) 100%); flex-shrink: 0; } @@ -527,7 +625,7 @@ position: absolute; bottom: 2px; left: 2px; - background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%); + background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%); color: white; font-size: 9px; font-weight: 700; @@ -548,7 +646,7 @@ } .episode-title { - color: #e2e8f0; + color: #e0e7ff; font-weight: 500; font-size: 13px; line-height: 1.3; @@ -560,7 +658,7 @@ } .episode-date { - color: #64748b; + color: #a78bfa; font-size: 11px; white-space: nowrap; flex-shrink: 0; @@ -578,14 +676,84 @@ font-size: 11px; padding: 3px 10px; border-radius: 4px; + background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%); + border: none; + color: white; + } + + /* Bootstrap Toggle Custom Styling */ + .toggle.btn-success, .toggle.btn-success:hover { + background: linear-gradient(135deg, #f472b6 0%, #db2777 100%) !important; /* Vibrant Pink/Magenta */ + border: none !important; + box-shadow: 0 2px 8px rgba(236, 72, 153, 0.3) !important; + transition: all 0.2s ease !important; + } + + .toggle.btn-success:hover { + transform: scale(1.05); + box-shadow: 0 4px 12px rgba(236, 72, 153, 0.4) !important; + } + + .toggle.btn-secondary { + background: rgba(30, 27, 75, 0.6) !important; + border: 1px solid rgba(167, 139, 250, 0.2) !important; + } + + .toggle-on.btn-success { + background: linear-gradient(135deg, #f472b6 0%, #db2777 100%) !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; + padding: 0 !important; + font-weight: 800 !important; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.4) !important; + } + + .toggle-off.btn-secondary { + display: flex !important; + align-items: center !important; + justify-content: center !important; + padding: 0 !important; + color: #94a3b8 !important; + } + + .toggle-handle { + background: rgba(255, 255, 255, 0.9) !important; + box-shadow: 0 0 10px rgba(255, 255, 255, 0.3) !important; } .episode-actions .toggle { - transform: scale(0.85); + transform: scale(0.9); + width: 60px !important; + height: 25px !important; + min-width: 60px !important; + min-height: 25px !important; } /* 모바일 반응형 - Bootstrap 모든 레이아웃 강제 덮어쓰기 */ @media (max-width: 768px) { + /* 상단 서브메뉴가 SJVA 메인 navbar에 가려지지 않도록 여백 추가 */ + ul.nav.nav-pills.bg-light { + margin-top: 60px !important; + } + + /* 입력창 크기 최적화 */ + .input-group.input-group-lg { + flex-wrap: nowrap !important; + } + .input-group.input-group-lg .form-control { + flex: 1 1 auto !important; + min-width: 0 !important; + } + .input-group.input-group-lg .input-group-append { + flex: 0 0 auto !important; + } + .input-group.input-group-lg .btn { + padding-left: 10px !important; + padding-right: 10px !important; + font-size: 0.9rem !important; + } + /* 전체 페이지 기본 설정 */ body { overflow-x: hidden !important; diff --git a/templates/anime_downloader_anilife_search.html b/templates/anime_downloader_anilife_search.html index 93a5aa4..bcf66e9 100644 --- a/templates/anime_downloader_anilife_search.html +++ b/templates/anime_downloader_anilife_search.html @@ -180,21 +180,20 @@ str += '
'; for (let i in data.anime_list) { - tmp = '
'; - tmp += '
'; + tmp = '
'; + tmp += '
'; // 이미지 프록시를 통해 CDN 이미지 로드 (hotlink 보호 우회) let airingImgUrl = data.anime_list[i].image_link; if (airingImgUrl && airingImgUrl.includes('cdn.anilife.live')) { airingImgUrl = '/' + package_name + '/ajax/' + sub + '/proxy_image?url=' + encodeURIComponent(airingImgUrl); } - tmp += ''; - tmp += '
' - // {#tmp += '
';#} - tmp += '
' + data.anime_list[i].title + '
'; - tmp += '

' + data.anime_list[i].code + '

'; - tmp += '

' + data.anime_list[i].epx + '

'; - tmp += '' + data.anime_list[i].title + ''; + tmp += '
'; + tmp += ''; + tmp += '' + data.anime_list[i].epx + ''; + tmp += '
'; + tmp += '
' + tmp += '
' + data.anime_list[i].title + '
'; + tmp += '' + data.anime_list[i].title + ''; tmp += '
'; tmp += '
'; tmp += '
'; @@ -253,13 +252,12 @@ if (imgUrl && imgUrl.includes('cdn.anilife.live')) { imgUrl = '/' + package_name + '/ajax/' + sub + '/proxy_image?url=' + encodeURIComponent(imgUrl); } + tmp += '
'; tmp += ''; + tmp += '' + data.anime_list[i].epx + ''; + tmp += '
'; tmp += '
' - // {#tmp += '
';#} tmp += '
' + data.anime_list[i].title + '
'; - tmp += '

' + data.anime_list[i].code + '

'; - tmp += '

' + data.anime_list[i].epx + '

'; tmp += '' + data.anime_list[i].title + ''; tmp += '
'; tmp += '
'; @@ -304,10 +302,14 @@ if (screenImgUrl && screenImgUrl.includes('cdn.anilife.live')) { screenImgUrl = '/' + package_name + '/ajax/' + sub + '/proxy_image?url=' + encodeURIComponent(screenImgUrl); } + tmp += '
'; tmp += ''; + if (data.anime_list[i].epx) { + tmp += '' + data.anime_list[i].epx + ''; + } + tmp += '
'; tmp += '
' tmp += '
' + data.anime_list[i].title + '
'; - tmp += '

' + data.anime_list[i].code + '

'; tmp += '' + data.anime_list[i].title + ''; tmp += '
'; tmp += '
'; @@ -762,6 +764,27 @@ border-radius: 12px 12px 0 0; } + /* Card Image Container for Badge Overlay */ + .card-img-container { + position: relative; + overflow: hidden; + } + + /* Episode Badge Overlay */ + .episode-badge { + position: absolute; + bottom: 8px; + right: 8px; + background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%); + color: white; + font-size: 11px; + font-weight: 700; + padding: 4px 10px; + border-radius: 6px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4); + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); + } + .card-body { padding: 12px !important; flex-grow: 1; @@ -854,11 +877,162 @@ } 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; + font-family: 'Inter', 'Noto Sans KR', NanumSquareNeo, system-ui, -apple-system, Segoe UI, Roboto, Helvetica Neue, sans-serif; } + /* ========== Cosmic Violet Theme (Anilife Exclusive) ========== */ body { - background-image: linear-gradient(90deg, #233f48, #6c6fa2, #768dae); + background: linear-gradient(135deg, #1e1b4b 0%, #312e81 40%, #4c1d95 100%) !important; + background-attachment: fixed; + color: #e0e7ff; + min-height: 100vh; + } + + /* Search Bar Styling */ + #yommi_wrapper { + padding: 20px 15px; + } + + .input-group { + background: rgba(49, 46, 129, 0.5); + border-radius: 12px; + padding: 6px; + border: 1px solid rgba(167, 139, 250, 0.25); + backdrop-filter: blur(10px); + } + + .input-group #input_search { + background: rgba(30, 27, 75, 0.7) !important; + border: 1px solid rgba(167, 139, 250, 0.2) !important; + color: #e0e7ff !important; + border-radius: 8px !important; + padding: 12px 16px !important; + font-size: 15px; + } + + .input-group #input_search::placeholder { + color: #c4b5fd; + opacity: 0.7; + } + + .input-group #input_search:focus { + outline: none !important; + border-color: #a78bfa !important; + box-shadow: 0 0 0 2px rgba(167, 139, 250, 0.3) !important; + } + + .input-group #btn_search { + background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%) !important; + border: none !important; + border-radius: 8px !important; + padding: 10px 24px !important; + font-weight: 600; + color: white; + transition: all 0.3s ease; + } + + .input-group #btn_search:hover { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(139, 92, 246, 0.4); + } + + /* Category Buttons */ + #anime_category { + margin: 20px 0; + display: flex; + flex-wrap: wrap; + gap: 8px; + } + + #anime_category .btn { + background: rgba(49, 46, 129, 0.5) !important; + border: 1px solid rgba(167, 139, 250, 0.3) !important; + color: #c4b5fd !important; + border-radius: 20px !important; + padding: 8px 20px !important; + font-weight: 500; + transition: all 0.3s ease; + } + + #anime_category .btn:hover { + background: rgba(139, 92, 246, 0.3) !important; + border-color: #a78bfa !important; + color: #fff !important; + transform: translateY(-2px); + } + + #anime_category .btn-success { + background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%) !important; + border-color: transparent !important; + color: white !important; + } + + #anime_category .btn-primary { + background: linear-gradient(135deg, #6366f1 0%, #4f46e5 100%) !important; + border-color: transparent !important; + color: white !important; + } + + #anime_category .btn-dark { + background: linear-gradient(135deg, #475569 0%, #334155 100%) !important; + border-color: transparent !important; + } + + #anime_category .btn-grey { + background: linear-gradient(135deg, #f472b6 0%, #ec4899 100%) !important; + border-color: transparent !important; + color: white !important; + } + + /* Page Badge */ + .btn-info { + background: linear-gradient(135deg, #a78bfa 0%, #8b5cf6 100%) !important; + border: none !important; + border-radius: 8px !important; + margin-bottom: 15px; + } + + .badge.bg-warning { + background: #fbbf24 !important; + color: #1e293b !important; + } + + /* Card Styling Override */ + .card { + background: rgba(49, 46, 129, 0.5) !important; + border: 1px solid rgba(167, 139, 250, 0.15) !important; + backdrop-filter: blur(8px); + } + + .card:hover { + border-color: rgba(167, 139, 250, 0.4) !important; + box-shadow: 0 8px 30px rgba(139, 92, 246, 0.2) !important; + } + + .card-body { + background: linear-gradient(180deg, rgba(30, 27, 75, 0.85) 0%, rgba(30, 27, 75, 0.95) 100%) !important; + } + + .card-title { + color: #a78bfa !important; + } + + .card-text { + color: #c4b5fd !important; + } + + .card .btn-primary { + background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%) !important; + border: none !important; + } + + .card .btn-primary:hover { + box-shadow: 0 4px 12px rgba(139, 92, 246, 0.4); + } + + /* Spinner */ + #spinner { + color: #a78bfa; } .demo { @@ -1055,7 +1229,18 @@ $(document).ready(function(){ {% endblock %} diff --git a/templates/anime_downloader_anilife_setting.html b/templates/anime_downloader_anilife_setting.html index e3939a9..be25f94 100644 --- a/templates/anime_downloader_anilife_setting.html +++ b/templates/anime_downloader_anilife_setting.html @@ -45,7 +45,10 @@
{{ macros.setting_input_int('anilife_max_ffmpeg_process_count', '동시 다운로드 수', value=arg['anilife_max_ffmpeg_process_count'], desc='동시에 다운로드 할 에피소드 갯수입니다.') }} - {{ macros.setting_select('anilife_download_method', '다운로드 방법', [['ffmpeg', 'ffmpeg (기본)'], ['ytdlp', 'yt-dlp']], value=arg.get('anilife_download_method', 'ffmpeg'), desc='m3u8 다운로드에 사용할 도구를 선택합니다.') }} + {{ macros.setting_select('anilife_download_method', '다운로드 방법', [['ffmpeg', 'ffmpeg (기본)'], ['ytdlp', 'yt-dlp (단일쓰레드)'], ['aria2c', 'yt-dlp (멀티쓰레드/aria2c)']], value=arg.get('anilife_download_method', 'ffmpeg'), desc='m3u8 다운로드에 사용할 도구를 선택합니다.') }} +
+ {{ macros.setting_select('anilife_download_threads', '다운로드 속도', [['1', '1배속 (1개, 안정)'], ['2', '2배속 (2개, 권장)'], ['4', '4배속 (4개)'], ['8', '8배속 (8개)'], ['16', '16배속 (16개, 빠름)']], value=arg.get('anilife_download_threads', '16'), desc='yt-dlp 모드에서 사용할 동시 다운로드 수입니다.') }} +
{{ macros.setting_checkbox('anilife_order_desc', '요청 화면 최신순 정렬', value=arg['anilife_order_desc'], desc='On : 최신화부터, Off : 1화부터') }} {{ macros.setting_checkbox('anilife_auto_make_folder', '제목 폴더 생성', value=arg['anilife_auto_make_folder'], desc='제목으로 폴더를 생성하고 폴더 안에 다운로드합니다.') }}
@@ -304,6 +307,22 @@ .folder-item.selected { background: rgba(59, 130, 246, 0.3) !important; } + + /* Mobile Responsive */ + @media (max-width: 768px) { + body { padding-top: 10px !important; } + ul.nav.nav-pills.bg-light { + margin-top: 50px !important; + margin-bottom: 10px !important; + width: 100% !important; + display: flex !important; + border-radius: 12px !important; + } + ul.nav.nav-pills .nav-link { + padding: 6px 12px !important; + font-size: 13px; + } + }