Compare commits

...

7 Commits

22 changed files with 2396 additions and 940 deletions

225
README.md
View File

@@ -251,225 +251,18 @@ API를 제공합니다. 다른 플러그인에서 동영상 정보나 다운로
## Changelog
v4.0.1
- 다운로드 요청 오류 수정
v4.0.0
- 최신 플러그인 구조로 변경
- download, thumbnail, sub API에 headers 키 추가
http 헤더 수정이 필요한 경우 활용할 수 있습니다.
v3.1.1
- 디폴트 youtube_dl 패키지를 yt-dlp로 변경
v3.1.0
- yt-dlp일 때 작동 안하는 문제 수정
v3.0.1
- 다운로드 후 파일 이동이 안되던 문제 수정
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.2
- 유튜브 검색 속도 대폭 개선 (extract_flat 적용)
- 검색 결과 캐싱 추가 (5분 유지)
- 인피니티 스크롤 안정화 및 최적화
- 미니 플레이어 (스크롤 시 오른쪽 하단 고정) 추가
- Artplayer 영상 비율 버그 수정 (16:9 aspect-ratio 적용)
- UI 개선: 검색 후 초기 메시지 자동 숨김
v0.1.1
- 다운로드 실패 시 임시파일 삭제가 안 되는 문제 수정
- 유지보수 업데이트
v0.1.0

View File

@@ -1,5 +1,5 @@
title: "유튜브 다운로더"
version: "0.1.0"
version: "0.1.2"
package_name: "youtube-dl"
developer: "flaskfarm"
description: "유튜브 다운로드"

547
main.py
View File

