Compare commits

...

17 Commits

Author SHA1 Message Date
901fcd0541 v0.1.2: 검색 속도 개선, 인피니티 스크롤 최적화, 미니 플레이어 추가 2026-01-24 22:22:02 +09:00
b27bf655f2 Fix: Pass template as outtmpl, not filename, to GDM to fix title issue 2026-01-06 22:15:00 +09:00
b0887eb422 Fix: Pass URL as title to GDM to avoid showing template string 2026-01-06 22:10:55 +09:00
5067b5ff89 Debug: Log ImportError for GDM 2026-01-06 21:53:52 +09:00
b0f494ed1c Fix: Update GDM package name for import 2026-01-06 21:27:10 +09:00
1ad3d0767a Update: plugin source code files 2026-01-06 19:25:41 +09:00
786e2b5026 v0.1.1: Maintenance update (bump version) 2026-01-06 19:23:17 +09:00
1167e60c36 Update repository URL to Gitea and sync structural changes 2026-01-05 15:57:24 +09:00
joyfuI
51ef1ee459 다운로드 요청 오류 수정 2022-08-07 14:50:55 +09:00
joyfuI
3410ddd73c download, thumbnail, sub API에 headers 키 추가 2022-07-24 21:56:46 +09:00
joyfuI
26e4f96d49 자바스크립트 리펙토링 2022-07-24 19:48:33 +09:00
joyfuI
3ef17ad0b3 최신 플러그인 구조로 변경 2022-07-10 23:22:28 +09:00
joyfuI
44b792bc28 js 캐시 문제 해결 2022-06-27 23:57:01 +09:00
joyfuI
3d1e12d081 디폴트 youtube_dl 패키지를 yt-dlp로 변경 2022-05-05 22:04:46 +09:00
joyfuI
8cd3c769b0 과도한 로그 방지 2022-05-05 21:59:24 +09:00
joyfuI
28dcded7ce Unknown format code 'd' for object of type 'float' 오류 수정 2022-05-05 21:43:57 +09:00
joyfuI
c74ad4bc78 파일 인코딩 문제 수정 2022-05-05 21:34:12 +09:00
30 changed files with 2748 additions and 1407 deletions

225
README.md
View File

