v0.7.2: Linkkf subtitle download, list-request integration, and hot reload stability improvements
This commit is contained in:
16
README.md
16
README.md
@@ -8,6 +8,9 @@
|
||||
## 🚀 주요 기능 (Key Features)
|
||||
|
||||
* **다중 사이트 지원**: Ohli24, Anilife, Linkkf 등 다양한 소스에서 영상 검색 및 다운로드.
|
||||
* **자막 최적화**:
|
||||
* **자막 전용 다운로드 (Linkkf)**: 영상 없이 자막(VTT)만 별도로 추출하여 SRT로 변환 후 다운로드할 수 있습니다.
|
||||
* **자막 합침 (Muxing)**: 다운로드된 외부 자막(SRT)을 MP4 컨테이너에 자동으로 삽입하여 범용성을 높입니다.
|
||||
* **강력한 우회 기술 (Anti-Bot Bypass)**:
|
||||
* **TLS Fingerprint 변조**: `curl_cffi`를 사용하여 실제 Chrome 브라우저처럼 위장, Cloudflare 및 각종 봇 차단을 무력화합니다.
|
||||
* **CDN 자동 감지**: 스트리밍 서버(CDN)의 도메인이 수시로 변경되더라도 자동으로 감지하여 대응합니다. (예: 14B 가짜 파일 문제 해결)
|
||||
@@ -81,6 +84,19 @@
|
||||
|
||||
## 📝 변경 이력 (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)
|
||||
- **GDM 통합 버그 수정 (Hotfix)**:
|
||||
- **UI 필드 매핑 수정**: 큐 페이지에서 GDM 작업의 상태, 진행률 등이 'undefined'로 표시되던 필드명 불일치 문제 해결.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
title: "애니 다운로더"
|
||||
version: 0.7.1
|
||||
version: 0.7.2
|
||||
package_name: "anime_downloader"
|
||||
developer: "projectdx"
|
||||
description: "anime downloader"
|
||||
|
||||
@@ -2228,7 +2228,7 @@ class AniLifeQueueEntity(FfmpegQueueEntity):
|
||||
|
||||
class ModelAniLifeItem(db.Model):
|
||||
__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
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
created_time = db.Column(db.DateTime)
|
||||
|
||||
@@ -196,7 +196,6 @@ class LogicLinkkf(AnimeModuleBase):
|
||||
logger.error(traceback.format_exc())
|
||||
ret["ret"] = "error"
|
||||
ret["log"] = str(e)
|
||||
return jsonify(ret)
|
||||
elif sub == "add_queue_checked_list":
|
||||
# 선택된 에피소드 일괄 추가 (백그라운드 스레드로 처리)
|
||||
import threading
|
||||
@@ -252,6 +251,69 @@ class LogicLinkkf(AnimeModuleBase):
|
||||
ret["ret"] = "error"
|
||||
ret["log"] = str(e)
|
||||
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":
|
||||
ret = ModelLinkkfItem.web_list(req)
|
||||
return jsonify(ret)
|
||||
@@ -2526,7 +2588,7 @@ class LinkkfQueueEntity(FfmpegQueueEntity):
|
||||
|
||||
class ModelLinkkfItem(db.Model):
|
||||
__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
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
created_time = db.Column(db.DateTime)
|
||||
|
||||
@@ -3447,7 +3447,7 @@ class Ohli24QueueEntity(AnimeQueueEntity):
|
||||
class ModelOhli24Item(ModelBase):
|
||||
P = P
|
||||
__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
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
created_time = db.Column(db.DateTime)
|
||||
@@ -3603,7 +3603,7 @@ class ModelOhli24Item(ModelBase):
|
||||
class ModelOhli24Program(ModelBase):
|
||||
P = P
|
||||
__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
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
@@ -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-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="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>';
|
||||
|
||||
@@ -195,45 +195,21 @@
|
||||
}
|
||||
|
||||
$(function () {
|
||||
// console.log(params.wr_id)
|
||||
// console.log(findGetParameter('wr_id'))
|
||||
// console.log(params.code)
|
||||
if (params.code === '') {
|
||||
// URL 파라미터 처리 (code)
|
||||
const urlCode = params.code || findGetParameter('code');
|
||||
const currentCode = "{{arg['linkkf_current_code']}}";
|
||||
|
||||
} else {
|
||||
document.getElementById("code").value = params.code
|
||||
// {#document.getElementById("analysis_btn").click();#}
|
||||
const targetCode = urlCode || currentCode;
|
||||
|
||||
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 () {
|
||||
|
||||
// console.log('wr_id::', params.wr_id)
|
||||
|
||||
});
|
||||
|
||||
// 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>
|
||||
<style>
|
||||
#anime_downloader_wrapper {
|
||||
|
||||
Reference in New Issue
Block a user