Compare commits

...

2 Commits

4 changed files with 315 additions and 19 deletions

View File

@@ -65,3 +65,19 @@
2. **Proxy URL**: 필요한 경우 `http://IP:PORT` 형식으로 입력 (기본값: 공란).
3. **저장 경로**: 다운로드된 파일이 저장될 경로 설정.
4. **다운로드 방법**: `yt-dlp` (기본) 추천.
---
## 📝 변경 이력 (Changelog)
### v0.2.2 (2025-12-31)
- **해상도 자동 감지**: m3u8 master playlist에서 해상도(1080p/720p 등)를 파싱하여 파일명에 반영
- **Discord 알림 개선**: 큰 썸네일 이미지, Discord Blurple 색상, ISO 타임스탬프 적용
- **Queue 페이지 UI**: 좌우 여백을 다른 페이지들과 일치하도록 수정
- **Pre-commit hook**: 커밋 시 info.yaml 버전 자동 증가 (patch 버전)
### v0.2.1 (2025-12-30)
- **CDN 보안 우회**: cdndania.com 쿠키 기반 인증 처리 (`curl_cffi` 세션 유지)
- **CdndaniaDownloader**: 별도 프로세스 기반 HLS 세그먼트 다운로더 추가
- **프록시 지원 강화**: 세그먼트 다운로드 시 프록시 적용

View File

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

View File

@@ -382,6 +382,72 @@ class LogicOhli24(PluginModuleBase):
logger.error(traceback.format_exc())
return jsonify({"error": str(e)}), 500
elif sub == "get_playlist":
# 현재 파일과 같은 폴더에서 다음 에피소드들 찾기
try:
file_path = request.args.get("path", "")
if not file_path or not os.path.exists(file_path):
return jsonify({"error": "File not found", "playlist": [], "current_index": 0}), 404
# 보안 체크
download_path = P.ModelSetting.get("ohli24_download_path")
if not file_path.startswith(download_path):
return jsonify({"error": "Access denied", "playlist": [], "current_index": 0}), 403
folder = os.path.dirname(file_path)
current_file = os.path.basename(file_path)
# 파일명에서 SxxExx 패턴 추출
ep_match = re.search(r'\.S(\d+)E(\d+)\.', current_file, re.IGNORECASE)
if not ep_match:
# 패턴 없으면 현재 파일만 반환
return jsonify({
"playlist": [{"path": file_path, "name": current_file}],
"current_index": 0
})
current_season = int(ep_match.group(1))
current_episode = int(ep_match.group(2))
# 같은 폴더의 모든 mp4 파일 가져오기
all_files = []
for f in os.listdir(folder):
if f.endswith('.mp4'):
match = re.search(r'\.S(\d+)E(\d+)\.', f, re.IGNORECASE)
if match:
s = int(match.group(1))
e = int(match.group(2))
all_files.append({
"path": os.path.join(folder, f),
"name": f,
"season": s,
"episode": e
})
# 시즌/에피소드 순으로 정렬
all_files.sort(key=lambda x: (x["season"], x["episode"]))
# 현재 에피소드 이상인 것만 필터링 (현재 + 다음 에피소드들)
playlist = []
current_index = 0
for i, f in enumerate(all_files):
if f["season"] == current_season and f["episode"] >= current_episode:
entry = {"path": f["path"], "name": f["name"]}
if f["episode"] == current_episode:
current_index = len(playlist)
playlist.append(entry)
logger.info(f"Playlist: {len(playlist)} items, current_index: {current_index}")
return jsonify({
"playlist": playlist,
"current_index": current_index
})
except Exception as e:
logger.error(f"Get playlist error: {e}")
logger.error(traceback.format_exc())
return jsonify({"error": str(e), "playlist": [], "current_index": 0}), 500
except Exception as e:
P.logger.error(f"Exception: {e}")
P.logger.error(traceback.format_exc())

View File

