From 503485da5eefac847b7459ea9c329e742059fdfb Mon Sep 17 00:00:00 2001 From: projectdx Date: Fri, 2 Jan 2026 01:06:12 +0900 Subject: [PATCH] v0.4.0: Discord notification timing, DB mapping, episode badges, Linkkf fixes, subtitle merge --- README.md | 30 +++-- info.yaml | 2 +- lib/ytdlp_downloader.py | 38 +++++- mod_linkkf.py | 133 +++++++++++++++++++- mod_ohli24.py | 33 +++-- templates/anime_downloader_linkkf_list.html | 37 ++++++ templates/anime_downloader_ohli24_list.html | 26 +++- 7 files changed, 264 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 0e6af27..33c78cf 100644 --- a/README.md +++ b/README.md @@ -91,19 +91,23 @@ - 검색창 및 버튼 UI 디자인 개선 (높이 조정, 정렬 수정, "Elegant" 스타일 적용) - "Top" 카테고리를 내부 API 연동으로 전환하여 정확도 향상 -### v0.4.0 (2025-01-01) -- **UI/UX 대규모 개편**: - - 전반적인 디자인을 **"Midnight Forest"** 테마로 통일 (짙은 녹색/짙은 청색 베이스) - - Linkkf 및 Ohli24 목록 페이지에 Animate.css 기반의 **Custom Delete Modal** 적용 (기존 native confirm 팝업 대체) - - 페이지 좌우 여백을 5px로 축소하여 모바일/데스크탑 모두에서 컴팩트한 레이아웃 제공 - - Linkkf 포스터 이미지에 에피소드 넘버 배지 추가 -- **기능 개선**: - - **Queue 관리 강화**: 큐 초기화(Reset) 및 완료된 항목 삭제(Delete Completed) 버튼 추가 - - **이미지 로딩 최적화**: 포스터 이미지 로딩 실패 시 효율적인 Fallback 처리 적용 (placeholder 서비스 연동) - - **페이지네이션 버그 수정**: Linkkf 목록 페이지에서 발생하던 undefined 페이지 오류 해결 -- **시스템 안정성**: - - 백그라운드 스레드 DB 작업 시 `app_context` 오류 수정 - - `yt-dlp` 다운로드 프로세스 관리 개선 (좀비 프로세스 방지 및 확실한 취소 처리) +### v0.4.0 (2026-01-02) +- **Discord 알림 개선**: + - 다운로드 완료 시에만 알림 전송 (시작 시 알림 제거) + - 알림 메시지에 포스터 이미지 및 파일명 포함 +- **DB 매핑 개선**: + - 다운로드 시작 즉시 메타데이터(제목, 에피소드 번호, 화질 등) DB 동기화 + - `download_completed`에서 모든 필드 정확히 매핑 +- **UI/UX 개선**: + - Ohli24 목록에 에피소드 번호 배지 추가 (고대비 노란색) + - Linkkf 목록에 **"자막합침"** 버튼 추가 (ffmpeg로 SRT 자막 MP4에 삽입) +- **Linkkf 다운로드 수정**: + - `get_downloader` 메서드 추가 및 설정 페이지의 다운로드 방식 반영 + - `prepare_extra` URL 덮어쓰기 버그 수정 + - yt-dlp Fragment 파일 자동 정리 +- **로그 최적화**: + - yt-dlp 진행률 로그 빈도 감소 (10회당 1회) + - 중복 로그 제거 (`download_completed` 단일 호출) ### v0.3.0 (2025-12-31) - **VideoJS 플레이리스트**: 비디오 플레이어에서 다음 에피소드 자동 재생 diff --git a/info.yaml b/info.yaml index a72c2b0..d289371 100644 --- a/info.yaml +++ b/info.yaml @@ -1,5 +1,5 @@ title: "애니 다운로더" -version: "0.3.9" +version: "0.4.0" package_name: "anime_downloader" developer: "projectdx" description: "anime downloader" diff --git a/lib/ytdlp_downloader.py b/lib/ytdlp_downloader.py index 46c5887..8720e13 100644 --- a/lib/ytdlp_downloader.py +++ b/lib/ytdlp_downloader.py @@ -357,9 +357,13 @@ class YtdlpDownloader: logger.warning(f"yt-dlp output notice: {line}") self.error_output.append(line) - # Aria2c / 병렬 다운로드 로그 로깅 + # Aria2c / 병렬 다운로드 로그 - 10회당 1회만 로깅 (로그 부하 감소) if 'aria2c' in line.lower() or 'fragment' in line.lower(): - logger.info(f"yt-dlp: {line}") + if not hasattr(self, '_fragment_log_count'): + self._fragment_log_count = 0 + self._fragment_log_count += 1 + if self._fragment_log_count % 10 == 1: # 1, 11, 21, 31... 번째만 로깅 + logger.debug(f"yt-dlp: {line}") self.process.wait() @@ -374,6 +378,36 @@ class YtdlpDownloader: os.remove(self.output_path) return False, f"CDN 보안 차단(가짜 파일 다운로드됨: {file_size}B)" except: pass + + # [Fragment Cleanup] yt-dlp 임시 파일 정리 (Frag*, .ytdl, .part 등) + try: + import glob + dirname = os.path.dirname(self.output_path) + basename = os.path.basename(self.output_path) + name_without_ext = os.path.splitext(basename)[0] + + # 패턴 목록: *-Frag*, .ytdl, .part 파일들 + cleanup_patterns = [ + os.path.join(dirname, f"{name_without_ext}*-Frag*"), + os.path.join(dirname, f"{name_without_ext}*.ytdl"), + os.path.join(dirname, f"{name_without_ext}*.part"), + os.path.join(dirname, "*-Frag*"), # 일반 Fragment 파일 + ] + + cleaned_count = 0 + for pattern in cleanup_patterns: + for frag_file in glob.glob(pattern): + try: + os.remove(frag_file) + cleaned_count += 1 + except: + pass + + if cleaned_count > 0: + logger.info(f"[Cleanup] Removed {cleaned_count} temporary fragment files") + except Exception as cleanup_err: + logger.debug(f"Fragment cleanup error (non-critical): {cleanup_err}") + return True, "Download completed" error_msg = "\n".join(self.error_output[-3:]) if self.error_output else f"Exit code {self.process.returncode}" diff --git a/mod_linkkf.py b/mod_linkkf.py index 9101da1..d43f9c0 100644 --- a/mod_linkkf.py +++ b/mod_linkkf.py @@ -250,6 +250,77 @@ class LogicLinkkf(AnimeModuleBase): return jsonify({"ret": "error", "log": "No ID provided"}) return jsonify(ModelLinkkfItem.delete_by_id(db_id)) + elif sub == "merge_subtitle": + # 자막 합치기 - ffmpeg로 SRT를 MP4에 삽입 + import subprocess + import shutil + + db_id = request.form.get("id") + if not db_id: + return jsonify({"ret": "error", "message": "No ID provided"}) + + try: + db_item = ModelLinkkfItem.get_by_id(int(db_id)) + if not db_item: + return jsonify({"ret": "error", "message": "Item not found"}) + + mp4_path = db_item.filepath + if not mp4_path or not os.path.exists(mp4_path): + return jsonify({"ret": "error", "message": f"MP4 file not found: {mp4_path}"}) + + # SRT 파일 경로 (MP4와 동일 경로에 .srt 확장자) + srt_path = os.path.splitext(mp4_path)[0] + ".srt" + if not os.path.exists(srt_path): + return jsonify({"ret": "error", "message": f"SRT file not found: {srt_path}"}) + + # 출력 파일: *_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 명령어: 자막을 soft embed (mov_text 코덱) + # -i mp4 -i srt -c:v copy -c:a copy -c:s mov_text output + 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=300) + + if result.returncode != 0: + logger.error(f"ffmpeg error: {result.stderr}") + return jsonify({"ret": "error", "message": f"ffmpeg failed: {result.stderr[-200:]}"}) + + if not os.path.exists(output_path): + return jsonify({"ret": "error", "message": "Output file was not created"}) + + output_size = os.path.getsize(output_path) + logger.info(f"[Merge Subtitle] Created: {output_path} ({output_size} bytes)") + + return jsonify({ + "ret": "success", + "message": f"자막 합침 완료!", + "output_file": os.path.basename(output_path), + "output_size": output_size + }) + + except subprocess.TimeoutExpired: + return jsonify({"ret": "error", "message": "ffmpeg timeout (5분 초과)"}) + except Exception as e: + logger.error(f"merge_subtitle error: {e}") + logger.error(traceback.format_exc()) + return jsonify({"ret": "error", "message": str(e)}) + elif sub == "get_playlist": # 현재 파일과 같은 폴더에서 다음 에피소드들 찾기 try: @@ -1460,9 +1531,8 @@ class LogicLinkkf(AnimeModuleBase): return "queue_exist" else: db_entity = ModelLinkkfItem.get_by_linkkf_id(episode_info["_id"]) - logger.info(f"db_entity: {db_entity}") - - logger.debug("db_entity:::> %s", db_entity) + # logger.info(f"db_entity: {db_entity}") + # logger.debug("db_entity:::> %s", db_entity) # logger.debug("db_entity.status ::: %s", db_entity.status) if db_entity is None: entity = LinkkfQueueEntity(P, self, episode_info) @@ -1614,6 +1684,27 @@ class LinkkfQueueEntity(FfmpegQueueEntity): self.playid_url = playid_url self.url = playid_url # 초기값 설정 + def get_downloader(self, video_url, output_file, callback=None, callback_function=None): + """ + Factory를 통해 다운로더 인스턴스를 반환합니다. + 설정에서 다운로드 방식을 읽어옵니다. + """ + 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}") + + return DownloaderFactory.get_downloader( + method=method, + video_url=video_url, + output_file=output_file, + headers=self.headers, + callback=callback, + callback_id="linkkf", + callback_function=callback_function + ) + def prepare_extra(self): """ [Lazy Extraction] @@ -1639,7 +1730,37 @@ class LinkkfQueueEntity(FfmpegQueueEntity): else: # 추출 실패 시 원본 URL 사용 (fallback) self.url = self.playid_url - logger.warning(f"Failed to extract video URL, using playid URL: {self.playid_url}") + + # ------------------------------------------------------------------ + # [IMMEDIATE SYNC] - Update DB with all extracted metadata + # ------------------------------------------------------------------ + try: + from .mod_linkkf import ModelLinkkfItem + db_item = ModelLinkkfItem.get_by_linkkf_id(self.info.get("_id")) + if db_item: + logger.debug(f"[SYNC] Syncing metadata for Linkkf _id: {self.info.get('_id')}") + # Parse episode number if possible for DB field + epi_no = None + try: + match = re.search(r"(?P\d+)", str(self.info.get("ep_num", ""))) + if match: + epi_no = int(match.group("epi_no")) + except: + pass + + db_item.title = self.content_title + db_item.season = int(self.season) if self.season else 1 + db_item.episode_no = epi_no + db_item.quality = self.quality + db_item.savepath = self.savepath + db_item.filename = self.filename + db_item.filepath = self.filepath + db_item.video_url = self.url + db_item.vtt_url = self.vtt + db_item.save() + except Exception as sync_err: + logger.error(f"[SYNC] Failed to sync Linkkf metadata in prepare_extra: {sync_err}") + except Exception as e: logger.error(f"Exception in video URL extraction: {e}") logger.error(traceback.format_exc()) @@ -1659,6 +1780,10 @@ class LinkkfQueueEntity(FfmpegQueueEntity): db_item.completed_time = datetime.now() db_item.filepath = self.filepath db_item.filename = self.filename + db_item.savepath = self.savepath + db_item.quality = self.quality + db_item.video_url = self.url + db_item.vtt_url = self.vtt db_item.save() logger.info(f"Updated DB status to 'completed' for episode {db_item.id}") else: diff --git a/mod_ohli24.py b/mod_ohli24.py index eeddb93..ba94fab 100644 --- a/mod_ohli24.py +++ b/mod_ohli24.py @@ -1553,19 +1553,6 @@ class Ohli24QueueEntity(AnimeQueueEntity): self.module_logic.socketio_callback("status", self.as_dict()) - # Discord Notification Trigger (All downloaders) - try: - if getattr(self, 'ffmpeg_status', 0) == 5: # DOWNLOADING - if not getattr(self, '_discord_sent', False): - self._discord_sent = True - title = self.info.get('title', 'Unknown Title') - filename = getattr(self, 'filename', 'Unknown File') - # 썸네일 이미지 - image_link 또는 thumbnail 필드에서 가져옴 - poster_url = self.info.get('image_link', '') or self.info.get('thumbnail', '') - logger.debug(f"Discord poster_url: {poster_url}") - self.module_logic.send_discord_notification("다운로드 시작", title, filename, poster_url) - except Exception as e: - logger.error(f"Failed to check/send discord notification in refresh_status: {e}") # 추가: /queue 네임스페이스로도 명시적으로 전송 try: from framework import socketio @@ -1803,6 +1790,26 @@ class Ohli24QueueEntity(AnimeQueueEntity): except Exception as srt_err: logger.warning(f"Subtitle download failed: {srt_err}") + # ------------------------------------------------------------------ + # [IMMEDIATE SYNC] - Update DB with all extracted metadata + # ------------------------------------------------------------------ + try: + db_entity = ModelOhli24Item.get_by_ohli24_id(self.info["_id"]) + if db_entity: + logger.debug(f"[SYNC] Syncing metadata for _id: {self.info['_id']}") + db_entity.title = self.content_title + db_entity.season = self.season + db_entity.episode_no = self.epi_queue + db_entity.quality = self.quality + db_entity.savepath = self.savepath + db_entity.filename = self.filename + db_entity.filepath = self.filepath + db_entity.video_url = self.url + db_entity.vtt_url = self.srt_url or self.vtt + db_entity.save() + except Exception as sync_err: + logger.error(f"[SYNC] Failed to sync metadata in prepare_extra: {sync_err}") + except Exception as e: P.logger.error("Exception:%s", e) P.logger.error(traceback.format_exc()) diff --git a/templates/anime_downloader_linkkf_list.html b/templates/anime_downloader_linkkf_list.html index f6a253d..b146d0c 100644 --- a/templates/anime_downloader_linkkf_list.html +++ b/templates/anime_downloader_linkkf_list.html @@ -765,6 +765,7 @@ $(document).ready(function(){ str += '
'; if (item.status === 'completed') { str += ''; + str += ''; } str += ''; str += ''; @@ -808,6 +809,42 @@ $(document).ready(function(){ } }); }); + + // 자막 합침 버튼 클릭 핸들러 + $(document).on('click', '.btn-merge-sub', function() { + var $btn = $(this); + var itemId = $btn.data('id'); + var filename = $btn.data('filename'); + + // 로딩 상태 표시 + $btn.prop('disabled', true).html(' 처리중...'); + + $.ajax({ + url: '/' + package_name + '/ajax/' + sub + '/merge_subtitle', + type: 'POST', + data: { id: itemId }, + dataType: 'json', + success: function(data) { + if (data.ret === 'success') { + $.notify(' ' + data.message + '
' + data.output_file + '', { + type: 'success', + delay: 5000 + }); + } else { + $.notify(' 실패: ' + data.message, { + type: 'danger', + delay: 8000 + }); + } + }, + error: function(xhr, status, error) { + $.notify('요청 실패: ' + error, { type: 'danger' }); + }, + complete: function() { + $btn.prop('disabled', false).html(' 자막합침'); + } + }); + }); {% endblock %} \ No newline at end of file diff --git a/templates/anime_downloader_ohli24_list.html b/templates/anime_downloader_ohli24_list.html index faf9258..e39eca5 100644 --- a/templates/anime_downloader_ohli24_list.html +++ b/templates/anime_downloader_ohli24_list.html @@ -468,10 +468,15 @@ // Thumbnail let imgHtml = ''; + let badgeHtml = ''; + if (item.episode_no) { + badgeHtml = `
${item.episode_no}화
`; + } + if (item.thumbnail) { - imgHtml = ``; + imgHtml = `${badgeHtml}`; } else { - imgHtml = ``; + imgHtml = `${badgeHtml}`; } // Date @@ -771,6 +776,23 @@ height: 100%; object-fit: cover; } + + .episode-badge { + position: absolute; + top: 5px; + left: 5px; + background: #facc15; /* Amber/Yellow 400 */ + color: #000; + padding: 2px 6px; + border-radius: 4px; + font-size: 11px; + font-weight: 800; + z-index: 5; + box-shadow: 0 2px 4px rgba(0,0,0,0.3); + border: 1px solid rgba(0,0,0,0.1); + pointer-events: none; + text-transform: uppercase; + } .episode-main-info { flex: 1;