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)
|
## 🚀 주요 기능 (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'로 표시되던 필드명 불일치 문제 해결.
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>';
|
||||||
|
|||||||
@@ -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 === '') {
|
|
||||||
|
const targetCode = urlCode || currentCode;
|
||||||
} else {
|
|
||||||
document.getElementById("code").value = params.code
|
if (targetCode) {
|
||||||
// {#document.getElementById("analysis_btn").click();#}
|
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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user