diff --git a/CHANGELOG.md b/CHANGELOG.md index 65e7dc5..87fc257 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 2.023 +- 自动更新 +- 修复保存任务时logger无法序列化的bug (感谢 @9chu) + ## 2.022 - 增加下载速度显示 - 增加低速自动重试 `low_speed_threshold`, 默认为`10KB/s`以下重试 diff --git a/README.chs.md b/README.chs.md index dde2f73..a50a7be 100755 --- a/README.chs.md +++ b/README.chs.md @@ -55,6 +55,8 @@ xeH - **download_timeout** 设置下载图片的超时,默认为`10`秒 - **low_speed_threshold** 设置最低下载速度,低于此值将换源重新下载,单位为KB/s,默认为`10` - **ignored_errors** 设置忽略的错误码,默认为空,错误码可以从`const.py`中获得 + - **auto_update** 自动检查更新,`check` 仅检查更新,`download` 下载更新,`off` 关闭检查;默认为`download` + - **update_beta_channel** 设置是否更新到测试版,默认为否 - **log_path** 日志路径,默认为`eh.log` - **log_verbose** 日志等级,可选1-3,值越大输出越详细,默认为`2` - **save_tasks** 是否保存任务到`h.json`,可用于断点续传,默认为否 @@ -67,7 +69,8 @@ xeH [--rpc-interface ADDR] [--rpc-port PORT] [--rpc-secret ...] [--rpc-open-browser BOOL] [--delete-task-files BOOL] [-a BOOL] [--download-range a-b,c-d,e] [-t N] [--timeout N] - [--low-speed-threshold N] [-f] [-l /path/to/eh.log] [-v] [-h] + [--low-speed-threshold N] [-f] [--auto-update {check,download,off}] + [--update-beta-channel BOOL] [-l /path/to/eh.log] [-v] [-h] [--version] [url [url ...]] @@ -112,6 +115,10 @@ xeH -t N, --thread N 下载线程数 (默认: 5) --timeout N 设置下载图片的超时 (默认: 10秒) -f, --force 忽略配额判断, 继续下载 (默认: False) + --auto-update {check,download,off} + 检查并自动下载更新 + --update-beta-channel BOOL + 是否更新到测试分支 -l /path/to/eh.log, --logpath /path/to/eh.log 保存日志的路径 (默认: eh.log) -v, --verbose 设置日志装逼等级 (默认: 2) diff --git a/README.md b/README.md index 274f70b..c7e62fe 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,8 @@ Configuration keys: - **download_timeout** Timeout of download images. Default to `10`s. - **low_speed_threshold** Retry download if speed is lower than specified value. Default to `10` KB/s. - **ignored_errors** Set the error codes to ignore and continue downloading. Default to *empty*. Error codes can be obtained from [const.py](xeHentai/const.py). + - **auto_update** turn on auto update of program `check` for check only and `download` for download; `off` to turn off. Default to `download`. + - **update_beta_channel** set to true to update to dev branch - **log_path** Set log file path. Default to `eh.log`. - **log_verbose** Set log level with integer from 1 to 3. Bigger value means more verbose output. Default to `2`. - **save_tasks** Set to save uncompleted tasks in `h.json`. Default to `False`. @@ -65,7 +67,8 @@ Usage: xeh [-u USERNAME] [-k KEY] [-c COOKIE] [-i] [--daemon] [-d DIR] [-o] [--rpc-interface ADDR] [--rpc-port PORT] [--rpc-secret ...] [--rpc-open-browser BOOL] [--delete-task-files BOOL] [-a BOOL] [--download-range a-b,c-d,e] [-t N] [--timeout N] - [--low-speed-threshold N] [-f] [-l /path/to/eh.log] [-v] [-h] + [--low-speed-threshold N] [-f] [--auto-update {check,download,off}] + [--update-beta-channel BOOL] [-l /path/to/eh.log] [-v] [-h] [--version] [url [url ...]] @@ -126,9 +129,14 @@ optional arguments: (default: 10 KB/s) -f, --force download regardless of quota exceeded warning (default: False) + --auto-update {check,download,off} + check or download update automatically + (default: download) + --update-beta-channel BOOL + check update upon beta channel + (default: True) -l /path/to/eh.log, --logpath /path/to/eh.log - define log path (current: - /Users/fffonion/Dev/Python/xeHentai/eh.log) + define log path (default: eh.log) -v, --verbose show more detailed log (default: 3) -h, --help show this help message and exit --version show program's version number and exit diff --git a/setup.py b/setup.py index 3a7a878..d06e871 100644 --- a/setup.py +++ b/setup.py @@ -22,6 +22,7 @@ 'xeHentai', 'xeHentai.util', 'xeHentai.i18n', + 'xeHentai.updater', ] requires = ['requests'] diff --git a/xeH b/xeH index 4dfa5ab..a92fa37 100755 --- a/xeH +++ b/xeH @@ -1,5 +1,49 @@ #!/usr/bin/env python -from xeHentai import cli +import os +import sys +import json +import zipfile +from threading import Thread +import xeHentai.const as const -cli.start() +SRC_UPDATE_FILE = const.SRC_UPDATE_FILE +if const.PY3K: + from importlib import reload + +def load_update(): + if os.path.exists(SRC_UPDATE_FILE): + try: + need_remove = False + update_id = "" + with zipfile.ZipFile(SRC_UPDATE_FILE, 'r') as z: + try: + r = json.loads(z.read("info.json")) + except: + need_remove = True + else: + if 'v' not in r and r['v'] != SRC_UPDATE_VERSION: + # ignoring legacy file + need_remove = True + else: + update_id = r["update_id"] + if need_remove: + os.remove(SRC_UPDATE_FILE) + return + v = const.__version__ + sys.path.insert(0, SRC_UPDATE_FILE) + import xeHentai + reload(xeHentai) + xeHentai.const.VERSION_UPDATE = update_id + xeHentai.const.VERSION_UPDATE_LOADER = v + except: + if sys.path[0] == SRC_UPDATE_FILE: + sys.path.pop(0) + os.remove(SRC_UPDATE_FILE) + + +if __name__ == "__main__": + load_update() + + from xeHentai import cli, i18n + cli.start() diff --git a/xeH.py b/xeH.py index 4dfa5ab..a92fa37 100755 --- a/xeH.py +++ b/xeH.py @@ -1,5 +1,49 @@ #!/usr/bin/env python -from xeHentai import cli +import os +import sys +import json +import zipfile +from threading import Thread +import xeHentai.const as const -cli.start() +SRC_UPDATE_FILE = const.SRC_UPDATE_FILE +if const.PY3K: + from importlib import reload + +def load_update(): + if os.path.exists(SRC_UPDATE_FILE): + try: + need_remove = False + update_id = "" + with zipfile.ZipFile(SRC_UPDATE_FILE, 'r') as z: + try: + r = json.loads(z.read("info.json")) + except: + need_remove = True + else: + if 'v' not in r and r['v'] != SRC_UPDATE_VERSION: + # ignoring legacy file + need_remove = True + else: + update_id = r["update_id"] + if need_remove: + os.remove(SRC_UPDATE_FILE) + return + v = const.__version__ + sys.path.insert(0, SRC_UPDATE_FILE) + import xeHentai + reload(xeHentai) + xeHentai.const.VERSION_UPDATE = update_id + xeHentai.const.VERSION_UPDATE_LOADER = v + except: + if sys.path[0] == SRC_UPDATE_FILE: + sys.path.pop(0) + os.remove(SRC_UPDATE_FILE) + + +if __name__ == "__main__": + load_update() + + from xeHentai import cli, i18n + cli.start() diff --git a/xeHentai/cli.py b/xeHentai/cli.py index 77da138..28ce37b 100644 --- a/xeHentai/cli.py +++ b/xeHentai/cli.py @@ -26,6 +26,11 @@ def start(): opt = parse_opt() xeH = xeHentai() + if opt.auto_update != "off": + check_update(xeH.logger, { + "auto_update": opt.auto_update, + "update_beta_channel": opt.update_beta_channel, + }) if opt.daemon: if opt.interactive: xeH.logger.warning(i18n.XEH_OPT_IGNORING_I) @@ -44,6 +49,13 @@ def start(): else: main(xeH, opt) +def check_update(l, cfg): + from .updater.updater import check_update + t = Thread(name="updater", target=check_update, args=(l, cfg)) + t.setDaemon(True) + t.start() + return t + def main(xeH, opt): xeH.update_config(**vars(opt)) log = xeH.logger @@ -181,6 +193,10 @@ def parse_opt(): current = ERR_QUOTA_EXCEEDED in _def['ignored_errors'], add_value = ERR_QUOTA_EXCEEDED, dest='ignored_errors', help = i18n.XEH_OPT_f) + parser.add_argument('--auto-update', default = _def['auto_update'], choices = ("check", "download", "off"), + dest = 'auto_update', help = i18n.XEH_OPT_auto_update) + parser.add_argument('--update-beta-channel', type = bool, metavar = "BOOL", default = _def['update_beta_channel'], + dest = 'update_beta_channel', help = i18n.XEH_OPT_update_beta_channel) parser.add_argument('-l', '--logpath', metavar = '/path/to/eh.log', default = os.path.abspath(_def['log_path']), help = i18n.XEH_OPT_l) diff --git a/xeHentai/config.py b/xeHentai/config.py index 3f540d6..3af4545 100644 --- a/xeHentai/config.py +++ b/xeHentai/config.py @@ -66,4 +66,9 @@ delete_task_files = False # retry a connection if per thread speed is lower than this value, unit is KB per second -low_speed_threshold = 10 \ No newline at end of file +low_speed_threshold = 10 + +# turn on auto update of program "check" for check only and "download" for download +auto_update = "download" +# set to true to update to dev branch +update_beta_channel = False \ No newline at end of file diff --git a/xeHentai/const.py b/xeHentai/const.py index e7e2b3b..1b99ff4 100644 --- a/xeHentai/const.py +++ b/xeHentai/const.py @@ -16,8 +16,9 @@ CODEPAGE = locale.getdefaultlocale()[1] or 'ascii' ANDROID = 'ANDROID_ARGUMENT' in os.environ -__version__ = 2.022 -DEVELOPMENT = False +__version__ = 2.023 +VERSION_UPDATE = "" +DEVELOPMENT = True SCRIPT_NAME = "xeHentai" @@ -29,6 +30,9 @@ # The application is not frozen # Change this bit to match where you store your data files: FILEPATH = sys.path[0] + # if update is being injected + if FILEPATH.endswith(".zip"): + FILEPATH = sys.path[1] DUMMY_FILENAME = "-dummy-" RENAME_TMPDIR = "-xeh-conflict-" @@ -37,6 +41,9 @@ STATIC_CACHE_TTL = 3600 STATIC_CACHE_VERSION = 1 +SRC_UPDATE_FILE = os.path.join(FILEPATH, "src.zip") +SRC_UPDATE_VERSION = 1 + RE_INDEX = re.compile('.+/(\d+)/([^\/]+)/*') RE_GALLERY = re.compile('/([a-f0-9]{10})/[^\-]+\-(\d+)') RE_IMGHASH = re.compile('/([a-f0-9]{40})-(\d+)-(\d+)-(\d+)-([a-z]{,4})') diff --git a/xeHentai/core.py b/xeHentai/core.py index 5a03e91..3335e55 100644 --- a/xeHentai/core.py +++ b/xeHentai/core.py @@ -37,6 +37,8 @@ class xeHentai(object): def __init__(self): self.verstr = "%.3f%s" % (__version__, '-dev' if DEVELOPMENT else "") + if VERSION_UPDATE: + self.verstr = "%s-%s(%s)" % (self.verstr, VERSION_UPDATE[:7], VERSION_UPDATE_LOADER) self.logger = logger.Logger() self._exit = False self.tasks = Queue() # for queueing, stores gid only diff --git a/xeHentai/i18n/en_us.py b/xeHentai/i18n/en_us.py index 5f9731f..f73a66a 100644 --- a/xeHentai/i18n/en_us.py +++ b/xeHentai/i18n/en_us.py @@ -70,6 +70,8 @@ XEH_OPT_h = "show this help message and exit" XEH_OPT_version = "show program's version number and exit" XEH_OPT_IGNORING_I = "ignoring -i option in daemon mode" +XEH_OPT_auto_update = "check or download update automatically" +XEH_OPT_update_beta_channel = "check update upon beta channel" PS_LOGIN = "login to exhentai (y/n)? > " PS_USERNAME = "Username > " @@ -135,3 +137,13 @@ QUEUE = "queue" PROXY_DISABLE_BANNED = "disable a banned proxy, expire in about %ss" + +UPDATE_CHANNEL = "Update channel is: %s" +UPDATE_DEV_CHANNEL = "dev" +UPDATE_RELEASE_CHANNEL = "release" +UPDATE_FAILED = "Failure when updating program: %s" +UPDATE_COMPLETE = "Update is complete, it will take effect on next run" +UPDATE_NO_UPDATE = "Program is up-to-date" +UPDATE_AVAILABLE = "Update available: %s \"%s\" (%s)" +UPDATE_DOWNLOAD_MANUALLY = "You can download update from https://dl.yooooo.us/share/xeHentai/" + diff --git a/xeHentai/i18n/zh_hans.py b/xeHentai/i18n/zh_hans.py index faea1c8..e518b79 100644 --- a/xeHentai/i18n/zh_hans.py +++ b/xeHentai/i18n/zh_hans.py @@ -67,6 +67,8 @@ XEH_OPT_h = "显示本帮助信息" XEH_OPT_version = "显示版本信息" XEH_OPT_IGNORING_I = "后台模式已忽略 -i 参数" +XEH_OPT_auto_update = "检查并自动下载更新" +XEH_OPT_update_beta_channel = "是否更新到测试分支" PS_LOGIN = "当前没有登陆,要登陆吗 (y/n)? > " @@ -133,3 +135,12 @@ QUEUE = "队列" PROXY_DISABLE_BANNED = "禁用了一个被ban的代理,将在约%s秒后恢复" + +UPDATE_CHANNEL = "更新渠道为: %s" +UPDATE_DEV_CHANNEL = "测试版" +UPDATE_RELEASE_CHANNEL = "正式版" +UPDATE_FAILED = "更新时遇到错误: %s" +UPDATE_COMPLETE = "更新完成,请重新启动程序应用更新" +UPDATE_NO_UPDATE = "没有可用更新" +UPDATE_AVAILABLE = "发现可用的更新: 发布于 %s \"%s\" (%s)" +UPDATE_DOWNLOAD_MANUALLY = "可以从 https://dl.yooooo.us/share/xeHentai/ 下载更新" diff --git a/xeHentai/i18n/zh_hant.py b/xeHentai/i18n/zh_hant.py index 696f854..788c1f0 100644 --- a/xeHentai/i18n/zh_hant.py +++ b/xeHentai/i18n/zh_hant.py @@ -67,6 +67,8 @@ XEH_OPT_h = "顯示本幫助信息" XEH_OPT_version = "顯示版本信息" XEH_OPT_IGNORING_I = "後台模式已忽略 -i 參數" +XEH_OPT_auto_update = "檢查並自動下載更新" +XEH_OPT_update_beta_channel = "是否更新到測試分支" PS_LOGIN = "當前沒有登陸,要登陸嗎 (y/n)? > " @@ -133,3 +135,12 @@ QUEUE = "隊列" PROXY_DISABLE_BANNED = "禁用了一個被ban的代理,將在約%s秒後恢復" + +UPDATE_CHANNEL = "更新渠道為: %s" +UPDATE_DEV_CHANNEL = "測試版" +UPDATE_RELEASE_CHANNEL = "正式版" +UPDATE_FAILED = "更新時遇到錯誤: %s" +UPDATE_COMPLETE = "更新完成,請重新啟動程序應用更新" +UPDATE_NO_UPDATE = "沒有可用更新" +UPDATE_AVAILABLE = "發現可用的更新: 發布於 %s \"%s\" (%s)" +UPDATE_DOWNLOAD_MANUALLY = "可以從 https://dl.yooooo.us/share/xeHentai/ 下載更新" diff --git a/xeHentai/rpc.py b/xeHentai/rpc.py index a1e0ebd..c93e7e7 100644 --- a/xeHentai/rpc.py +++ b/xeHentai/rpc.py @@ -19,7 +19,6 @@ if PY3K: from socketserver import ThreadingMixIn from http.server import HTTPServer, BaseHTTPRequestHandler - from io import IOBase from io import BytesIO as StringIO from urllib.parse import urlparse else: diff --git a/xeHentai/updater/__init__.py b/xeHentai/updater/__init__.py new file mode 100644 index 0000000..c637c8b --- /dev/null +++ b/xeHentai/updater/__init__.py @@ -0,0 +1,18 @@ +# coding:utf-8 +# Contributor: +# fffonion + +class Updater(object): + def get_latest_release(self, dev=False): + raise NotImplementedError("get_latest_release not implemented") + + def get_src_path_in_archive(self, info): + raise NotImplementedError("get_src_path_in_archive not implemented") + +class UpdateInfo(object): + def __init__(self, update_id, download_link, ts, message): + self.update_id = update_id + self.download_link = download_link + self.message = message + self.ts = ts + diff --git a/xeHentai/updater/github.py b/xeHentai/updater/github.py new file mode 100644 index 0000000..9052c70 --- /dev/null +++ b/xeHentai/updater/github.py @@ -0,0 +1,29 @@ +# coding:utf-8 +# Contributor: +# fffonion + +import requests +import time + +from . import Updater, UpdateInfo + +class GithubUpdater(Updater): + def __init__(self, session): + self.session = session + + def get_latest_release(self, dev=False): + param = dev and "dev" or "master" + r = self.session.get("https://api.github.com/repos/fffonion/xeHentai/commits?sha=%s" % param) + commit = r.json()[0] + sha = commit["sha"] + url = "https://github.com/fffonion/xeHentai/archive/%s.zip" % sha + + return UpdateInfo( + sha, + url, + commit["commit"]["author"]["date"], + commit["commit"]["message"].replace("\r", " ").replace("\n", " "), + ) + + def get_src_path_in_archive(self, info): + return "xeHentai-%s/xeHentai" % info.update_id diff --git a/xeHentai/updater/updater.py b/xeHentai/updater/updater.py new file mode 100644 index 0000000..4fe5a36 --- /dev/null +++ b/xeHentai/updater/updater.py @@ -0,0 +1,63 @@ +# coding:utf-8 +# Contributor: +# fffonion + +import os +import requests +import zipfile +import json +from ..i18n import i18n +from ..util import logger +from ..const import * +from .. import const +from .github import GithubUpdater +from . import UpdateInfo + +if PY3K: + from io import BytesIO as StringIO +else: + from cStringIO import StringIO + +def check_update(l=None, config={}): + if not l: + l = logger.Logger() + dev = "update_beta_channel" in config and config["update_beta_channel"] + download_update = "auto_update" in config and config["auto_update"] == "download" + l.debug(i18n.UPDATE_CHANNEL % (dev and i18n.UPDATE_DEV_CHANNEL or i18n.UPDATE_RELEASE_CHANNEL)) + s = requests.Session() + g = GithubUpdater(s) + try: + info = g.get_latest_release(dev) + if hasattr(const, "VERSION_UPDATE") and VERSION_UPDATE == info.update_id: + l.debug(i18n.UPDATE_NO_UPDATE) + return + l.info(i18n.UPDATE_AVAILABLE % (info.ts, info.message, info.update_id)) + if not download_update: + l.info(i18n.UPDATE_DOWNLOAD_MANUALLY) + return + resp = s.get(info.download_link) + z = resp.content + with zipfile.ZipFile(StringIO(z)) as zf: + make_src_update_file(zf, g.get_src_path_in_archive(info), info) + l.info(i18n.UPDATE_COMPLETE) + except MemoryError as ex: + l.warn(i18n.UPDATE_FAILED % str(ex)) + + +def make_src_update_file(infile, path, info): + if not path.endswith("/"): + path += "/" + + with zipfile.ZipFile(SRC_UPDATE_FILE, "w") as z: + z.writestr( + "info.json", + json.dumps({ + "v": SRC_UPDATE_VERSION, + "update_id": info.update_id, + }), + zipfile.ZIP_STORED, + ) + + for f in infile.namelist(): + if f.startswith(path) and not f.endswith("/"): + z.writestr("xeHentai/%s" % f[len(path):], infile.read(f), zipfile.ZIP_STORED) \ No newline at end of file