feat: Implement video playback for completed downloads via a modal player and a new streaming endpoint.

This commit is contained in:
2025-12-31 20:20:25 +09:00
parent 6a1b30510c
commit 094b9ec733
3 changed files with 182 additions and 8 deletions

1
.gitignore vendored
View File

@@ -159,3 +159,4 @@ test.ipynb
unanalyzed.py
test.ipynb
.DS_Store
test*.py

View File

@@ -286,12 +286,12 @@ class LogicOhli24(PluginModuleBase):
thread.start()
return jsonify("")
elif sub == "web_list3":
print("web_list3")
print(request)
P.logger.debug(req)
P.logger.debug("web_list3")
# print("web_list3")
# print(request)
# P.logger.debug(req)
# P.logger.debug("web_list3")
ret = ModelOhli24Item.web_list(req)
print(ret)
# print(ret)
return jsonify(ret)
elif sub == "web_list2":
@@ -318,6 +318,70 @@ class LogicOhli24(PluginModuleBase):
except Exception as e:
logger.error(f"Exception: {e}")
logger.error(traceback.format_exc())
elif sub == "stream_video":
# 비디오 스트리밍 (MP4 파일 직접 서빙)
try:
from flask import send_file, Response
import mimetypes
file_path = request.args.get("path", "")
logger.info(f"Stream video request: {file_path}")
if not file_path or not os.path.exists(file_path):
return jsonify({"error": "File not found"}), 404
# 보안 체크: 다운로드 폴더 내부인지 확인
download_path = P.ModelSetting.get("ohli24_download_path")
if not file_path.startswith(download_path):
return jsonify({"error": "Access denied"}), 403
# Range 요청 지원 (비디오 시킹)
file_size = os.path.getsize(file_path)
range_header = request.headers.get('Range', None)
if range_header:
byte_start, byte_end = 0, None
match = re.search(r'bytes=(\d+)-(\d*)', range_header)
if match:
byte_start = int(match.group(1))
byte_end = int(match.group(2)) if match.group(2) else file_size - 1
if byte_end is None or byte_end >= file_size:
byte_end = file_size - 1
length = byte_end - byte_start + 1
def generate():
with open(file_path, 'rb') as f:
f.seek(byte_start)
remaining = length
while remaining > 0:
chunk_size = min(8192, remaining)
data = f.read(chunk_size)
if not data:
break
remaining -= len(data)
yield data
resp = Response(
generate(),
status=206,
mimetype=mimetypes.guess_type(file_path)[0] or 'video/mp4',
direct_passthrough=True
)
resp.headers.add('Content-Range', f'bytes {byte_start}-{byte_end}/{file_size}')
resp.headers.add('Accept-Ranges', 'bytes')
resp.headers.add('Content-Length', length)
return resp
else:
return send_file(file_path, mimetype=mimetypes.guess_type(file_path)[0] or 'video/mp4')
except Exception as e:
logger.error(f"Stream video error: {e}")
logger.error(traceback.format_exc())
return jsonify({"error": str(e)}), 500
except Exception as e:
P.logger.error(f"Exception: {e}")
P.logger.error(traceback.format_exc())

View File

