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)
|
## 📝 변경 이력 (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)
|
### v0.3.7 (2026-01-01)
|
||||||
- **설정 페이지 폴더 탐색 기능 추가**:
|
- **설정 페이지 폴더 탐색 기능 추가**:
|
||||||
- Ohli24, Anilife, Linkkf 모든 설정 페이지에 **폴더 탐색 버튼** 적용
|
- Ohli24, Anilife, Linkkf 모든 설정 페이지에 **폴더 탐색 버튼** 적용
|
||||||
@@ -91,20 +109,6 @@
|
|||||||
- 검색창 및 버튼 UI 디자인 개선 (높이 조정, 정렬 수정, "Elegant" 스타일 적용)
|
- 검색창 및 버튼 UI 디자인 개선 (높이 조정, 정렬 수정, "Elegant" 스타일 적용)
|
||||||
- "Top" 카테고리를 내부 API 연동으로 전환하여 정확도 향상
|
- "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)
|
### v0.3.0 (2025-12-31)
|
||||||
- **VideoJS 플레이리스트**: 비디오 플레이어에서 다음 에피소드 자동 재생
|
- **VideoJS 플레이리스트**: 비디오 플레이어에서 다음 에피소드 자동 재생
|
||||||
- **플레이리스트 UI**: 이전/다음 버튼, 에피소드 목록 토글
|
- **플레이리스트 UI**: 이전/다음 버튼, 에피소드 목록 토글
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
title: "애니 다운로더"
|
title: "애니 다운로더"
|
||||||
version: "0.3.9"
|
version: "0.4.1"
|
||||||
package_name: "anime_downloader"
|
package_name: "anime_downloader"
|
||||||
developer: "projectdx"
|
developer: "projectdx"
|
||||||
description: "anime downloader"
|
description: "anime downloader"
|
||||||
|
|||||||
@@ -357,9 +357,13 @@ class YtdlpDownloader:
|
|||||||
logger.warning(f"yt-dlp output notice: {line}")
|
logger.warning(f"yt-dlp output notice: {line}")
|
||||||
self.error_output.append(line)
|
self.error_output.append(line)
|
||||||
|
|
||||||
# Aria2c / 병렬 다운로드 로그 로깅
|
# Aria2c / 병렬 다운로드 로그 - 10회당 1회만 로깅 (로그 부하 감소)
|
||||||
if 'aria2c' in line.lower() or 'fragment' in line.lower():
|
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()
|
self.process.wait()
|
||||||
|
|
||||||
@@ -374,6 +378,36 @@ class YtdlpDownloader:
|
|||||||
os.remove(self.output_path)
|
os.remove(self.output_path)
|
||||||
return False, f"CDN 보안 차단(가짜 파일 다운로드됨: {file_size}B)"
|
return False, f"CDN 보안 차단(가짜 파일 다운로드됨: {file_size}B)"
|
||||||
except: pass
|
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"
|
return True, "Download completed"
|
||||||
|
|
||||||
error_msg = "\n".join(self.error_output[-3:]) if self.error_output else f"Exit code {self.process.returncode}"
|
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({"ret": "error", "log": "No ID provided"})
|
||||||
return jsonify(ModelLinkkfItem.delete_by_id(db_id))
|
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":
|
elif sub == "get_playlist":
|
||||||
# 현재 파일과 같은 폴더에서 다음 에피소드들 찾기
|
# 현재 파일과 같은 폴더에서 다음 에피소드들 찾기
|
||||||
try:
|
try:
|
||||||
@@ -1460,9 +1531,8 @@ class LogicLinkkf(AnimeModuleBase):
|
|||||||
return "queue_exist"
|
return "queue_exist"
|
||||||
else:
|
else:
|
||||||
db_entity = ModelLinkkfItem.get_by_linkkf_id(episode_info["_id"])
|
db_entity = ModelLinkkfItem.get_by_linkkf_id(episode_info["_id"])
|
||||||
logger.info(f"db_entity: {db_entity}")
|
# logger.info(f"db_entity: {db_entity}")
|
||||||
|
# logger.debug("db_entity:::> %s", db_entity)
|
||||||
logger.debug("db_entity:::> %s", db_entity)
|
|
||||||
# logger.debug("db_entity.status ::: %s", db_entity.status)
|
# logger.debug("db_entity.status ::: %s", db_entity.status)
|
||||||
if db_entity is None:
|
if db_entity is None:
|
||||||
entity = LinkkfQueueEntity(P, self, episode_info)
|
entity = LinkkfQueueEntity(P, self, episode_info)
|
||||||
@@ -1614,6 +1684,27 @@ class LinkkfQueueEntity(FfmpegQueueEntity):
|
|||||||
self.playid_url = playid_url
|
self.playid_url = playid_url
|
||||||
self.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):
|
def prepare_extra(self):
|
||||||
"""
|
"""
|
||||||
[Lazy Extraction]
|
[Lazy Extraction]
|
||||||
@@ -1639,7 +1730,37 @@ class LinkkfQueueEntity(FfmpegQueueEntity):
|
|||||||
else:
|
else:
|
||||||
# 추출 실패 시 원본 URL 사용 (fallback)
|
# 추출 실패 시 원본 URL 사용 (fallback)
|
||||||
self.url = self.playid_url
|
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:
|
except Exception as e:
|
||||||
logger.error(f"Exception in video URL extraction: {e}")
|
logger.error(f"Exception in video URL extraction: {e}")
|
||||||
logger.error(traceback.format_exc())
|
logger.error(traceback.format_exc())
|
||||||
@@ -1659,6 +1780,10 @@ class LinkkfQueueEntity(FfmpegQueueEntity):
|
|||||||
db_item.completed_time = datetime.now()
|
db_item.completed_time = datetime.now()
|
||||||
db_item.filepath = self.filepath
|
db_item.filepath = self.filepath
|
||||||
db_item.filename = self.filename
|
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()
|
db_item.save()
|
||||||
logger.info(f"Updated DB status to 'completed' for episode {db_item.id}")
|
logger.info(f"Updated DB status to 'completed' for episode {db_item.id}")
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -1553,19 +1553,6 @@ class Ohli24QueueEntity(AnimeQueueEntity):
|
|||||||
|
|
||||||
self.module_logic.socketio_callback("status", self.as_dict())
|
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 네임스페이스로도 명시적으로 전송
|
# 추가: /queue 네임스페이스로도 명시적으로 전송
|
||||||
try:
|
try:
|
||||||
from framework import socketio
|
from framework import socketio
|
||||||
@@ -1803,6 +1790,26 @@ class Ohli24QueueEntity(AnimeQueueEntity):
|
|||||||
except Exception as srt_err:
|
except Exception as srt_err:
|
||||||
logger.warning(f"Subtitle download failed: {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:
|
except Exception as e:
|
||||||
P.logger.error("Exception:%s", e)
|
P.logger.error("Exception:%s", e)
|
||||||
P.logger.error(traceback.format_exc())
|
P.logger.error(traceback.format_exc())
|
||||||
|
|||||||
@@ -765,6 +765,7 @@ $(document).ready(function(){
|
|||||||
str += '<div class="item-actions">';
|
str += '<div class="item-actions">';
|
||||||
if (item.status === 'completed') {
|
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-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="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>';
|
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>
|
</script>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -468,10 +468,15 @@
|
|||||||
|
|
||||||
// Thumbnail
|
// Thumbnail
|
||||||
let imgHtml = '';
|
let imgHtml = '';
|
||||||
|
let badgeHtml = '';
|
||||||
|
if (item.episode_no) {
|
||||||
|
badgeHtml = `<div class="episode-badge">${item.episode_no}화</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
if (item.thumbnail) {
|
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 {
|
} 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
|
// Date
|
||||||
@@ -772,6 +777,23 @@
|
|||||||
object-fit: cover;
|
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 {
|
.episode-main-info {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user