@@ -15,7 +15,7 @@ SJVA에서 "시스템 → 플러그인 → 플러그인 수동 설치" 칸에
API를 제공합니다. 다른 플러그인에서 동영상 정보나 다운로드를 요청할 수 있습니다. API를 제공합니다. 다른 플러그인에서 동영상 정보나 다운로드를 요청할 수 있습니다.
다른 플러그인이 멋대로 다운로드를 중지할 수 없도록 다운로드를 요청할 때 임의의 키를 넘겨받습니다. 중지 요청 시 키가 일치해야 요청이 실행됩니다. 다른 플러그인이 멋대로 다운로드를 중지할 수 없도록 다운로드를 요청할 때 임의의 키를 넘겨받습니다. 중지 요청 시 키가 일치해야 요청이 실행됩니다.
[youtube-dl](https://github.com/ytdl-org/youtube-dl)의 DMCA 테이크다운 사태 이후, 비슷한 상황을 대비하기 위해 youtube-dl의 포크 프로젝트 [youtube-dlc](https://github.com/blackjack4494/yt-dlc)의 포크 프로젝트(...)인 [yt-dlp](https://github.com/pukkandan/yt-dlp)를 추가했습니다. [youtube-dl](https://github.com/ytdl-org/youtube-dl)의 DMCA 테이크다운 사태 이후, 비슷한 상황을 대비하기 위해 youtube-dl의 포크 프로젝트 [youtube-dlc](https://github.com/blackjack4494/yt-dlc)의 포크 프로젝트(...)인 [yt-dlp](https://github.com/yt-dlp/yt-dlp)를 추가했습니다.
설정에서 취향껏 골라서 사용하시면 되며 youtube-dl가 우선 지원됩니다. 설정에서 취향껏 골라서 사용하시면 되며 youtube-dl가 우선 지원됩니다.
## API ## API
@@ -93,8 +93,10 @@ API를 제공합니다. 다른 플러그인에서 동영상 정보나 다운로
| `archive` | 다운로드한 동영상의 ID를 기록할 파일 경로. 파일이 이미 있으면 이미 다운로드한 동영상은 다운로드 하지 않음. 미지정 시 기록하지 않음 | X | String | | `archive` | 다운로드한 동영상의 ID를 기록할 파일 경로. 파일이 이미 있으면 이미 다운로드한 동영상은 다운로드 하지 않음. 미지정 시 기록하지 않음 | X | String |
| `start` | 다운로드 준비 후 바로 다운로드를 시작할지 여부. 기본값: `false` | X | Boolean | | `start` | 다운로드 준비 후 바로 다운로드를 시작할지 여부. 기본값: `false` | X | Boolean |
| `cookiefile` | 다운로드 시 필요한 쿠키 파일 경로 | X | String | | `cookiefile` | 다운로드 시 필요한 쿠키 파일 경로 | X | String |
| `headers` | 다운로드 시 사용할 HTTP 헤더 정보 | X | String |
`dateafter` 키에 넣을 수 있는 날짜는 `"YYYYMMDD"` 또는 `"(now|today)[+-][0-9](day|week|month|year)(s)?"` 형식의 문자열입니다. `dateafter` 키에 넣을 수 있는 날짜는 `"YYYYMMDD"` 또는 `"(now|today)[+-][0-9](day|week|month|year)(s)?"` 형식의 문자열입니다.
`headers` 키에 넣는 값은 `json` 형식의 문자열입니다.
`playlist` 키에 넣을 수 있는 값은 `"1-3,7,10-13"` 형식의 범위 또는 `"reverse"`, `"random"`입니다. `playlist` 키에 넣을 수 있는 값은 `"1-3,7,10-13"` 형식의 범위 또는 `"reverse"`, `"random"`입니다.
범위를 넣으면 플레이리스트에서 선택한 것만 다운로드합니다. 범위를 넣으면 플레이리스트에서 선택한 것만 다운로드합니다.
@@ -127,8 +129,10 @@ API를 제공합니다. 다른 플러그인에서 동영상 정보나 다운로
| `archive` | 다운로드한 동영상의 ID를 기록할 파일 경로. 파일이 이미 있으면 이미 다운로드한 동영상은 다운로드 하지 않음. 미지정 시 기록하지 않음 | X | String | | `archive` | 다운로드한 동영상의 ID를 기록할 파일 경로. 파일이 이미 있으면 이미 다운로드한 동영상은 다운로드 하지 않음. 미지정 시 기록하지 않음 | X | String |
| `start` | 다운로드 준비 후 바로 다운로드를 시작할지 여부. 기본값: `false` | X | Boolean | | `start` | 다운로드 준비 후 바로 다운로드를 시작할지 여부. 기본값: `false` | X | Boolean |
| `cookiefile` | 다운로드 시 필요한 쿠키 파일 경로 | X | String | | `cookiefile` | 다운로드 시 필요한 쿠키 파일 경로 | X | String |
| `headers` | 다운로드 시 사용할 HTTP 헤더 정보 | X | String |
`dateafter` 키에 넣을 수 있는 날짜는 `"YYYYMMDD"` 또는 `"(now|today)[+-][0-9](day|week|month|year)(s)?"` 형식의 문자열입니다. `dateafter` 키에 넣을 수 있는 날짜는 `"YYYYMMDD"` 또는 `"(now|today)[+-][0-9](day|week|month|year)(s)?"` 형식의 문자열입니다.
`headers` 키에 넣는 값은 `json` 형식의 문자열입니다.
`playlist` 키에 넣을 수 있는 값은 `"1-3,7,10-13"` 형식의 범위 또는 `"reverse"`, `"random"`입니다. `playlist` 키에 넣을 수 있는 값은 `"1-3,7,10-13"` 형식의 범위 또는 `"reverse"`, `"random"`입니다.
범위를 넣으면 플레이리스트에서 선택한 것만 다운로드합니다. 범위를 넣으면 플레이리스트에서 선택한 것만 다운로드합니다.
@@ -163,8 +167,10 @@ API를 제공합니다. 다른 플러그인에서 동영상 정보나 다운로
| `archive` | 다운로드한 동영상의 ID를 기록할 파일 경로. 파일이 이미 있으면 이미 다운로드한 동영상은 다운로드 하지 않음. 미지정 시 기록하지 않음 | X | String | | `archive` | 다운로드한 동영상의 ID를 기록할 파일 경로. 파일이 이미 있으면 이미 다운로드한 동영상은 다운로드 하지 않음. 미지정 시 기록하지 않음 | X | String |
| `start` | 다운로드 준비 후 바로 다운로드를 시작할지 여부. 기본값: `false` | X | Boolean | | `start` | 다운로드 준비 후 바로 다운로드를 시작할지 여부. 기본값: `false` | X | Boolean |
| `cookiefile` | 다운로드 시 필요한 쿠키 파일 경로 | X | String | | `cookiefile` | 다운로드 시 필요한 쿠키 파일 경로 | X | String |
| `headers` | 다운로드 시 사용할 HTTP 헤더 정보 | X | String |
`dateafter` 키에 넣을 수 있는 날짜는 `"YYYYMMDD"` 또는 `"(now|today)[+-][0-9](day|week|month|year)(s)?"` 형식의 문자열입니다. `dateafter` 키에 넣을 수 있는 날짜는 `"YYYYMMDD"` 또는 `"(now|today)[+-][0-9](day|week|month|year)(s)?"` 형식의 문자열입니다.
`headers` 키에 넣는 값은 `json` 형식의 문자열입니다.
`playlist` 키에 넣을 수 있는 값은 `"1-3,7,10-13"` 형식의 범위 또는 `"reverse"`, `"random"`입니다. `playlist` 키에 넣을 수 있는 값은 `"1-3,7,10-13"` 형식의 범위 또는 `"reverse"`, `"random"`입니다.
범위를 넣으면 플레이리스트에서 선택한 것만 다운로드합니다. 범위를 넣으면 플레이리스트에서 선택한 것만 다운로드합니다.
@@ -245,211 +251,18 @@ API를 제공합니다. 다른 플러그인에서 동영상 정보나 다운로
## Changelog ## Changelog
v3.1.0 v0.1.2
- yt-dlp일 때 작동 안하는 문제 수정 - 유튜브 검색 속도 대폭 개선 (extract_flat 적용)
- 검색 결과 캐싱 추가 (5분 유지)
v3.0.1 - 인피니티 스크롤 안정화 및 최적화
- 미니 플레이어 (스크롤 시 오른쪽 하단 고정) 추가
- 다운로드 후 파일 이동이 안되던 문제 수정 - Artplayer 영상 비율 버그 수정 (16:9 aspect-ratio 적용)
- UI 개선: 검색 후 초기 메시지 자동 숨김
v3.0.0
- SJVA3 대응
- SJVA에 flask_cors가 내장됨에 따라 항상 CORS 허용
v2.4.0
- API에 playlist 키 추가
플레이리스트 다운로드 시 범위나 순서를 지정할 수 있습니다.
- 썸네일 다운로드 기능 추가
- thumbnail API 추가
- 자막 다운로드 기능 추가
- sub API 추가
- 설정에 경로 선택 버튼 추가
v2.3.1
- yt-dlp 패키지명 변경 대응
v2.3.0
- youtube-dlc를 yt-dlp로 변경
youtube-dlc가 2개월 넘게 커밋이 없어서 youtube-dlc의 포크 프로젝트인 yt-dlp로 변경했습니다.
기존에 youtube-dlc로 사용하던 분들은 yt-dlp로 변경됩니다.
v2.2.0
- download API의 headers 키 제거
v2.1.2
- youtube-dlc 호환 문제 수정
v2.1.1
v2.1.0
- download API에 cookiefile, headers 키 추가
카카오TV 동영상 다운로드에 활용할 수 있습니다. 잘하면 로봇 체크 패스에도 사용이 가능한 듯 싶습니다.
Thanks to [soju6jan](https://github.com/soju6jan)
v2.0.0
- youtube-dlc 추가
사용할 youtube-dl 패키지를 선택할 수 있습니다. 설정 변경 후 재시작해야 적용됩니다.
v1.7.0
- Python 3 지원
Python 2를 유지한 채로 Python 3 지원을 추가했습니다.
- download API에 dateafter 추가
지정한 날짜 이후에 업로드된 동영상만 다운로드하는 옵션입니다. 플레이리스트 다운로드 시에 유용하게 사용할 수 있습니다.
v1.6.11
- 목록 메뉴에 전체 중지 버튼 추가
v1.6.10
- CORS 허용 설정이 작동하지 않는 문제 수정
Thanks to [dbswnschl](https://github.com/dbswnschl)
v1.6.9
- 일부 동영상 사이트에서 다운로드 실패하는 문제 수정
v1.6.8
- download API에서 filename 키가 적용되지 않는 문제 수정
v1.6.7
- 일부 상황에서 다운로드가 완료로 표시되지 않는 문제 수정
v1.6.6
v1.6.5
- info_dict API가 동작하지 않는 문제 수정
v1.6.4
- FFmpeg 경로 설정 추가
SJVA에 내장된 버전 말고 원하는 버전을 사용할 수 있습니다.
- API에서 GET 요청 지원
이제 GET 요청으로도 API를 사용할 수 있습니다.
v1.6.3
- 프록시 기능을 사용해도 국가차단 우회가 안 되는 문제 수정
v1.6.2
v1.6.1
v1.6.0
- API에 format_code를 format으로 변경
- API에 temp_path 삭제
v1.5.1
v1.5.0
- 프록시 설정 추가
- API에 archive 추가
download-archive 기능으로 다운로드한 동영상의 ID를 기록하여 이미 다운로드한 동영상은 다운로드 하지 않는 옵션입니다.
- status API의 시간 형식 변경 (ISO 8601)
v1.4.2
- --rm-cache-dir 옵션 추가
- 플러그인 최초 설치 시 작동 안 되는 문제 수정
v1.4.1
- CORS 허용 설정 추가
Thanks to [dbswnschl](https://github.com/dbswnschl)
v1.4.0
- socketio 적용
목록 페이지에서 목록이 실시간으로 업데이트됩니다.
v1.3.5
v1.3.4
- 플러그인 구조를 가이드에 맞춰 변경
플러그인 설정이 초기화됩니다.
v1.3.3
- API 변경
v1.3.2
- 파일명에 경로 지정 시 이미 존재하는 경로면 에러가 발생하는 문제 수정
- celery 적용
v1.3.1
v1.3.0
- 후처리 기능 추가
이제 비디오 변환이나 오디오 추출이 가능합니다.
오디오 추출의 비트레이트는 192kbps로 설정되어 있습니다.
v1.2.5
- 기본 파일명 설정 추가
- API에서 일부 키를 선택으로 변경
v1.2.4
- API Key 기능 추가
v1.2.3
- 저장 경로가 존재하지 않으면 생성하도록 개선
v1.2.2
- youtube-dl 패키지 업그레이드도 로그 찍히도록 수정
v1.2.1
v1.2.0
- API 추가
이제 다른 플러그인에서 동영상 정보 가져오기, 다운로드 요청이 가능합니다.
자세한 명세는 API 문단을 참고하세요.
v1.1.1
- 플레이리스트 다운로드 중 국가차단 등의 이유로 다운로드 실패한 동영상이 있으면 건너뛰도록 개선
v1.1.0
- 화질 선택 기능 추가
- 잘못된 예외처리 수정
v1.0.2
v1.0.1
- 로그 좀 더 상세히 찍도록 수정
v1.0.0
- 바이너리 실행 방식에서 파이썬 임베딩 방식으로 변경
- SJVA 시작 시 자동으로 youtube-dl 업데이트
- 목록에서 진행률 표시 추가
v0.1.1 v0.1.1
- 다운로드 실패 시 임시파일 삭제가 안 되는 문제 수정 - 유지보수 업데이트
v0.1.0 v0.1.0

View File

@@ -1 +0,0 @@
from .plugin import blueprint, menu, plugin_info, plugin_load, plugin_unload

8
abort.py Normal file
View File

@@ -0,0 +1,8 @@
from flask import jsonify
class LogicAbort:
@staticmethod
def abort(base, code):
base["errorCode"] = code
return jsonify(base)

View File

@@ -1,10 +0,0 @@
{
"version": "3.1.0",
"name": "youtube-dl",
"category_name": "vod",
"developer": "joyfuI",
"description": "유튜브, 네이버TV 등 동영상 사이트에서 동영상 다운로드",
"home": "https://github.com/joyfuI/youtube-dl",
"more": "",
"category": "vod"
}

7
info.yaml Normal file
View File

@@ -0,0 +1,7 @@
title: "유튜브 다운로더"
version: "0.1.2"
package_name: "youtube-dl"
developer: "flaskfarm"
description: "유튜브 다운로드"
home: "https://gitea.yommi.duckdns.org/ff_plugins/youtube-dl"
more: "https://gitea.yommi.duckdns.org/ff_plugins/youtube-dl/raw/branch/main/README.md"

103
logic.py
View File

@@ -1,103 +0,0 @@
import os
import sys
import platform
import traceback
import subprocess
import sqlite3
from framework import db, path_app_root, path_data
from framework.logger import get_logger
from framework.util import Util
from .logic_normal import LogicNormal
from .model import ModelSetting
package_name = __name__.split(".", maxsplit=1)[0]
logger = get_logger(package_name)
class Logic(object):
db_default = {
"db_version": "2",
"youtube_dl_package": "0",
"ffmpeg_path": ""
if platform.system() != "Windows"
else os.path.join(path_app_root, "bin", "Windows", "ffmpeg.exe"),
"temp_path": os.path.join(path_data, "download_tmp"),
"save_path": os.path.join(path_data, "download"),
"default_filename": "",
"proxy": "",
}
@staticmethod
def db_init():
try:
for key, value in Logic.db_default.items():
if db.session.query(ModelSetting).filter_by(key=key).count() == 0:
db.session.add(ModelSetting(key, value))
db.session.commit()
Logic.migration()
except Exception as e:
logger.error("Exception:%s", e)
logger.error(traceback.format_exc())
@staticmethod
def plugin_load():
try:
logger.debug("%s plugin_load", package_name)
Logic.db_init()
# youtube-dl 업데이트
youtube_dl = LogicNormal.get_youtube_dl_package(
ModelSetting.get("youtube_dl_package")
)
logger.debug(f"{youtube_dl} upgrade")
logger.debug(
subprocess.check_output(
[sys.executable, "-m", "pip", "install", "--upgrade", youtube_dl],
universal_newlines=True,
)
)
# 편의를 위해 json 파일 생성
from .plugin import plugin_info
Util.save_from_dict_to_json(
plugin_info, os.path.join(os.path.dirname(__file__), "info.json")
)
except Exception as e:
logger.error("Exception:%s", e)
logger.error(traceback.format_exc())
@staticmethod
def plugin_unload():
try:
logger.debug("%s plugin_unload", package_name)
except Exception as e:
logger.error("Exception:%s", e)
logger.error(traceback.format_exc())
@staticmethod
def migration():
try:
db_version = ModelSetting.get_int("db_version")
connect = sqlite3.connect(
os.path.join(path_data, "db", f"{package_name}.db")
)
if db_version < 2:
logger.debug("youtube-dlc uninstall")
logger.debug(
subprocess.check_output(
[sys.executable, "-m", "pip", "uninstall", "-y", "youtube-dlc"],
universal_newlines=True,
)
)
connect.commit()
connect.close()
ModelSetting.set("db_version", Logic.db_default["db_version"])
db.session.flush()
except Exception as e:
logger.error("Exception:%s", e)
logger.error(traceback.format_exc())

10
main.py Normal file
View File

@@ -0,0 +1,10 @@
from typing import Any
from .setup import P
logger: Any = P.logger
package_name: str = P.package_name
ModelSetting: Any = P.ModelSetting
# LogicMain은 ModuleBasic으로 기능이 통합되어 더 이상 사용되지 않음.
# 하위 호환성이나 프레임워크 요구사항이 있을 경우를 위해 파일 구조만 유지.

View File

@@ -1,28 +1,230 @@
import os
import sys
import platform
import traceback import traceback
import subprocess
import sqlite3
from datetime import datetime from datetime import datetime
from flask import jsonify from flask import render_template, jsonify
from framework.logger import get_logger from framework import db, path_app_root, path_data, socketio
from framework.common.plugin import LogicModuleBase, default_route_socketio
from .plugin import Plugin
from .my_youtube_dl import MyYoutubeDL, Status from .my_youtube_dl import MyYoutubeDL, Status
package_name = __name__.split(".", maxsplit=1)[0] logger = Plugin.logger
logger = get_logger(package_name) package_name = Plugin.package_name
ModelSetting = Plugin.ModelSetting
class LogicNormal(object): class LogicMain(LogicModuleBase):
db_default = {
"db_version": "2",
"youtube_dl_package": "1",
"ffmpeg_path": ""
if platform.system() != "Windows"
else os.path.join(path_app_root, "bin", "Windows", "ffmpeg.exe"),
"temp_path": os.path.join(path_data, "download_tmp"),
"save_path": os.path.join(path_data, "download"),
"default_filename": "",
"proxy": "",
}
def __init__(self, plugin):
super(LogicMain, self).__init__(plugin, None)
self.name = package_name # 모듈명
default_route_socketio(plugin, self)
def plugin_load(self):
try:
# youtube-dl 업데이트
youtube_dl = Plugin.youtube_dl_packages[
int(ModelSetting.get("youtube_dl_package"))
]
logger.debug(f"{youtube_dl} upgrade")
logger.debug(
subprocess.check_output(
[sys.executable, "-m", "pip", "install", "--upgrade", youtube_dl],
universal_newlines=True,
)
)
except Exception as error:
logger.error("Exception:%s", error)
logger.error(traceback.format_exc())
def process_menu(self, sub, req):
try:
arg = {
"package_name": package_name,
"sub": sub,
"template_name": f"{package_name}_{sub}",
"package_version": Plugin.plugin_info["version"],
}
if sub == "setting":
arg.update(ModelSetting.to_dict())
arg["package_list"] = Plugin.youtube_dl_packages
arg["youtube_dl_version"] = LogicMain.get_youtube_dl_version()
arg["DEFAULT_FILENAME"] = LogicMain.get_default_filename()
elif sub == "download":
default_filename = ModelSetting.get("default_filename")
arg["filename"] = (
default_filename
if default_filename
else LogicMain.get_default_filename()
)
arg["preset_list"] = LogicMain.get_preset_list()
arg["postprocessor_list"] = LogicMain.get_postprocessor_list()
elif sub == "thumbnail":
default_filename = ModelSetting.get("default_filename")
arg["filename"] = (
default_filename
if default_filename
else LogicMain.get_default_filename()
)
elif sub == "sub":
default_filename = ModelSetting.get("default_filename")
arg["filename"] = (
default_filename
if default_filename
else LogicMain.get_default_filename()
)
elif sub == "list":
pass
return render_template(f"{package_name}_{sub}.html", arg=arg)
except Exception as error:
logger.error("Exception:%s", error)
logger.error(traceback.format_exc())
return render_template("sample.html", title=f"{package_name} - {sub}")
def process_ajax(self, sub, req):
try:
logger.debug("AJAX: %s, %s", sub, req.values)
ret = {"ret": "success"}
if sub == "ffmpeg_version":
path = req.form["path"]
output = subprocess.check_output([path, "-version"])
output = output.decode().replace("\n", "<br>")
ret["data"] = output
elif sub == "download":
postprocessor = req.form["postprocessor"]
video_convertor, extract_audio = LogicMain.get_postprocessor()
preferedformat = None
preferredcodec = None
preferredquality = None
if postprocessor in video_convertor:
preferedformat = postprocessor
elif postprocessor in extract_audio:
preferredcodec = postprocessor
preferredquality = 192
youtube_dl = LogicMain.download(
plugin=package_name,
url=req.form["url"],
filename=req.form["filename"],
temp_path=ModelSetting.get("temp_path"),
save_path=ModelSetting.get("save_path"),
format=req.form["format"],
preferedformat=preferedformat,
preferredcodec=preferredcodec,
preferredquality=preferredquality,
proxy=ModelSetting.get("proxy"),
ffmpeg_path=ModelSetting.get("ffmpeg_path"),
)
youtube_dl.start()
LogicMain.socketio_emit("add", youtube_dl)
ret["ret"] = "info"
ret["msg"] = "분석중..."
elif sub == "thumbnail":
youtube_dl = LogicMain.thumbnail(
plugin=package_name,
url=req.form["url"],
filename=req.form["filename"],
temp_path=ModelSetting.get("temp_path"),
save_path=ModelSetting.get("save_path"),
all_thumbnails=req.form["all_thumbnails"],
proxy=ModelSetting.get("proxy"),
ffmpeg_path=ModelSetting.get("ffmpeg_path"),
)
youtube_dl.start()
LogicMain.socketio_emit("add", youtube_dl)
ret["ret"] = "info"
ret["msg"] = "분석중..."
elif sub == "sub":
youtube_dl = LogicMain.sub(
plugin=package_name,
url=req.form["url"],
filename=req.form["filename"],
temp_path=ModelSetting.get("temp_path"),
save_path=ModelSetting.get("save_path"),
all_subs=req.form["all_subs"],
sub_lang=req.form["sub_lang"],
auto_sub=req.form["auto_sub"],
proxy=ModelSetting.get("proxy"),
ffmpeg_path=ModelSetting.get("ffmpeg_path"),
)
youtube_dl.start()
LogicMain.socketio_emit("add", youtube_dl)
ret["ret"] = "info"
ret["msg"] = "분석중..."
elif sub == "list":
ret["data"] = []
for i in LogicMain.youtube_dl_list:
data = LogicMain.get_data(i)
if data is not None:
ret["data"].append(data)
elif sub == "all_stop":
for i in LogicMain.youtube_dl_list:
i.stop()
elif sub == "stop":
index = int(req.form["index"])
LogicMain.youtube_dl_list[index].stop()
return jsonify(ret)
except Exception as error:
logger.error("Exception:%s", error)
logger.error(traceback.format_exc())
return jsonify({"ret": "danger", "msg": str(error)})
def migration(self):
try:
db_version = ModelSetting.get_int("db_version")
connect = sqlite3.connect(
os.path.join(path_data, "db", f"{package_name}.db")
)
if db_version < 2:
logger.debug("youtube-dlc uninstall")
logger.debug(
subprocess.check_output(
[sys.executable, "-m", "pip", "uninstall", "-y", "youtube-dlc"],
universal_newlines=True,
)
)
connect.commit()
connect.close()
ModelSetting.set("db_version", LogicMain.db_default["db_version"])
db.session.flush()
except Exception as error:
logger.error("Exception:%s", error)
logger.error(traceback.format_exc())
youtube_dl_list = [] youtube_dl_list = []
@staticmethod
def get_youtube_dl_package(index=None, import_pkg=False):
packages = ["youtube-dl", "yt-dlp"]
import_name = ["youtube_dl", "yt_dlp"]
if import_pkg:
return import_name if index is None else import_name[int(index)]
else:
return packages if index is None else packages[int(index)]
@staticmethod @staticmethod
def get_youtube_dl_version(): def get_youtube_dl_version():
try: try:
@@ -79,7 +281,7 @@ class LogicNormal(object):
def get_postprocessor(): def get_postprocessor():
video_convertor = [] video_convertor = []
extract_audio = [] extract_audio = []
for i in LogicNormal.get_postprocessor_list(): for i in LogicMain.get_postprocessor_list():
if i[2] == "비디오 변환": if i[2] == "비디오 변환":
video_convertor.append(i[0]) video_convertor.append(i[0])
elif i[2] == "오디오 추출": elif i[2] == "오디오 추출":
@@ -131,12 +333,14 @@ class LogicNormal(object):
opts["ffmpeg_location"] = kwagrs["ffmpeg_path"] opts["ffmpeg_location"] = kwagrs["ffmpeg_path"]
if "cookiefile" in kwagrs and kwagrs["cookiefile"]: if "cookiefile" in kwagrs and kwagrs["cookiefile"]:
opts["cookiefile"] = kwagrs["cookiefile"] opts["cookiefile"] = kwagrs["cookiefile"]
if "headers" in kwagrs and kwagrs["headers"]:
opts["http_headers"] = kwagrs["headers"]
dateafter = kwagrs.get("dateafter") dateafter = kwagrs.get("dateafter")
youtube_dl = MyYoutubeDL( youtube_dl = MyYoutubeDL(
plugin, "video", url, filename, temp_path, save_path, opts, dateafter plugin, "video", url, filename, temp_path, save_path, opts, dateafter
) )
youtube_dl.key = kwagrs.get("key") youtube_dl.key = kwagrs.get("key")
LogicNormal.youtube_dl_list.append(youtube_dl) # 리스트 추가 LogicMain.youtube_dl_list.append(youtube_dl) # 리스트 추가
return youtube_dl return youtube_dl
except Exception as error: except Exception as error:
logger.error("Exception:%s", error) logger.error("Exception:%s", error)
@@ -175,6 +379,8 @@ class LogicNormal(object):
opts["ffmpeg_location"] = kwagrs["ffmpeg_path"] opts["ffmpeg_location"] = kwagrs["ffmpeg_path"]
if "cookiefile" in kwagrs and kwagrs["cookiefile"]: if "cookiefile" in kwagrs and kwagrs["cookiefile"]:
opts["cookiefile"] = kwagrs["cookiefile"] opts["cookiefile"] = kwagrs["cookiefile"]
if "headers" in kwagrs and kwagrs["headers"]:
opts["http_headers"] = kwagrs["headers"]
dateafter = kwagrs.get("dateafter") dateafter = kwagrs.get("dateafter")
youtube_dl = MyYoutubeDL( youtube_dl = MyYoutubeDL(
plugin, plugin,
@@ -187,7 +393,7 @@ class LogicNormal(object):
dateafter, dateafter,
) )
youtube_dl.key = kwagrs.get("key") youtube_dl.key = kwagrs.get("key")
LogicNormal.youtube_dl_list.append(youtube_dl) # 리스트 추가 LogicMain.youtube_dl_list.append(youtube_dl) # 리스트 추가
return youtube_dl return youtube_dl
except Exception as error: except Exception as error:
logger.error("Exception:%s", error) logger.error("Exception:%s", error)
@@ -230,12 +436,14 @@ class LogicNormal(object):
opts["ffmpeg_location"] = kwagrs["ffmpeg_path"] opts["ffmpeg_location"] = kwagrs["ffmpeg_path"]
if "cookiefile" in kwagrs and kwagrs["cookiefile"]: if "cookiefile" in kwagrs and kwagrs["cookiefile"]:
opts["cookiefile"] = kwagrs["cookiefile"] opts["cookiefile"] = kwagrs["cookiefile"]
if "headers" in kwagrs and kwagrs["headers"]:
opts["http_headers"] = kwagrs["headers"]
dateafter = kwagrs.get("dateafter") dateafter = kwagrs.get("dateafter")
youtube_dl = MyYoutubeDL( youtube_dl = MyYoutubeDL(
plugin, "subtitle", url, filename, temp_path, save_path, opts, dateafter plugin, "subtitle", url, filename, temp_path, save_path, opts, dateafter
) )
youtube_dl.key = kwagrs.get("key") youtube_dl.key = kwagrs.get("key")
LogicNormal.youtube_dl_list.append(youtube_dl) # 리스트 추가 LogicMain.youtube_dl_list.append(youtube_dl) # 리스트 추가
return youtube_dl return youtube_dl
except Exception as error: except Exception as error:
logger.error("Exception:%s", error) logger.error("Exception:%s", error)
@@ -284,9 +492,7 @@ class LogicNormal(object):
else "" else ""
) )
data["speed_str"] = ( data["speed_str"] = (
LogicNormal.human_readable_size( LogicMain.human_readable_size(youtube_dl.progress_hooks["speed"], "/s")
youtube_dl.progress_hooks["speed"], "/s"
)
if youtube_dl.progress_hooks["speed"] is not None if youtube_dl.progress_hooks["speed"] is not None
else "" else ""
) )
@@ -303,19 +509,19 @@ class LogicNormal(object):
youtube_dl.progress_hooks["downloaded_bytes"], youtube_dl.progress_hooks["downloaded_bytes"],
youtube_dl.progress_hooks["total_bytes"], youtube_dl.progress_hooks["total_bytes"],
): # 둘 다 값이 있으면 ): # 둘 다 값이 있으면
data["downloaded_bytes_str"] = LogicNormal.human_readable_size( data["downloaded_bytes_str"] = LogicMain.human_readable_size(
youtube_dl.progress_hooks["downloaded_bytes"] youtube_dl.progress_hooks["downloaded_bytes"]
) )
data["total_bytes_str"] = LogicNormal.human_readable_size( data["total_bytes_str"] = LogicMain.human_readable_size(
youtube_dl.progress_hooks["total_bytes"] youtube_dl.progress_hooks["total_bytes"]
) )
data[ data[
"percent" "percent"
] = f"{float(youtube_dl.progress_hooks['downloaded_bytes']) / float(youtube_dl.progress_hooks['total_bytes']) * 100:.2f}" ] = f"{(float(youtube_dl.progress_hooks['downloaded_bytes']) / float(youtube_dl.progress_hooks['total_bytes']) * 100):.2f}"
data["start_time"] = youtube_dl.start_time.strftime("%m-%d %H:%M:%S") data["start_time"] = youtube_dl.start_time.strftime("%m-%d %H:%M:%S")
data[ data[
"download_time" "download_time"
] = f"{download_time.seconds / 60:02f}:{download_time.seconds % 60:02f}" ] = f"{int(download_time.seconds / 60):02d}:{int(download_time.seconds % 60):02d}"
return data return data
except Exception as error: except Exception as error:
logger.error("Exception:%s", error) logger.error("Exception:%s", error)
@@ -335,6 +541,7 @@ class LogicNormal(object):
return f"{size:.1f} YB{suffix}" return f"{size:.1f} YB{suffix}"
@staticmethod @staticmethod
def abort(base, code): def socketio_emit(cmd, data):
base["errorCode"] = code socketio.emit(
return jsonify(base) cmd, LogicMain.get_data(data), namespace=f"/{package_name}", broadcast=True
)

627
mod_basic.py Normal file
View File

@@ -0,0 +1,627 @@
from typing import Any, Dict, List, Optional, Tuple, Union
from flask import Response, jsonify, render_template
from loguru import logger
from support import SupportYaml
from tool import ToolUtil
from .model import ModelYoutubeDlItem
from .my_youtube_dl import MyYoutubeDL, Status
from .setup import *
import platform
import os
import subprocess
import traceback
import sys
from datetime import datetime
try:
from gommi_downloader_manager.mod_queue import ModuleQueue
except ImportError as e:
# 디버깅용 로그 - 실제 운영 시에는 logger를 써야 하지만, 초기화 전일 수 있음
print(f"GDM Import Error: {e}")
try:
from framework import app, logger
logger.error(f"GDM Import Error in youtube-dl: {e}")
except:
pass
ModuleQueue = None
class ModuleBasic(PluginModuleBase):
"""유튜브 다운로더의 기본 기능을 담당하는 모듈"""
youtube_dl_list: List[MyYoutubeDL] = []
def __init__(self, P: Any) -> None:
super(ModuleBasic, self).__init__(
P, name="basic", first_menu="setting", scheduler_desc="유튜브 다운로더"
)
self.db_default: Dict[str, str] = {
"db_version": "2",
"youtube_dl_package": "1",
"ffmpeg_path": ""
if platform.system() != "Windows"
else os.path.join("PATH_APP_ROOT", "bin", "Windows", "ffmpeg.exe"),
"temp_path": os.path.join("{PATH_DATA}", "download_tmp"),
"save_path": os.path.join("{PATH_DATA}", "download"),
"default_filename": "",
"proxy": "",
}
self.web_list_model: Any = ModelYoutubeDlItem
def process_menu(self, sub: str, req: Any) -> Union[Response, str]:
"""메뉴별 템플릿 렌더링"""
logger.debug(f"sub: {sub}")
arg: Dict[str, Any] = P.ModelSetting.to_dict()
arg["package_name"] = P.package_name
arg["package_version"] = P.plugin_info["version"]
arg["template_name"] = f"{P.package_name}_{sub}" # JS 파일명 하위 호환성
logger.debug(f"arg:: {arg}")
if sub == "setting":
arg["is_include"] = F.scheduler.is_include(self.get_scheduler_name())
arg["is_running"] = F.scheduler.is_running(self.get_scheduler_name())
arg["package_list"] = [[x, x] for x in P.youtube_dl_packages]
arg["youtube_dl_version"] = self.get_youtube_dl_version()
arg["DEFAULT_FILENAME"] = self.get_default_filename()
elif sub == "download":
default_filename: Optional[str] = P.ModelSetting.get("default_filename")
arg["filename"] = (
default_filename
if default_filename
else self.get_default_filename()
)
arg["preset_list"] = self.get_preset_list()
arg["postprocessor_list"] = self.get_postprocessor_list()
elif sub in ["thumbnail", "sub", "search"]:
default_filename: Optional[str] = P.ModelSetting.get("default_filename")
arg["filename"] = (
default_filename
if default_filename
else self.get_default_filename()
)
# These templates don't have the module prefix in their name
return render_template(f"{P.package_name}_{sub}.html", arg=arg)
return render_template(f"{P.package_name}_{self.name}_{sub}.html", arg=arg)
def plugin_load(self) -> None:
"""플러그인 로드 시 업그레이드 체크"""
try:
package_idx: str = P.ModelSetting.get("youtube_dl_package")
if not package_idx:
package_idx = "1"
youtube_dl: str = P.youtube_dl_packages[int(package_idx)]
logger.debug(f"{youtube_dl} 업그레이드 체크")
# 선택된 패키지(yt-dlp 등)를 최신 버전으로 업데이트
subprocess.check_output(
[sys.executable, "-m", "pip", "install", "--upgrade", youtube_dl],
universal_newlines=True,
)
except Exception as error:
logger.error(f"플러그인 로드 중 예외 발생: {error}")
logger.error(traceback.format_exc())
def get_youtube_dl_version(self) -> str:
"""다운로드 라이브러리 버전 획득"""
try:
return MyYoutubeDL.get_version()
except Exception as error:
logger.error(f"버전 확인 중 예외 발생: {error}")
# logger.error(traceback.format_exc())
return "패키지 임포트 실패"
def process_command(self, command: str, arg1: Any, arg2: Any, arg3: Any, req: Any) -> Response:
"""일반 커맨드 처리 (현재 미사용)"""
ret: Dict[str, str] = {"ret": "success"}
return jsonify(ret)
def process_ajax(self, sub: str, req: Any) -> Response:
"""UI에서의 AJAX 요청 처리"""
try:
logger.debug(f"AJAX 요청: {sub}, {req.values}")
ret: Dict[str, Any] = {"ret": "success"}
if sub == "ffmpeg_version":
path: str = req.form["path"]
output: bytes = subprocess.check_output([path, "-version"])
ret["data"] = output.decode().replace("\n", "<br>")
elif sub == "download":
postprocessor: str = req.form["postprocessor"]
video_convertor: List[str]
extract_audio: List[str]
video_convertor, extract_audio = self.get_postprocessor()
preferedformat: Optional[str] = None
preferredcodec: Optional[str] = None
preferredquality: Optional[int] = None
if postprocessor in video_convertor:
preferedformat = postprocessor
elif postprocessor in extract_audio:
preferredcodec = postprocessor
preferredquality = 192
# GDM 연동 시도
if ModuleQueue:
gdm_options = {
'proxy': P.ModelSetting.get("proxy"),
'ffmpeg_path': P.ModelSetting.get("ffmpeg_path"),
}
if req.form.get("format"):
gdm_options['format'] = req.form["format"]
if preferredcodec:
gdm_options['extract_audio'] = True
gdm_options['audio_format'] = preferredcodec
if preferedformat:
gdm_options['merge_output_format'] = preferedformat
if postprocessor == "60fps":
# recode-video를 mkv로 강제하여 무조건 재인코딩(변환) 단계가 실행되도록 함.
# 그래야만 VideoConvertor 필터가 적용됨.
gdm_options['extra_args'] = gdm_options.get('extra_args', []) + [
'--recode-video', 'mkv',
'--postprocessor-args', 'VideoConvertor:-c:v libx264 -crf 23 -vf minterpolate=fps=60'
]
# 상세 로그 확인을 위해 verbose 추가 가능 (디버깅용)
# gdm_options['extra_args'] = gdm_options.get('extra_args', []) + ['--verbose']
if req.form.get("filename"):
gdm_options['outtmpl'] = req.form["filename"]
# GDM 큐에 추가
task = ModuleQueue.add_download(
url=req.form["url"],
save_path=ToolUtil.make_path(P.ModelSetting.get("save_path")),
filename=None, # 템플릿 스트링이 제목/파일명으로 박히는 것 방지 (outtmpl로 전달)
source_type='youtube',
caller_plugin='youtube-dl',
title=req.form["url"],
**gdm_options
)
if task:
ret["ret"] = "success"
ret["msg"] = "GDM 대기열에 추가되었습니다."
return jsonify(ret)
# GDM 미설치 시 기존 방식 (직접 다운로드)
youtube_dl: Optional[MyYoutubeDL] = self.download(
plugin=P.package_name,
url=req.form["url"],
filename=req.form["filename"],
temp_path=ToolUtil.make_path(P.ModelSetting.get("temp_path")),
save_path=ToolUtil.make_path(P.ModelSetting.get("save_path")),
format=req.form["format"],
preferedformat=preferedformat,
preferredcodec=preferredcodec,
preferredquality=preferredquality,
proxy=P.ModelSetting.get("proxy"),
ffmpeg_path=P.ModelSetting.get("ffmpeg_path"),
)
if youtube_dl:
youtube_dl.start()
self.socketio_emit("add", youtube_dl)
ret["ret"] = "info"
ret["msg"] = "분석중..."
else:
ret["ret"] = "danger"
ret["msg"] = "다운로드 실패"
elif sub == "list":
ret["data"] = []
for i in self.youtube_dl_list:
data: Optional[Dict[str, Any]] = self.get_data(i)
if data is not None:
ret["data"].append(data)
elif sub == "all_stop":
for i in self.youtube_dl_list:
i.stop()
elif sub == "stop":
index: int = int(req.form["index"])
self.youtube_dl_list[index].stop()
elif sub == "thumbnail":
youtube_dl = self.download(
plugin=P.package_name,
url=req.form["url"],
filename=req.form["filename"],
temp_path=ToolUtil.make_path(P.ModelSetting.get("temp_path")),
save_path=ToolUtil.make_path(P.ModelSetting.get("save_path")),
format="best",
proxy=P.ModelSetting.get("proxy"),
ffmpeg_path=P.ModelSetting.get("ffmpeg_path"),
type="thumbnail",
)
if youtube_dl:
youtube_dl.start()
self.socketio_emit("add", youtube_dl)
ret["ret"] = "info"
ret["msg"] = "분석중..."
else:
ret["ret"] = "danger"
ret["msg"] = "다운로드 실패"
elif sub == "sub":
opts: Dict[str, Any] = {}
if req.form.get("all_subs") == "true":
opts["allsubtitles"] = True
else:
opts["writesubtitles"] = True
youtube_dl = self.download(
plugin=P.package_name,
url=req.form["url"],
filename=req.form["filename"],
temp_path=ToolUtil.make_path(P.ModelSetting.get("temp_path")),
save_path=ToolUtil.make_path(P.ModelSetting.get("save_path")),
format="best",
proxy=P.ModelSetting.get("proxy"),
ffmpeg_path=P.ModelSetting.get("ffmpeg_path"),
type="sub",
opts=opts,
)
if youtube_dl:
youtube_dl.start()
self.socketio_emit("add", youtube_dl)
ret["ret"] = "info"
ret["msg"] = "분석중..."
else:
ret["ret"] = "danger"
ret["msg"] = "다운로드 실패"
elif sub == "preview":
url: str = req.form["url"]
data: Optional[str] = MyYoutubeDL.get_preview_url(url)
if data:
ret["data"] = data
else:
ret["ret"] = "warning"
ret["msg"] = "미리보기 URL을 가져올 수 없습니다."
elif sub == "search":
keyword: str = req.form["keyword"]
page: int = int(req.form.get("page", 1))
page_size: int = 20
# 캐시 키 및 만료 시간 (5분)
import time
cache_key = f"search:{keyword}"
cache_expiry = 300 # 5분
# 캐시에서 결과 확인
cached = getattr(self, '_search_cache', {}).get(cache_key)
current_time = time.time()
if cached and current_time - cached['time'] < cache_expiry:
all_results = cached['data']
else:
# 새 검색 수행 - 최대 100개 결과 가져오기
search_url = f"ytsearch100:{keyword}" if not keyword.startswith('http') else keyword
search_data = MyYoutubeDL.get_info_dict(
search_url,
proxy=P.ModelSetting.get("proxy"),
)
if search_data and 'entries' in search_data:
all_results = [r for r in search_data['entries'] if r] # None 제거
# 캐시에 저장
if not hasattr(self, '_search_cache'):
self._search_cache = {}
self._search_cache[cache_key] = {'data': all_results, 'time': current_time}
else:
all_results = []
if all_results:
# 현재 페이지에 해당하는 결과만 슬라이싱
start_idx = (page - 1) * page_size
results = all_results[start_idx:start_idx + page_size]
ret["data"] = results
# 더 이상 결과가 없으면 알림
if not results:
ret["ret"] = "info"
ret["msg"] = "모든 검색 결과를 불러왔습니다."
else:
ret["ret"] = "warning"
ret["msg"] = "검색 결과를 찾을 수 없습니다."
return jsonify(ret)
except Exception as error:
logger.error(f"AJAX 처리 중 예외 발생: {error}")
logger.error(traceback.format_exc())
return jsonify({"ret": "danger", "msg": str(error)})
def process_api(self, sub: str, req: Any) -> Response:
"""외부 모듈(Youtube 플러그인 등)에서의 API 요청 처리"""
try:
if sub == "info_dict":
url: str = req.values.get("url")
proxy: Optional[str] = req.values.get("proxy")
data: Optional[Dict[str, Any]] = MyYoutubeDL.get_info_dict(url, proxy)
return jsonify(data)
elif sub == "download":
# Gommi Download Manager 연동
if ModuleQueue:
url = req.values.get("url")
save_path = ToolUtil.make_path(req.values.get("save_path") or P.ModelSetting.get("save_path"))
filename = req.values.get("filename")
task = ModuleQueue.add_download(
url=url,
save_path=save_path,
filename=filename,
source_type='youtube',
caller_plugin='youtube-dl'
)
if task:
return jsonify({'ret': 'success', 'msg': '큐에 추가됨', 'task_id': task.id})
else:
return jsonify({'ret': 'fail'})
youtube_dl: Optional[MyYoutubeDL] = self.download(
plugin=req.values.get("plugin"),
url=req.values.get("url"),
filename=req.values.get("filename"),
temp_path=ToolUtil.make_path(P.ModelSetting.get("temp_path")),
save_path=ToolUtil.make_path(req.values.get("save_path") or P.ModelSetting.get("save_path")),
format=req.values.get("format"),
preferedformat=req.values.get("preferedformat"),
preferredcodec=req.values.get("preferredcodec"),
preferredquality=req.values.get("preferredquality"),
proxy=req.values.get("proxy") or P.ModelSetting.get("proxy"),
ffmpeg_path=req.values.get("ffmpeg_path") or P.ModelSetting.get("ffmpeg_path"),
key=req.values.get("key"),
)
if youtube_dl:
if req.values.get("start") == "True" or req.values.get("start") is None:
youtube_dl.start()
return jsonify(self.get_data(youtube_dl))
else:
return jsonify({"ret": "error"})
elif sub == "status":
index: int = int(req.values.get("index"))
return jsonify(self.get_data(self.youtube_dl_list[index]))
elif sub == "stop":
index: int = int(req.values.get("index"))
self.youtube_dl_list[index].stop()
return jsonify({"ret": "success"})
except Exception as error:
logger.error(f"API 처리 중 예외 발생: {error}")
logger.error(traceback.format_exc())
return jsonify({"ret": "error", "msg": str(error)})
def download(self, **kwargs: Any) -> Optional[MyYoutubeDL]:
"""다운로드 객체 생성 및 리스트 관리"""
try:
logger.debug(kwargs)
plugin: str = kwargs["plugin"]
url: str = kwargs["url"]
filename: str = kwargs["filename"]
temp_path: str = kwargs["temp_path"]
save_path: str = kwargs["save_path"]
type_name: str = kwargs.get("type", "video")
opts: Dict[str, Any] = kwargs.get("opts", {})
if "format" in kwargs and kwargs["format"]:
opts["format"] = kwargs["format"]
if type_name == "thumbnail":
opts["writethumbnail"] = True
opts["skip_download"] = True
elif type_name == "sub":
opts["skip_download"] = True
postprocessor: List[Dict[str, Any]] = opts.get("postprocessors", [])
if "preferedformat" in kwargs and kwargs["preferedformat"]:
postprocessor.append(
{
"key": "FFmpegVideoConvertor",
"preferedformat": kwargs["preferedformat"],
}
)
if "preferredcodec" in kwargs and kwargs["preferredcodec"]:
postprocessor.append(
{
"key": "FFmpegExtractAudio",
"preferredcodec": kwargs["preferredcodec"],
"preferredquality": str(kwargs.get("preferredquality", 192)),
}
)
if postprocessor:
opts["postprocessors"] = postprocessor
if "playlist" in kwargs and kwargs["playlist"]:
if kwargs["playlist"] == "reverse":
opts["playlistreverse"] = True
elif kwargs["playlist"] == "random":
opts["playlistrandom"] = True
else:
opts["playlist_items"] = kwargs["playlist"]
if "archive" in kwargs and kwargs["archive"]:
opts["download_archive"] = kwargs["archive"]
if "proxy" in kwargs and kwargs["proxy"]:
opts["proxy"] = kwargs["proxy"]
if "ffmpeg_path" in kwargs and kwargs["ffmpeg_path"]:
opts["ffmpeg_location"] = kwargs["ffmpeg_path"]
if "cookiefile" in kwargs and kwargs["cookiefile"]:
opts["cookiefile"] = kwargs["cookiefile"]
if "headers" in kwargs and kwargs["headers"]:
opts["http_headers"] = kwargs["headers"]
dateafter: Optional[str] = kwargs.get("dateafter")
youtube_dl: MyYoutubeDL = MyYoutubeDL(
plugin, type_name, url, filename, temp_path, save_path, opts, dateafter
)
youtube_dl.key = kwargs.get("key")
self.youtube_dl_list.append(youtube_dl)
return youtube_dl
except Exception as error:
logger.error(f"다운로드 객체 생성 중 예외 발생: {error}")
logger.error(traceback.format_exc())
return None
def get_data(self, youtube_dl: MyYoutubeDL) -> Optional[Dict[str, Any]]:
"""다운로드 객체의 현재 상태 데이터를 딕셔너리로 변환"""
try:
data: Dict[str, Any] = {}
data["plugin"] = youtube_dl.plugin
data["url"] = youtube_dl.url
data["filename"] = youtube_dl.filename
data["temp_path"] = youtube_dl.temp_path
data["save_path"] = youtube_dl.save_path
data["index"] = youtube_dl.index
data["status_str"] = youtube_dl.status.name
data["status_ko"] = str(youtube_dl.status)
data["end_time"] = ""
data["extractor"] = youtube_dl.type + (
" - " + youtube_dl.info_dict["extractor"]
if youtube_dl.info_dict["extractor"] is not None
else ""
)
data["title"] = (
youtube_dl.info_dict["title"]
if youtube_dl.info_dict["title"] is not None
else youtube_dl.url
)
data["uploader"] = (
youtube_dl.info_dict["uploader"]
if youtube_dl.info_dict["uploader"] is not None
else ""
)
data["uploader_url"] = (
youtube_dl.info_dict["uploader_url"]
if youtube_dl.info_dict["uploader_url"] is not None
else ""
)
data["downloaded_bytes_str"] = ""
data["total_bytes_str"] = ""
data["percent"] = "0"
data["eta"] = (
str(youtube_dl.progress_hooks["eta"])
if youtube_dl.progress_hooks["eta"] is not None
else ""
)
data["speed_str"] = (
self.human_readable_size(youtube_dl.progress_hooks["speed"], "/s")
if youtube_dl.progress_hooks["speed"] is not None
else ""
)
if youtube_dl.status == Status.READY:
data["start_time"] = ""
data["download_time"] = ""
else:
if youtube_dl.end_time is None:
download_time: Any = datetime.now() - youtube_dl.start_time
else:
download_time = youtube_dl.end_time - youtube_dl.start_time
data["end_time"] = youtube_dl.end_time.strftime("%m-%d %H:%M:%S")
if None not in (
youtube_dl.progress_hooks["downloaded_bytes"],
youtube_dl.progress_hooks["total_bytes"],
):
data["downloaded_bytes_str"] = self.human_readable_size(
youtube_dl.progress_hooks["downloaded_bytes"]
)
data["total_bytes_str"] = self.human_readable_size(
youtube_dl.progress_hooks["total_bytes"]
)
data[
"percent"
] = f"{(float(youtube_dl.progress_hooks['downloaded_bytes']) / float(youtube_dl.progress_hooks['total_bytes']) * 100):.2f}"
data["start_time"] = youtube_dl.start_time.strftime("%m-%d %H:%M:%S")
data[
"download_time"
] = f"{int(download_time.seconds / 60):02d}:{int(download_time.seconds % 60):02d}"
return data
except Exception as error:
logger.error(f"상태 데이터 변환 중 예외 발생: {error}")
logger.error(traceback.format_exc())
return None
def human_readable_size(self, size: Union[int, float, None], suffix: str = "") -> str:
"""바이트 단위를 사람이 읽기 쉬운 형식으로 변환"""
if size is None:
return ""
for unit in ("Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB"):
if size < 1024.0:
return f"{size:3.1f} {unit}{suffix}"
size /= 1024.0
return f"{size:.1f} YB{suffix}"
def socketio_emit(self, cmd: str, data: MyYoutubeDL) -> None:
"""Socket.IO 메시지 전송"""
F.socketio.emit(
cmd,
self.get_data(data),
namespace=f"/{P.package_name}",
)
def get_postprocessor(self) -> Tuple[List[str], List[str]]:
"""후처리 리스트 분류 (비디오/오디오)"""
video_convertor: List[str] = []
extract_audio: List[str] = []
for i in self.get_postprocessor_list():
if i[2] == "비디오 변환":
video_convertor.append(i[0])
elif i[2] == "오디오 추출":
extract_audio.append(i[0])
return video_convertor, extract_audio
# def plugin_load(self):
# if (
# os.path.exists(
# ToolUtil.make_path(P.ModelSetting.get(f"{self.name}_path_config"))
# )
# is False
# ):
# shutil.copyfile(
# os.path.join(
# os.path.dirname(__file__), "files", f"config_{self.name}.yaml"
# ),
# ToolUtil.make_path(P.ModelSetting.get(f"{self.name}_path_config")),
# )
def get_default_filename(self) -> str:
return MyYoutubeDL.DEFAULT_FILENAME
@staticmethod
def get_preset_list() -> List[List[str]]:
return [
["bestvideo+bestaudio/best", "최고 화질"],
["bestvideo[height<=1080]+bestaudio/best[height<=1080]", "1080p"],
["worstvideo+worstaudio/worst", "최저 화질"],
["bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]", "최고 화질(mp4)"],
[
"bestvideo[ext=mp4][height<=1080]+bestaudio[ext=m4a]/best[ext=mp4][height<=1080]",
"1080p(mp4)",
],
["bestvideo[filesize<50M]+bestaudio/best[filesize<50M]", "50MB 미만"],
["bestaudio/best", "오디오만"],
["_custom", "사용자 정의"],
]
@staticmethod
def get_postprocessor_list() -> List[List[Union[str, None]]]:
return [
["", "후처리 안함", None],
["mp4", "MP4", "비디오 변환"],
["flv", "FLV", "비디오 변환"],
["webm", "WebM", "비디오 변환"],
["ogg", "Ogg", "비디오 변환"],
["mkv", "MKV", "비디오 변환"],
["ts", "TS", "비디오 변환"],
["avi", "AVI", "비디오 변환"],
["wmv", "WMV", "비디오 변환"],
["mov", "MOV", "비디오 변환"],
["gif", "GIF", "비디오 변환"],
["mp3", "MP3", "오디오 추출"],
["aac", "AAC", "오디오 추출"],
["flac", "FLAC", "오디오 추출"],
["m4a", "M4A", "오디오 추출"],
["opus", "Opus", "오디오 추출"],
["vorbis", "Vorbis", "오디오 추출"],
["wav", "WAV", "오디오 추출"],
# ["60fps", "60fps 보간 (느림)", "고급 변환"],
]

125
model.py
View File

@@ -1,119 +1,18 @@
import os # -*- coding: utf-8 -*-
import traceback # @Time : 2023/02/25 7:55 PM
# @Author : yommi
# @Site :
# @File : model
# @Software: PyCharm
# @Path : youtube-dl/model.py
from framework import app, db, path_data from .setup import *
from framework.logger import get_logger
from framework.util import Util
package_name = __name__.split(".", maxsplit=1)[0]
logger = get_logger(package_name)
app.config["SQLALCHEMY_BINDS"][package_name] = "sqlite:///%s" % (
os.path.join(path_data, "db", f"{package_name}.db")
)
class ModelSetting(db.Model): class ModelYoutubeDlItem(ModelBase):
__tablename__ = f"{package_name}_setting" P = P
__tablename__ = "youtube_dl_item"
__table_args__ = {"mysql_collate": "utf8_general_ci"} __table_args__ = {"mysql_collate": "utf8_general_ci"}
__bind_key__ = package_name __bind_key__ = P.package_name
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
key = db.Column(db.String(100), unique=True, nullable=False)
value = db.Column(db.String, nullable=False)
def __init__(self, key, value):
self.key = key
self.value = value
def __repr__(self):
return repr(self.as_dict())
def as_dict(self):
return {x.name: getattr(self, x.name) for x in self.__table__.columns}
@staticmethod
def get(key):
try:
return (
db.session.query(ModelSetting).filter_by(key=key).first().value.strip()
)
except Exception as e:
logger.error("Exception:%s %s", e, key)
logger.error(traceback.format_exc())
@staticmethod
def get_int(key):
try:
return int(ModelSetting.get(key))
except Exception as e:
logger.error("Exception:%s %s", e, key)
logger.error(traceback.format_exc())
@staticmethod
def get_bool(key):
try:
return ModelSetting.get(key) == "True"
except Exception as e:
logger.error("Exception:%s %s", e, key)
logger.error(traceback.format_exc())
@staticmethod
def set(key, value):
try:
item = (
db.session.query(ModelSetting)
.filter_by(key=key)
.with_for_update()
.first()
)
if item is not None:
item.value = value.strip()
db.session.commit()
else:
db.session.add(ModelSetting(key, value.strip()))
except Exception as e:
logger.error("Exception:%s", e)
logger.error(traceback.format_exc())
logger.error("Error Key:%s Value:%s", key, value)
@staticmethod
def to_dict():
try:
return Util.db_list_to_dict(db.session.query(ModelSetting).all())
except Exception as e:
logger.error("Exception:%s", e)
logger.error(traceback.format_exc())
@staticmethod
def setting_save(req):
try:
for key, value in req.form.items():
if key in ["scheduler", "is_running"]:
continue
if key.startswith("tmp_"):
continue
logger.debug("Key:%s Value:%s", key, value)
entity = (
db.session.query(ModelSetting)
.filter_by(key=key)
.with_for_update()
.first()
)
entity.value = value
db.session.commit()
return True
except Exception as e:
logger.error("Exception:%s", e)
logger.error(traceback.format_exc())
return False
@staticmethod
def get_list(key):
try:
value = ModelSetting.get(key)
values = [x.strip().strip() for x in value.replace("\n", "|").split("|")]
values = Util.get_list_except_empty(values)
return values
except Exception as e:
logger.error("Exception:%s %s", e, key)
logger.error(traceback.format_exc())

247
my_youtube_dl.old.py Normal file
View File

@@ -0,0 +1,247 @@
from __future__ import unicode_literals
import os
import traceback
import tempfile
from glob import glob
from datetime import datetime
from threading import Thread
from enum import Enum
import framework.common.celery as celery_shutil
from .plugin import Plugin
logger = Plugin.logger
ModelSetting = Plugin.ModelSetting
youtube_dl_package = Plugin.youtube_dl_packages[
int(ModelSetting.get("youtube_dl_package"))
if ModelSetting.get("youtube_dl_package")
else 1 # LogicMain.db_default["youtube_dl_package"]
].replace("-", "_")
class Status(Enum):
READY = 0
START = 1
DOWNLOADING = 2
ERROR = 3
FINISHED = 4
STOP = 5
COMPLETED = 6
def __str__(self):
str_list = ["준비", "분석중", "다운로드중", "실패", "변환중", "중지", "완료"]
return str_list[self.value]
class MyYoutubeDL:
DEFAULT_FILENAME = "%(title)s-%(id)s.%(ext)s"
_index = 0
def __init__(
self,
plugin,
type_name,
url,
filename,
temp_path,
save_path=None,
opts=None,
dateafter=None,
datebefore=None,
):
# from youtube_dl.utils import DateRange
DateRange = __import__(
f"{youtube_dl_package}.utils", fromlist=["DateRange"]
).DateRange
if save_path is None:
save_path = temp_path
if opts is None:
opts = {}
self.plugin = plugin
self.type = type_name
self.url = url
self.filename = filename
if not os.path.isdir(temp_path):
os.makedirs(temp_path)
self.temp_path = tempfile.mkdtemp(prefix="youtube-dl_", dir=temp_path)
if not os.path.isdir(save_path):
os.makedirs(save_path)
self.save_path = save_path
self.opts = opts
if dateafter or datebefore:
self.opts["daterange"] = DateRange(start=dateafter, end=datebefore)
self.index = MyYoutubeDL._index
MyYoutubeDL._index += 1
self._status = Status.READY
self._thread = None
self.key = None
self.start_time = None # 시작 시간
self.end_time = None # 종료 시간
# info_dict에서 얻는 정보
self.info_dict = {
"extractor": None, # 타입
"title": None, # 제목
"uploader": None, # 업로더
"uploader_url": None, # 업로더 주소
}
# info_dict에서 얻는 정보(entries)
# self.info_dict['playlist_index'] = None
# self.info_dict['duration'] = None # 길이
# self.info_dict['format'] = None # 포맷
# self.info_dict['thumbnail'] = None # 썸네일
# progress_hooks에서 얻는 정보
self.progress_hooks = {
"downloaded_bytes": None, # 다운로드한 크기
"total_bytes": None, # 전체 크기
"eta": None, # 예상 시간(s)
"speed": None, # 다운로드 속도(bytes/s)
}
def start(self):
if self.status != Status.READY:
return False
self._thread = Thread(target=self.run)
self._thread.start()
return True
def run(self):
# import youtube_dl
youtube_dl = __import__(youtube_dl_package)
try:
self.start_time = datetime.now()
self.status = Status.START
# 동영상 정보 가져오기
info_dict = MyYoutubeDL.get_info_dict(
self.url,
self.opts.get("proxy"),
self.opts.get("cookiefile"),
self.opts.get("http_headers"),
)
if info_dict is None:
self.status = Status.ERROR
return
self.info_dict["extractor"] = info_dict["extractor"]
self.info_dict["title"] = info_dict.get("title", info_dict["id"])
self.info_dict["uploader"] = info_dict.get("uploader", "")
self.info_dict["uploader_url"] = info_dict.get("uploader_url", "")
ydl_opts = {
"logger": MyLogger(),
"progress_hooks": [self.my_hook],
# 'match_filter': self.match_filter_func,
"outtmpl": os.path.join(self.temp_path, self.filename),
"ignoreerrors": True,
"cachedir": False,
}
ydl_opts.update(self.opts)
with youtube_dl.YoutubeDL(ydl_opts) as ydl:
logger.debug(self.url)
error_code = ydl.download([self.url])
logger.debug(error_code)
if self.status in (Status.START, Status.FINISHED): # 다운로드 성공
for i in glob(self.temp_path + "/**/*", recursive=True):
path = i.replace(self.temp_path, self.save_path, 1)
if os.path.isdir(i):
if not os.path.isdir(path):
os.mkdir(path)
continue
celery_shutil.move(i, path)
self.status = Status.COMPLETED
except Exception as error:
self.status = Status.ERROR
logger.error("Exception:%s", error)
logger.error(traceback.format_exc())
finally:
# 임시폴더 삭제
celery_shutil.rmtree(self.temp_path)
if self.status != Status.STOP:
self.end_time = datetime.now()
def stop(self):
if self.status in (Status.ERROR, Status.STOP, Status.COMPLETED):
return False
self.status = Status.STOP
self.end_time = datetime.now()
return True
@staticmethod
def get_version():
# from youtube_dl.version import __version__
__version__ = __import__(
f"{youtube_dl_package}.version", fromlist=["__version__"]
).__version__
return __version__
@staticmethod
def get_info_dict(url, proxy=None, cookiefile=None, http_headers=None):
# import youtube_dl
youtube_dl = __import__(youtube_dl_package)
try:
ydl_opts = {"extract_flat": "in_playlist", "logger": MyLogger()}
if proxy:
ydl_opts["proxy"] = proxy
if cookiefile:
ydl_opts["cookiefile"] = cookiefile
if http_headers:
ydl_opts["http_headers"] = http_headers
with youtube_dl.YoutubeDL(ydl_opts) as ydl:
# info = ydl.extract_info(url, download=False)
info = ydl.extract_info(url, download=False)
except Exception as error:
logger.error("Exception:%s", error)
logger.error(traceback.format_exc())
return None
return ydl.sanitize_info(info)
def my_hook(self, data):
if self.status != Status.STOP:
self.status = {
"downloading": Status.DOWNLOADING,
"error": Status.ERROR,
"finished": Status.FINISHED, # 다운로드 완료. 변환 시작
}[data["status"]]
if data["status"] != "error":
self.filename = os.path.basename(data.get("filename"))
self.progress_hooks["downloaded_bytes"] = data.get("downloaded_bytes")
self.progress_hooks["total_bytes"] = data.get("total_bytes")
self.progress_hooks["eta"] = data.get("eta")
self.progress_hooks["speed"] = data.get("speed")
def match_filter_func(self, info_dict):
self.info_dict["playlist_index"] = info_dict["playlist_index"]
self.info_dict["duration"] = info_dict["duration"]
self.info_dict["format"] = info_dict["format"]
self.info_dict["thumbnail"] = info_dict["thumbnail"]
return None
@property
def status(self):
return self._status
@status.setter
def status(self, value):
from .main import LogicMain
self._status = value
LogicMain.socketio_emit("status", self)
class MyLogger:
def debug(self, msg):
if msg.find(" ETA ") != -1:
# 과도한 로그 방지
return
logger.debug(msg)
def warning(self, msg):
logger.warning(msg)
def error(self, msg):
logger.error(msg)

View File

@@ -7,12 +7,19 @@ from glob import glob
from datetime import datetime from datetime import datetime
from threading import Thread from threading import Thread
from enum import Enum from enum import Enum
from typing import Optional, Dict, List, Any, Tuple, Union
from framework.logger import get_logger import shutil as celery_shutil
import framework.common.celery as celery_shutil from .setup import P
package_name = __name__.split(".", maxsplit=1)[0] logger = P.logger
logger = get_logger(package_name) ModelSetting = P.ModelSetting
# yt-dlp 패키지 설정 (기본값 index 1)
package_idx: str = ModelSetting.get("youtube_dl_package")
if not package_idx:
package_idx = "1"
youtube_dl_package: str = P.youtube_dl_packages[int(package_idx)].replace("-", "_")
class Status(Enum): class Status(Enum):
@@ -24,31 +31,29 @@ class Status(Enum):
STOP = 5 STOP = 5
COMPLETED = 6 COMPLETED = 6
def __str__(self): def __str__(self) -> str:
str_list = ["??", "???", "?????", "??", "???", "??", "??"] str_list: List[str] = ["준비", "분석중", "다운로드중", "실패", "변환중", "중지", "완료"]
return str_list[self.value] return str_list[self.value]
class MyYoutubeDL(object): class MyYoutubeDL:
DEFAULT_FILENAME = "%(title)s-%(id)s.%(ext)s" DEFAULT_FILENAME: str = "%(title)s-%(id)s.%(ext)s"
_index = 0 _index: int = 0
def __init__( def __init__(
self, self,
plugin, plugin: str,
type_name, type_name: str,
url, url: str,
filename, filename: str,
temp_path, temp_path: str,
save_path=None, save_path: Optional[str] = None,
opts=None, opts: Optional[Dict[str, Any]] = None,
dateafter=None, dateafter: Optional[str] = None,
datebefore=None, datebefore: Optional[str] = None,
): ):
# from youtube_dl.utils import DateRange # yt-dlp/youtube-dlutils 모듈에서 DateRange 임포트
from .plugin import youtube_dl_package
DateRange = __import__( DateRange = __import__(
f"{youtube_dl_package}.utils", fromlist=["DateRange"] f"{youtube_dl_package}.utils", fromlist=["DateRange"]
).DateRange ).DateRange
@@ -57,104 +62,130 @@ class MyYoutubeDL(object):
save_path = temp_path save_path = temp_path
if opts is None: if opts is None:
opts = {} opts = {}
self.plugin = plugin
self.type = type_name self.plugin: str = plugin
self.url = url self.type: str = type_name
self.filename = filename self.url: str = url
self.filename: str = filename
# 임시 폴더 생성
if not os.path.isdir(temp_path): if not os.path.isdir(temp_path):
os.makedirs(temp_path) os.makedirs(temp_path)
self.temp_path = tempfile.mkdtemp(prefix="youtube-dl_", dir=temp_path) self.temp_path: str = tempfile.mkdtemp(prefix="youtube-dl_", dir=temp_path)
# 저장 폴더 생성
if not os.path.isdir(save_path): if not os.path.isdir(save_path):
os.makedirs(save_path) os.makedirs(save_path)
self.save_path = save_path self.save_path: str = save_path
self.opts = opts
self.opts: Dict[str, Any] = opts
if dateafter or datebefore: if dateafter or datebefore:
self.opts["daterange"] = DateRange(start=dateafter, end=datebefore) self.opts["daterange"] = DateRange(start=dateafter, end=datebefore)
self.index = MyYoutubeDL._index
self.index: int = MyYoutubeDL._index
MyYoutubeDL._index += 1 MyYoutubeDL._index += 1
self._status = Status.READY
self._thread = None self._status: Status = Status.READY
self.key = None self._thread: Optional[Thread] = None
self.start_time = None # 시작 시간 self.key: Optional[str] = None
self.end_time = None # 종료 시간 self.start_time: Optional[datetime] = None
# info_dict에서 얻는 정보 self.end_time: Optional[datetime] = None
self.info_dict = {
"extractor": None, # ?? # 비디오 정보
"title": None, # ?? self.info_dict: Dict[str, Optional[str]] = {
"uploader": None, # ??? "extractor": None,
"uploader_url": None, # ??? ?? "title": None,
"uploader": None,
"uploader_url": None,
} }
# info_dict에서 얻는 정보(entries)
# self.info_dict['playlist_index'] = None # 진행률 정보
# self.info_dict['duration'] = None # 길이 self.progress_hooks: Dict[str, Optional[Union[int, float, str]]] = {
# self.info_dict['format'] = None # 포맷 "downloaded_bytes": None,
# self.info_dict['thumbnail'] = None # 썸네일 "total_bytes": None,
# progress_hooks에서 얻는 정보 "eta": None,
self.progress_hooks = { "speed": None,
"downloaded_bytes": None, # ????? ??
"total_bytes": None, # ?? ??
"eta": None, # ?? ??(s)
"speed": None, # ???? ??(bytes/s)
} }
def start(self): def start(self) -> bool:
"""다운로드 스레드 시작"""
if self.status != Status.READY: if self.status != Status.READY:
return False return False
self._thread = Thread(target=self.run) self._thread = Thread(target=self.run)
self._thread.start() self._thread.start()
return True return True
def run(self): def run(self) -> None:
# import youtube_dl """다운로드 실행 본체"""
from .plugin import youtube_dl_package
youtube_dl = __import__(youtube_dl_package) youtube_dl = __import__(youtube_dl_package)
try: try:
self.start_time = datetime.now() self.start_time = datetime.now()
self.status = Status.START self.status = Status.START
# 동영상 정보 가져오기
# 정보 추출
info_dict = MyYoutubeDL.get_info_dict( info_dict = MyYoutubeDL.get_info_dict(
self.url, self.opts.get("proxy"), self.opts.get("cookiefile") self.url,
self.opts.get("proxy"),
self.opts.get("cookiefile"),
self.opts.get("http_headers"),
self.opts.get("cookiesfrombrowser"),
) )
if info_dict is None: if info_dict is None:
self.status = Status.ERROR self.status = Status.ERROR
return return
self.info_dict["extractor"] = info_dict["extractor"]
self.info_dict["title"] = info_dict.get("title", info_dict["id"]) self.info_dict["extractor"] = info_dict.get("extractor", "unknown")
self.info_dict["title"] = info_dict.get("title", info_dict.get("id", "unknown"))
self.info_dict["uploader"] = info_dict.get("uploader", "") self.info_dict["uploader"] = info_dict.get("uploader", "")
self.info_dict["uploader_url"] = info_dict.get("uploader_url", "") self.info_dict["uploader_url"] = info_dict.get("uploader_url", "")
ydl_opts = {
ydl_opts: Dict[str, Any] = {
"logger": MyLogger(), "logger": MyLogger(),
"progress_hooks": [self.my_hook], "progress_hooks": [self.my_hook],
# 'match_filter': self.match_filter_func,
"outtmpl": os.path.join(self.temp_path, self.filename), "outtmpl": os.path.join(self.temp_path, self.filename),
"ignoreerrors": True, "ignoreerrors": True,
"cachedir": False, "cachedir": False,
"nocheckcertificate": True,
} }
# yt-dlp 전용 성능 향상 옵션
if youtube_dl_package == "yt_dlp":
ydl_opts.update({
"concurrent_fragment_downloads": 5,
"retries": 10,
})
ydl_opts.update(self.opts) ydl_opts.update(self.opts)
with youtube_dl.YoutubeDL(ydl_opts) as ydl: with youtube_dl.YoutubeDL(ydl_opts) as ydl:
ydl.download([self.url]) logger.debug(f"다운로드 시작: {self.url}")
if self.status in (Status.START, Status.FINISHED): # 다운로드 성공 error_code: int = ydl.download([self.url])
logger.debug(f"다운로드 종료 (코드: {error_code})")
if self.status in (Status.START, Status.FINISHED, Status.DOWNLOADING):
# 임시 폴더의 파일을 실제 저장 경로로 이동
for i in glob(self.temp_path + "/**/*", recursive=True): for i in glob(self.temp_path + "/**/*", recursive=True):
path = i.replace(self.temp_path, self.save_path, 1) path: str = i.replace(self.temp_path, self.save_path, 1)
if os.path.isdir(i): if os.path.isdir(i):
if not os.path.isdir(path): if not os.path.isdir(path):
os.mkdir(path) os.mkdir(path)
continue continue
celery_shutil.move(i, path) celery_shutil.move(i, path)
self.status = Status.COMPLETED self.status = Status.COMPLETED
except Exception as e: except Exception as error:
self.status = Status.ERROR self.status = Status.ERROR
logger.error("Exception:%s", e) logger.error(f"실행 중 예외 발생: {error}")
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
finally: finally:
# 임시폴더 삭제 if os.path.exists(self.temp_path):
celery_shutil.rmtree(self.temp_path) celery_shutil.rmtree(self.temp_path)
if self.status != Status.STOP: if self.status != Status.STOP:
self.end_time = datetime.now() self.end_time = datetime.now()
def stop(self): def stop(self) -> bool:
"""다운로드 중지"""
if self.status in (Status.ERROR, Status.STOP, Status.COMPLETED): if self.status in (Status.ERROR, Status.STOP, Status.COMPLETED):
return False return False
self.status = Status.STOP self.status = Status.STOP
@@ -162,79 +193,137 @@ class MyYoutubeDL(object):
return True return True
@staticmethod @staticmethod
def get_version(): def get_preview_url(url: str) -> Optional[str]:
# from youtube_dl.version import __version__ """미리보기용 직접 재생 가능한 URL 추출"""
from .plugin import youtube_dl_package youtube_dl = __import__(youtube_dl_package)
try:
# 미리보기를 위해 다양한 포맷 시도 (mp4, hls 등)
ydl_opts: Dict[str, Any] = {
"format": "best[ext=mp4]/bestvideo[ext=mp4]+bestaudio[ext=m4a]/best",
"logger": MyLogger(),
"nocheckcertificate": True,
"quiet": True,
"js_runtimes": {"node": {"path": "/Users/yommi/.local/state/fnm_multishells/53824_1769161399333/bin/node"}},
}
with youtube_dl.YoutubeDL(ydl_opts) as ydl:
info: Dict[str, Any] = ydl.extract_info(url, download=False)
# 1. HLS 매니페스트 우선 (가장 안정적으로 오디오+비디오 제공)
if info.get("manifest_url"):
return info["manifest_url"]
# 2. 직접 URL (ydl_opts에서 지정한 best[ext=mp4] 결과)
if info.get("url"):
return info["url"]
# 3. 포맷 목록에서 적절한 것 찾기
formats = info.get("formats", [])
# 오디오와 비디오가 모두 있는 포맷 찾기
combined_formats = [f for f in formats if f.get("vcodec") != "none" and f.get("acodec") != "none"]
if combined_formats:
# 가장 좋은 화질의 결합 포맷 선택
return combined_formats[-1].get("url")
return None
except Exception as error:
logger.error(f"미리보기 URL 추출 중 예외 발생: {error}")
return None
__version__ = __import__( @staticmethod
def get_version() -> str:
"""라이브러리 버전 확인"""
__version__: str = __import__(
f"{youtube_dl_package}.version", fromlist=["__version__"] f"{youtube_dl_package}.version", fromlist=["__version__"]
).__version__ ).__version__
return __version__ return __version__
@staticmethod @staticmethod
def get_info_dict(url, proxy=None, cookiefile=None): def get_info_dict(
# import youtube_dl url: str,
from .plugin import youtube_dl_package proxy: Optional[str] = None,
cookiefile: Optional[str] = None,
http_headers: Optional[Dict[str, str]] = None,
cookiesfrombrowser: Optional[str] = None,
**extra_opts
) -> Optional[Dict[str, Any]]:
"""비디오 메타데이터 정보 추출"""
youtube_dl = __import__(youtube_dl_package) youtube_dl = __import__(youtube_dl_package)
try: try:
ydl_opts = {"extract_flat": "in_playlist", "logger": MyLogger()} ydl_opts: Dict[str, Any] = {
"logger": MyLogger(),
"nocheckcertificate": True,
"quiet": True,
# JS 런타임 수동 지정 (유저 시스템 환경 반영)
"js_runtimes": {"node": {"path": "/Users/yommi/.local/state/fnm_multishells/53824_1769161399333/bin/node"}},
}
# 기본값으로 extract_flat 적용 (명시적으로 override 가능)
if "extract_flat" not in extra_opts:
ydl_opts["extract_flat"] = True # True = 모든 추출기에 적용
if proxy: if proxy:
ydl_opts["proxy"] = proxy ydl_opts["proxy"] = proxy
if cookiefile: if cookiefile:
ydl_opts["cookiefile"] = cookiefile ydl_opts["cookiefile"] = cookiefile
if http_headers:
ydl_opts["http_headers"] = http_headers
if cookiesfrombrowser:
ydl_opts["cookiesfrombrowser"] = (cookiesfrombrowser, None, None, None)
# 추가 옵션 반영 (playliststart, playlistend 등)
ydl_opts.update(extra_opts)
with youtube_dl.YoutubeDL(ydl_opts) as ydl: with youtube_dl.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(url, download=False) info: Dict[str, Any] = ydl.extract_info(url, download=False)
except Exception as e: except Exception as error:
logger.error("Exception:%s", e) logger.error(f"정보 추출 중 예외 발생: {error}")
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
return None return None
return info return ydl.sanitize_info(info)
def my_hook(self, d): def my_hook(self, data: Dict[str, Any]) -> None:
"""진행률 업데이트 훅"""
if self.status != Status.STOP: if self.status != Status.STOP:
self.status = { if data["status"] == "downloading":
"downloading": Status.DOWNLOADING, self.status = Status.DOWNLOADING
"error": Status.ERROR, elif data["status"] == "error":
"finished": Status.FINISHED, # ???? ??. ?? ?? self.status = Status.ERROR
}[d["status"]] elif data["status"] == "finished":
if d["status"] != "error": self.status = Status.FINISHED
self.filename = os.path.basename(d.get("filename"))
self.progress_hooks["downloaded_bytes"] = d.get("downloaded_bytes") if data["status"] != "error":
self.progress_hooks["total_bytes"] = d.get("total_bytes") self.filename = os.path.basename(data.get("filename", self.filename))
self.progress_hooks["eta"] = d.get("eta") self.progress_hooks["downloaded_bytes"] = data.get("downloaded_bytes")
self.progress_hooks["speed"] = d.get("speed") self.progress_hooks["total_bytes"] = data.get("total_bytes") or data.get("total_bytes_estimate")
self.progress_hooks["eta"] = data.get("eta")
def match_filter_func(self, info_dict): self.progress_hooks["speed"] = data.get("speed")
self.info_dict["playlist_index"] = info_dict["playlist_index"]
self.info_dict["duration"] = info_dict["duration"]
self.info_dict["format"] = info_dict["format"]
self.info_dict["thumbnail"] = info_dict["thumbnail"]
return None
@property @property
def status(self): def status(self) -> Status:
return self._status return self._status
@status.setter @status.setter
def status(self, value): def status(self, value: Status) -> None:
from .plugin import socketio_emit
self._status = value self._status = value
socketio_emit("status", self) # Socket.IO를 통한 상태 업데이트 전송
try:
basic_module = P.get_module('basic')
if basic_module:
basic_module.socketio_emit("status", self)
except Exception as e:
logger.error(f"SocketIO 전송 에러: {e}")
class MyLogger(object): class MyLogger:
def debug(self, msg): """yt-dlp의 로그를 가로채서 처리하는 클래성"""
if msg.find("\x1B") != -1 or msg.find("{") != -1: def debug(self, msg: str) -> None:
# 과도한 로그 방지 # 진행 상황 관련 로그는 걸러냄
if " ETA " in msg or "at" in msg and "B/s" in msg:
return return
logger.debug(msg) logger.debug(msg)
def warning(self, msg): def warning(self, msg: str) -> None:
logger.warning(msg) logger.warning(msg)
def error(self, msg): def error(self, msg: str) -> None:
logger.error(msg) logger.error(msg)

