v0.7.2: Linkkf subtitle download, list-request integration, and hot reload stability improvements

This commit is contained in:
2026-01-11 16:56:35 +09:00
parent 8e3594386d
commit 9b3f4f72bd
7 changed files with 132 additions and 41 deletions

View File

@@ -8,6 +8,9 @@
## 🚀 주요 기능 (Key Features) ## 🚀 주요 기능 (Key Features)
* **다중 사이트 지원**: Ohli24, Anilife, Linkkf 등 다양한 소스에서 영상 검색 및 다운로드. * **다중 사이트 지원**: Ohli24, Anilife, Linkkf 등 다양한 소스에서 영상 검색 및 다운로드.
* **자막 최적화**:
* **자막 전용 다운로드 (Linkkf)**: 영상 없이 자막(VTT)만 별도로 추출하여 SRT로 변환 후 다운로드할 수 있습니다.
* **자막 합침 (Muxing)**: 다운로드된 외부 자막(SRT)을 MP4 컨테이너에 자동으로 삽입하여 범용성을 높입니다.
* **강력한 우회 기술 (Anti-Bot Bypass)**: * **강력한 우회 기술 (Anti-Bot Bypass)**:
* **TLS Fingerprint 변조**: `curl_cffi`를 사용하여 실제 Chrome 브라우저처럼 위장, Cloudflare 및 각종 봇 차단을 무력화합니다. * **TLS Fingerprint 변조**: `curl_cffi`를 사용하여 실제 Chrome 브라우저처럼 위장, Cloudflare 및 각종 봇 차단을 무력화합니다.
* **CDN 자동 감지**: 스트리밍 서버(CDN)의 도메인이 수시로 변경되더라도 자동으로 감지하여 대응합니다. (예: 14B 가짜 파일 문제 해결) * **CDN 자동 감지**: 스트리밍 서버(CDN)의 도메인이 수시로 변경되더라도 자동으로 감지하여 대응합니다. (예: 14B 가짜 파일 문제 해결)
@@ -81,6 +84,19 @@
## 📝 변경 이력 (Changelog) ## 📝 변경 이력 (Changelog)
### v0.7.2 (2026-01-11)
- **Linkkf 자막 전용 다운로드 지원**:
- 에피소드 분석 페이지에 **"자막만 다운로드"** 버튼 추가. (VTT 추출 및 SRT 자동 변환)
- 백그라운드 스레드 처리를 통해 대량의 자막을 끊김 없이 다운로드 가능.
- **Linkkf 목록-분석 연동 강화**:
- 목록(`list`) 페이지 카드에 **"작품소개"** 버튼 추가하여 원클릭 분석 지원.
- URL 파라미터(`code`)를 통한 자동 분석 로직 개선으로 연동 편의성 증대.
- **플러그인 핫 리로드 안정화**:
- Linkkf, AniLife, Ohli24 모든 모듈의 DB 모델에 `extend_existing: True` 옵션 적용.
- 플러그인 자가 업데이트 시 DB 모델 제약으로 인한 리로드 실패 문제 해결.
- **UI/UX 보안**:
- 분석 페이지의 AJAX 요청 결과 핸들링을 강화하여 사용자에게 정확한 성공/실패 알림 제공.
### v0.7.1 (2026-01-11) ### v0.7.1 (2026-01-11)
- **GDM 통합 버그 수정 (Hotfix)**: - **GDM 통합 버그 수정 (Hotfix)**:
- **UI 필드 매핑 수정**: 큐 페이지에서 GDM 작업의 상태, 진행률 등이 'undefined'로 표시되던 필드명 불일치 문제 해결. - **UI 필드 매핑 수정**: 큐 페이지에서 GDM 작업의 상태, 진행률 등이 'undefined'로 표시되던 필드명 불일치 문제 해결.

View File

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

View File

@@ -2228,7 +2228,7 @@ class AniLifeQueueEntity(FfmpegQueueEntity):
class ModelAniLifeItem(db.Model): class ModelAniLifeItem(db.Model):
__tablename__ = "{package_name}_anilife_item".format(package_name=P.package_name) __tablename__ = "{package_name}_anilife_item".format(package_name=P.package_name)
__table_args__ = {"mysql_collate": "utf8_general_ci"} __table_args__ = {"mysql_collate": "utf8_general_ci", "extend_existing": True}
__bind_key__ = P.package_name __bind_key__ = P.package_name
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
created_time = db.Column(db.DateTime) created_time = db.Column(db.DateTime)

