v0.4.0: Discord notification timing, DB mapping, episode badges, Linkkf fixes, subtitle merge

This commit is contained in:
2026-01-02 01:06:12 +09:00
parent 0c0ab8cd77
commit 4e9203ed00
7 changed files with 265 additions and 36 deletions

View File

@@ -70,6 +70,24 @@
## 📝 변경 이력 (Changelog)
### 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.7 (2026-01-01)
- **설정 페이지 폴더 탐색 기능 추가**:
- Ohli24, Anilife, Linkkf 모든 설정 페이지에 **폴더 탐색 버튼** 적용
@@ -91,20 +109,6 @@
- 검색창 및 버튼 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.3.0 (2025-12-31)
- **VideoJS 플레이리스트**: 비디오 플레이어에서 다음 에피소드 자동 재생
- **플레이리스트 UI**: 이전/다음 버튼, 에피소드 목록 토글

View File

@@ -1,5 +1,5 @@
title: "애니 다운로더"
version: "0.3.9"
version: "0.4.1"
package_name: "anime_downloader"
developer: "projectdx"
description: "anime downloader"

View File

@@ -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}"

View File

@@ -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<epi_no>\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:

View File

@@ -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())

View File

@@ -765,6 +765,7 @@ $(document).ready(function(){
str += '<div class="item-actions">';
if (item.status === 'completed') {
str += '<button class="action-btn btn-play" data-path="' + item.filepath + '" data-filename="' + item.filename + '"><i class="fa fa-play"></i> 재생</button>';
str += '<button class="action-btn btn-merge-sub" data-id="' + item.id + '" data-filename="' + item.filename + '"><i class="fa fa-cc"></i> 자막합침</button>';
}
str += '<button class="action-btn" onclick="m_modal(current_data.list[' + i + '])"><i class="fa fa-code"></i> JSON</button>';
str += '<button class="action-btn" onclick="search_item(\'' + (item.title || '') + '\')"><i class="fa fa-search"></i> 검색</button>';
@@ -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('<i class="fa fa-spinner fa-spin"></i> 처리중...');
$.ajax({
url: '/' + package_name + '/ajax/' + sub + '/merge_subtitle',
type: 'POST',
data: { id: itemId },
dataType: 'json',
success: function(data) {
if (data.ret === 'success') {
$.notify('<i class="fa fa-check"></i> ' + data.message + '<br><small>' + data.output_file + '</small>', {
type: 'success',
delay: 5000
});
} else {
$.notify('<i class="fa fa-exclamation-triangle"></i> 실패: ' + data.message, {
type: 'danger',
delay: 8000
});
}
},
error: function(xhr, status, error) {
$.notify('요청 실패: ' + error, { type: 'danger' });
},
complete: function() {
$btn.prop('disabled', false).html('<i class="fa fa-cc"></i> 자막합침');
}
});
});
</script>
{% endblock %}

View File

@@ -468,10 +468,15 @@
// Thumbnail
let imgHtml = '';
let badgeHtml = '';
if (item.episode_no) {
badgeHtml = `<div class="episode-badge">${item.episode_no}화</div>`;
}
if (item.thumbnail) {
imgHtml = `<img src="${item.thumbnail}" onerror="this.src='https://via.placeholder.com/60x80?text=No+Img'">`;
imgHtml = `${badgeHtml}<img src="${item.thumbnail}" onerror="this.src='https://via.placeholder.com/60x80?text=No+Img'">`;
} else {
imgHtml = `<img src="https://via.placeholder.com/60x80?text=No+Img">`;
imgHtml = `${badgeHtml}<img src="https://via.placeholder.com/60x80?text=No+Img">`;
}
// Date
@@ -772,6 +777,23 @@
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;
min-width: 0;