From fac33cff0baaaab9e7fbfda6ac23b71321dd3bab Mon Sep 17 00:00:00 2001 From: projectdx Date: Mon, 5 Jan 2026 21:14:51 +0900 Subject: [PATCH] Release v0.1.0: GDM Refactor, Rate Limit, Metallic UI --- README.md | 46 ++ __init__.py | 1 + __pycache__/__init__.cpython-314.pyc | Bin 0 -> 178 bytes __pycache__/mod_queue.cpython-314.pyc | Bin 0 -> 24090 bytes __pycache__/model.cpython-314.pyc | Bin 0 -> 3209 bytes __pycache__/setup.cpython-314.pyc | Bin 0 -> 1678 bytes downloader/__init__.py | 30 ++ .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 1248 bytes downloader/__pycache__/base.cpython-314.pyc | Bin 0 -> 3650 bytes .../__pycache__/ytdlp_aria2.cpython-314.pyc | Bin 0 -> 9323 bytes downloader/anilife.py | 144 ++++++ downloader/base.py | 77 +++ downloader/ffmpeg_hls.py | 153 ++++++ downloader/http_direct.py | 91 ++++ downloader/ytdlp_aria2.py | 222 +++++++++ info.yaml | 7 + mod_queue.py | 466 ++++++++++++++++++ model.py | 45 ++ setup.py | 56 +++ static/gommi_download_manager.js | 19 + .../gommi_download_manager_queue_list.html | 316 ++++++++++++ .../gommi_download_manager_queue_setting.html | 156 ++++++ 22 files changed, 1829 insertions(+) create mode 100644 README.md create mode 100644 __init__.py create mode 100644 __pycache__/__init__.cpython-314.pyc create mode 100644 __pycache__/mod_queue.cpython-314.pyc create mode 100644 __pycache__/model.cpython-314.pyc create mode 100644 __pycache__/setup.cpython-314.pyc create mode 100644 downloader/__init__.py create mode 100644 downloader/__pycache__/__init__.cpython-314.pyc create mode 100644 downloader/__pycache__/base.cpython-314.pyc create mode 100644 downloader/__pycache__/ytdlp_aria2.cpython-314.pyc create mode 100644 downloader/anilife.py create mode 100644 downloader/base.py create mode 100644 downloader/ffmpeg_hls.py create mode 100644 downloader/http_direct.py create mode 100644 downloader/ytdlp_aria2.py create mode 100644 info.yaml create mode 100644 mod_queue.py create mode 100644 model.py create mode 100644 setup.py create mode 100644 static/gommi_download_manager.js create mode 100644 templates/gommi_download_manager_queue_list.html create mode 100644 templates/gommi_download_manager_queue_setting.html 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 0000000000000000000000000000000000000000..880f97797a9f395ac7e4b8dc40006cee0258c7a6 GIT binary patch literal 178 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 literal 0 HcmV?d00001 diff --git a/__pycache__/setup.cpython-314.pyc b/__pycache__/setup.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6efe485f9fed6cee67ce5575064793a580967855 GIT binary patch literal 1678 zcma)6?{5=j9DlC&qrba#UFSANxCM~?VvS@1A~C73xkVuJoWC&Ta%->SLa$e^cd)^m zHBLbWTEdWmBbHbkGrTAZMiPBP{0Ax+X`aMHtlk~~w`|I<3 zp6~a$_WQgp1k3jEyV5+3&~KKpw%QxuQIJM-WGh5gNhSA~ufXD2|37!`V3p z=Zuh?i*s`x&Iz`Q^VX0LP*cbTJ`bSgkZsTwBEj;5)l$m{0BQw4QS%4EY6FV|>lv;+ z#Ap~?k}A*7Fu2&&8) zD+}h;W!9K4m{;!@*B6cR3v4BmWh)EkjL&baeskBncGu-HmuAh+vaI>xIrD5`k83;o zj-pTKlOo$Qqiv5RQfxmfsFJWF+6yuBQr?(9vtuY^EM+R0TXmD|iO1!XIK_@0c-=}0 zC#8fG2ZDWqtmtuN8Yt=+R&@1lvGV>bJ328j&YDXZGk;;7!i$R_XzsRg_6BQyy=dk$ zl`9LDrn&;wSe~uS7tL=A#@r3_gB;uQ+|JeFB5Qnd#hhPc&CA8rm10e1omY}mimI_j zaoL=|#`c8A$5=C0FjnR&mkY*4D9A6a-mVo0>6v6yQflonS8~Rew_Jy?BQ7Pxl%O5O zjGh+x*yIEb)PNWl^n}KzMNN~EQ`mh}kwsosVtPWv^n1Ffif2ll`xeQWKnN{ zDZgUPY~5|oFJLMmr8T`7f=|W-%3w^`-r(06h?;W+Gk-}B{5#S3YT3Ac4Kpx*T}bFo zE51}PKDuLm`rSzNiaT!(7H4!sOMU{m_FM)|Z z*xUzemwL^*(n;6wbW}`fl9Jrh>&JE;9$%E_u~Sops5mJ^k6{PTTP*{po99#DfhWoH zsvAUMCY8{qq$F;Os-mEYeBI=wWLguFQ4tg4*avcp$u-U9nC&B5%6a&ud$y$|+suroK7KYnw% z)VkZkBl(fiK)BTUa+M}KTB>%t*H>*p{ 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 0000000000000000000000000000000000000000..921138b9ffa1e9f70ac20ee5e2c459f3ace1e7a2 GIT binary patch literal 1248 zcmZ8g&ube;6rR~#?XD!(>)?=-;@XPsl!_=8IB9Vqgd~`-x}D_*vW&L{q}p`ym>S8 z-l^gDatI9H{MNHog#MF115@_F?xz6S$Uz(}A_rHnt#D-mEn-KhsJ6zn3FN59k)s_) z{s_-F8AqQ{xb7HJsAwh=usGMfyViTO-u?A)_x?|X?jO&(Ki)3%?>*~ZU+X<>=ZaXE zpGVva1J?&Ue94XJeE5Cfhi;9sB@7Ydkd}b~d7|CRFtt$?Eh=+25UK$;hQ7ed*ue|n z*)vC37@PquXalzN!2lstRv<_9f2k7rI84|QCA<|;p)<-878F&X#+)%JU~*qbPp^lp z>GD{nG%3C*S3^H((zyK9eEG|mG5{f4DMu~790uijoz&=eB=VDn7sTa8*lc>FwtqI# zbOX0RS$RMJB*Y6mPRLBu5<`TzK@f76Q$koC_A_C`@a zk1wRuE=W*%aS}+ACx8QJyT1XD7w4Ft0jdqli-V`c6=dTGPoOG3fzE&pc_pjCd!&P{yqgQ#&k1jILojs^0;72dl zn-Oi;etbX+!wo#&t5fh>7x&=&ehCU~ne#{nwBbANF}lPLR$eJ8!f1sFPp(m6G-yB> z)Uhfu5ODUa(0wmhS*Z(k*^i5+$hTxp#Fa@gxP2sY^)wp-er5r|M&xuvy7F>gQcE{v zqren1OqX7{a8E3CsVkPf@lgSc$(|O&4a(n?8f9z@$h3a00%)Th9hsw@W2H@_v~7N} zWle2ZCp+fJf3inA@6T>#XSdBWTh`=;HQh0%fn3dQW>0LJr7dfG!zy&lg5>ng?D*lu z|K=?=1kuyI(tf#8G=xDQjU_^a4z1+{jkKGDb`82b)ud{va($nW-_c%(^bF>?W>`yn vdY(-}SlMy$G)y}x#`uMzVC|)j@Ypsgywsrsc1mbytn=aNPHyJ9kw$q9^VUbK literal 0 HcmV?d00001 diff --git a/downloader/__pycache__/base.cpython-314.pyc b/downloader/__pycache__/base.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8bdb3b58e0e00896cd3b1fb9474a872cff37fb0d GIT binary patch literal 3650 zcmbUj?{6H{@$KHP^~OFsN`gat*c*b9TrfUpQbbKe3vsDw(Y0NQs|q=*_1)TDvA27? zeFut~ibEuA(m2HxNgG#A>ZpmxAwIY?4PW4YKth%1erXY(nq5ohTt)cE%-h}E*_V=v zC*ACOGxOff@0o|QeKLWSIQf$Kiwq&3;~`$DXw#a6O`WKOk)uQvo)acTCQc?;Vg%3P zbIHjROHHO(dNRW@lM<6g$PTibsEI+MCa1+iiSQlPqo($de7g1^oMr#amEcd;{maY# z*>j5j{z`E1dhpvT%Gw*({Yw{MC+9^k@$@rCysSRsGOCwY#bEPJIiK{B6K074(LUx$ zKdv&#(V?{p8y%FGCQJZD#G?{R+(#Txhb2{Ukg(KrS{;}o z2L&}bNGwTB0UWS|L(u~)Go9wtG^b{edWR;d61>o(_5hSs8Q_-bUe3LRbN6oG?u%%B zoR-}{%c}jXe~PdHH36pD>SdqNU1P#|&9)r9Y|z?cAddg;HUI4urPF}H#S2Ps<6Q9O z8+S8hNr;rdj|e|-h}1$api`%ajd;HJ=?=FlfDGcZ#fIp~T1mG{hGiLLFQZlUnroCB zf|qm+Yu3dJd%dZ`lw;K@hFkdgMBzo(ps;c1%Z2JOaGPD2oz=?5t6J5n&6&1am~$!> zQ!7WBX%*ep=L}ki5wM*B&BvtaRW95SmY=0OCH=nEAYtw4;tS~)Trh4xcaLG7?RC<(yVD2wq7y3tySvGQNwk? z+Lkq=mtOYL)L=Dg=Tlw+vVf-H0Sb9aXc7TtDC9|c)}a-hd3~BzvUJxiLw;zQi~sgu zouXyDUQN?&+hIC0z>-5WWU{BB`;JV%xDg}|q zqpfxv&>h9$?PVo6b7k!hr)BwRI_Hkd3M??-ct`vL&x`8woN~vi<4Ukn4=!C*ZodB! z6d6v8?L4lmEi42VKlI-{sq8O0wgI)rZ(LV`ON;(nOUljF#o(QKo?q#52)z5w2@uD> zx}pRhE&Ho~RrbeBql#NKjPj^rFg?6};DE#HlMB~woV&SNSAq+F4^Cc?WtF=%(&v}_ zzfiM%Zmm>;0N6LK%s7rU+9sgz7%o78Kp0h&JxZ{2!vD*HzqlOf)2SPdW#c!Mo&MbqD%6_!%!Zk4Qawzbx8)2#kU_Iq^-NoQGj^&*bE338^M-N;MIxR)X$;CqfM{ zaRD)eh>onGIYc}FpfgeEFk(jlj3Q+Z0G9E>ASJHA>fZUM+HL7~Gg| z@j{hAl}*9j0IUqL3c-2a{~JUc)a)C}%6{<3+M9L%>?%a!+KJWt|Ber4GRufu+71pj zU#ra6x@mP$$~CHEhXjIYv(9+pB?AM^GTjcs*w`3<7b1b~266LZI~cg3_U?@wK4&nf z%(G6H!(+n3a6NW-ZiB;tz!zGKTWLFNumo&qM%g3}`FGSeOkP#k%+*X4T4h zNGvW~A8>RDm*TzC+rCLQj{PoNh(#M5!Up!uMC-QLrT1>GEki|*jrD5OT#34o`acarzXRNN50@U$J{$nO zotvWg4&_I1yTd5QPapqS8sfrG9M~0Nqy{e3~mExzyt!7qR0D}lk1-2eap literal 0 HcmV?d00001 diff --git a/downloader/__pycache__/ytdlp_aria2.cpython-314.pyc b/downloader/__pycache__/ytdlp_aria2.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ef019bee8eee3a606b7ba2ca2866863b7bcab605 GIT binary patch literal 9323 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 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 %}