feat: Implement video playback for completed downloads via a modal player and a new streaming endpoint.
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -159,3 +159,4 @@ test.ipynb
|
||||
unanalyzed.py
|
||||
test.ipynb
|
||||
.DS_Store
|
||||
test*.py
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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">×</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 %}
|
||||
Reference in New Issue
Block a user