@@ -1,543 +1,10 @@
import os
import sys
import platform
import traceback
import subprocess
import sqlite3
from datetime import datetime
from typing import Any
from flask import render_template, jsonify
from .setup import P
from .my_youtube_dl import MyYoutubeDL, Status
logger: Any = P.logger
package_name: str = P.package_name
ModelSetting: Any = P.ModelSetting
logger = P.logger
package_name = P.package_name
ModelSetting = P.ModelSetting
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 = []
@staticmethod
def get_youtube_dl_version():
try:
return MyYoutubeDL.get_version()
except Exception as error:
logger.error("Exception:%s", error)
logger.error(traceback.format_exc())
return "패키지 임포트 실패"
@staticmethod
def get_default_filename():
return MyYoutubeDL.DEFAULT_FILENAME
@staticmethod
def get_preset_list():
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():
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", "오디오 추출"],
]
@staticmethod
def get_postprocessor():
video_convertor = []
extract_audio = []
for i in LogicMain.get_postprocessor_list():
if i[2] == "비디오 변환":
video_convertor.append(i[0])
elif i[2] == "오디오 추출":
extract_audio.append(i[0])
return video_convertor, extract_audio
@staticmethod
def download(**kwagrs):
try:
logger.debug(kwagrs)
plugin = kwagrs["plugin"]
url = kwagrs["url"]
filename = kwagrs["filename"]
temp_path = kwagrs["temp_path"]
save_path = kwagrs["save_path"]
opts = {}
if "format" in kwagrs and kwagrs["format"]:
opts["format"] = kwagrs["format"]
postprocessor = []
if "preferedformat" in kwagrs and kwagrs["preferedformat"]:
postprocessor.append(
{
"key": "FFmpegVideoConvertor",
"preferedformat": kwagrs["preferedformat"],
}
)
if "preferredcodec" in kwagrs and kwagrs["preferredcodec"]:
postprocessor.append(
{
"key": "FFmpegExtractAudio",
"preferredcodec": kwagrs["preferredcodec"],
"preferredquality": str(kwagrs["preferredquality"]),
}
)
if postprocessor:
opts["postprocessors"] = postprocessor
if "playlist" in kwagrs and kwagrs["playlist"]:
if kwagrs["playlist"] == "reverse":
opts["playlistreverse"] = True
elif kwagrs["playlist"] == "random":
opts["playlistrandom"] = True
else:
opts["playlist_items"] = kwagrs["playlist"]
if "archive" in kwagrs and kwagrs["archive"]:
opts["download_archive"] = kwagrs["archive"]
if "proxy" in kwagrs and kwagrs["proxy"]:
opts["proxy"] = kwagrs["proxy"]
if "ffmpeg_path" in kwagrs and kwagrs["ffmpeg_path"]:
opts["ffmpeg_location"] = kwagrs["ffmpeg_path"]
if "cookiefile" in kwagrs and kwagrs["cookiefile"]:
opts["cookiefile"] = kwagrs["cookiefile"]
if "headers" in kwagrs and kwagrs["headers"]:
opts["http_headers"] = kwagrs["headers"]
dateafter = kwagrs.get("dateafter")
youtube_dl = MyYoutubeDL(
plugin, "video", url, filename, temp_path, save_path, opts, dateafter
)
youtube_dl.key = kwagrs.get("key")
LogicMain.youtube_dl_list.append(youtube_dl) # 리스트 추가
return youtube_dl
except Exception as error:
logger.error("Exception:%s", error)
logger.error(traceback.format_exc())
return None
@staticmethod
def thumbnail(**kwagrs):
try:
logger.debug(kwagrs)
plugin = kwagrs["plugin"]
url = kwagrs["url"]
filename = kwagrs["filename"]
temp_path = kwagrs["temp_path"]
save_path = kwagrs["save_path"]
opts = {"skip_download": True}
if (
"all_thumbnails" in kwagrs
and str(kwagrs["all_thumbnails"]).lower() != "false"
):
opts["write_all_thumbnails"] = True
else:
opts["writethumbnail"] = True
if "playlist" in kwagrs and kwagrs["playlist"]:
if kwagrs["playlist"] == "reverse":
opts["playlistreverse"] = True
elif kwagrs["playlist"] == "random":
opts["playlistrandom"] = True
else:
opts["playlist_items"] = kwagrs["playlist"]
if "archive" in kwagrs and kwagrs["archive"]:
opts["download_archive"] = kwagrs["archive"]
if "proxy" in kwagrs and kwagrs["proxy"]:
opts["proxy"] = kwagrs["proxy"]
if "ffmpeg_path" in kwagrs and kwagrs["ffmpeg_path"]:
opts["ffmpeg_location"] = kwagrs["ffmpeg_path"]
if "cookiefile" in kwagrs and kwagrs["cookiefile"]:
opts["cookiefile"] = kwagrs["cookiefile"]
if "headers" in kwagrs and kwagrs["headers"]:
opts["http_headers"] = kwagrs["headers"]
dateafter = kwagrs.get("dateafter")
youtube_dl = MyYoutubeDL(
plugin,
"thumbnail",
url,
filename,
temp_path,
save_path,
opts,
dateafter,
)
youtube_dl.key = kwagrs.get("key")
LogicMain.youtube_dl_list.append(youtube_dl) # 리스트 추가
return youtube_dl
except Exception as error:
logger.error("Exception:%s", error)
logger.error(traceback.format_exc())
return None
@staticmethod
def sub(**kwagrs):
try:
logger.debug(kwagrs)
plugin = kwagrs["plugin"]
url = kwagrs["url"]
filename = kwagrs["filename"]
temp_path = kwagrs["temp_path"]
save_path = kwagrs["save_path"]
opts = {"skip_download": True}
sub_lang = map(
lambda x: x.strip(), kwagrs["sub_lang"].split(",")
) # 문자열을 리스트로 변환
if "all_subs" in kwagrs and str(kwagrs["all_subs"]).lower() != "false":
opts["allsubtitles"] = True
else:
opts["subtitleslangs"] = sub_lang
if "auto_sub" in kwagrs and str(kwagrs["auto_sub"]).lower() != "false":
opts["writeautomaticsub"] = True
else:
opts["writesubtitles"] = True
if "playlist" in kwagrs and kwagrs["playlist"]:
if kwagrs["playlist"] == "reverse":
opts["playlistreverse"] = True
elif kwagrs["playlist"] == "random":
opts["playlistrandom"] = True
else:
opts["playlist_items"] = kwagrs["playlist"]
if "archive" in kwagrs and kwagrs["archive"]:
opts["download_archive"] = kwagrs["archive"]
if "proxy" in kwagrs and kwagrs["proxy"]:
opts["proxy"] = kwagrs["proxy"]
if "ffmpeg_path" in kwagrs and kwagrs["ffmpeg_path"]:
opts["ffmpeg_location"] = kwagrs["ffmpeg_path"]
if "cookiefile" in kwagrs and kwagrs["cookiefile"]:
opts["cookiefile"] = kwagrs["cookiefile"]
if "headers" in kwagrs and kwagrs["headers"]:
opts["http_headers"] = kwagrs["headers"]
dateafter = kwagrs.get("dateafter")
youtube_dl = MyYoutubeDL(
plugin, "subtitle", url, filename, temp_path, save_path, opts, dateafter
)
youtube_dl.key = kwagrs.get("key")
LogicMain.youtube_dl_list.append(youtube_dl) # 리스트 추가
return youtube_dl
except Exception as error:
logger.error("Exception:%s", error)
logger.error(traceback.format_exc())
return None
@staticmethod
def get_data(youtube_dl):
try:
data = {}
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"] = (
youtube_dl.progress_hooks["eta"]
if youtube_dl.progress_hooks["eta"] is not None
else ""
)
data["speed_str"] = (
LogicMain.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 = 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"] = LogicMain.human_readable_size(
youtube_dl.progress_hooks["downloaded_bytes"]
)
data["total_bytes_str"] = LogicMain.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("Exception:%s", error)
logger.error(traceback.format_exc())
return None
@staticmethod
def get_info_dict(url, proxy):
return MyYoutubeDL.get_info_dict(url, proxy)
@staticmethod
def human_readable_size(size, suffix=""):
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}"
@staticmethod
def socketio_emit(cmd, data):
socketio.emit(
cmd, LogicMain.get_data(data), namespace=f"/{package_name}", broadcast=True
)
# LogicMain은 ModuleBasic으로 기능이 통합되어 더 이상 사용되지 않음.
# 하위 호환성이나 프레임워크 요구사항이 있을 경우를 위해 파일 구조만 유지.