502
plugin.py
View File

@@ -1,502 +0,0 @@
import os
import traceback
import subprocess
from flask import Blueprint, request, render_template, redirect, jsonify, abort
from flask_login import login_required
from flask_cors import cross_origin
from framework import check_api, socketio
from framework.logger import get_logger
from .logic import Logic
from .logic_normal import LogicNormal
from .model import ModelSetting
package_name = __name__.split(".", maxsplit=1)[0]
logger = get_logger(package_name)
youtube_dl_package = LogicNormal.get_youtube_dl_package(
ModelSetting.get("youtube_dl_package")
if ModelSetting.get("youtube_dl_package")
else Logic.db_default["youtube_dl_package"],
import_pkg=True,
)
#########################################################
# 플러그인 공용
#########################################################
blueprint = Blueprint(
package_name,
package_name,
url_prefix=f"/{package_name}",
template_folder=os.path.join(os.path.dirname(__file__), "templates"),
static_folder=os.path.join(os.path.dirname(__file__), "static"),
)
menu = {
"main": [package_name, "youtube-dl"],
"sub": [
["setting", "설정"],
["download", "다운로드"],
["thumbnail", "썸네일 다운로드"],
["sub", "자막 다운로드"],
["list", "목록"],
["log", "로그"],
],
"category": "vod",
}
plugin_info = {
"version": "3.1.0",
"name": "youtube-dl",
"category_name": "vod",
"developer": "joyfuI",
"description": "유튜브, 네이버TV 등 동영상 사이트에서 동영상 다운로드",
"home": "https://github.com/joyfuI/youtube-dl",
"more": "",
}
def plugin_load():
Logic.plugin_load()
def plugin_unload():
Logic.plugin_unload()
#########################################################
# WEB Menu
#########################################################
@blueprint.route("/")
def home():
return redirect(f"/{package_name}/list")
@blueprint.route("/<sub>")
@login_required
def first_menu(sub):
try:
arg = {
"package_name": package_name,
"template_name": f"{package_name}_{sub}",
}
if sub == "setting":
arg.update(ModelSetting.to_dict())
arg["package_list"] = LogicNormal.get_youtube_dl_package()
arg["youtube_dl_version"] = LogicNormal.get_youtube_dl_version()
arg["DEFAULT_FILENAME"] = LogicNormal.get_default_filename()
return render_template(f"{package_name}_{sub}.html", arg=arg)
elif sub == "download":
default_filename = ModelSetting.get("default_filename")
arg["filename"] = (
default_filename
if default_filename
else LogicNormal.get_default_filename()
)
arg["preset_list"] = LogicNormal.get_preset_list()
arg["postprocessor_list"] = LogicNormal.get_postprocessor_list()
return render_template(f"{package_name}_{sub}.html", arg=arg)
elif sub == "thumbnail":
default_filename = ModelSetting.get("default_filename")
arg["filename"] = (
default_filename
if default_filename
else LogicNormal.get_default_filename()
)
return render_template(f"{package_name}_{sub}.html", arg=arg)
elif sub == "sub":
default_filename = ModelSetting.get("default_filename")
arg["filename"] = (
default_filename
if default_filename
else LogicNormal.get_default_filename()
)
return render_template(f"{package_name}_{sub}.html", arg=arg)
elif sub == "list":
return render_template(f"{package_name}_{sub}.html", arg=arg)
elif sub == "log":
return render_template("log.html", package=package_name)
except Exception as e:
logger.error("Exception:%s", e)
logger.error(traceback.format_exc())
return render_template("sample.html", title=f"{package_name} - {sub}")
#########################################################
# For UI
#########################################################
@blueprint.route("/ajax/<sub>", methods=["POST"])
@login_required
def ajax(sub):
logger.debug("AJAX %s %s", package_name, sub)
try:
# 공통 요청
if sub == "setting_save":
ret = ModelSetting.setting_save(request)
if request.form["ffmpeg_path"] == "ffmpeg":
ModelSetting.set("ffmpeg_path", "")
return jsonify(ret)
# UI 요청
elif sub == "ffmpeg_version":
path = request.form["path"]
ret = subprocess.check_output([path, "-version"])
ret = ret.decode().replace("\n", "<br>")
return jsonify(ret)
elif sub == "download":
postprocessor = request.form["postprocessor"]
video_convertor, extract_audio = LogicNormal.get_postprocessor()
preferedformat = None
preferredcodec = None
preferredquality = None
if postprocessor in video_convertor:
preferedformat = postprocessor
elif postprocessor in extract_audio:
preferredcodec = postprocessor
preferredquality = 192
youtube_dl = LogicNormal.download(
plugin=package_name,
url=request.form["url"],
filename=request.form["filename"],
temp_path=ModelSetting.get("temp_path"),
save_path=ModelSetting.get("save_path"),
format=request.form["format"],
preferedformat=preferedformat,
preferredcodec=preferredcodec,
preferredquality=preferredquality,
proxy=ModelSetting.get("proxy"),
ffmpeg_path=ModelSetting.get("ffmpeg_path"),
)
youtube_dl.start()
socketio_emit("add", youtube_dl)
return jsonify([])
elif sub == "thumbnail":
youtube_dl = LogicNormal.thumbnail(
plugin=package_name,
url=request.form["url"],
filename=request.form["filename"],
temp_path=ModelSetting.get("temp_path"),
save_path=ModelSetting.get("save_path"),
all_thumbnails=request.form["all_thumbnails"],
proxy=ModelSetting.get("proxy"),
ffmpeg_path=ModelSetting.get("ffmpeg_path"),
)
youtube_dl.start()
socketio_emit("add", youtube_dl)
return jsonify([])
elif sub == "sub":
youtube_dl = LogicNormal.sub(
plugin=package_name,
url=request.form["url"],
filename=request.form["filename"],
temp_path=ModelSetting.get("temp_path"),
save_path=ModelSetting.get("save_path"),
all_subs=request.form["all_subs"],
sub_lang=request.form["sub_lang"],
auto_sub=request.form["auto_sub"],
proxy=ModelSetting.get("proxy"),
ffmpeg_path=ModelSetting.get("ffmpeg_path"),
)
youtube_dl.start()
socketio_emit("add", youtube_dl)
return jsonify([])
elif sub == "list":
ret = []
for i in LogicNormal.youtube_dl_list:
data = LogicNormal.get_data(i)
if data is not None:
ret.append(data)
return jsonify(ret)
elif sub == "all_stop":
for i in LogicNormal.youtube_dl_list:
i.stop()
return jsonify([])
elif sub == "stop":
index = int(request.form["index"])
LogicNormal.youtube_dl_list[index].stop()
return jsonify([])
except Exception as e:
logger.error("Exception:%s", e)
logger.error(traceback.format_exc())
#########################################################
# API
#########################################################
# API 명세는 https://github.com/joyfuI/youtube-dl#api
@blueprint.route("/api/<sub>", methods=["GET", "POST"])
@cross_origin()
@check_api
def api(sub):
plugin = request.values.get("plugin")
logger.debug("API %s %s: %s", package_name, sub, plugin)
if not plugin: # 요청한 플러그인명이 빈문자열이거나 None면
abort(403) # 403 에러(거부)
try:
# 동영상 정보를 반환하는 API
if sub == "info_dict":
url = request.values.get("url")
ret = {"errorCode": 0, "info_dict": None}
if None in (url,):
return LogicNormal.abort(ret, 1) # 필수 요청 변수가 없음
if not url.startswith("http"):
return LogicNormal.abort(ret, 2) # 잘못된 동영상 주소
info_dict = LogicNormal.get_info_dict(url, ModelSetting.get("proxy"))
if info_dict is None:
return LogicNormal.abort(ret, 10) # 실패
ret["info_dict"] = info_dict
return jsonify(ret)
# 비디오 다운로드 준비를 요청하는 API
elif sub == "download":
key = request.values.get("key")
url = request.values.get("url")
filename = request.values.get(
"filename", ModelSetting.get("default_filename")
)
save_path = request.values.get("save_path", ModelSetting.get("save_path"))
format_code = request.values.get("format", None)
preferedformat = request.values.get("preferedformat", None)
preferredcodec = request.values.get("preferredcodec", None)
preferredquality = request.values.get("preferredquality", 192)
dateafter = request.values.get("dateafter", None)
playlist = request.values.get("playlist", None)
archive = request.values.get("archive", None)
start = request.values.get("start", False)
cookiefile = request.values.get("cookiefile", None)
ret = {"errorCode": 0, "index": None}
if None in (key, url):
return LogicNormal.abort(ret, 1) # 필수 요청 변수가 없음
if not url.startswith("http"):
return LogicNormal.abort(ret, 2) # 잘못된 동영상 주소
if preferredcodec not in (
None,
"best",
"mp3",
"aac",
"flac",
"m4a",
"opus",
"vorbis",
"wav",
):
return LogicNormal.abort(ret, 5) # 허용되지 않은 값이 있음
if not filename:
filename = LogicNormal.get_default_filename()
youtube_dl = LogicNormal.download(
plugin=plugin,
url=url,
filename=filename,
temp_path=ModelSetting.get("temp_path"),
save_path=save_path,
format=format_code,
preferedformat=preferedformat,
preferredcodec=preferredcodec,
preferredquality=preferredquality,
dateafter=dateafter,
playlist=playlist,
archive=archive,
proxy=ModelSetting.get("proxy"),
ffmpeg_path=ModelSetting.get("ffmpeg_path"),
key=key,
cookiefile=cookiefile,
)
if youtube_dl is None:
return LogicNormal.abort(ret, 10) # 실패
ret["index"] = youtube_dl.index
if start:
youtube_dl.start()
socketio_emit("add", youtube_dl)
return jsonify(ret)
# 썸네일 다운로드 준비를 요청하는 API
elif sub == "thumbnail":
key = request.values.get("key")
url = request.values.get("url")
filename = request.values.get(
"filename", ModelSetting.get("default_filename")
)
save_path = request.values.get("save_path", ModelSetting.get("save_path"))
all_thumbnails = request.values.get("all_thumbnails", False)
dateafter = request.values.get("dateafter", None)
playlist = request.values.get("playlist", None)
archive = request.values.get("archive", None)
start = request.values.get("start", False)
cookiefile = request.values.get("cookiefile", None)
ret = {"errorCode": 0, "index": None}
if None in (key, url):
return LogicNormal.abort(ret, 1) # 필수 요청 변수가 없음
if not url.startswith("http"):
return LogicNormal.abort(ret, 2) # 잘못된 동영상 주소
if not filename:
filename = LogicNormal.get_default_filename()
youtube_dl = LogicNormal.thumbnail(
plugin=plugin,
url=url,
filename=filename,
temp_path=ModelSetting.get("temp_path"),
save_path=save_path,
all_thumbnails=all_thumbnails,
dateafter=dateafter,
playlist=playlist,
archive=archive,
proxy=ModelSetting.get("proxy"),
ffmpeg_path=ModelSetting.get("ffmpeg_path"),
key=key,
cookiefile=cookiefile,
)
if youtube_dl is None:
return LogicNormal.abort(ret, 10) # 실패
ret["index"] = youtube_dl.index
if start:
youtube_dl.start()
socketio_emit("add", youtube_dl)
return jsonify(ret)
# 자막 다운로드 준비를 요청하는 API
elif sub == "sub":
key = request.values.get("key")
url = request.values.get("url")
filename = request.values.get(
"filename", ModelSetting.get("default_filename")
)
save_path = request.values.get("save_path", ModelSetting.get("save_path"))
all_subs = request.values.get("all_subs", False)
sub_lang = request.values.get("sub_lang", "ko")
auto_sub = request.values.get("all_subs", False)
dateafter = request.values.get("dateafter", None)
playlist = request.values.get("playlist", None)
archive = request.values.get("archive", None)
start = request.values.get("start", False)
cookiefile = request.values.get("cookiefile", None)
ret = {"errorCode": 0, "index": None}
if None in (key, url):
return LogicNormal.abort(ret, 1) # 필수 요청 변수가 없음
if not url.startswith("http"):
return LogicNormal.abort(ret, 2) # 잘못된 동영상 주소
if not filename:
filename = LogicNormal.get_default_filename()
youtube_dl = LogicNormal.sub(
plugin=plugin,
url=url,
filename=filename,
temp_path=ModelSetting.get("temp_path"),
save_path=save_path,
all_subs=all_subs,
sub_lang=sub_lang,
auto_sub=auto_sub,
dateafter=dateafter,
playlist=playlist,
archive=archive,
proxy=ModelSetting.get("proxy"),
ffmpeg_path=ModelSetting.get("ffmpeg_path"),
key=key,
cookiefile=cookiefile,
)
if youtube_dl is None:
return LogicNormal.abort(ret, 10) # 실패
ret["index"] = youtube_dl.index
if start:
youtube_dl.start()
socketio_emit("add", youtube_dl)
return jsonify(ret)
# 다운로드 시작을 요청하는 API
elif sub == "start":
index = request.values.get("index")
key = request.values.get("key")
ret = {"errorCode": 0, "status": None}
if None in (index, key):
return LogicNormal.abort(ret, 1) # 필수 요청 변수가 없음
index = int(index)
if not 0 <= index < len(LogicNormal.youtube_dl_list):
return LogicNormal.abort(ret, 3) # 인덱스 범위를 벗어남
youtube_dl = LogicNormal.youtube_dl_list[index]
if youtube_dl.key != key:
return LogicNormal.abort(ret, 4) # 키가 일치하지 않음
ret["status"] = youtube_dl.status.name
if not youtube_dl.start():
return LogicNormal.abort(ret, 10) # 실패
return jsonify(ret)
# 다운로드 중지를 요청하는 API
elif sub == "stop":
index = request.values.get("index")
key = request.values.get("key")
ret = {"errorCode": 0, "status": None}
if None in (index, key):
return LogicNormal.abort(ret, 1) # 필수 요청 변수가 없음
index = int(index)
if not 0 <= index < len(LogicNormal.youtube_dl_list):
return LogicNormal.abort(ret, 3) # 인덱스 범위를 벗어남
youtube_dl = LogicNormal.youtube_dl_list[index]
if youtube_dl.key != key:
return LogicNormal.abort(ret, 4) # 키가 일치하지 않음
ret["status"] = youtube_dl.status.name
if not youtube_dl.stop():
return LogicNormal.abort(ret, 10) # 실패
return jsonify(ret)
# 현재 상태를 반환하는 API
elif sub == "status":
index = request.values.get("index")
key = request.values.get("key")
ret = {
"errorCode": 0,
"status": None,
"type": None,
"start_time": None,
"end_time": None,
"temp_path": None,
"save_path": None,
}
if None in (index, key):
return LogicNormal.abort(ret, 1) # 필수 요청 변수가 없음
index = int(index)
if not 0 <= index < len(LogicNormal.youtube_dl_list):
return LogicNormal.abort(ret, 3) # 인덱스 범위를 벗어남
youtube_dl = LogicNormal.youtube_dl_list[index]
if youtube_dl.key != key:
return LogicNormal.abort(ret, 4) # 키가 일치하지 않음
ret["status"] = youtube_dl.status.name
ret["type"] = youtube_dl.type
ret["start_time"] = (
youtube_dl.start_time.strftime("%Y-%m-%dT%H:%M:%S")
if youtube_dl.start_time is not None
else None
)
ret["end_time"] = (
youtube_dl.end_time.strftime("%Y-%m-%dT%H:%M:%S")
if youtube_dl.end_time is not None
else None
)
ret["temp_path"] = youtube_dl.temp_path
ret["save_path"] = youtube_dl.save_path
return jsonify(ret)
except Exception as e:
logger.error("Exception:%s", e)
logger.error(traceback.format_exc())
abort(500) # 500 에러(서버 오류)
abort(404) # 404 에러(페이지 없음)
#########################################################
# socketio
#########################################################
def socketio_emit(cmd, data):
socketio.emit(
cmd, LogicNormal.get_data(data), namespace=f"/{package_name}", broadcast=True
)

