commit fac33cff0baaaab9e7fbfda6ac23b71321dd3bab Author: projectdx Date: Mon Jan 5 21:14:51 2026 +0900 Release v0.1.0: GDM Refactor, Rate Limit, Metallic UI diff --git a/README.md b/README.md new file mode 100644 index 0000000..8d7b0e4 --- /dev/null +++ b/README.md @@ -0,0 +1,46 @@ +# gommi_download_manager (GDM) + +FlaskFarm 범용 다운로더 큐 플러그인 (v0.1.0) + +## 🆕 0.1.0 업데이트 (Latest) +- **다운로드 속도 제한**: 설정 페이지에서 대역폭 제한 설정 가능 (무제한, 1MB/s, 5MB/s...) +- **UI 리뉴얼**: 고급스러운 Dark Metallic 디자인 & 반응형 웹 지원 +- **안정성 강화**: 서버 재시작 시 대기 중인 다운로드 상태 복원 (Queue Persistence) +- **목록 관리**: 전체 삭제 및 자동 목록 갱신 기능 (Flickr-free) + +## 주요 기능 + +- **YouTube/일반 사이트**: yt-dlp + aria2c 지원 (고속 분할 다운로드) +- **스트리밍 사이트**: 애니24, 링크애니, Anilife (ffmpeg HLS / Camoufox) 지원 +- **중앙 집중식 관리**: 여러 플러그인의 다운로드 요청을 한곳에서 통합 관리 +- **전역 속도 제한 (Smart Limiter)**: 모든 다운로드에 공통 적용되는 속도 제한 기능 + +## 외부 플러그인에서 사용하기 + +```python +from gommi_download_manager.mod_queue import ModuleQueue + +# 다운로드 추가 (속도 제한은 사용자가 설정한 값 자동 적용) +task = ModuleQueue.add_download( + url='https://www.youtube.com/watch?v=...', + save_path='/path/to/save', # 플러그인별 저장 경로 우선 적용 + filename='video.mp4', # 선택 + source_type='auto', # 자동 감지 + caller_plugin='youtube', # 호출자 식별 +) +``` + +## 설정 가이드 + +웹 인터페이스 (`/gommi_download_manager/queue/setting`)에서 다음을 설정할 수 있습니다: +- **속도 제한**: 네트워크 상황에 맞춰 최대 다운로드 속도 조절 +- **동시 다운로드 수**: 한 번에 몇 개를 받을지 설정 +- **기본 저장 경로**: 경로 미지정 요청에 대한 백업 경로 + +## 성능 비교 + +| 다운로더 | 방식 | 특징 | +|---------|------|------| +| **yt-dlp (Native)** | 안정적 | 속도 제한 기능 완벽 지원 | +| **aria2c** | 고속 (분할) | 대용량 파일에 최적화 (현재 실험적 지원) | +| **ffmpeg** | 스트림 | HLS/M3U8 영상 저장에 사용 | diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..da5cb9d --- /dev/null +++ b/__init__.py @@ -0,0 +1 @@ +# gommi_download_manager - Universal Downloader Queue Plugin diff --git a/__pycache__/__init__.cpython-314.pyc b/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..880f977 Binary files /dev/null and b/__pycache__/__init__.cpython-314.pyc differ diff --git a/__pycache__/mod_queue.cpython-314.pyc b/__pycache__/mod_queue.cpython-314.pyc new file mode 100644 index 0000000..0bd6098 Binary files /dev/null and b/__pycache__/mod_queue.cpython-314.pyc differ diff --git a/__pycache__/model.cpython-314.pyc b/__pycache__/model.cpython-314.pyc new file mode 100644 index 0000000..896af46 Binary files /dev/null and b/__pycache__/model.cpython-314.pyc differ diff --git a/__pycache__/setup.cpython-314.pyc b/__pycache__/setup.cpython-314.pyc new file mode 100644 index 0000000..6efe485 Binary files /dev/null and b/__pycache__/setup.cpython-314.pyc differ diff --git a/downloader/__init__.py b/downloader/__init__.py new file mode 100644 index 0000000..9aee1cc --- /dev/null +++ b/downloader/__init__.py @@ -0,0 +1,30 @@ +""" +다운로더 모듈 패키지 +""" +from typing import Optional +from .base import BaseDownloader + + +def get_downloader(source_type: str) -> Optional[BaseDownloader]: + """소스 타입에 맞는 다운로더 인스턴스 반환""" + + if source_type in ('youtube', 'general'): + from .ytdlp_aria2 import YtdlpAria2Downloader + return YtdlpAria2Downloader() + + elif source_type in ('ani24', 'linkkf', 'hls'): + from .ffmpeg_hls import FfmpegHlsDownloader + return FfmpegHlsDownloader() + + elif source_type == 'anilife': + from .anilife import AnilifeDnloader + return AnilifeDnloader() + + elif source_type == 'http': + from .http_direct import HttpDirectDownloader + return HttpDirectDownloader() + + return None + + +__all__ = ['get_downloader', 'BaseDownloader'] diff --git a/downloader/__pycache__/__init__.cpython-314.pyc b/downloader/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..921138b Binary files /dev/null and b/downloader/__pycache__/__init__.cpython-314.pyc differ diff --git a/downloader/__pycache__/base.cpython-314.pyc b/downloader/__pycache__/base.cpython-314.pyc new file mode 100644 index 0000000..8bdb3b5 Binary files /dev/null and b/downloader/__pycache__/base.cpython-314.pyc differ diff --git a/downloader/__pycache__/ytdlp_aria2.cpython-314.pyc b/downloader/__pycache__/ytdlp_aria2.cpython-314.pyc new file mode 100644 index 0000000..ef019be Binary files /dev/null and b/downloader/__pycache__/ytdlp_aria2.cpython-314.pyc differ diff --git a/downloader/anilife.py b/downloader/anilife.py new file mode 100644 index 0000000..5d73db5 --- /dev/null +++ b/downloader/anilife.py @@ -0,0 +1,144 @@ +""" +Anilife 전용 다운로더 +- Camoufox로 _aldata 추출 후 ffmpeg 다운로드 +- 기존 anime_downloader의 camoufox_anilife.py 로직 활용 +""" +import os +import traceback +from typing import Dict, Any, Optional, Callable + +from .base import BaseDownloader +from .ffmpeg_hls import FfmpegHlsDownloader + +try: + from ..setup import P + logger = P.logger +except: + import logging + logger = logging.getLogger(__name__) + + +class AnilifeDnloader(BaseDownloader): + """Anilife 전용 다운로더 (Camoufox + FFmpeg)""" + + def __init__(self): + super().__init__() + self._ffmpeg_downloader = FfmpegHlsDownloader() + + def download( + self, + url: str, + save_path: str, + filename: Optional[str] = None, + progress_callback: Optional[Callable] = None, + **options + ) -> Dict[str, Any]: + """Anilife 다운로드 (추출 + 다운로드)""" + try: + # 1. 스트림 URL 추출 + if progress_callback: + progress_callback(0, 'Extracting...', '') + + stream_url = self._extract_stream_url(url, options) + + if not stream_url: + return {'success': False, 'error': 'Failed to extract stream URL'} + + logger.info(f'Anilife 스트림 URL 추출 완료: {stream_url[:50]}...') + + # 2. FFmpeg로 다운로드 + return self._ffmpeg_downloader.download( + url=stream_url, + save_path=save_path, + filename=filename, + progress_callback=progress_callback, + **options + ) + + except Exception as e: + logger.error(f'Anilife download error: {e}') + logger.error(traceback.format_exc()) + return {'success': False, 'error': str(e)} + + def get_info(self, url: str) -> Dict[str, Any]: + """URL 정보 추출""" + return {'source': 'anilife'} + + def cancel(self): + """다운로드 취소""" + super().cancel() + self._ffmpeg_downloader.cancel() + + def _extract_stream_url(self, url: str, options: Dict) -> Optional[str]: + """Camoufox를 사용하여 스트림 URL 추출""" + try: + # anime_downloader의 기존 로직 활용 시도 + try: + from anime_downloader.lib.camoufox_anilife import extract_aldata + import asyncio + + # URL에서 detail_url과 episode_num 파싱 + detail_url = options.get('detail_url', url) + episode_num = options.get('episode_num', '1') + + # 비동기 추출 실행 + result = asyncio.run(extract_aldata(detail_url, episode_num)) + + if result.get('success') and result.get('aldata'): + # aldata 디코딩하여 실제 스트림 URL 획득 + return self._decode_aldata(result['aldata']) + + except ImportError: + logger.warning('anime_downloader 모듈을 찾을 수 없습니다. 기본 추출 로직 사용') + + # 폴백: 직접 Camoufox 사용 + return self._extract_with_camoufox(url, options) + + except Exception as e: + logger.error(f'Stream URL extraction error: {e}') + return None + + def _decode_aldata(self, aldata: str) -> Optional[str]: + """_aldata base64 디코딩""" + try: + import base64 + import json + + decoded = base64.b64decode(aldata).decode('utf-8') + data = json.loads(decoded) + + # 스트림 URL 추출 (구조에 따라 다를 수 있음) + if isinstance(data, dict): + return data.get('url') or data.get('stream') or data.get('file') + elif isinstance(data, str): + return data + + except Exception as e: + logger.error(f'_aldata decode error: {e}') + return None + + def _extract_with_camoufox(self, url: str, options: Dict) -> Optional[str]: + """직접 Camoufox 사용하여 추출""" + try: + from camoufox.async_api import AsyncCamoufox + import asyncio + + async def extract(): + async with AsyncCamoufox(headless=True) as browser: + page = await browser.new_page() + await page.goto(url, wait_until='domcontentloaded', timeout=30000) + + # _aldata 변수 추출 시도 + aldata = await page.evaluate("typeof _aldata !== 'undefined' ? _aldata : null") + + await page.close() + return aldata + + aldata = asyncio.run(extract()) + if aldata: + return self._decode_aldata(aldata) + + except Exception as e: + logger.error(f'Camoufox extraction error: {e}') + + return None diff --git a/downloader/base.py b/downloader/base.py new file mode 100644 index 0000000..402630a --- /dev/null +++ b/downloader/base.py @@ -0,0 +1,77 @@ +""" +다운로더 베이스 클래스 +""" +from abc import ABC, abstractmethod +from typing import Dict, Any, Optional, Callable + + +class BaseDownloader(ABC): + """모든 다운로더의 추상 베이스 클래스""" + + def __init__(self): + self._cancelled = False + self._paused = False + + @abstractmethod + def download( + self, + url: str, + save_path: str, + filename: Optional[str] = None, + progress_callback: Optional[Callable] = None, + **options + ) -> Dict[str, Any]: + """ + 다운로드 실행 + + Args: + url: 다운로드 URL + save_path: 저장 경로 + filename: 파일명 (None이면 자동 감지) + progress_callback: 진행률 콜백 (progress, speed, eta) + **options: 추가 옵션 + + Returns: + { + 'success': bool, + 'filepath': str, # 완료된 파일 경로 + 'error': str, # 에러 메시지 (실패 시) + } + """ + pass + + @abstractmethod + def get_info(self, url: str) -> Dict[str, Any]: + """ + URL 정보 추출 (메타데이터) + + Returns: + { + 'title': str, + 'thumbnail': str, + 'duration': int, + 'formats': list, + ... + } + """ + pass + + def cancel(self): + """다운로드 취소""" + self._cancelled = True + + def pause(self): + """다운로드 일시정지""" + self._paused = True + + def resume(self): + """다운로드 재개""" + self._paused = False + + @property + def is_cancelled(self) -> bool: + return self._cancelled + + @property + def is_paused(self) -> bool: + return self._paused diff --git a/downloader/ffmpeg_hls.py b/downloader/ffmpeg_hls.py new file mode 100644 index 0000000..a33075f --- /dev/null +++ b/downloader/ffmpeg_hls.py @@ -0,0 +1,153 @@ +""" +FFmpeg HLS 다운로더 +- ani24, 링크애니 등 HLS 스트림용 +- 기존 SupportFfmpeg 로직 재사용 +""" +import os +import subprocess +import re +import traceback +from typing import Dict, Any, Optional, Callable + +from .base import BaseDownloader + +try: + from ..setup import P + logger = P.logger +except: + import logging + logger = logging.getLogger(__name__) + + +class FfmpegHlsDownloader(BaseDownloader): + """FFmpeg HLS 다운로더""" + + def __init__(self): + super().__init__() + self._process: Optional[subprocess.Popen] = None + + def download( + self, + url: str, + save_path: str, + filename: Optional[str] = None, + progress_callback: Optional[Callable] = None, + **options + ) -> Dict[str, Any]: + """ffmpeg로 HLS 스트림 다운로드""" + try: + os.makedirs(save_path, exist_ok=True) + + # 파일명 결정 + if not filename: + filename = f"download_{int(__import__('time').time())}.mp4" + + filepath = os.path.join(save_path, filename) + + # ffmpeg 명령어 구성 + ffmpeg_path = options.get('ffmpeg_path', 'ffmpeg') + + cmd = [ffmpeg_path, '-y'] + + # 헤더 추가 + headers = options.get('headers', {}) + if headers: + header_str = '\r\n'.join([f'{k}: {v}' for k, v in headers.items()]) + cmd.extend(['-headers', header_str]) + + # 입력 URL + cmd.extend(['-i', url]) + + # 코덱 복사 (트랜스코딩 없이 빠르게) + cmd.extend(['-c', 'copy']) + + # 출력 파일 + cmd.append(filepath) + + logger.debug(f'ffmpeg 명령어: {" ".join(cmd[:10])}...') + + # 먼저 duration 얻기 위해 ffprobe 실행 + duration = self._get_duration(url, options.get('ffprobe_path', 'ffprobe'), headers) + + # 프로세스 실행 + self._process = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1 + ) + + # 출력 파싱 + for line in self._process.stdout: + if self._cancelled: + self._process.terminate() + return {'success': False, 'error': 'Cancelled'} + + line = line.strip() + + # 진행률 계산 (time= 파싱) + if duration > 0 and progress_callback: + time_match = re.search(r'time=(\d+):(\d+):(\d+)', line) + if time_match: + h, m, s = map(int, time_match.groups()) + current_time = h * 3600 + m * 60 + s + progress = min(int(current_time / duration * 100), 99) + + # 속도 파싱 + speed = '' + speed_match = re.search(r'speed=\s*([\d.]+)x', line) + if speed_match: + speed = f'{speed_match.group(1)}x' + + progress_callback(progress, speed, '') + + self._process.wait() + + if self._process.returncode == 0 and os.path.exists(filepath): + if progress_callback: + progress_callback(100, '', '') + return {'success': True, 'filepath': filepath} + else: + return {'success': False, 'error': f'FFmpeg exit code: {self._process.returncode}'} + + except Exception as e: + logger.error(f'FfmpegHls download error: {e}') + logger.error(traceback.format_exc()) + return {'success': False, 'error': str(e)} + + def get_info(self, url: str) -> Dict[str, Any]: + """스트림 정보 추출""" + try: + duration = self._get_duration(url, 'ffprobe', {}) + return { + 'duration': duration, + 'type': 'hls', + } + except: + return {} + + def cancel(self): + """다운로드 취소""" + super().cancel() + if self._process: + self._process.terminate() + + def _get_duration(self, url: str, ffprobe_path: str, headers: Dict) -> float: + """ffprobe로 영상 길이 획득""" + try: + cmd = [ffprobe_path, '-v', 'error', '-show_entries', 'format=duration', + '-of', 'default=noprint_wrappers=1:nokey=1'] + + if headers: + header_str = '\r\n'.join([f'{k}: {v}' for k, v in headers.items()]) + cmd.extend(['-headers', header_str]) + + cmd.append(url) + + result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) + if result.returncode == 0: + return float(result.stdout.strip()) + except: + pass + return 0 diff --git a/downloader/http_direct.py b/downloader/http_direct.py new file mode 100644 index 0000000..2b3c5b8 --- /dev/null +++ b/downloader/http_direct.py @@ -0,0 +1,91 @@ +""" +HTTP 직접 다운로더 +- 단순 HTTP 파일 다운로드 +- aiohttp 비동기 사용 (고성능) +""" +import os +import traceback +from typing import Dict, Any, Optional, Callable + +from .base import BaseDownloader + +try: + from ..setup import P + logger = P.logger +except: + import logging + logger = logging.getLogger(__name__) + + +class HttpDirectDownloader(BaseDownloader): + """HTTP 직접 다운로더""" + + def download( + self, + url: str, + save_path: str, + filename: Optional[str] = None, + progress_callback: Optional[Callable] = None, + **options + ) -> Dict[str, Any]: + """HTTP로 직접 다운로드""" + try: + import requests + + os.makedirs(save_path, exist_ok=True) + + # 파일명 결정 + if not filename: + filename = url.split('/')[-1].split('?')[0] or f"download_{int(__import__('time').time())}" + + filepath = os.path.join(save_path, filename) + + # 헤더 설정 + headers = options.get('headers', {}) + if 'User-Agent' not in headers: + headers['User-Agent'] = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' + + # 스트리밍 다운로드 + response = requests.get(url, headers=headers, stream=True, timeout=60) + response.raise_for_status() + + total_size = int(response.headers.get('content-length', 0)) + downloaded = 0 + chunk_size = 1024 * 1024 # 1MB 청크 + + with open(filepath, 'wb') as f: + for chunk in response.iter_content(chunk_size=chunk_size): + if self._cancelled: + return {'success': False, 'error': 'Cancelled'} + + if chunk: + f.write(chunk) + downloaded += len(chunk) + + if total_size > 0 and progress_callback: + progress = int(downloaded / total_size * 100) + speed = '' # TODO: 속도 계산 + progress_callback(progress, speed, '') + + if progress_callback: + progress_callback(100, '', '') + + return {'success': True, 'filepath': filepath} + + except Exception as e: + logger.error(f'HTTP download error: {e}') + logger.error(traceback.format_exc()) + return {'success': False, 'error': str(e)} + + def get_info(self, url: str) -> Dict[str, Any]: + """URL 정보 추출""" + try: + import requests + + response = requests.head(url, timeout=10) + return { + 'content_length': response.headers.get('content-length'), + 'content_type': response.headers.get('content-type'), + } + except: + return {} diff --git a/downloader/ytdlp_aria2.py b/downloader/ytdlp_aria2.py new file mode 100644 index 0000000..ccdb8ba --- /dev/null +++ b/downloader/ytdlp_aria2.py @@ -0,0 +1,222 @@ +""" +yt-dlp + aria2c 다운로더 (최고속) +- aria2c 16개 연결로 3-5배 속도 향상 +- YouTube 및 yt-dlp 지원 사이트 전용 +""" +import os +import re +import subprocess +import traceback +from typing import Dict, Any, Optional, Callable + +from .base import BaseDownloader + +# 상위 모듈에서 로거 가져오기 +try: + from ..setup import P + logger = P.logger +except: + import logging + logger = logging.getLogger(__name__) + + +class YtdlpAria2Downloader(BaseDownloader): + """yt-dlp + aria2c 다운로더""" + + def __init__(self): + super().__init__() + self._process: Optional[subprocess.Popen] = None + + def download( + self, + url: str, + save_path: str, + filename: Optional[str] = None, + progress_callback: Optional[Callable] = None, + **options + ) -> Dict[str, Any]: + """yt-dlp + aria2c로 다운로드""" + try: + os.makedirs(save_path, exist_ok=True) + + # 출력 템플릿 + if filename: + output_template = os.path.join(save_path, filename) + else: + output_template = os.path.join(save_path, '%(title)s.%(ext)s') + + # yt-dlp 명령어 구성 + cmd = [ + 'yt-dlp', + '--newline', # 진행률 파싱용 + '-o', output_template, + ] + + # aria2c 사용 (설치되어 있으면) + aria2c_path = options.get('aria2c_path', 'aria2c') + # TODO: 나중에 설정에서 쓰레드 수 지정 (기본값 4로 변경) + connections = options.get('connections', 4) + + # 속도 제한 설정 + max_rate = P.ModelSetting.get('max_download_rate') + if max_rate == '0': + max_rate_arg = '' + log_rate_msg = '무제한' + else: + max_rate_arg = f'--max-download-limit={max_rate}' + log_rate_msg = max_rate + cmd.extend(['--limit-rate', max_rate]) # Native downloader limit + + # aria2c 사용 (일시 중지: 진행률 파싱 문제 해결 전까지 Native 사용) + if False and self._check_aria2c(aria2c_path): + cmd.extend([ + '--downloader', 'aria2c', + '--downloader-args', f'aria2c:-x {connections} -s {connections} -k 1M {max_rate_arg}', + ]) + logger.debug(f'aria2c 사용: {connections}개 연결 (속도제한 {log_rate_msg})') + + # 포맷 선택 + format_spec = options.get('format', 'bestvideo+bestaudio/best') + cmd.extend(['-f', format_spec]) + + # 병합 포맷 + merge_format = options.get('merge_output_format', 'mp4') + cmd.extend(['--merge-output-format', merge_format]) + + # 쿠키 파일 + if options.get('cookiefile'): + cmd.extend(['--cookies', options['cookiefile']]) + + # 프록시 + if options.get('proxy'): + cmd.extend(['--proxy', options['proxy']]) + + # URL 추가 + cmd.append(url) + + logger.debug(f'yt-dlp 명령어: {" ".join(cmd)}') + + # 프로세스 실행 + self._process = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1 + ) + + final_filepath = '' + + # 출력 파싱 + for line in self._process.stdout: + if self._cancelled: + self._process.terminate() + return {'success': False, 'error': 'Cancelled'} + + line = line.strip() + # logger.debug(line) + + # 진행률 파싱 (yt-dlp default) + progress_match = re.search(r'\[download\]\s+(\d+\.?\d*)%', line) + + # 진행률 파싱 (aria2c) + if not progress_match: + # logger.error(f'DEBUG LINE: {line}') # Log raw line to debug + aria2_match = re.search(r'\(\s*([\d.]+)%\)', line) # Allow spaces ( 7%) + if aria2_match and (('DL:' in line) or ('CN:' in line)): # DL or CN must be present + try: + progress = int(float(aria2_match.group(1))) + # logger.error(f'MATCHED PROGRESS: {progress}%') + + speed_match = re.search(r'DL:(\S+)', line) + speed = speed_match.group(1) if speed_match else '' + # Strip color codes from speed if needed? output is usually clean text if no TTY + + eta_match = re.search(r'ETA:(\S+)', line) + eta = eta_match.group(1) if eta_match else '' + + if progress_callback: + progress_callback(progress, speed, eta) + continue + except Exception as e: + logger.error(f'Parsing Error: {e}') + + if progress_match and progress_callback: + progress = int(float(progress_match.group(1))) + + # 속도 파싱 + speed = '' + speed_match = re.search(r'at\s+([\d.]+\s*[KMG]?i?B/s)', line) + if speed_match: + speed = speed_match.group(1) + + # ETA 파싱 + eta = '' + eta_match = re.search(r'ETA\s+([\d:]+)', line) + if eta_match: + eta = eta_match.group(1) + + progress_callback(progress, speed, eta) + + # 최종 파일 경로 추출 + if '[Merger]' in line or 'Destination:' in line: + path_match = re.search(r'(?:Destination:|into\s+["\'])(.+?)(?:["\']|$)', line) + if path_match: + final_filepath = path_match.group(1).strip('"\'') + + self._process.wait() + + if self._process.returncode == 0: + if progress_callback: + progress_callback(100, '', '') + return {'success': True, 'filepath': final_filepath} + else: + return {'success': False, 'error': f'Exit code: {self._process.returncode}'} + + except Exception as e: + logger.error(f'YtdlpAria2 download error: {e}') + logger.error(traceback.format_exc()) + return {'success': False, 'error': str(e)} + + def get_info(self, url: str) -> Dict[str, Any]: + """URL 정보 추출""" + try: + import yt_dlp + + ydl_opts = { + 'quiet': True, + 'no_warnings': True, + 'extract_flat': False, + } + + with yt_dlp.YoutubeDL(ydl_opts) as ydl: + info = ydl.extract_info(url, download=False) + return { + 'title': info.get('title', ''), + 'thumbnail': info.get('thumbnail', ''), + 'duration': info.get('duration', 0), + 'formats': info.get('formats', []), + 'uploader': info.get('uploader', ''), + 'view_count': info.get('view_count', 0), + } + except Exception as e: + logger.error(f'get_info error: {e}') + return {} + + def cancel(self): + """다운로드 취소""" + super().cancel() + if self._process: + self._process.terminate() + + def _check_aria2c(self, aria2c_path: str) -> bool: + """aria2c 설치 확인""" + try: + result = subprocess.run( + [aria2c_path, '--version'], + capture_output=True, + timeout=5 + ) + return result.returncode == 0 + except: + return False diff --git a/info.yaml b/info.yaml new file mode 100644 index 0000000..b4c7a26 --- /dev/null +++ b/info.yaml @@ -0,0 +1,7 @@ +name: gommi_download_manager +package_name: gommi_download_manager +version: '0.1.0' +description: FlaskFarm 범용 다운로더 큐 - YouTube, 애니24, 링크애니, Anilife 지원 +developer: projectdx +home: https://gitea.yommi.duckdns.org/projectdx/gommi_downloader_manager +category: tool diff --git a/mod_queue.py b/mod_queue.py new file mode 100644 index 0000000..9c235b8 --- /dev/null +++ b/mod_queue.py @@ -0,0 +1,466 @@ +""" +gommi_download_manager - 다운로드 큐 관리 모듈 +""" +import os +import time +import threading +import traceback +from datetime import datetime +from typing import Optional, Dict, Any, List, Callable +from enum import Enum + +from flask import render_template, jsonify +from framework import F, socketio + +from .setup import P, PluginModuleBase, default_route_socketio_module, ToolUtil + + +class DownloadStatus(str, Enum): + PENDING = "pending" + EXTRACTING = "extracting" # 메타데이터 추출 중 + DOWNLOADING = "downloading" + PAUSED = "paused" + COMPLETED = "completed" + ERROR = "error" + CANCELLED = "cancelled" + + +class ModuleQueue(PluginModuleBase): + """다운로드 큐 관리 모듈""" + + db_default = { + 'aria2c_path': 'aria2c', + 'aria2c_connections': '16', # 동시 연결 수 + 'ffmpeg_path': 'ffmpeg', + 'yt_dlp_path': '', # 비어있으면 python module 사용 + 'save_path': '{PATH_DATA}/download', + 'temp_path': '{PATH_DATA}/download_tmp', + 'max_concurrent': '3', # 동시 다운로드 수 + 'max_download_rate': '0', # 최대 다운로드 속도 (0: 무제한, 5M, 10M...) + 'auto_retry': 'true', + 'max_retry': '3', + } + + # 진행 중인 다운로드 인스턴스들 + _downloads: Dict[str, 'DownloadTask'] = {} + _queue_lock = threading.Lock() + + def __init__(self, P: Any) -> None: + super(ModuleQueue, self).__init__(P, name='queue', first_menu='list') + default_route_socketio_module(self, attach='/queue') + + + def process_menu(self, page_name: str, req: Any) -> Any: + """메뉴 페이지 렌더링""" + P.logger.debug(f'Page Request: {page_name}') + arg = P.ModelSetting.to_dict() + try: + arg['module_name'] = self.name + arg['package_name'] = P.package_name # 명시적 추가 + arg['path_data'] = F.config['path_data'] + return render_template(f'{P.package_name}_{self.name}_{page_name}.html', arg=arg) + except Exception as e: + P.logger.error(f'Exception:{str(e)}') + P.logger.error(traceback.format_exc()) + return render_template('sample.html', title=f"{P.package_name}/{self.name}/{page_name}") + + def process_ajax(self, command: str, req: Any) -> Any: + """AJAX 명령 처리""" + # P.logger.debug(f'Command: {command}') + ret = {'ret': 'success'} + try: + if command == 'add': + # 큐에 다운로드 추가 + url = req.form['url'] + save_path = req.form.get('save_path') or ToolUtil.make_path(P.ModelSetting.get('save_path')) + filename = req.form.get('filename') + + item = self.add_download(url, save_path, filename) + ret['data'] = item.as_dict() if item else None + + elif command == 'list': + # 진행 중인 다운로드 목록 + items = [d.get_status() for d in self._downloads.values()] + P.logger.debug(f'List Command: {len(items)} items') + ret['data'] = items + + elif command == 'cancel': + # 다운로드 취소 + download_id = req.form['id'] + if download_id in self._downloads: + self._downloads[download_id].cancel() + ret['msg'] = '다운로드가 취소되었습니다.' + + elif command == 'pause': + download_id = req.form['id'] + if download_id in self._downloads: + self._downloads[download_id].pause() + + elif command == 'resume': + download_id = req.form['id'] + if download_id in self._downloads: + self._downloads[download_id].resume() + + elif command == 'reset': + # 전체 목록 초기화 (진행중인건 취소) + for task in list(self._downloads.values()): + task.cancel() + self._downloads.clear() + + # DB에서도 삭제 + try: + with F.app.app_context(): + from .model import ModelDownloadItem + F.db.session.query(ModelDownloadItem).delete() + F.db.session.commit() + except Exception as e: + P.logger.error(f'DB Clear Error: {e}') + + ret['msg'] = '목록을 초기화했습니다.' + + except Exception as e: + P.logger.error(f'Exception:{str(e)}') + P.logger.error(traceback.format_exc()) + ret['ret'] = 'error' + ret['msg'] = str(e) + + return jsonify(ret) + + # ===== 외부 플러그인용 API ===== + + @classmethod + def add_download( + cls, + url: str, + save_path: str, + filename: Optional[str] = None, + source_type: Optional[str] = None, + caller_plugin: Optional[str] = None, + callback_id: Optional[str] = None, + on_progress: Optional[Callable] = None, + on_complete: Optional[Callable] = None, + on_error: Optional[Callable] = None, + **options + ) -> Optional['DownloadTask']: + """ + 다운로드를 큐에 추가 (외부 플러그인에서 호출) + + Args: + url: 다운로드 URL + save_path: 저장 경로 + filename: 파일명 (자동 감지 가능) + source_type: 소스 타입 (auto, youtube, ani24, linkkf, anilife, http) + caller_plugin: 호출 플러그인 이름 + callback_id: 콜백 식별자 + on_progress: 진행률 콜백 (progress, speed, eta) + on_complete: 완료 콜백 (filepath) + on_error: 에러 콜백 (error_message) + **options: 추가 옵션 (headers, cookies 등) + + Returns: + DownloadTask 인스턴스 + """ + try: + # 소스 타입 자동 감지 + if not source_type or source_type == 'auto': + source_type = cls._detect_source_type(url) + + # DownloadTask 생성 + task = DownloadTask( + url=url, + save_path=save_path, + filename=filename, + source_type=source_type, + caller_plugin=caller_plugin, + callback_id=callback_id, + on_progress=on_progress, + on_complete=on_complete, + on_error=on_error, + **options + ) + + with cls._queue_lock: + cls._downloads[task.id] = task + + + # 비동기 시작 + task.start() + + # DB 저장 + from .model import ModelDownloadItem + db_item = ModelDownloadItem() + db_item.created_time = datetime.now() + db_item.url = url + db_item.save_path = save_path + db_item.filename = filename + db_item.source_type = source_type + db_item.status = DownloadStatus.PENDING + db_item.caller_plugin = caller_plugin + db_item.callback_id = callback_id + db_item.save() + + task.db_id = db_item.id + + + return task + + except Exception as e: + P.logger.error(f'add_download error: {e}') + P.logger.error(traceback.format_exc()) + return None + + @classmethod + def get_download(cls, download_id: str) -> Optional['DownloadTask']: + """다운로드 태스크 조회""" + return cls._downloads.get(download_id) + + @classmethod + def get_all_downloads(cls) -> List['DownloadTask']: + """모든 다운로드 태스크 조회""" + return list(cls._downloads.values()) + + @classmethod + def _detect_source_type(cls, url: str) -> str: + """URL에서 소스 타입 자동 감지""" + url_lower = url.lower() + + if 'youtube.com' in url_lower or 'youtu.be' in url_lower: + return 'youtube' + elif 'ani24' in url_lower or 'ohli24' in url_lower: + return 'ani24' + elif 'linkkf' in url_lower: + return 'linkkf' + elif 'anilife' in url_lower: + return 'anilife' + elif url_lower.endswith('.m3u8') or 'manifest' in url_lower: + return 'hls' + else: + return 'http' + + def plugin_load(self) -> None: + """플러그인 로드 시 초기화""" + P.logger.info('gommi_downloader 플러그인 로드') + try: + # DB에서 진행 중인 작업 로드 + with F.app.app_context(): + from .model import ModelDownloadItem + + # 간단하게 status != completed, cancelled, error + items = F.db.session.query(ModelDownloadItem).filter( + ModelDownloadItem.status.in_([ + DownloadStatus.PENDING, + DownloadStatus.DOWNLOADING, + DownloadStatus.EXTRACTING + ]) + ).all() + + for item in items: + # DownloadTask 복원 + task = DownloadTask( + url=item.url, + save_path=item.save_path, + filename=item.filename, + source_type=item.source_type, + caller_plugin=item.caller_plugin, + callback_id=item.callback_id + # options? DB에 저장 안함. 필요하면 추가해야 함. + ) + task.status = DownloadStatus(item.status) + task.db_id = item.id + task.title = item.title or '' + + # 상태가 downloading/extracting이었다면 pending으로 되돌려서 재시작하거나, + # 바로 시작 + # 여기서는 pending으로 변경 후 다시 start 호출 + task.status = DownloadStatus.PENDING + + self._downloads[task.id] = task + task.start() + + P.logger.info(f'{len(items)}개의 중단된 다운로드 작업 복원됨') + + except Exception as e: + P.logger.error(f'plugin_load error: {e}') + P.logger.error(traceback.format_exc()) + + def plugin_unload(self) -> None: + """플러그인 언로드 시 정리""" + # 모든 다운로드 중지 + for task in self._downloads.values(): + task.cancel() + + +class DownloadTask: + """개별 다운로드 태스크""" + + _counter = 0 + _counter_lock = threading.Lock() + + def __init__( + self, + url: str, + save_path: str, + filename: Optional[str] = None, + source_type: str = 'auto', + caller_plugin: Optional[str] = None, + callback_id: Optional[str] = None, + on_progress: Optional[Callable] = None, + on_complete: Optional[Callable] = None, + on_error: Optional[Callable] = None, + **options + ): + with self._counter_lock: + DownloadTask._counter += 1 + self.id = f"dl_{int(time.time())}_{DownloadTask._counter}" + + self.url = url + self.save_path = save_path + self.filename = filename + self.source_type = source_type + self.caller_plugin = caller_plugin + self.callback_id = callback_id + self.options = options + + # 콜백 + self._on_progress = on_progress + self._on_complete = on_complete + self._on_error = on_error + + # 상태 + self.status = DownloadStatus.PENDING + self.progress = 0 + self.speed = '' + self.eta = '' + self.error_message = '' + self.filepath = '' + + # 메타데이터 + self.title = '' + self.thumbnail = '' + self.duration = 0 + self.filesize = 0 + + # 내부 + self._thread: Optional[threading.Thread] = None + self._downloader = None + self._cancelled = False + self.db_id: Optional[int] = None + + def start(self): + """다운로드 시작 (비동기)""" + self._thread = threading.Thread(target=self._run, daemon=True) + self._thread.start() + + def _run(self): + """다운로드 실행""" + try: + self.status = DownloadStatus.EXTRACTING + self._emit_status() + + # 다운로더 선택 및 실행 + from .downloader import get_downloader + self._downloader = get_downloader(self.source_type) + + if not self._downloader: + raise Exception(f"지원하지 않는 소스 타입: {self.source_type}") + + self.status = DownloadStatus.DOWNLOADING + self._emit_status() + + # 다운로드 실행 + result = self._downloader.download( + url=self.url, + save_path=self.save_path, + filename=self.filename, + progress_callback=self._progress_callback, + **self.options + ) + + if self._cancelled: + self.status = DownloadStatus.CANCELLED + elif result.get('success'): + self.status = DownloadStatus.COMPLETED + self.filepath = result.get('filepath', '') + self.progress = 100 + if self._on_complete: + self._on_complete(self.filepath) + else: + self.status = DownloadStatus.ERROR + self.error_message = result.get('error', 'Unknown error') + if self._on_error: + self._on_error(self.error_message) + + except Exception as e: + P.logger.error(f'Download error: {e}') + P.logger.error(traceback.format_exc()) + self.status = DownloadStatus.ERROR + self.error_message = str(e) + if self._on_error: + self._on_error(self.error_message) + + finally: + self._emit_status() + + def _progress_callback(self, progress: int, speed: str = '', eta: str = ''): + """진행률 콜백""" + self.progress = progress + self.speed = speed + self.eta = eta + + if self._on_progress: + self._on_progress(progress, speed, eta) + + self._emit_status() + + def _emit_status(self): + """Socket.IO로 상태 전송""" + try: + socketio.emit( + 'download_status', + self.get_status(), + namespace=f'/{P.package_name}' + ) + except: + pass + + def cancel(self): + """다운로드 취소""" + self._cancelled = True + if self._downloader: + self._downloader.cancel() + self.status = DownloadStatus.CANCELLED + self._emit_status() + + def pause(self): + """다운로드 일시정지""" + if self._downloader and hasattr(self._downloader, 'pause'): + self._downloader.pause() + self.status = DownloadStatus.PAUSED + self._emit_status() + + def resume(self): + """다운로드 재개""" + if self._downloader and hasattr(self._downloader, 'resume'): + self._downloader.resume() + self.status = DownloadStatus.DOWNLOADING + self._emit_status() + + def get_status(self) -> Dict[str, Any]: + """현재 상태 반환""" + return { + 'id': self.id, + 'url': self.url, + 'filename': self.filename, + 'save_path': self.save_path, + 'source_type': self.source_type, + 'status': self.status, + 'progress': self.progress, + 'speed': self.speed, + 'eta': self.eta, + 'title': self.title, + 'thumbnail': self.thumbnail, + 'error_message': self.error_message, + 'filepath': self.filepath, + 'caller_plugin': self.caller_plugin, + 'callback_id': self.callback_id, + } diff --git a/model.py b/model.py new file mode 100644 index 0000000..ec3f22a --- /dev/null +++ b/model.py @@ -0,0 +1,45 @@ +""" +다운로드 큐 모델 정의 +""" +from plugin import ModelBase, db + +package_name = 'gommi_download_manager' + + +from datetime import datetime + +class ModelDownloadItem(ModelBase): + """다운로드 아이템 DB 모델""" + __tablename__ = f'{package_name}_download_item' + __table_args__ = {'mysql_collate': 'utf8_general_ci'} + __bind_key__ = package_name + + id: int = db.Column(db.Integer, primary_key=True) + created_time: datetime = db.Column(db.DateTime) + + # 다운로드 정보 + url: str = db.Column(db.String) + filename: str = db.Column(db.String) + save_path: str = db.Column(db.String) + source_type: str = db.Column(db.String) # youtube, ani24, linkkf, anilife, http + + # 상태 + status: str = db.Column(db.String) # pending, downloading, paused, completed, error + progress: int = db.Column(db.Integer, default=0) + speed: str = db.Column(db.String) + eta: str = db.Column(db.String) + + # 메타데이터 + title: str = db.Column(db.String) + thumbnail: str = db.Column(db.String) + duration: int = db.Column(db.Integer) + filesize: int = db.Column(db.Integer) + + # 호출자 정보 + caller_plugin: str = db.Column(db.String) + callback_id: str = db.Column(db.String) + + # 에러 정보 + error_message: str = db.Column(db.Text) + retry_count: int = db.Column(db.Integer, default=0) + diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..b351b95 --- /dev/null +++ b/setup.py @@ -0,0 +1,56 @@ +""" +gommi_download_manager - FlaskFarm 범용 다운로더 큐 플러그인 + +지원 소스: +- YouTube (yt-dlp + aria2c) +- 애니24/링크애니 (ffmpeg HLS) +- Anilife (Camoufox + ffmpeg) +- 기타 HTTP 직접 다운로드 + +성능 최적화: +- aria2c 멀티커넥션 (16개 동시 연결) +- 직접 import 방식 (API 오버헤드 제거) +- asyncio 큐 처리 +""" +import traceback + +setting = { + 'filepath': __file__, + 'use_db': True, + 'use_default_setting': True, + 'home_module': 'queue', + 'menu': { + 'uri': __package__, + 'name': 'Gommi 다운로더', + 'list': [ + { + 'uri': 'queue', + 'name': '다운로드 큐', + 'list': [ + {'uri': 'setting', 'name': '설정'}, + {'uri': 'list', 'name': '다운로드 목록'}, + ] + }, + { + 'uri': 'manual', + 'name': '매뉴얼', + 'list': [ + {'uri': 'README.md', 'name': 'README'}, + ] + }, + {'uri': 'log', 'name': '로그'}, + ] + }, + 'default_route': 'normal', +} + +from plugin import * + +P = create_plugin_instance(setting) + +try: + from .mod_queue import ModuleQueue + P.set_module_list([ModuleQueue]) +except Exception as e: + P.logger.error(f'Exception:{str(e)}') + P.logger.error(traceback.format_exc()) diff --git a/static/gommi_download_manager.js b/static/gommi_download_manager.js new file mode 100644 index 0000000..b1afd44 --- /dev/null +++ b/static/gommi_download_manager.js @@ -0,0 +1,19 @@ +/** + * gommi_download_manager 플러그인 JavaScript + */ + +// 설정 저장 +function setting_save() { + var form_data = getFormdata('#setting_form'); + FF.ajax({ + url: '/gommi_download_manager/queue/command/setting_save', + data: form_data, + success: function(ret) { + if (ret.ret === 'success') { + notify.success('설정이 저장되었습니다.'); + } else { + notify.danger(ret.msg || '저장 실패'); + } + } + }); +} diff --git a/templates/gommi_download_manager_queue_list.html b/templates/gommi_download_manager_queue_list.html new file mode 100644 index 0000000..a4f3fe5 --- /dev/null +++ b/templates/gommi_download_manager_queue_list.html @@ -0,0 +1,316 @@ +{% extends "base.html" %} +{% import "macro.html" as macros %} + +{% block content %} + +
+
+
+
다운로드 목록
+
+ + +
+
+
+ + + + + + + + + + + + + + + + + + +
#요청제목/URL소스진행률속도상태작업
+ 다운로드 항목이 없습니다. +
+
+
+
+ + +{% endblock %} diff --git a/templates/gommi_download_manager_queue_setting.html b/templates/gommi_download_manager_queue_setting.html new file mode 100644 index 0000000..eaf4cbb --- /dev/null +++ b/templates/gommi_download_manager_queue_setting.html @@ -0,0 +1,156 @@ +{% extends "base.html" %} +{% import "macro.html" as macros %} + +{% block content %} + +
+ {{ macros.m_row_start('5') }} + {{ macros.m_row_end() }} + + +
+