@@ -31,6 +31,29 @@
<div id="list_div"></div>
<div id='page2'></div>
</div>
<!-- Video.js CDN -->
<link href="https://vjs.zencdn.net/8.10.0/video-js.css" rel="stylesheet" />
<script src="https://vjs.zencdn.net/8.10.0/video.min.js"></script>
<!-- Video Player Modal -->
<div class="modal fade" id="videoModal" tabindex="-1" role="dialog" aria-labelledby="videoModalLabel" aria-hidden="true">
<div class="modal-dialog modal-xl" role="document">
<div class="modal-content" style="background: #0f172a; border-radius: 12px;">
<div class="modal-header" style="border-bottom: 1px solid rgba(255,255,255,0.1);">
<h5 class="modal-title" id="videoModalLabel" style="color: #f1f5f9;">비디오 플레이어</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close" style="color: #f1f5f9;">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body" style="padding: 0;">
<video id="video-player" class="video-js vjs-big-play-centered vjs-theme-fantasy" controls preload="auto" style="width: 100%; height: auto; max-height: 75vh;">
<p class="vjs-no-js">JavaScript가 필요합니다.</p>
</video>
</div>
</div>
</div>
</div>
<script src="{{ url_for('.static', filename='js/sjva_global1.js') }}"></script>
<script src="{{ url_for('.static', filename='js/sjva_ui14.js') }}"></script>
@@ -149,6 +172,46 @@
global_sub_request_search('1')
});
// 비디오 보기 버튼 클릭 핸들러
var videoPlayer = null;
$("body").on('click', '.btn-watch', function (e) {
e.preventDefault();
var filePath = $(this).data('path');
var streamUrl = '/' + package_name + '/ajax/' + sub + '/stream_video?path=' + encodeURIComponent(filePath);
// Video.js 초기화 또는 소스 변경
if (videoPlayer) {
videoPlayer.src({ type: 'video/mp4', src: streamUrl });
} else {
videoPlayer = videojs('video-player', {
controls: true,
autoplay: false,
preload: 'auto',
fluid: true,
playbackRates: [0.5, 1, 1.5, 2],
controlBar: {
skipButtons: { forward: 10, backward: 10 }
}
});
videoPlayer.src({ type: 'video/mp4', src: streamUrl });
}
// 모달 열기
$('#videoModal').modal('show');
});
// 모달 닫을 때 비디오 정지 + 포커스 해제 (aria-hidden 경고 방지)
$('#videoModal').on('hide.bs.modal', function () {
// 포커스된 요소에서 포커스 해제 (aria-hidden 경고 방지)
document.activeElement.blur();
});
$('#videoModal').on('hidden.bs.modal', function () {
if (videoPlayer) {
videoPlayer.pause();
}
});
function make_list(data) {
let str = '';
@@ -219,6 +282,7 @@
<button data-id="${item.id}" class="btn btn-outline-danger btn-remove">
<i class="fa fa-trash"></i> 삭제
</button>
${item.status == 'completed' ? `<button data-path="${item.savepath}/${item.filename}" class="btn btn-outline-success btn-watch"><i class="fa fa-play"></i> 보기</button>` : ''}
</div>
</div>
</div>
@@ -332,9 +396,9 @@
/* General Layout Tweaks */
.container-fluid {
padding-left: 10px !important;
padding-right: 10px !important;
max-width: 1600px;
padding-left: 4px !important;
padding-right: 4px !important;
max-width: 100%;
margin: 0 auto;
}
@@ -645,6 +709,13 @@ $(document).ready(function(){
});
</script>
<style>
/* Desktop Full Width Override - .container max-width 제거 */
#main_container.container {
max-width: 100% !important;
padding-left: 8px !important;
padding-right: 8px !important;
}
/* Mobile Margin Fix */
@media (max-width: 768px) {
body { overflow-x: hidden !important; padding: 0 !important; margin: 0 !important; }
@@ -668,6 +739,22 @@ $(document).ready(function(){
white-space: normal !important; text-align: left !important;
line-height: 1.4 !important; height: auto !important; display: inline-block !important;
}
/* Mobile Nav Pills - blur 제거하고 solid background 사용 */
ul.nav.nav-pills.bg-light {
background-color: #1e293b !important;
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
border-radius: 12px !important;
padding: 4px !important;
flex-wrap: wrap;
gap: 4px;
}
ul.nav.nav-pills .nav-link {
padding: 6px 12px !important;
font-size: 12px !important;
}
}
/* Pagination Styling */
.btn-toolbar {
@@ -698,5 +785,27 @@ $(document).ready(function(){
opacity: 1;
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.3);
}
/* JSON Modal 팝업 폭 확대 */
.modal-dialog {
max-width: 90% !important;
width: 900px !important;
}
.modal-content {
background: rgba(15, 23, 42, 0.95) !important;
border: 1px solid rgba(148, 163, 184, 0.2) !important;
border-radius: 12px !important;
}
.modal-body pre {
background: rgba(0, 0, 0, 0.5) !important;
color: #4ade80 !important;
padding: 15px !important;
border-radius: 8px !important;
max-height: 70vh !important;
overflow: auto !important;
font-size: 12px !important;
}
</style>
{% endblock %}