68
setup.py Normal file
View File

@@ -0,0 +1,68 @@
# -*- coding: utf-8 -*-
# @Time : 2023/02/25 7:20 PM
# @Author : yommi
# @Site :
# @File : setup.py
# @Software: PyCharm
# @Path : youtube-dl/setup.py
__menu = {
"uri": __package__,
"name": "유튜브 다운로더",
"list": [
{
"uri": "basic",
"name": "유튜브",
"list": [
{
"uri": "setting",
"name": "설정",
},
{
"uri": "download",
"name": "직접 다운로드",
},
{"uri": "search", "name": "유튜브 검색"},
{"uri": "thumbnail", "name": "썸네일 다운로드"},
{"uri": "sub", "name": "자막 다운로드"},
],
},
{
"uri": "manual",
"name": "매뉴얼",
"list": [
{"uri": "README.md", "name": "README.md"},
],
},
{
"uri": "log",
"name": "로그",
},
],
}
setting = {
"filepath": __file__,
"use_db": True,
"use_default_setting": True,
"home_module": None,
"menu": __menu,
"setting_menu": None,
"default_route": "normal",
}
from plugin import *
P = create_plugin_instance(setting)
P.youtube_dl_packages = ["youtube-dl", "yt-dlp", "youtube-dlc"]
try:
from .mod_basic import ModuleBasic
P.set_module_list([ModuleBasic])
except Exception as e:
P.logger.error(f"Exception:{str(e)}")
P.logger.error(traceback.format_exc())
logger = P.logger