View File

@@ -196,7 +196,6 @@ class LogicLinkkf(AnimeModuleBase):
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
ret["ret"] = "error" ret["ret"] = "error"
ret["log"] = str(e) ret["log"] = str(e)
return jsonify(ret)
elif sub == "add_queue_checked_list": elif sub == "add_queue_checked_list":
# 선택된 에피소드 일괄 추가 (백그라운드 스레드로 처리) # 선택된 에피소드 일괄 추가 (백그라운드 스레드로 처리)
import threading import threading
@@ -252,6 +251,69 @@ class LogicLinkkf(AnimeModuleBase):
ret["ret"] = "error" ret["ret"] = "error"
ret["log"] = str(e) ret["log"] = str(e)
return jsonify(ret) return jsonify(ret)
elif sub == "add_sub_queue_checked_list":
# 선택된 에피소드 자막만 일괄 다운로드 (백그라운드 스레드로 처리)
import threading
from flask import current_app
logger.info("========= add_sub_queue_checked_list START =========")
ret = {"ret": "success", "message": "백그라운드에서 자막 다운로드 중..."}
try:
form_data = request.form.get("data")
if not form_data:
ret["ret"] = "error"
ret["log"] = "No data received"
return jsonify(ret)
episode_list = json.loads(form_data)
logger.info(f"Received {len(episode_list)} episodes to download subtitles in background")
# Flask app 참조 저장
app = current_app._get_current_object()
def download_subtitles_background(flask_app, episode_list):
added = 0
skipped = 0
with flask_app.app_context():
for episode_info in episode_list:
try:
# LinkkfQueueEntity를 사용하여 자막 URL 추출 (prepare_extra 활용)
entity = LinkkfQueueEntity(P, self, episode_info)
entity.prepare_extra()
if entity.vtt:
# 자막 다운로드 및 변환
# entity.filepath는 prepare_extra에서 설정됨 (기본 저장 경로 + 파일명)
res = Util.download_subtitle(entity.vtt, entity.filepath, headers=entity.headers)
if res:
added += 1
logger.debug(f"Downloaded subtitle for {episode_info.get('title')}")
else:
skipped += 1
logger.info(f"Failed to download subtitle for {episode_info.get('title')}")
else:
skipped += 1
logger.info(f"No subtitle found for {episode_info.get('title')}")
except Exception as e:
logger.error(f"Error in download_subtitles_background for one episode: {e}")
skipped += 1
logger.info(f"add_sub_queue_checked_list completed: downloaded={added}, skipped={skipped}")
thread = threading.Thread(
target=download_subtitles_background,
args=(app, episode_list)
)
thread.daemon = True
thread.start()
ret["count"] = len(episode_list)
except Exception as e:
logger.error(f"add_sub_queue_checked_list error: {e}")
logger.error(traceback.format_exc())
ret["ret"] = "error"
ret["log"] = str(e)
return jsonify(ret)
elif sub == "web_list": elif sub == "web_list":
ret = ModelLinkkfItem.web_list(req) ret = ModelLinkkfItem.web_list(req)
return jsonify(ret) return jsonify(ret)
@@ -2526,7 +2588,7 @@ class LinkkfQueueEntity(FfmpegQueueEntity):
class ModelLinkkfItem(db.Model): class ModelLinkkfItem(db.Model):
__tablename__ = "{package_name}_linkkf_item".format(package_name=P.package_name) __tablename__ = "{package_name}_linkkf_item".format(package_name=P.package_name)
__table_args__ = {"mysql_collate": "utf8_general_ci"} __table_args__ = {"mysql_collate": "utf8_general_ci", "extend_existing": True}
__bind_key__ = P.package_name __bind_key__ = P.package_name
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
created_time = db.Column(db.DateTime) created_time = db.Column(db.DateTime)

View File