GDM 설정

+ {{ macros.m_button_group([['globalSettingSaveBtn', '설정 저장']]) }} +
+ {{ macros.m_hr_head_bottom() }} + +
+ + {{ macros.setting_top_big('기본 설정') }} + {{ macros.setting_bottom() }} + + {{ macros.setting_input_text('save_path', '저장 경로', value=arg['save_path'], desc='{PATH_DATA}는 실제 데이터 경로로 치환됩니다.') }} + {{ macros.setting_input_text('temp_path', '임시 경로', value=arg['temp_path'], desc='다운로드 중 임시 파일 저장 경로') }} + {{ macros.setting_input_text('max_concurrent', '동시 다운로드 수', value=arg['max_concurrent'], desc='동시에 진행할 최대 다운로드 수') }} + {{ macros.setting_select('max_download_rate', '속도 제한', [['0', '무제한'], ['1M', '1 MB/s'], ['3M', '3 MB/s'], ['5M', '5 MB/s'], ['10M', '10 MB/s']], value=arg['max_download_rate'], desc='다운로드 속도를 제한합니다.') }} + + {{ macros.m_hr() }} + + + {{ macros.setting_top_big('다운로더 설정') }} + {{ macros.setting_bottom() }} + + {{ macros.setting_input_text('aria2c_path', 'aria2c 경로', value=arg['aria2c_path'], desc='aria2c 실행 파일 경로 (고속 다운로드용)') }} + {{ macros.setting_input_text('aria2c_connections', 'aria2c 연결 수', value=arg['aria2c_connections'], desc='aria2c 동시 연결 수 (기본 16)') }} + {{ macros.setting_input_text('ffmpeg_path', 'ffmpeg 경로', value=arg['ffmpeg_path'], desc='ffmpeg 실행 파일 경로 (HLS 스트림용)') }} + {{ macros.setting_input_text('yt_dlp_path', 'yt-dlp 경로', value=arg['yt_dlp_path'], desc='비워두면 Python 모듈 사용') }} + + {{ macros.m_hr() }} + + + {{ macros.setting_top_big('재시도 설정') }} + {{ macros.setting_bottom() }} + + {{ macros.setting_checkbox('auto_retry', '자동 재시도', value=arg['auto_retry'], desc='다운로드 실패 시 자동으로 재시도') }} + {{ macros.setting_input_text('max_retry', '최대 재시도 횟수', value=arg['max_retry'], desc='최대 재시도 횟수') }} + +
+
+{% endblock %} + +{% block tail_js %} +` + // I will explicitly add the save logic just in case the static JS relies on specific form IDs. + + $(document).ready(function(){ + // Nothing special needed + }); + + $("body").on('click', '#globalSettingSaveBtn', function(e){ + e.preventDefault(); + var formData = get_formdata('#setting'); + $.ajax({ + url: '/' + package_name + '/ajax/' + sub + '/setting_save', + type: "POST", + cache: false, + data: formData, + dataType: "json", + success: function(ret) { + if (ret.ret == 'success') { + $.notify('설정을 저장했습니다.', {type:'success'}); + } else { + $.notify('저장 실패: ' + ret.msg, {type:'danger'}); + } + } + }); + }); + +{% endblock %}