View File

@@ -1,51 +1,115 @@
'use strict'; 'use strict';
const url = document.getElementById('url'); (() => {
const preset = document.getElementById('preset'); const post_ajax = (url, data) => {
const format = document.getElementById('format'); const loading = document.getElementById('loading');
const postprocessor = document.getElementById('postprocessor'); if (loading) {
const download_btn = document.getElementById('download_btn'); loading.style.display = 'block';
}
// 프리셋 변경 return fetch(`/${package_name}/ajax${url}`, {
preset.addEventListener('change', () => { method: 'POST',
if (preset.value !== '_custom') { cache: 'no-cache',
format.value = preset.value; headers: {
} 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
}); },
format.addEventListener('input', () => { body: new URLSearchParams(data),
preset.value = '_custom';
});
// 후처리 변경
postprocessor.addEventListener('change', () => {
const select = postprocessor.selectedOptions[0];
if (select.parentElement.label === '오디오 추출') {
preset.value = 'bestaudio/best';
format.value = preset.value;
}
});
// 다운로드
download_btn.addEventListener('click', (event) => {
event.preventDefault();
if (!url.value.startsWith('http')) {
notify('URL을 입력하세요.', 'warning');
return;
}
fetch(`/${package_name}/ajax/download`, {
method: 'POST',
cache: 'no-cache',
headers: {
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
},
body: get_formdata('#download'),
})
.then((response) => response.json())
.then(() => {
notify('분석중..', 'info');
}) })
.catch(() => { .then((response) => response.json())
notify('다운로드 요청 실패', 'danger'); .then((ret) => {
if (ret.msg) {
notify(ret.msg, ret.ret);
}
return ret;
})
.catch(() => {
notify('요청 실패', 'danger');
})
.finally(() => {
if (loading) {
loading.style.display = 'none';
}
});
};
const url = document.getElementById('url');
const preset = document.getElementById('preset');
const format = document.getElementById('format');
const postprocessor = document.getElementById('postprocessor');
const download_btn = document.getElementById('download_btn');
// 프리셋 변경
preset.addEventListener('change', () => {
if (preset.value !== '_custom') {
format.value = preset.value;
}
});
format.addEventListener('input', () => {
preset.value = '_custom';
});
// 후처리 변경
postprocessor.addEventListener('change', () => {
const select = postprocessor.selectedOptions[0];
if (select.parentElement.label === '오디오 추출') {
preset.value = 'bestaudio/best';
format.value = preset.value;
}
});
// 다운로드
download_btn.addEventListener('click', (event) => {
event.preventDefault();
if (!url.value.startsWith('http')) {
notify('URL을 입력하세요.', 'warning');
return;
}
post_ajax('/basic/download', getFormdata('#download'));
});
// Artplayer 미리보기 로직
let art = null;
let last_preview_url = '';
const init_artplayer = (video_url) => {
const wrapper = document.getElementById('player-wrapper');
wrapper.style.display = 'block';
if (art) {
art.switchUrl(video_url);
return;
}
art = new Artplayer({
container: '#player-wrapper',
url: video_url,
autoplay: false,
pip: true,
setting: true,
flip: true,
playbackRate: true,
aspectRatio: true,
fullscreen: true,
fullscreenWeb: true,
miniProgressBar: true,
mutex: true,
backdrop: true,
playsInline: true,
autoPlayback: false,
airplay: true,
theme: '#23ade5',
}); });
}); };
url.addEventListener('change', () => {
const target_url = url.value.trim();
if (target_url && target_url.startsWith('http') && target_url !== last_preview_url) {
last_preview_url = target_url;
post_ajax('/basic/preview', { url: target_url }).then((ret) => {
if (ret.ret === 'success' && ret.data) {
init_artplayer(ret.data);
}
});
}
});
})();