547
main_old.py Normal file
View File

@@ -0,0 +1,547 @@
import os
import sys
import platform
import traceback
import subprocess
import sqlite3
from datetime import datetime
from flask import render_template, jsonify
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
logger = Plugin.logger
package_name = Plugin.package_name
ModelSetting = Plugin.ModelSetting
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 = []
@staticmethod
def get_youtube_dl_version():
try:
return MyYoutubeDL.get_version()
except Exception as error:
logger.error("Exception:%s", error)
logger.error(traceback.format_exc())
return "패키지 임포트 실패"
@staticmethod
def get_default_filename():
return MyYoutubeDL.DEFAULT_FILENAME
@staticmethod
def get_preset_list():
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():
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", "오디오 추출"],
]
@staticmethod
def get_postprocessor():
video_convertor = []
extract_audio = []
for i in LogicMain.get_postprocessor_list():
if i[2] == "비디오 변환":
video_convertor.append(i[0])
elif i[2] == "오디오 추출":
extract_audio.append(i[0])
return video_convertor, extract_audio
@staticmethod
def download(**kwagrs):
try:
logger.debug(kwagrs)
plugin = kwagrs["plugin"]
url = kwagrs["url"]
filename = kwagrs["filename"]
temp_path = kwagrs["temp_path"]
save_path = kwagrs["save_path"]
opts = {}
if "format" in kwagrs and kwagrs["format"]:
opts["format"] = kwagrs["format"]
postprocessor = []
if "preferedformat" in kwagrs and kwagrs["preferedformat"]:
postprocessor.append(
{
"key": "FFmpegVideoConvertor",
"preferedformat": kwagrs["preferedformat"],
}
)
if "preferredcodec" in kwagrs and kwagrs["preferredcodec"]:
postprocessor.append(
{
"key": "FFmpegExtractAudio",
"preferredcodec": kwagrs["preferredcodec"],
"preferredquality": str(kwagrs["preferredquality"]),
}
)
if postprocessor:
opts["postprocessors"] = postprocessor
if "playlist" in kwagrs and kwagrs["playlist"]:
if kwagrs["playlist"] == "reverse":
opts["playlistreverse"] = True
elif kwagrs["playlist"] == "random":
opts["playlistrandom"] = True
else:
opts["playlist_items"] = kwagrs["playlist"]
if "archive" in kwagrs and kwagrs["archive"]:
opts["download_archive"] = kwagrs["archive"]
if "proxy" in kwagrs and kwagrs["proxy"]:
opts["proxy"] = kwagrs["proxy"]
if "ffmpeg_path" in kwagrs and kwagrs["ffmpeg_path"]:
opts["ffmpeg_location"] = kwagrs["ffmpeg_path"]
if "cookiefile" in kwagrs and kwagrs["cookiefile"]:
opts["cookiefile"] = kwagrs["cookiefile"]
if "headers" in kwagrs and kwagrs["headers"]:
opts["http_headers"] = kwagrs["headers"]
dateafter = kwagrs.get("dateafter")
youtube_dl = MyYoutubeDL(
plugin, "video", url, filename, temp_path, save_path, opts, dateafter
)
youtube_dl.key = kwagrs.get("key")
LogicMain.youtube_dl_list.append(youtube_dl) # 리스트 추가
return youtube_dl
except Exception as error:
logger.error("Exception:%s", error)
logger.error(traceback.format_exc())
return None
@staticmethod
def thumbnail(**kwagrs):
try:
logger.debug(kwagrs)
plugin = kwagrs["plugin"]
url = kwagrs["url"]
filename = kwagrs["filename"]
temp_path = kwagrs["temp_path"]
save_path = kwagrs["save_path"]
opts = {"skip_download": True}
if (
"all_thumbnails" in kwagrs
and str(kwagrs["all_thumbnails"]).lower() != "false"
):
opts["write_all_thumbnails"] = True
else:
opts["writethumbnail"] = True
if "playlist" in kwagrs and kwagrs["playlist"]:
if kwagrs["playlist"] == "reverse":
opts["playlistreverse"] = True
elif kwagrs["playlist"] == "random":
opts["playlistrandom"] = True
else:
opts["playlist_items"] = kwagrs["playlist"]
if "archive" in kwagrs and kwagrs["archive"]:
opts["download_archive"] = kwagrs["archive"]
if "proxy" in kwagrs and kwagrs["proxy"]:
opts["proxy"] = kwagrs["proxy"]
if "ffmpeg_path" in kwagrs and kwagrs["ffmpeg_path"]:
opts["ffmpeg_location"] = kwagrs["ffmpeg_path"]
if "cookiefile" in kwagrs and kwagrs["cookiefile"]:
opts["cookiefile"] = kwagrs["cookiefile"]
if "headers" in kwagrs and kwagrs["headers"]:
opts["http_headers"] = kwagrs["headers"]
dateafter = kwagrs.get("dateafter")
youtube_dl = MyYoutubeDL(
plugin,
"thumbnail",
url,
filename,
temp_path,
save_path,
opts,
dateafter,
)
youtube_dl.key = kwagrs.get("key")
LogicMain.youtube_dl_list.append(youtube_dl) # 리스트 추가
return youtube_dl
except Exception as error:
logger.error("Exception:%s", error)
logger.error(traceback.format_exc())
return None
@staticmethod
def sub(**kwagrs):
try:
logger.debug(kwagrs)
plugin = kwagrs["plugin"]
url = kwagrs["url"]
filename = kwagrs["filename"]
temp_path = kwagrs["temp_path"]
save_path = kwagrs["save_path"]
opts = {"skip_download": True}
sub_lang = map(
lambda x: x.strip(), kwagrs["sub_lang"].split(",")
) # 문자열을 리스트로 변환
if "all_subs" in kwagrs and str(kwagrs["all_subs"]).lower() != "false":
opts["allsubtitles"] = True
else:
opts["subtitleslangs"] = sub_lang
if "auto_sub" in kwagrs and str(kwagrs["auto_sub"]).lower() != "false":
opts["writeautomaticsub"] = True
else:
opts["writesubtitles"] = True
if "playlist" in kwagrs and kwagrs["playlist"]:
if kwagrs["playlist"] == "reverse":
opts["playlistreverse"] = True
elif kwagrs["playlist"] == "random":
opts["playlistrandom"] = True
else:
opts["playlist_items"] = kwagrs["playlist"]
if "archive" in kwagrs and kwagrs["archive"]:
opts["download_archive"] = kwagrs["archive"]
if "proxy" in kwagrs and kwagrs["proxy"]:
opts["proxy"] = kwagrs["proxy"]
if "ffmpeg_path" in kwagrs and kwagrs["ffmpeg_path"]:
opts["ffmpeg_location"] = kwagrs["ffmpeg_path"]
if "cookiefile" in kwagrs and kwagrs["cookiefile"]:
opts["cookiefile"] = kwagrs["cookiefile"]
if "headers" in kwagrs and kwagrs["headers"]:
opts["http_headers"] = kwagrs["headers"]
dateafter = kwagrs.get("dateafter")
youtube_dl = MyYoutubeDL(
plugin, "subtitle", url, filename, temp_path, save_path, opts, dateafter
)
youtube_dl.key = kwagrs.get("key")
LogicMain.youtube_dl_list.append(youtube_dl) # 리스트 추가
return youtube_dl
except Exception as error:
logger.error("Exception:%s", error)
logger.error(traceback.format_exc())
return None
@staticmethod
def get_data(youtube_dl):
try:
data = {}
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"] = (
youtube_dl.progress_hooks["eta"]
if youtube_dl.progress_hooks["eta"] is not None
else ""
)
data["speed_str"] = (
LogicMain.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 = 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"] = LogicMain.human_readable_size(
youtube_dl.progress_hooks["downloaded_bytes"]
)
data["total_bytes_str"] = LogicMain.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("Exception:%s", error)
logger.error(traceback.format_exc())
return None
@staticmethod
def get_info_dict(url, proxy):
return MyYoutubeDL.get_info_dict(url, proxy)
@staticmethod
def human_readable_size(size, suffix=""):
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}"
@staticmethod
def socketio_emit(cmd, data):
socketio.emit(
cmd, LogicMain.get_data(data), namespace=f"/{package_name}", broadcast=True
)