@@ -3447,7 +3447,7 @@ class Ohli24QueueEntity(AnimeQueueEntity):
class ModelOhli24Item(ModelBase): class ModelOhli24Item(ModelBase):
P = P P = P
__tablename__ = "{package_name}_ohli24_item".format(package_name=P.package_name) __tablename__ = "{package_name}_ohli24_item".format(package_name=P.package_name)
__table_args__ = {"mysql_collate": "utf8_general_ci"} __table_args__ = {"mysql_collate": "utf8_general_ci", "extend_existing": True}
__bind_key__ = P.package_name __bind_key__ = P.package_name
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
created_time = db.Column(db.DateTime) created_time = db.Column(db.DateTime)
@@ -3603,7 +3603,7 @@ class ModelOhli24Item(ModelBase):
class ModelOhli24Program(ModelBase): class ModelOhli24Program(ModelBase):
P = P P = P
__tablename__ = f"{P.package_name}_{name}_program" __tablename__ = f"{P.package_name}_{name}_program"
__table_args__ = {"mysql_collate": "utf8_general_ci"} __table_args__ = {"mysql_collate": "utf8_general_ci", "extend_existing": True}
__bind_key__ = P.package_name __bind_key__ = P.package_name
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)

View File

@@ -768,6 +768,12 @@ $(document).ready(function(){
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 btn-merge-sub" data-id="' + item.id + '" data-filename="' + item.filename + '"><i class="fa fa-cc"></i> 자막합침</button>';
} }
// [작품소개] 버튼 추가 - JSON 버튼 왼쪽에 배치
if (item.content_code) {
str += '<button class="action-btn" onclick="location.href=\'/' + package_name + '/' + sub + '/request?code=' + item.content_code + '\'"><i class="fa fa-info-circle"></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>';
str += '<button class="action-btn" onclick="db_remove(' + item.id + ')"><i class="fa fa-trash"></i> 삭제</button>'; str += '<button class="action-btn" onclick="db_remove(' + item.id + ')"><i class="fa fa-trash"></i> 삭제</button>';

View File

@@ -195,45 +195,21 @@
} }
$(function () { $(function () {
// console.log(params.wr_id) // URL 파라미터 처리 (code)
// console.log(findGetParameter('wr_id')) const urlCode = params.code || findGetParameter('code');
// console.log(params.code) const currentCode = "{{arg['linkkf_current_code']}}";
if (params.code === '') {
} else { const targetCode = urlCode || currentCode;
document.getElementById("code").value = params.code
// {#document.getElementById("analysis_btn").click();#} if (targetCode) {
document.getElementById("code").value = targetCode;
// wr_id, bo_table 등이 있으면 같이 전달
analyze(params.wr_id || findGetParameter('wr_id'), params.bo_table || findGetParameter('bo_table'));
} }
});
if ("{{arg['linkkf_current_code']}}" !== "") {
if (params.code === null) {
// console.log('params.code === null')
document.getElementById("code").value = "{{arg['linkkf_current_code']}}";
} else if (params.code === '') {
document.getElementById("code").value = "{{arg['linkkf_current_code']}}";
} else {
// console.log('params code exist')
// console.log(params.code)
document.getElementById("code").value = params.code
analyze(params.wr_id, params.bo_table)
// document.getElementById("analysis_btn").click();
// $('#analysis_btn').trigger('click')
}
// 값이 공백이 아니면 분석 버튼 계속 누름
// {#document.getElementById("analysis_btn").click();#}
} else {
}
})
$(document).ready(function () { $(document).ready(function () {
// console.log('wr_id::', params.wr_id) // console.log('wr_id::', params.wr_id)
}); });
// Enter 키로 검색 트리거 // Enter 키로 검색 트리거
@@ -364,6 +340,37 @@
} }
}); });
}); });
$("body").on('click', '#down_subtitle_btn', function (e) {
e.preventDefault();
all = $('input[id^="checkbox_"]');
let data = [];
let idx;
for (let i in all) {
if (all[i].checked) {
idx = parseInt(all[i].id.split('_')[1])
data.push(current_data.episode[idx]);
}
}
if (data.length == 0) {
$.notify('<strong>선택하세요.</strong>', {type: 'warning'});
return;
}
$.ajax({
url: '/' + package_name + '/ajax/' + sub + '/add_sub_queue_checked_list',
type: "POST",
cache: false,
data: {data: JSON.stringify(data)},
dataType: "json",
success: function (data) {
if (data.ret == "success") {
$.notify('<strong>백그라운드로 자막 다운로드를 시작합니다.</strong>', {type: 'success'});
} else {
$.notify('<strong>자막 다운로드 요청 실패: ' + data.log + '</strong>', {type: 'warning'});
}
}
});
});
</script> </script>
<style> <style>
#anime_downloader_wrapper { #anime_downloader_wrapper {