View File

@@ -24,3 +24,34 @@
padding-left: 10px; padding-left: 10px;
padding-top: 3px; padding-top: 3px;
} }
/* Mobile Responsive - 5px padding for maximum screen usage */
@media (max-width: 768px) {
.container, .container-fluid, #main_container {
padding-left: 5px !important;
padding-right: 5px !important;
margin-left: 0 !important;
margin-right: 0 !important;
max-width: 100% !important;
}
.row {
margin-left: 0 !important;
margin-right: 0 !important;
}
[class*="col-"] {
padding-left: 4px !important;
padding-right: 4px !important;
}
.card {
border-radius: 8px !important;
margin-left: 0 !important;
margin-right: 0 !important;
}
.table-responsive {
margin: 0 !important;
}
}

View File

@@ -1,152 +1,175 @@
'use strict'; 'use strict';
const all_stop_btn = document.getElementById('all_stop_btn'); (() => {
const list_tbody = document.getElementById('list_tbody'); const post_ajax = (url, data) => {
const loading = document.getElementById('loading');
// 소켓 if (loading) {
const socket = io.connect(`${location.origin}/${package_name}`); loading.style.display = 'block';
socket.on('add', (data) => {
list_tbody.innerHTML += make_item(data);
});
socket.on('status', (data) => {
status_html(data);
});
// 목록 불러오기
fetch(`/${package_name}/ajax/list`, {
method: 'POST',
cache: 'no-cache',
headers: {
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
},
})
.then((response) => response.json())
.then((data) => {
let str = '';
for (const item of data) {
str += make_item(item);
} }
list_tbody.innerHTML = str; return fetch(`/${package_name}/ajax${url}`, {
method: 'POST',
cache: 'no-cache',
headers: {
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
},
body: new URLSearchParams(data),
})
.then((response) => response.json())
.then((ret) => {
if (ret.msg) {
notify(ret.msg, ret.ret);
}
return ret;
})
.catch(() => {
notify('요청 실패', 'danger');
})
.finally(() => {
if (loading) {
loading.style.display = 'none';
}
});
};
const all_stop_btn = document.getElementById('all_stop_btn');
const list_tbody = document.getElementById('list_tbody');
const get_item = (data) => {
let str = `<td class="text-center font-weight-bold text-muted">${data.index + 1}</td>`;
str += `<td class="text-center"><span class="badge badge-light border" style="font-size:11px;">${data.plugin}</span></td>`;
str += `<td class="text-muted" style="font-size:12px;">${data.start_time}</td>`;
str += `<td class="text-center"><span class="badge badge-info" style="font-size:11px; opacity:0.8;">${data.extractor}</span></td>`;
str += `<td class="font-weight-bold">${data.title}</td>`;
// Status color mapping
let status_class = 'badge-secondary';
if (data.status_str === 'COMPLETED') status_class = 'badge-success';
else if (data.status_str === 'DOWNLOADING') status_class = 'badge-primary';
else if (data.status_str === 'ERROR') status_class = 'badge-danger';
str += `<td class="text-center"><span class="badge ${status_class}" style="padding: 5px 10px;">${data.status_ko}</span></td>`;
let visi = 'hidden';
if (parseInt(data.percent) > 0 && data.status_str !== 'STOP') {
visi = 'visible';
}
str += `<td>
<div class="progress">
<div class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar"
style="visibility: ${visi}; width: ${data.percent}%"
aria-valuenow="${data.percent}" aria-valuemin="0" aria-valuemax="100">
</div>
</div>
<div class="text-right text-muted" style="font-size: 10px; margin-top: 2px; visibility: ${visi}; font-weight: 600;">${data.percent}%</div>
</td>`;
str += `<td class="text-center text-muted">${data.download_time}</td>`;
str += '<td class="tableRowHoverOff text-center">';
if (
data.status_str === 'START' ||
data.status_str === 'DOWNLOADING' ||
data.status_str === 'FINISHED'
) {
str += `<button class="btn btn-outline-danger btn-sm youtubeDl-stop" data-index="${data.index}"><i class="fa fa-stop-circle mr-1"></i>중지</button>`;
}
str += '</td>';
return str;
};
const info_html = (left, right, option) => {
if (!right) return '';
let str = '<div class="row align-items-center py-2 border-bottom mx-0">';
const link = left === 'URL' || left === '업로더';
str += '<div class="col-sm-3 text-muted font-weight-bold" style="font-size: 13px;">';
str += `${left}`;
str += '</div>';
str += '<div class="col-sm-9">';
str += '<div class="info-value">';
if (link) {
str += `<a href="${option}" target="_blank" class="text-primary font-weight-bold">`;
}
str += right;
if (link) {
str += '</a>';
}
str += '</div></div></div>';
return str;
};
const get_detail = (data) => {
let str = '<div class="details-container p-3 rounded shadow-inner" style="background: #1e293b; border: 1px solid #334155;">';
str += info_html('URL', data.url, data.url);
str += info_html('업로더', data.uploader, data.uploader_url);
str += info_html('임시폴더', data.temp_path);
str += info_html('저장폴더', data.save_path);
str += info_html('종료시간', data.end_time);
if (data.status_str === 'DOWNLOADING') {
str += '<div class="mt-3 p-2 rounded border" style="background: #0f172a; border-color: #334155 !important;">';
str += '<div class="font-weight-bold text-info mb-2" style="font-size: 12px;"><i class="fa fa-info-circle mr-1"></i>실시간 다운로드 정보</div>';
str += info_html('파일명', data.filename);
str += info_html(
'현재 진행량',
`<span class="text-light font-weight-bold">${data.percent}%</span> <small class="text-muted">(${data.downloaded_bytes_str} / ${data.total_bytes_str})</small>`
);
str += info_html('남은 시간', `<span class="text-info">${data.eta}초</span>`);
str += info_html('다운 속도', `<span class="text-success font-weight-bold">${data.speed_str}</span>`);
str += '</div>';
}
str += '</div>';
return str;
};
const make_item = (data) => {
let str = `<tr id="item_${data.index}" class="cursor-pointer" aria-expanded="true" data-toggle="collapse" data-target="#collapse_${data.index}">`;
str += get_item(data);
str += '</tr>';
str += `<tr id="collapse_${data.index}" class="collapse tableRowHoverOff" style="background-color: #0f172a;">`;
str += '<td colspan="9" class="p-0 border-0">';
str += `<div id="detail_${data.index}" class="p-4" style="background: #111827;">`;
str += get_detail(data);
str += '</div>';
str += '</td>';
str += '</tr>';
return str;
};
const status_html = (data) => {
document.getElementById(`item_${data.index}`).innerHTML = get_item(data);
document.getElementById(`detail_${data.index}`).innerHTML =
get_detail(data);
};
// 소켓
const socket = io.connect(`${location.origin}/${package_name}`);
socket.on('add', (data) => {
list_tbody.innerHTML += make_item(data);
});
socket.on('status', (data) => {
status_html(data);
}); });
// 전체 중지 const reload_list = async () => {
all_stop_btn.addEventListener('click', (event) => { const { data } = await post_ajax('/basic/list');
event.preventDefault(); list_tbody.innerHTML = data.map((item) => make_item(item)).join('');
fetch(`/${package_name}/ajax/all_stop`, { };
method: 'POST',
cache: 'no-cache',
headers: {
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
},
})
.then((response) => response.json())
.then(() => {
location.reload();
});
});
// 중지 // 전체 중지
list_tbody.addEventListener('click', (event) => { all_stop_btn.addEventListener('click', (event) => {
event.preventDefault(); event.preventDefault();
const target = event.target; post_ajax('/basic/all_stop').then(reload_list);
if (!target.classList.contains('youtubeDl-stop')) { });
return;
} // 중지
fetch(`/${package_name}/ajax/stop`, { list_tbody.addEventListener('click', (event) => {
method: 'POST', event.preventDefault();
cache: 'no-cache', const target = event.target;
headers: { if (!target.classList.contains('youtubeDl-stop')) {
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', return;
}, }
body: new URLSearchParams({ post_ajax('/basic/stop', {
index: target.dataset.index, index: target.dataset.index,
}), }).then(reload_list);
}) });
.then((response) => response.json())
.then(() => {
location.reload();
});
});
function make_item(data) { // 목록 불러오기
let str = `<tr id="item_${data.index}" class="cursor-pointer" aria-expanded="true" data-toggle="collapse" data-target="#collapse_${data.index}">`; reload_list();
str += get_item(data); })();
str += '</tr>';
str += `<tr id="collapse_${data.index}" class="collapse tableRowHoverOff">`;
str += '<td colspan="9">';
str += `<div id="detail_${data.index}">`;
str += get_detail(data);
str += '</div>';
str += '</td>';
str += '</tr>';
return str;
}
function get_item(data) {
let str = `<td>${data.index + 1}</td>`;
str += `<td>${data.plugin}</td>`;
str += `<td>${data.start_time}</td>`;
str += `<td>${data.extractor}</td>`;
str += `<td>${data.title}</td>`;
str += `<td>${data.status_ko}</td>`;
let visi = 'hidden';
if (parseInt(data.percent) > 0 && data.status_str !== 'STOP') {
visi = 'visible';
}
str += `<td><div class="progress"><div class="progress-bar" style="visibility: ${visi}; width: ${data.percent}%">${data.percent}%</div></div></td>`;
str += `<td>${data.download_time}</td>`;
str += '<td class="tableRowHoverOff">';
if (
data.status_str === 'START' ||
data.status_str === 'DOWNLOADING' ||
data.status_str === 'FINISHED'
) {
str += `<button class="align-middle btn btn-outline-danger btn-sm youtubeDl-stop" data-index="${data.index}">중지</button>`;
}
str += '</td>';
return str;
}
function get_detail(data) {
let str = info_html('URL', data.url, data.url);
str += info_html('업로더', data.uploader, data.uploader_url);
str += info_html('임시폴더', data.temp_path);
str += info_html('저장폴더', data.save_path);
str += info_html('종료시간', data.end_time);
if (data.status_str === 'DOWNLOADING') {
str += info_html('', '<b>현재 다운로드 중인 파일에 대한 정보</b>');
str += info_html('파일명', data.filename);
str += info_html(
'진행률(current/total)',
`${data.percent}% (${data.downloaded_bytes_str} / ${data.total_bytes_str})`
);
str += info_html('남은 시간', `${data.eta}`);
str += info_html('다운 속도', data.speed_str);
}
return str;
}
function info_html(left, right, option) {
let str = '<div class="row">';
const link = left === 'URL' || left === '업로더';
str += '<div class="col-sm-2">';
str += `<b>${left}</b>`;
str += '</div>';
str += '<div class="col-sm-10">';
str += '<div class="input-group col-sm-9">';
str += '<span class="text-left info-padding">';
if (link) {
str += `<a href="${option}" target="_blank">`;
}
str += right;
if (link) {
str += '</a>';
}
str += '</span></div></div></div>';
return str;
}
function status_html(data) {
document.getElementById(`item_${data.index}`).innerHTML = get_item(data);
document.getElementById(`detail_${data.index}`).innerHTML = get_detail(data);
}