View File

@@ -1,29 +1,43 @@
# -*- coding: utf-8 -*-
# @Time : 2023/02/25 7:29 PM
# @Author : yommi
# @Site :
# @File : mod_basic
# @Software: PyCharm
# @Path : youtube-dl/mod_basic.py
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 .setup import *
# from .main import LogicMain
from .model import ModelYoutubeDlItem
from .my_youtube_dl import MyYoutubeDL, Status
from .setup import *
import platform
import os
from .model import ModelYoutubeDlItem
from loguru import logger
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):
def __init__(self, P):
"""유튜브 다운로더의 기본 기능을 담당하는 모듈"""
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 = {
self.db_default: Dict[str, str] = {
"db_version": "2",
"youtube_dl_package": "1",
"ffmpeg_path": ""
@@ -34,32 +48,527 @@ class ModuleBasic(PluginModuleBase):
"default_filename": "",
"proxy": "",
}
self.web_list_model = ModelYoutubeDlItem
self.web_list_model: Any = ModelYoutubeDlItem
def process_menu(self, sub, req):
def process_menu(self, sub: str, req: Any) -> Union[Response, str]:
"""메뉴별 템플릿 렌더링"""
logger.debug(f"sub: {sub}")
arg = P.ModelSetting.to_dict()
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 = P.ModelSetting.get("default_filename")
default_filename: Optional[str] = P.ModelSetting.get("default_filename")
arg["filename"] = (
default_filename
if default_filename
else ModuleBasic.get_default_filename()
else self.get_default_filename()
)
arg["preset_list"] = ModuleBasic.get_preset_list()
arg["postprocessor_list"] = ModuleBasic.get_postprocessor_list()
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 process_command(self, command, arg1, arg2, arg3, req):
ret = {"ret": "success"}
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(
@@ -74,12 +583,11 @@ class ModuleBasic(PluginModuleBase):
# ToolUtil.make_path(P.ModelSetting.get(f"{self.name}_path_config")),
# )
@staticmethod
def get_default_filename():
def get_default_filename(self) -> str:
return MyYoutubeDL.DEFAULT_FILENAME
@staticmethod
def get_preset_list():
def get_preset_list() -> List[List[str]]:
return [
["bestvideo+bestaudio/best", "최고 화질"],
["bestvideo[height<=1080]+bestaudio/best[height<=1080]", "1080p"],
@@ -95,7 +603,7 @@ class ModuleBasic(PluginModuleBase):
]
@staticmethod
def get_postprocessor_list():
def get_postprocessor_list() -> List[List[Union[str, None]]]:
return [
["", "후처리 안함", None],
["mp4", "MP4", "비디오 변환"],
@@ -115,4 +623,5 @@ class ModuleBasic(PluginModuleBase):
["opus", "Opus", "오디오 추출"],
["vorbis", "Vorbis", "오디오 추출"],
["wav", "WAV", "오디오 추출"],
# ["60fps", "60fps 보간 (느림)", "고급 변환"],
]

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,21 +7,19 @@ from glob import glob
from datetime import datetime
from threading import Thread
from enum import Enum
from typing import Optional, Dict, List, Any, Tuple, Union
from framework import celery as celery_shutil
# from .plugin import Plugin
import shutil as celery_shutil
from .setup import P
logger = P.logger
ModelSetting = P.ModelSetting
youtube_dl_package = P.youtube_dl_packages[
int(ModelSetting.get("youtube_dl_package"))
if ModelSetting.get("youtube_dl_package")
else 1 # LogicMain.db_default["youtube_dl_package"]
].replace("-", "_")
# 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):
@@ -33,29 +31,29 @@ class Status(Enum):
STOP = 5
COMPLETED = 6
def __str__(self):
str_list = ["준비", "분석중", "다운로드중", "실패", "변환중", "중지", "완료"]
def __str__(self) -> str:
str_list: List[str] = ["준비", "분석중", "다운로드중", "실패", "변환중", "중지", "완료"]
return str_list[self.value]
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__(
self,
plugin,
type_name,
url,
filename,
temp_path,
save_path=None,
opts=None,
dateafter=None,
datebefore=None,
plugin: str,
type_name: str,
url: str,
filename: str,
temp_path: str,
save_path: Optional[str] = None,
opts: Optional[Dict[str, Any]] = None,
dateafter: Optional[str] = None,
datebefore: Optional[str] = None,
):
# from youtube_dl.utils import DateRange
# yt-dlp/youtube-dlutils 모듈에서 DateRange 임포트
DateRange = __import__(
f"{youtube_dl_package}.utils", fromlist=["DateRange"]
).DateRange
@@ -64,90 +62,112 @@ class MyYoutubeDL:
save_path = temp_path
if opts is None:
opts = {}
self.plugin = plugin
self.type = type_name
self.url = url
self.filename = filename
self.plugin: str = plugin
self.type: str = type_name
self.url: str = url
self.filename: str = filename
# 임시 폴더 생성
if not os.path.isdir(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):
os.makedirs(save_path)
self.save_path = save_path
self.opts = opts
self.save_path: str = save_path
self.opts: Dict[str, Any] = opts
if dateafter or datebefore:
self.opts["daterange"] = DateRange(start=dateafter, end=datebefore)
self.index = MyYoutubeDL._index
self.index: int = 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, # 업로더 주소
self._status: Status = Status.READY
self._thread: Optional[Thread] = None
self.key: Optional[str] = None
self.start_time: Optional[datetime] = None
self.end_time: Optional[datetime] = None
# 비디오 정보
self.info_dict: Dict[str, Optional[str]] = {
"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)
# 진행률 정보
self.progress_hooks: Dict[str, Optional[Union[int, float, str]]] = {
"downloaded_bytes": None,
"total_bytes": None,
"eta": None,
"speed": None,
}
def start(self):
def start(self) -> bool:
"""다운로드 스레드 시작"""
if self.status != Status.READY:
return False
self._thread = Thread(target=self.run)
self._thread.start()
return True
def run(self):
# import youtube_dl
def run(self) -> None:
"""다운로드 실행 본체"""
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"),
self.opts.get("cookiesfrombrowser"),
)
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["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_url"] = info_dict.get("uploader_url", "")
ydl_opts = {
ydl_opts: Dict[str, Any] = {
"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,
"nocheckcertificate": True,
}
# yt-dlp 전용 성능 향상 옵션
if youtube_dl_package == "yt_dlp":
ydl_opts.update({
"concurrent_fragment_downloads": 5,
"retries": 10,
})
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): # 다운로드 성공
logger.debug(f"다운로드 시작: {self.url}")
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):
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 not os.path.isdir(path):
os.mkdir(path)
@@ -156,15 +176,16 @@ class MyYoutubeDL:
self.status = Status.COMPLETED
except Exception as error:
self.status = Status.ERROR
logger.error("Exception:%s", error)
logger.error(f"실행 중 예외 발생: {error}")
logger.error(traceback.format_exc())
finally:
# 임시폴더 삭제
celery_shutil.rmtree(self.temp_path)
if os.path.exists(self.temp_path):
celery_shutil.rmtree(self.temp_path)
if self.status != Status.STOP:
self.end_time = datetime.now()
def stop(self):
def stop(self) -> bool:
"""다운로드 중지"""
if self.status in (Status.ERROR, Status.STOP, Status.COMPLETED):
return False
self.status = Status.STOP
@@ -172,78 +193,137 @@ class MyYoutubeDL:
return True
@staticmethod
def get_version():
# from youtube_dl.version import __version__
__version__ = __import__(
def get_preview_url(url: str) -> Optional[str]:
"""미리보기용 직접 재생 가능한 URL 추출"""
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
@staticmethod
def get_version() -> str:
"""라이브러리 버전 확인"""
__version__: str = __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
def get_info_dict(
url: str,
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)
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:
ydl_opts["proxy"] = proxy
if 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:
# info = ydl.extract_info(url, download=False)
info = ydl.extract_info(url, download=False)
info: Dict[str, Any] = ydl.extract_info(url, download=False)
except Exception as error:
logger.error("Exception:%s", error)
logger.error(f"정보 추출 중 예외 발생: {error}")
logger.error(traceback.format_exc())
return None
return ydl.sanitize_info(info)
def my_hook(self, data):
def my_hook(self, data: Dict[str, Any]) -> None:
"""진행률 업데이트 훅"""
if self.status != Status.STOP:
self.status = {
"downloading": Status.DOWNLOADING,
"error": Status.ERROR,
"finished": Status.FINISHED, # 다운로드 완료. 변환 시작
}[data["status"]]
if data["status"] == "downloading":
self.status = Status.DOWNLOADING
elif data["status"] == "error":
self.status = Status.ERROR
elif data["status"] == "finished":
self.status = Status.FINISHED
if data["status"] != "error":
self.filename = os.path.basename(data.get("filename"))
self.filename = os.path.basename(data.get("filename", self.filename))
self.progress_hooks["downloaded_bytes"] = data.get("downloaded_bytes")
self.progress_hooks["total_bytes"] = data.get("total_bytes")
self.progress_hooks["total_bytes"] = data.get("total_bytes") or data.get("total_bytes_estimate")
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):
def status(self) -> Status:
return self._status
@status.setter
def status(self, value):
from .main import LogicMain
def status(self, value: Status) -> None:
self._status = value
LogicMain.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:
def debug(self, msg):
if msg.find(" ETA ") != -1:
# 과도한 로그 방지
"""yt-dlp의 로그를 가로채서 처리하는 클래성"""
def debug(self, msg: str) -> None:
# 진행 상황 관련 로그는 걸러냄
if " ETA " in msg or "at" in msg and "B/s" in msg:
return
logger.debug(msg)
def warning(self, msg):
def warning(self, msg: str) -> None:
logger.warning(msg)
def error(self, msg):
def error(self, msg: str) -> None:
logger.error(msg)

View File

@@ -12,30 +12,21 @@ __menu = {
"list": [
{
"uri": "basic",
"name": "기본 처리",
"name": "유튜브",
"list": [
{
"uri": "setting",
"name": "설정",
},
],
},
{
"uri": "basic/download",
"name": "다운로드",
},
{
"uri": "download",
"name": "다운로드",
"list": [
{
"uri": "basic",
"name": "다운로드",
"uri": "download",
"name": "직접 다운로드",
},
{"uri": "search", "name": "유튜브 검색"},
{"uri": "thumbnail", "name": "썸네일 다운로드"},
{"uri": "sub", "name": "자막 다운로드"},
],
},
{"uri": "thumbnail", "name": "썸네일 다운로드"},
{"uri": "sub", "name": "자막 다운로드"},
{
"uri": "manual",
"name": "매뉴얼",

View File

@@ -64,6 +64,52 @@
return;
}
post_ajax('/download', get_formdata('#download'));
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-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

@@ -35,66 +35,86 @@
const list_tbody = document.getElementById('list_tbody');
const 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 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" style="visibility: ${visi}; width: ${data.percent}%">${data.percent}%</div></div></td>`;
str += `<td>${data.download_time}</td>`;
str += '<td class="tableRowHoverOff">';
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="align-middle btn btn-outline-danger btn-sm youtubeDl-stop" data-index="${data.index}">중지</button>`;
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) => {
let str = '<div class="row">';
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-2">';
str += `<b>${left}</b>`;
str += '<div class="col-sm-3 text-muted font-weight-bold" style="font-size: 13px;">';
str += `${left}`;
str += '</div>';
str += '<div class="col-sm-10">';
str += '<div class="input-group col-sm-9">';
str += '<span class="text-left info-padding">';
str += '<div class="col-sm-9">';
str += '<div class="info-value">';
if (link) {
str += `<a href="${option}" target="_blank">`;
str += `<a href="${option}" target="_blank" class="text-primary font-weight-bold">`;
}
str += right;
if (link) {
str += '</a>';
}
str += '</span></div></div></div>';
str += '</div></div></div>';
return str;
};
const get_detail = (data) => {
let str = info_html('URL', data.url, data.url);
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 += info_html('', '<b>현재 다운로드 중인 파일에 대한 정보</b>');
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(
'진행률(current/total)',
`${data.percent}% (${data.downloaded_bytes_str} / ${data.total_bytes_str})`
'현재 진행량',
`<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('남은 시간', `${data.eta}`);
str += info_html('다운 속도', data.speed_str);
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;
};
@@ -102,9 +122,9 @@
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">`;
str += '<td colspan="9">';
str += `<div id="detail_${data.index}">`;
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>';
@@ -128,14 +148,14 @@
});
const reload_list = async () => {
const { data } = await post_ajax('/list');
const { data } = await post_ajax('/basic/list');
list_tbody.innerHTML = data.map((item) => make_item(item)).join('');
};
// 전체 중지
all_stop_btn.addEventListener('click', (event) => {
event.preventDefault();
post_ajax('/all_stop').then(reload_list);
post_ajax('/basic/all_stop').then(reload_list);
});
// 중지
@@ -145,7 +165,7 @@
if (!target.classList.contains('youtubeDl-stop')) {
return;
}
post_ajax('/stop', {
post_ajax('/basic/stop', {
index: target.dataset.index,
}).then(reload_list);
});

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

@@ -49,7 +49,7 @@
ffmpeg = 'ffmpeg';
}
post_ajax('/ffmpeg_version', {
post_ajax('/basic/ffmpeg_version', {
path: ffmpeg,
}).then(({ data }) => {
modal_title.innerHTML = `${ffmpeg} -version`;

View File

@@ -47,6 +47,6 @@
return;
}
post_ajax('/sub', get_formdata('#download'));
post_ajax('/basic/sub', get_formdata('#download'));
});
})();

View File

@@ -42,6 +42,6 @@
return;
}
post_ajax('/thumbnail', get_formdata('#download'));
post_ajax('/basic/thumbnail', get_formdata('#download'));
});
})();

View File

@@ -30,6 +30,22 @@
{% 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 등 동영상 주소') }}
@@ -44,6 +60,7 @@
"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

@@ -1,4 +1,5 @@
{% 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() }}

View File

@@ -1,4 +1,5 @@
{% extends "base.html" %} {% block content %}
<link rel="stylesheet" href="{{ url_for('.static', filename='youtube-dl_modern.css') }}?ver={{ arg['package_version'] }}">
<link
rel="stylesheet"

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

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

View File

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