@@ -50,7 +50,67 @@
<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>
<!-- 플레이리스트 컨트롤 UI -->
<div class="playlist-controls" style="padding: 12px 16px; background: linear-gradient(135deg, rgba(15, 23, 42, 0.95) 0%, rgba(30, 41, 59, 0.95) 100%); border-top: 1px solid rgba(255,255,255,0.1);">
<!-- 현재 재생 정보 + 버튼 -->
<div style="display: flex; align-items: center; gap: 12px; flex-wrap: wrap;">
<button id="btn-prev-ep" class="playlist-nav-btn" style="display: none;" title="이전 에피소드">
<i class="fa fa-step-backward"></i>
</button>
<div style="flex: 1; min-width: 200px;">
<div id="current-video-title" style="color: #fbbf24; font-weight: 600; font-size: 14px;"></div>
<div id="playlist-progress" style="color: #64748b; font-size: 12px; margin-top: 2px;"></div>
</div>
<button id="btn-next-ep" class="playlist-nav-btn" style="display: none;" title="다음 에피소드">
<i class="fa fa-step-forward"></i>
</button>
<button id="btn-toggle-playlist" class="playlist-toggle-btn" title="에피소드 목록">
<i class="fa fa-list"></i>
</button>
</div>
<!-- 에피소드 목록 (접히는) -->
<div id="playlist-list-container" style="display: none; margin-top: 12px; max-height: 200px; overflow-y: auto; background: rgba(0,0,0,0.3); border-radius: 8px; padding: 8px;">
<div id="playlist-list"></div>
</div>
</div>
</div>
<style>
.playlist-nav-btn {
width: 40px; height: 40px;
background: linear-gradient(135deg, #3b82f6, #2563eb);
border: none; border-radius: 50%;
color: white; font-size: 14px;
cursor: pointer; transition: all 0.2s ease;
display: flex; align-items: center; justify-content: center;
}
.playlist-nav-btn:hover { transform: scale(1.1); box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4); }
.playlist-nav-btn:disabled { opacity: 0.4; cursor: not-allowed; transform: none; }
.playlist-toggle-btn {
padding: 8px 14px;
background: rgba(100, 116, 139, 0.3);
border: 1px solid rgba(148, 163, 184, 0.3);
border-radius: 8px;
color: #94a3b8; font-size: 13px;
cursor: pointer; transition: all 0.2s ease;
}
.playlist-toggle-btn:hover { background: rgba(100, 116, 139, 0.5); color: #e2e8f0; }
.playlist-toggle-btn.active { background: rgba(59, 130, 246, 0.3); border-color: #3b82f6; color: #60a5fa; }
.playlist-item {
padding: 8px 12px; margin: 4px 0;
background: rgba(255,255,255,0.05);
border-radius: 6px; cursor: pointer;
color: #cbd5e1; font-size: 13px;
transition: all 0.2s ease;
display: flex; align-items: center; gap: 8px;
}
.playlist-item:hover { background: rgba(59, 130, 246, 0.2); }
.playlist-item.active { background: linear-gradient(135deg, rgba(59, 130, 246, 0.4), rgba(37, 99, 235, 0.4)); color: #fbbf24; font-weight: 600; }
.playlist-item .ep-num { color: #64748b; font-size: 11px; min-width: 40px; }
</style>
</div>
</div>
</div>
@@ -172,18 +232,44 @@
global_sub_request_search('1')
});
// 비디오 보기 버튼 클릭 핸들러
// 비디오 보기 버튼 클릭 핸들러 (플레이리스트 지원)
var videoPlayer = null;
var playlist = [];
var currentPlaylistIndex = 0;
function playVideoAtIndex(index) {
if (index < 0 || index >= playlist.length) return;
currentPlaylistIndex = index;
var item = playlist[index];
var streamUrl = '/' + package_name + '/ajax/' + sub + '/stream_video?path=' + encodeURIComponent(item.path);
if (videoPlayer) {
videoPlayer.src({ type: 'video/mp4', src: streamUrl });
videoPlayer.play();
}
// 플레이리스트 UI 업데이트 (현재 파일명, 버튼 상태, 목록 표시)
updatePlaylistUI();
}
$("body").on('click', '.btn-watch', function (e) {
e.preventDefault();
var filePath = $(this).data('path');
// 플레이리스트 API 호출
$.ajax({
url: '/' + package_name + '/ajax/' + sub + '/get_playlist?path=' + encodeURIComponent(filePath),
type: 'GET',
dataType: 'json',
success: function(data) {
playlist = data.playlist || [];
currentPlaylistIndex = data.current_index || 0;
currentPlayingPath = filePath; // 실시간 갱신용 경로 저장
var streamUrl = '/' + package_name + '/ajax/' + sub + '/stream_video?path=' + encodeURIComponent(filePath);
// Video.js 초기화 또는 소스 변경
if (videoPlayer) {
videoPlayer.src({ type: 'video/mp4', src: streamUrl });
} else {
// Video.js 초기화
if (!videoPlayer) {
videoPlayer = videojs('video-player', {
controls: true,
autoplay: false,
@@ -194,23 +280,151 @@
skipButtons: { forward: 10, backward: 10 }
}
});
videoPlayer.src({ type: 'video/mp4', src: streamUrl });
// 비디오 종료 시 다음 에피소드 자동 재생
videoPlayer.on('ended', function() {
if (currentPlaylistIndex < playlist.length - 1) {
currentPlaylistIndex++;
playVideoAtIndex(currentPlaylistIndex);
}
});
}
videoPlayer.src({ type: 'video/mp4', src: streamUrl });
// 플레이리스트 UI 업데이트
updatePlaylistUI();
// 모달 열기
$('#videoModal').modal('show');
},
error: function() {
// 에러 시 기본 동작
var streamUrl = '/' + package_name + '/ajax/' + sub + '/stream_video?path=' + encodeURIComponent(filePath);
if (!videoPlayer) {
videoPlayer = videojs('video-player', { controls: true, fluid: true });
}
videoPlayer.src({ type: 'video/mp4', src: streamUrl });
$('#videoModal').modal('show');
}
});
});
// 플레이리스트 UI 업데이트 함수
function updatePlaylistUI() {
if (!playlist || playlist.length === 0) return;
var currentFile = playlist[currentPlaylistIndex];
$('#current-video-title').text(currentFile ? currentFile.name : '');
$('#playlist-progress').text((currentPlaylistIndex + 1) + ' / ' + playlist.length + ' 에피소드');
// 이전/다음 버튼 표시
if (currentPlaylistIndex > 0) {
$('#btn-prev-ep').show();
} else {
$('#btn-prev-ep').hide();
}
if (currentPlaylistIndex < playlist.length - 1) {
$('#btn-next-ep').show();
} else {
$('#btn-next-ep').hide();
}
// 에피소드 목록 렌더링
var listHtml = '';
for (var i = 0; i < playlist.length; i++) {
var isActive = (i === currentPlaylistIndex) ? 'active' : '';
listHtml += '<div class="playlist-item ' + isActive + '" data-index="' + i + '">';
listHtml += '<span class="ep-num">E' + (i + 1) + '</span>';
listHtml += '<span>' + playlist[i].name + '</span>';
listHtml += '</div>';
}
$('#playlist-list').html(listHtml);
}
// 이전 에피소드 버튼
$('#btn-prev-ep').click(function() {
if (currentPlaylistIndex > 0) {
currentPlaylistIndex--;
playVideoAtIndex(currentPlaylistIndex);
}
});
// 다음 에피소드 버튼
$('#btn-next-ep').click(function() {
if (currentPlaylistIndex < playlist.length - 1) {
currentPlaylistIndex++;
playVideoAtIndex(currentPlaylistIndex);
}
});
// 목록 토글 버튼
$('#btn-toggle-playlist').click(function() {
$(this).toggleClass('active');
$('#playlist-list-container').slideToggle(200);
});
// 목록 아이템 클릭
$(document).on('click', '.playlist-item', function() {
var index = parseInt($(this).data('index'));
if (index !== currentPlaylistIndex) {
currentPlaylistIndex = index;
playVideoAtIndex(index);
}
});
// 모달 닫을 때 비디오 정지 + 포커스 해제 (aria-hidden 경고 방지)
var playlistRefreshInterval = null;
var currentPlayingPath = null; // 현재 재생 중인 파일 경로 저장
function startPlaylistRefresh() {
if (playlistRefreshInterval) return;
playlistRefreshInterval = setInterval(function() {
if (!currentPlayingPath) return;
$.ajax({
url: '/' + package_name + '/ajax/' + sub + '/get_playlist?path=' + encodeURIComponent(currentPlayingPath),
type: 'GET',
dataType: 'json',
success: function(data) {
var newPlaylist = data.playlist || [];
// 새 에피소드 추가됐는지 확인
if (newPlaylist.length > playlist.length) {
var addedCount = newPlaylist.length - playlist.length;
playlist = newPlaylist;
updatePlaylistUI();
// 알림 표시
$.notify('<i class="fa fa-download"></i> ' + addedCount + '개 에피소드 추가됨!', {type: 'success'});
}
}
});
}, 10000); // 10초마다 체크
}
function stopPlaylistRefresh() {
if (playlistRefreshInterval) {
clearInterval(playlistRefreshInterval);
playlistRefreshInterval = null;
}
}
$('#videoModal').on('show.bs.modal', function() {
startPlaylistRefresh();
});
$('#videoModal').on('hide.bs.modal', function () {
stopPlaylistRefresh();
// 포커스된 요소에서 포커스 해제 (aria-hidden 경고 방지)
document.activeElement.blur();
// 목록 닫기
$('#playlist-list-container').hide();
$('#btn-toggle-playlist').removeClass('active');
});
$('#videoModal').on('hidden.bs.modal', function () {
if (videoPlayer) {
videoPlayer.pause();
}
currentPlayingPath = null;
});
function make_list(data) {