View File

@@ -0,0 +1,191 @@
/* Modern & Professional Design for youtube-dl */
:root {
--primary-color: #38bdf8; /* Softer Light Blue */
--primary-hover: #7dd3fc;
--bg-body: #0f172a; /* Deep Soothing Navy */
--bg-surface: #1e293b; /* Surface color */
--text-main: #e2e8f0; /* Soft Off-White */
--text-muted: #94a3b8; /* Muted Blue-Gray */
--border-color: #334155; /* Subtle Border */
--radius-md: 8px;
--shadow-sm: 0 1px 3px 0 rgb(0 0 0 / 0.4);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.5);
}
/* Base adjustment for global layout */
body {
background-color: var(--bg-body) !important;
font-size: 14px;
color: var(--text-main);
}
#main_container {
padding-top: 20px;
padding-bottom: 40px;
}
/* Compact Margins - Desktop */
.row {
margin-right: -10px !important;
margin-left: -10px !important;
}
.col, [class*="col-"] {
padding-right: 10px !important;
padding-left: 10px !important;
}
/* Professional Card Style */
.card, .form-container {
background: var(--bg-surface);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
margin-bottom: 1.5rem;
overflow: hidden;
}
/* Modern Inputs & Macros adjustment */
.form-control-sm {
height: 34px !important;
background-color: #0f172a !important;
color: #f1f5f9 !important;
border-radius: 6px !important;
border: 1px solid var(--border-color) !important;
padding: 0.5rem 0.75rem !important;
transition: all 0.2s;
}
.form-control-sm:focus {
border-color: var(--primary-color) !important;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1) !important;
}
.col-sm-3.col-form-label {
font-weight: 600;
color: var(--text-main);
font-size: 0.875rem;
padding-top: 8px !important;
}
.small.text-muted {
font-size: 0.75rem !important;
margin-top: 4px;
}
/* Professional Buttons */
.btn-sm {
border-radius: 6px !important;
font-weight: 500 !important;
padding: 6px 16px !important;
transition: all 0.2s !important;
}
.btn-primary {
background-color: var(--primary-color) !important;
border-color: var(--primary-color) !important;
color: #0f172a !important; /* Dark text on light button */
}
.btn-primary:hover {
background-color: var(--primary-hover) !important;
border-color: var(--primary-hover) !important;
color: #000000 !important;
}
/* Modern Progress Bar */
.progress {
height: 10px !important;
background-color: #e2e8f0 !important;
border-radius: 9999px !important;
overflow: hidden;
margin-top: 4px;
}
.progress-bar {
background: linear-gradient(90deg, #3b82f6, #2563eb) !important;
border-radius: 9999px !important;
font-size: 0 !important; /* Hide text inside thin bar */
transition: width 0.4s ease-in-out !important;
}
/* Table Enhancements */
.table {
color: var(--text-main) !important;
}
.table-sm td, .table-sm th {
padding: 0.75rem 0.5rem !important;
vertical-align: middle !important;
border-top: 1px solid #334155 !important;
}
.table thead th {
background: #1e293b;
border-bottom: 2px solid var(--border-color);
color: var(--text-muted);
font-weight: 600;
text-transform: uppercase;
font-size: 0.75rem;
letter-spacing: 0.025em;
}
.tableRowHover tr:hover {
background-color: rgba(56, 189, 248, 0.05) !important;
}
/* Detail Info row in list */
.info-padding {
padding: 8px 12px !important;
background: #0f172a;
border-radius: 6px;
display: inline-block;
color: #38bdf8;
}
.info-value {
color: #cbd5e1;
}
.details-container {
background: #1e293b !important;
border: 1px solid #334155 !important;
}
/* Mobile Responsive - 5px padding */
@media (max-width: 768px) {
.container, .container-fluid, #main_container {
padding-left: 5px !important;
padding-right: 5px !important;
}
.row {
margin-left: -5px !important;
margin-right: -5px !important;
}
[class*="col-"] {
padding-left: 5px !important;
padding-right: 5px !important;
}
/* Stack labels and inputs on mobile */
.col-sm-3.col-form-label {
text-align: left !important;
width: 100%;
max-width: 100%;
flex: 0 0 100%;
padding-bottom: 4px !important;
}
.col-sm-9 {
width: 100%;
max-width: 100%;
flex: 0 0 100%;
}
form > .row {
margin-bottom: 12px !important;
}
}

297
static/youtube-dl_search.js Normal file
View File

@@ -0,0 +1,297 @@
'use strict';
(() => {
// ====================
// DOM 요소 참조
// ====================
const searchKeyword = document.getElementById('search_keyword');
const searchBtn = document.getElementById('search_btn');
const searchResults = document.getElementById('search_results');
const sentinel = document.getElementById('sentinel');
const sentinelLoading = document.getElementById('sentinel_loading');
const playerWrapper = document.getElementById('player-wrapper');
const initialMessage = document.getElementById('initial_message');
// ====================
// 상태 변수
// ====================
let currentPage = 1;
let isLoading = false;
let hasMore = true;
let art = null;
let lastPreviewUrl = '';
// ====================
// AJAX 헬퍼
// ====================
const postAjax = (url, data) => {
return fetch(`/${package_name}/ajax${url}`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
body: new URLSearchParams(data),
})
.then(res => res.json())
.then(ret => {
if (ret.msg) notify(ret.msg, ret.ret);
return ret;
})
.catch(err => {
console.error('[YouTube-DL] AJAX Error:', err);
notify('요청 실패', 'danger');
return { ret: 'error' };
});
};
// ====================
// 유틸리티 함수
// ====================
const formatDuration = (seconds) => {
if (!seconds) return '--:--';
const hrs = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
if (hrs > 0) return `${hrs}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
const formatUploadDate = (item) => {
const dateVal = item.upload_date || item.publication_date || item.date;
if (!dateVal) return '';
const dateStr = String(dateVal);
if (dateStr.length === 8 && /^\d+$/.test(dateStr)) {
return `${dateStr.slice(0, 4)}.${dateStr.slice(4, 6)}.${dateStr.slice(6, 8)}`;
}
return dateStr;
};
const getBestThumbnail = (item) => {
if (item.thumbnail && typeof item.thumbnail === 'string') return item.thumbnail;
if (item.thumbnails && item.thumbnails.length > 0) {
const sorted = [...item.thumbnails].sort((a, b) => (b.width || 0) - (a.width || 0));
return sorted[0].url;
}
return '/static/img/no_image.png';
};
// ====================
// 플레이어 초기화
// ====================
const initArtplayer = (videoUrl) => {
playerWrapper.style.display = 'block';
if (art) { art.switchUrl(videoUrl); return; }
art = new Artplayer({
container: '#player-wrapper',
url: videoUrl,
autoplay: true, muted: false, volume: 1.0,
autoSize: false, // 컨테이너 크기에 맞춤
aspectRatio: true, // 16:9 비율 유지
pip: true, setting: true,
playbackRate: true, fullscreen: true, fullscreenWeb: true,
theme: '#38bdf8',
autoMini: true, // 스크롤 시 미니 플레이어로 자동 전환
customType: {
m3u8: (video, url) => {
if (Hls.isSupported()) {
const hls = new Hls();
hls.loadSource(url);
hls.attachMedia(video);
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = url;
}
}
}
});
};
// ====================
// 결과 카드 생성
// ====================
const makeResultCard = (item) => {
const videoId = item.id || item.url;
const url = (item.url && item.url.startsWith('http'))
? item.url
: `https://www.youtube.com/watch?v=${videoId}`;
const thumbnail = getBestThumbnail(item);
const duration = formatDuration(item.duration);
const uploader = item.uploader || item.channel || '';
const uploadDate = formatUploadDate(item);
return `
<div class="search-result-card">
<div class="thumbnail-wrapper preview-trigger" data-url="${url}">
<img src="${thumbnail}" alt="${item.title}" loading="lazy" onerror="this.src='/static/img/no_image.png'">
<div class="play-overlay"><i class="fa fa-play-circle"></i></div>
<span class="duration-badge">${duration}</span>
</div>
<div class="card-body-content">
<h5 class="video-title" title="${item.title}">${item.title}</h5>
<div class="meta-info">
${uploader ? `<div class="uploader-info"><i class="fa fa-user-circle mr-1"></i>${uploader}</div>` : ''}
${uploadDate ? `<div class="upload-date"><i class="fa fa-calendar-alt mr-1"></i>${uploadDate}</div>` : ''}
</div>
<div class="card-actions">
<button class="btn btn-primary btn-sm flex-grow-1 download-video-btn" data-url="${url}">
<i class="fa fa-download mr-1"></i>다운로드
</button>
<a href="${url}" target="_blank" class="btn btn-outline-info btn-sm"><i class="fa fa-external-link"></i></a>
</div>
</div>
</div>
`;
};
// ====================
// 검색 결과 렌더링
// ====================
const renderResults = (data) => {
const fragment = document.createDocumentFragment();
data.forEach(item => {
if (!item) return; // null 아이템 스킵
const col = document.createElement('div');
col.className = 'col-12 col-sm-6 col-lg-4 col-xl-3 mb-4';
col.innerHTML = makeResultCard(item);
// 이벤트 바인딩
col.querySelector('.download-video-btn')?.addEventListener('click', (e) => {
e.preventDefault();
triggerDownload(e.currentTarget);
});
col.querySelector('.preview-trigger')?.addEventListener('click', (e) => {
e.preventDefault();
triggerPreview(e.currentTarget);
});
fragment.appendChild(col);
});
searchResults.appendChild(fragment);
};
// ====================
// 검색 수행
// ====================
const performSearch = (isNew = true) => {
const keyword = searchKeyword.value.trim();
if (!keyword) {
if (isNew) notify('검색어를 입력하세요.', 'warning');
return;
}
// 이미 로딩 중이면 무시
if (isLoading) return;
// 새 검색 시 상태 초기화
if (isNew) {
currentPage = 1;
hasMore = true;
searchResults.innerHTML = `
<div class="col-12 text-center py-5">
<div class="spinner-border text-primary mb-3" role="status"></div>
<p class="text-muted">유튜브 검색 중...</p>
</div>
`;
playerWrapper.style.display = 'none';
if (art) { art.destroy(); art = null; }
} else {
if (!hasMore) return;
if (sentinelLoading) sentinelLoading.style.display = 'block';
}
isLoading = true;
console.log(`[YouTube-DL] Search: "${keyword}" (Page ${currentPage})`);
postAjax('/basic/search', { keyword, page: currentPage })
.then(ret => {
// 새 검색이면 기존 로딩 스피너 제거
if (isNew) searchResults.innerHTML = '';
// 초기 안내 메시지 숨기기
if (initialMessage) initialMessage.style.display = 'none';
if (ret.ret === 'success' && ret.data && ret.data.length > 0) {
renderResults(ret.data);
// 다음 페이지 체크
if (ret.data.length < 20) {
hasMore = false;
} else {
currentPage++;
}
} else {
if (isNew) {
searchResults.innerHTML = `
<div class="col-12 text-center py-5 text-muted">
<i class="fa fa-exclamation-triangle fa-3x mb-3" style="opacity: 0.3;"></i>
<p>검색 결과가 없습니다.</p>
</div>
`;
}
hasMore = false;
}
})
.finally(() => {
isLoading = false;
if (sentinelLoading) sentinelLoading.style.display = 'none';
});
};
// ====================
// 미리보기 & 다운로드
// ====================
const triggerPreview = (el) => {
const targetUrl = el.dataset.url;
if (!targetUrl) return;
if (targetUrl === lastPreviewUrl && art) {
window.scrollTo({ top: 0, behavior: 'smooth' });
art.play();
return;
}
lastPreviewUrl = targetUrl;
postAjax('/basic/preview', { url: targetUrl }).then(ret => {
if (ret.ret === 'success' && ret.data) {
initArtplayer(ret.data);
window.scrollTo({ top: 0, behavior: 'smooth' });
}
});
};
const triggerDownload = (btn) => {
postAjax('/basic/download', {
url: btn.dataset.url,
filename: '%(title)s-%(id)s.%(ext)s',
format: 'bestvideo+bestaudio/best',
postprocessor: ''
}).then(res => {
if (res.ret === 'success' || res.ret === 'info') {
btn.disabled = true;
btn.innerHTML = '<i class="fa fa-check mr-1"></i>추가됨';
btn.classList.replace('btn-primary', 'btn-success');
}
});
};
// ====================
// 인피니티 스크롤 (단순화)
// ====================
// 1초마다 sentinel 위치 체크 (가장 확실한 방법)
setInterval(() => {
if (isLoading || !hasMore) return;
if (!sentinel) return;
const rect = sentinel.getBoundingClientRect();
// sentinel이 화면 아래 800px 이내에 들어오면 다음 페이지 로드
if (rect.top < window.innerHeight + 800) {
console.log('[YouTube-DL] Loading next page...');
performSearch(false);
}
}, 1000);
// ====================
// 이벤트 바인딩
// ====================
searchBtn.addEventListener('click', (e) => { e.preventDefault(); performSearch(true); });
searchKeyword.addEventListener('keypress', (e) => { if (e.key === 'Enter') performSearch(true); });
})();

View File

@@ -1,74 +1,94 @@
'use strict'; 'use strict';
const ffmpeg_path = document.getElementById('ffmpeg_path'); (() => {
const ffmpeg_version_btn = document.getElementById('ffmpeg_version_btn'); const post_ajax = (url, data) => {
const ffmpeg_path_btn = document.getElementById('ffmpeg_path_btn'); const loading = document.getElementById('loading');
const temp_path = document.getElementById('temp_path'); if (loading) {
const temp_path_btn = document.getElementById('temp_path_btn'); loading.style.display = 'block';
const save_path = document.getElementById('save_path'); }
const save_path_btn = document.getElementById('save_path_btn'); return fetch(`/${package_name}/ajax${url}`, {
const modal_title = document.getElementById('modal_title'); method: 'POST',
const modal_body = document.getElementById('modal_body'); cache: 'no-cache',
headers: {
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
},
body: new URLSearchParams(data),
})
.then((response) => response.json())
.then((ret) => {
if (ret.msg) {
notify(ret.msg, ret.ret);
}
return ret;
})
.catch(() => {
notify('요청 실패', 'danger');
})
.finally(() => {
if (loading) {
loading.style.display = 'none';
}
});
};
// FFmpeg 버전확인 const ffmpeg_path = document.getElementById('ffmpeg_path');
ffmpeg_version_btn.addEventListener('click', (event) => { const ffmpeg_version_btn = document.getElementById('ffmpeg_version_btn');
event.preventDefault(); const ffmpeg_path_btn = document.getElementById('ffmpeg_path_btn');
let ffmpeg = ffmpeg_path.value; const temp_path = document.getElementById('temp_path');
if (ffmpeg.length === 0) { const temp_path_btn = document.getElementById('temp_path_btn');
ffmpeg = 'ffmpeg'; const save_path = document.getElementById('save_path');
} const save_path_btn = document.getElementById('save_path_btn');
const modal_title = document.getElementById('modal_title');
const modal_body = document.getElementById('modal_body');
fetch(`/${package_name}/ajax/ffmpeg_version`, { // FFmpeg 버전확인
method: 'POST', ffmpeg_version_btn.addEventListener('click', (event) => {
cache: 'no-cache', event.preventDefault();
headers: { let ffmpeg = ffmpeg_path.value;
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', if (ffmpeg.length === 0) {
}, ffmpeg = 'ffmpeg';
body: new URLSearchParams({ }
post_ajax('/basic/ffmpeg_version', {
path: ffmpeg, path: ffmpeg,
}), }).then(({ data }) => {
})
.then((response) => response.json())
.then((data) => {
modal_title.innerHTML = `${ffmpeg} -version`; modal_title.innerHTML = `${ffmpeg} -version`;
modal_body.innerHTML = data; modal_body.innerHTML = data;
$('#large_modal').modal(); $('#large_modal').modal();
})
.catch(() => {
notify('버전확인 실패', 'danger');
}); });
});
// FFmpeg 파일 선택
ffmpeg_path_btn.addEventListener('click', (event) => {
event.preventDefault();
m_select_local_file_modal('실행 파일 선택', '/', false, (result) => {
ffmpeg_path.value = result;
}); });
});
// 임시 폴더 경로 선택 // FFmpeg 파일 선택
temp_path_btn.addEventListener('click', (event) => { ffmpeg_path_btn.addEventListener('click', (event) => {
event.preventDefault(); event.preventDefault();
m_select_local_file_modal( m_select_local_file_modal('실행 파일 선택', '/', false, (result) => {
'저장 경로 선택', ffmpeg_path.value = result;
temp_path.value, });
true, });
(result) => {
temp_path.value = result;
}
);
});
// 저장 폴더 경로 선택 // 임시 폴더 경로 선택
save_path_btn.addEventListener('click', (event) => { temp_path_btn.addEventListener('click', (event) => {
event.preventDefault(); event.preventDefault();
m_select_local_file_modal( m_select_local_file_modal(
'저장 경로 선택', '저장 경로 선택',
save_path.value, temp_path.value,
true, true,
(result) => { (result) => {
save_path.value = result; temp_path.value = result;
} }
); );
}); });
// 저장 폴더 경로 선택
save_path_btn.addEventListener('click', (event) => {
event.preventDefault();
m_select_local_file_modal(
'저장 경로 선택',
save_path.value,
true,
(result) => {
save_path.value = result;
}
);
});
})();

View File

@@ -1,34 +1,52 @@
'use strict'; 'use strict';
const url = document.getElementById('url'); (() => {
const download_btn = document.getElementById('download_btn'); const post_ajax = (url, data) => {
const loading = document.getElementById('loading');
// 모든 자막 다운로드 if (loading) {
$('#all_subs').change(() => { loading.style.display = 'block';
use_collapse('all_subs', true); }
}); return fetch(`/${package_name}/ajax${url}`, {
method: 'POST',
// 다운로드 cache: 'no-cache',
download_btn.addEventListener('click', (event) => { headers: {
event.preventDefault(); 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
if (!url.value.startsWith('http')) { },
notify('URL을 입력하세요.', 'warning'); body: new URLSearchParams(data),
return;
}
fetch(`/${package_name}/ajax/sub`, {
method: 'POST',
cache: 'no-cache',
headers: {
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
},
body: get_formdata('#download'),
})
.then((response) => response.json())
.then(() => {
notify('분석중..', 'info');
}) })
.catch(() => { .then((response) => response.json())
notify('다운로드 요청 실패', 'danger'); .then((ret) => {
}); if (ret.msg) {
}); notify(ret.msg, ret.ret);
}
return ret;
})
.catch(() => {
notify('요청 실패', 'danger');
})
.finally(() => {
if (loading) {
loading.style.display = 'none';
}
});
};
const url = document.getElementById('url');
const download_btn = document.getElementById('download_btn');
// 모든 자막 다운로드
$('#all_subs').change(() => {
use_collapse('all_subs', true);
});
// 다운로드
download_btn.addEventListener('click', (event) => {
event.preventDefault();
if (!url.value.startsWith('http')) {
notify('URL을 입력하세요.', 'warning');
return;
}
post_ajax('/basic/sub', get_formdata('#download'));
});
})();

