v0.4.0: Discord notification timing, DB mapping, episode badges, Linkkf fixes, subtitle merge
This commit is contained in:
32
README.md
32
README.md
@@ -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**: 이전/다음 버튼, 에피소드 목록 토글
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
title: "애니 다운로더"
|
||||
version: "0.3.9"
|
||||
version: "0.4.1"
|
||||
package_name: "anime_downloader"
|
||||
developer: "projectdx"
|
||||
description: "anime downloader"
|
||||
|
||||
@@ -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}"
|
||||
|
||||
133
mod_linkkf.py
133
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<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:
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user