View File

@@ -1,29 +1,47 @@
'use strict'; 'use strict';
const url = document.getElementById('url'); (() => {
const download_btn = document.getElementById('download_btn'); const post_ajax = (url, data) => {
const loading = document.getElementById('loading');
// 다운로드 if (loading) {
download_btn.addEventListener('click', (event) => { loading.style.display = 'block';
event.preventDefault(); }
if (!url.value.startsWith('http')) { return fetch(`/${package_name}/ajax${url}`, {
notify('URL을 입력하세요.', 'warning'); method: 'POST',
return; cache: 'no-cache',
} headers: {
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
fetch(`/${package_name}/ajax/thumbnail`, { },
method: 'POST', body: new URLSearchParams(data),
cache: 'no-cache',
headers: {
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
},
body: get_formdata('#download'),
})
.then((response) => response.json())
.then(() => {
notify('분석중..', 'info');
}) })
.catch(() => { .then((response) => response.json())
notify('다운로드 요청 실패', 'danger'); .then((ret) => {
}); if (ret.msg) {
}); notify(ret.msg, ret.ret);
}
return ret;
})
.catch(() => {
notify('요청 실패', 'danger');
})
.finally(() => {
if (loading) {
loading.style.display = 'none';
}
});
};
const url = document.getElementById('url');
const download_btn = document.getElementById('download_btn');
// 다운로드
download_btn.addEventListener('click', (event) => {
event.preventDefault();
if (!url.value.startsWith('http')) {
notify('URL을 입력하세요.', 'warning');
return;
}
post_ajax('/basic/thumbnail', get_formdata('#download'));
});
})();

View File

@@ -0,0 +1,66 @@
{% extends "base.html" %}
{% macro my_setting_select(id, title, options, col='9', desc=None, value=None) %}
{{ macros.setting_top(title) }}
<div class="input-group col-sm-{{ col }}">
<select id="{{ id }}" name="{{ id }}" class="form-control form-control-sm">
{% set ns = namespace(optgroup=none) %}
{% for item in options %}
{% if ns.optgroup != item[2] %}
{% if ns.optgroup is not none %}
</optgroup>
{% endif %}
{% if item[2] is not none %}
<optgroup label="{{ item[2] }}">
{% endif %}
{% set ns.optgroup = item[2] %}
{% endif %}
{% if value is not none and value == item[0] %}
<option value="{{ item[0] }}" selected>{{ item[1] }}</option>
{% else %}
<option value="{{ item[0] }}">{{ item[1] }}</option>
{% endif %}
{% endfor %}
{% if ns.optgroup is not none %}
</optgroup>
{% endif %}
</select>
</div>
{{ macros.setting_bottom(desc) }}
{% endmacro %}
{% block content %}
<link rel="stylesheet" href="{{ url_for('.static', filename='youtube-dl_modern.css') }}?ver={{ arg['package_version'] }}">
<style>
#player-wrapper {
width: 100%;
max-width: 800px;
height: 450px;
margin: 0 auto 20px auto;
display: none; /* 초기에는 숨김 */
background: #000;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 15px rgba(0,0,0,0.3);
}
</style>
<div id="player-wrapper"></div>
<form id="download">
{{ macros.setting_input_text('url', 'URL', placeholder='http:// 주소', desc='유튜브, 네이버TV 등 동영상 주소') }}
{{ macros.setting_input_text('filename', '파일명', value=arg['filename'], desc='템플릿 규칙은 https://github.com/ytdl-org/youtube-dl/#output-template 참고') }}
{{ macros.setting_select('preset', '동영상 포맷 프리셋', arg['preset_list'], col='3') }}
{{ macros.setting_input_text('format', '동영상 포맷', desc=['포맷 지정은 https://github.com/ytdl-org/youtube-dl/#format-selection 참고', '빈칸으로 두면 최고 화질로 다운로드합니다.']) }}
{{ my_setting_select('postprocessor', '후처리', arg['postprocessor_list'], col='3', desc='다운로드 후 FFmpeg로 후처리합니다.') }}
{{ macros.setting_buttons([['download_btn', '다운로드']]) }}
</form>
<script>
"use strict";
const package_name = '{{ arg["package_name"] }}';
</script>
<script src="https://cdn.jsdelivr.net/npm/artplayer/dist/artplayer.js"></script>
<script src="{{ url_for('.static', filename='%s.js' % arg['template_name']) }}?ver={{ arg['package_version'] }}"></script>
{% endblock %}

View File

@@ -0,0 +1,35 @@
{% extends "base.html" %} {% block content %}
<link rel="stylesheet" href="{{ url_for('.static', filename='youtube-dl_modern.css') }}?ver={{ arg['package_version'] }}">
{{ macros.m_button_group([['globalSettingSaveBtn', '설정 저장'], ['globalOneExecuteBtn', '1회 실행'], ['globalImmediatelyExecuteBtn', '즉시 실행']])}}
{{ macros.m_row_start('5') }}
{{ macros.m_row_end() }}
<form id="setting">
{{ macros.setting_radio_with_value('youtube_dl_package', 'youtube-dl', arg['package_list'],
value=arg['youtube_dl_package'], desc='사용할 youtube-dl 패키지를 선택합니다. 설정 저장 후
재시작이 필요합니다.') }}
{{ macros.setting_input_text('youtube_dl_version', 'youtube-dl 버전',
value=arg['youtube_dl_version'], disabled=True) }}
{{
macros.setting_input_text_and_buttons('ffmpeg_path', 'FFmpeg 경로', [['ffmpeg_version_btn',
'버전확인'], ['ffmpeg_path_btn', '파일 선택']], value=arg['ffmpeg_path'], placeholder='ffmpeg',
desc='SJVA에 내장된 버전 말고 원하는 버전을 사용할 수 있습니다.') }} {{
macros.setting_input_text_and_buttons('temp_path', '임시 폴더', [['temp_path_btn', '경로 선택']],
value=arg['temp_path'], desc='다운로드 파일이 임시로 저장될 폴더입니다.') }} {{
macros.setting_input_text_and_buttons('save_path', '저장 폴더', [['save_path_btn', '경로 선택']],
value=arg['save_path'], desc='정상적으로 완료된 파일이 이동할 폴더입니다.') }} {{
macros.setting_input_text('default_filename', '기본 파일명', value=arg['default_filename'],
placeholder=arg['DEFAULT_FILENAME'], desc='템플릿 규칙은
https://github.com/ytdl-org/youtube-dl/#output-template 참고') }} {{
macros.setting_input_text('proxy', '프록시', value=arg['proxy'], desc=['HTTP/HTTPS/SOCKS를
지원합니다. 예) socks5://127.0.0.1:1080/', '빈칸으로 두면 프록시를 사용하지 않습니다.']) }}
{# {{ macros.setting_button([['global_setting_save_btn', '저장']]) }}#}
</form>
<script>
"use strict"
const package_name = '{{ arg["package_name"] }}'
</script>
<script src="{{ url_for('.static', filename='%s.js' % arg['template_name']) }}?ver={{ arg['package_version'] }}"></script>
{% endblock %}

View File

@@ -31,19 +31,19 @@
{% block content %} {% block content %}
<form id="download"> <form id="download">
{{ macros.setting_input_text('url', 'URL', placeholder='http:// 주소', desc='유튜브, 네이버TV 등 동영상 주소') }} {{ macros.setting_input_text('url', 'URL', placeholder='http:// 주소', desc='유튜브, 네이버TV 등 동영상 주소') }}
{{ macros.setting_input_text('filename', '파일명', value=arg['filename'], desc='템플릿 규칙은 https://github.com/ytdl-org/youtube-dl/#output-template 참고') }} {{ macros.setting_input_text('filename', '파일명', value=arg['filename'], desc='템플릿 규칙은 https://github.com/ytdl-org/youtube-dl/#output-template 참고') }}
{{ macros.setting_select('preset', '동영상 포맷 프리셋', arg['preset_list'], col='3') }} {{ macros.setting_select('preset', '동영상 포맷 프리셋', arg['preset_list'], col='3') }}
{{ macros.setting_input_text('format', '동영상 포맷', desc=['포맷 지정은 https://github.com/ytdl-org/youtube-dl/#format-selection 참고', '빈칸으로 두면 최고 화질로 다운로드합니다.']) }} {{ macros.setting_input_text('format', '동영상 포맷', desc=['포맷 지정은 https://github.com/ytdl-org/youtube-dl/#format-selection 참고', '빈칸으로 두면 최고 화질로 다운로드합니다.']) }}
{{ my_setting_select('postprocessor', '후처리', arg['postprocessor_list'], col='3', desc='다운로드 후 FFmpeg로 후처리합니다.') }} {{ my_setting_select('postprocessor', '후처리', arg['postprocessor_list'], col='3', desc='다운로드 후 FFmpeg로 후처리합니다.') }}
{{ macros.setting_button([['download_btn', '다운로드']]) }} {{ macros.setting_button([['download_btn', '다운로드']]) }}
</form> </form>
<script> <script>
"use strict"; "use strict";
const package_name = '{{ arg["package_name"] }}'; const package_name = '{{ arg["package_name"] }}';
</script> </script>
<script src="{{ url_for('.static', filename='%s.js' % arg['template_name']) }}"></script> <script src="{{ url_for('.static', filename='%s.js' % arg['template_name']) }}?ver={{ arg['package_version'] }}"></script>
{% endblock %} {% endblock %}

View File

@@ -1,8 +1,9 @@
{% extends "base.html" %} {% block content %} {% extends "base.html" %} {% block content %}
<link rel="stylesheet" href="{{ url_for('.static', filename='youtube-dl_modern.css') }}?ver={{ arg['package_version'] }}">
<link <link
rel="stylesheet" rel="stylesheet"
href="{{ url_for('.static', filename='%s.css' % arg['template_name']) }}" href="{{ url_for('.static', filename='%s.css' % arg['template_name']) }}?ver={{ arg['package_version'] }}"
/> />
{{ macros.m_row_start() }} {{ macros.m_button('all_stop_btn', '전체 중지') }} {{ {{ macros.m_row_start() }} {{ macros.m_button('all_stop_btn', '전체 중지') }} {{
@@ -30,6 +31,6 @@ macros.m_row_end() }}
'use strict'; 'use strict';
const package_name = '{{ arg["package_name"] }}'; const package_name = '{{ arg["package_name"] }}';
</script> </script>
<script src="{{ url_for('.static', filename='%s.js' % arg['template_name']) }}"></script> <script src="{{ url_for('.static', filename='%s.js' % arg['template_name']) }}?ver={{ arg['package_version'] }}"></script>
{% endblock %} {% endblock %}

View File

@@ -0,0 +1,216 @@
{% extends "base.html" %} {% block content %}
<link rel="stylesheet" href="{{ url_for('.static', filename='youtube-dl_modern.css') }}?ver={{ arg['package_version'] }}">
<style>
.search-result-card {
background: var(--bg-surface);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
overflow: hidden;
transition: transform 0.2s, box-shadow 0.2s;
margin-bottom: 20px;
height: 100%;
display: flex;
flex-direction: column;
}
.search-result-card:hover {
transform: translateY(-4px);
box-shadow: var(--shadow-md);
border-color: var(--primary-color);
}
.thumbnail-wrapper {
position: relative;
padding-top: 56.25%; /* 16:9 */
background: #000;
overflow: hidden;
}
.thumbnail-wrapper img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.duration-badge {
position: absolute;
bottom: 8px;
right: 8px;
background: rgba(0,0,0,0.8);
color: #fff;
padding: 2px 6px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
}
.card-body-content {
padding: 12px;
flex-grow: 1;
display: flex;
flex-direction: column;
}
.video-title {
font-size: 14px;
font-weight: 600;
color: var(--text-main);
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
margin-bottom: 8px;
line-height: 1.4;
height: 2.8em;
}
.meta-info {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
font-size: 11px;
color: var(--text-muted);
}
.uploader-info {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-right: 8px;
}
.upload-date {
flex-shrink: 0;
background: rgba(255,255,255,0.05);
padding: 2px 6px;
border-radius: 4px;
}
.card-actions {
margin-top: auto;
display: flex;
gap: 8px;
}
.search-container {
max-width: 800px;
margin: 0 auto 30px auto;
}
.search-input-group {
display: flex;
background: var(--bg-surface);
border: 1px solid var(--border-color);
border-radius: 9999px;
padding: 4px 4px 4px 20px;
box-shadow: var(--shadow-sm);
transition: border-color 0.2s, box-shadow 0.2s;
}
.search-input-group:focus-within {
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(56, 189, 248, 0.15);
}
.search-input {
flex-grow: 1;
background: transparent !important;
border: none !important;
color: var(--text-main) !important;
outline: none !important;
font-size: 15px;
height: 42px;
}
.search-btn {
background: var(--primary-color);
color: #0f172a;
border: none;
border-radius: 9999px;
padding: 0 24px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.search-btn:hover {
background: var(--primary-hover);
}
/* Play Icon Overlay */
.thumbnail-wrapper {
cursor: pointer;
}
.play-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.3);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s;
}
.thumbnail-wrapper:hover .play-overlay {
opacity: 1;
}
.play-overlay i {
color: #fff;
font-size: 3rem;
text-shadow: 0 0 20px rgba(0,0,0,0.5);
}
#player-wrapper {
width: 100%;
max-width: 800px;
aspect-ratio: 16 / 9;
margin: 0 auto 20px auto;
display: none;
background: #000;
border-radius: 8px;
overflow: hidden;
box-shadow: var(--shadow-md);
}
#player-wrapper .art-video-player {
width: 100% !important;
height: 100% !important;
}
#sentinel {
min-height: 100px;
}
/* 미니 플레이어 오른쪽 하단 위치 */
.art-mini-state {
right: 20px !important;
left: auto !important;
bottom: 20px !important;
transform: none !important;
}
</style>
<div class="search-container mt-4">
<div id="player-wrapper"></div>
<div class="search-input-group">
<input type="text" id="search_keyword" class="search-input" placeholder="유튜브 검색어 입력..." aria-label="Search">
<button id="search_btn" class="search-btn">검색</button>
</div>
</div>
<div id="search_results" class="row">
<div class="col-12 text-center py-5 text-muted" id="initial_message">
<i class="fa fa-search fa-3x mb-3" style="opacity: 0.3;"></i>
<p>검색어를 입력하고 검색 버튼을 눌러주세요.</p>
</div>
</div>
<div id="sentinel" class="py-4 text-center">
<!-- Spinner is shown/hidden via JS -->
<div id="sentinel_loading" style="display: none;">
<div class="spinner-border text-info" role="status"></div>
<p class="text-muted mt-2" style="font-size: 12px;">더 많은 결과 불러오는 중...</p>
</div>
</div>
<script>
'use strict';
const package_name = '{{ arg["package_name"] }}';
</script>
<script src="https://cdn.jsdelivr.net/npm/hls.js/dist/hls.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/artplayer/dist/artplayer.js"></script>
<script src="{{ url_for('.static', filename='%s.js' % arg['template_name']) }}?ver={{ arg['package_version'] }}"></script>
{% endblock %}

View File

@@ -29,6 +29,6 @@
'use strict'; 'use strict';
const package_name = '{{ arg["package_name"] }}'; const package_name = '{{ arg["package_name"] }}';
</script> </script>
<script src="{{ url_for('.static', filename='%s.js' % arg['template_name']) }}"></script> <script src="{{ url_for('.static', filename='%s.js' % arg['template_name']) }}?ver={{ arg['package_version'] }}"></script>
{% endblock %} {% endblock %}

View File

@@ -1,4 +1,5 @@
{% extends "base.html" %} {% block content %} {% extends "base.html" %} {% block content %}
<link rel="stylesheet" href="{{ url_for('.static', filename='youtube-dl_modern.css') }}?ver={{ arg['package_version'] }}">
<form id="download"> <form id="download">
{{ macros.setting_input_text('url', 'URL', placeholder='http:// 주소', {{ macros.setting_input_text('url', 'URL', placeholder='http:// 주소',
@@ -13,13 +14,13 @@
</div> </div>
{{ macros.setting_checkbox('auto_sub', '자동생성 자막 다운로드', {{ macros.setting_checkbox('auto_sub', '자동생성 자막 다운로드',
value='False', desc='유튜브 전용') }} {{ value='False', desc='유튜브 전용') }} {{
macros.setting_button([['download_btn', '다운로드']]) }} macros.setting_buttons([['download_btn', '다운로드']]) }}
</form> </form>
<script> <script>
'use strict'; 'use strict';
const package_name = '{{ arg["package_name"] }}'; const package_name = '{{ arg["package_name"] }}';
</script> </script>
<script src="{{ url_for('.static', filename='%s.js' % arg['template_name']) }}"></script> <script src="{{ url_for('.static', filename='%s.js' % arg['template_name']) }}?ver={{ arg['package_version'] }}"></script>
{% endblock %} {% endblock %}

View File

@@ -1,4 +1,5 @@
{% extends "base.html" %} {% block content %} {% extends "base.html" %} {% block content %}
<link rel="stylesheet" href="{{ url_for('.static', filename='youtube-dl_modern.css') }}?ver={{ arg['package_version'] }}">
<form id="download"> <form id="download">
{{ macros.setting_input_text('url', 'URL', placeholder='http:// 주소', {{ macros.setting_input_text('url', 'URL', placeholder='http:// 주소',
@@ -6,13 +7,13 @@
macros.setting_input_text('filename', '파일명', value=arg['filename'], macros.setting_input_text('filename', '파일명', value=arg['filename'],
desc='템플릿 규칙은 https://github.com/ytdl-org/youtube-dl/#output-template desc='템플릿 규칙은 https://github.com/ytdl-org/youtube-dl/#output-template
참고') }} {{ macros.setting_checkbox('all_thumbnails', '모든 썸네일 다운로드', 참고') }} {{ macros.setting_checkbox('all_thumbnails', '모든 썸네일 다운로드',
value='False') }} {{ macros.setting_button([['download_btn', '다운로드']]) }} value='False') }} {{ macros.setting_buttons([['download_btn', '다운로드']]) }}
</form> </form>
<script> <script>
'use strict'; 'use strict';
const package_name = '{{ arg["package_name"] }}'; const package_name = '{{ arg["package_name"] }}';
</script> </script>
<script src="{{ url_for('.static', filename='%s.js' % arg['template_name']) }}"></script> <script src="{{ url_for('.static', filename='%s.js' % arg['template_name']) }}?ver={{ arg['package_version'] }}"></script>
{% endblock %} {% endblock %}