├── .gitignore ├── LICENSE ├── README.md ├── funnel.png ├── setup.py ├── tests └── test_funnel.py └── video_funnel ├── __init__.py ├── __main__.py ├── funnel.py ├── index.dev.html ├── index.html ├── server.py └── utils.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | .static_storage/ 56 | .media/ 57 | local_settings.py 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Chen Shuaimin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
让你在线看视频也能达到多线程下载的速度
5 | 6 | #### 马上使用: 7 | 8 | 1. 从 [PyPI](https://pypi.python.org/pypi/video_funnel) 安装: 9 | ```bash 10 | $ pip(3) install --user video_funnel 11 | # or 12 | $ sudo pip(3) install video_funnel 13 | ``` 14 | 15 | 2. 启动 `video_funnel` 的服务器: 16 | ```console 17 | $ vf -u http://pcs.baidu.com/... & 18 | * Listening at port 8080 ... 19 | ``` 20 | 百度网盘的直链有很多油猴脚本可以获取,比如[这个](https://github.com/syhyz1990/baiduyun),注意加上 Cookie 参数。 21 | 22 | 3. 用 `vlc` 播放: 23 | ```bash 24 | $ vlc http://localhost:8080 25 | ``` 26 | `mpv` 播放时会出现 `Seek failed` 的错误,原因未知(如果有路过的大神遇见过类似情况,请一定给我解释下~) #2 27 | 28 | 另外 @Zxilly 贡献了个 Web UI,启动 vf 时不加 `-u` 参数即可使用。 29 | 30 | #### 动机: 31 | 32 | 众所周知,百度网盘之类产品的视频在线播放非常模糊,下载吧又限速,于是我写了 [aiodl](https://github.com/cshuaimin/aiodl) 这个下载器,通过 [EX-百度云盘](https://github.com/gxvv/ex-baiduyunpan/) 获取的直链来“多线程”下载。可是每次都要下载完才能看又十分不爽,直接用 mpv 之类的播放器播放直链又因为限速的原因根本没法看,遂有了本项目。 33 | 34 | #### 实现思路: 35 | 36 | 1. 先将视频按照一定大小分块。块的大小根据视频的清晰度而异,可通过命令行参数 `--block-size/-b` 来指定,默认为 4MB 。 37 | 2. 对于上一步中的一个块,再次分块——为区别改叫切片,启动多个协程来下载这些切片,以实现“多线程”提速的目的。块和切片大小一起决定了有多少个连接在同时下载。切片的大小通过 `--piece-size/-p` 来指定,默认为 1MB 。即默认有 4 个连接同时下载。 38 | 3. 一个块中的切片全部下载完后,就可以将数据传给播放器了。当播放器播放这一块的时候,回到第 2 步下载下一块数据。为节省内存,设置了在内存中最多存在 2 个下载完而又没有传给播放器的块。 39 | 4. 该如何把数据传给播放器呢?我最初的设想是通过标准输出,这样简单好写。但 stdio 是无法 seek 的,这就意味着你只能从视频的开头看起,无法快进 :P 现在的解决方案是用 HTTP 协议与播放器传输数据。需要快进的时候播放器发送 HTTP Range 请求,video_funnel 将请求中的范围经过分块、切片后“多线程”下载。 40 | -------------------------------------------------------------------------------- /funnel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshuaimin/video-funnel/1535f8c75f6c3ced2bc3a1b4eb2a49bc283e26de/funnel.png -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | version = '0.3.0' 4 | 5 | setup( 6 | name='video_funnel', 7 | packages=['video_funnel'], 8 | version=version, 9 | description='Use multiple connections to request the video, then feed the combined data to the player.', 10 | author='Chen Shuaimin', 11 | author_email='chen_shuaimin@outlook.com', 12 | url='https://github.com/cshuaimin/video-funnel', 13 | python_requires='>=3.6', 14 | install_requires=['aiohttp == 3.5.4', 'argparse', 'tqdm', 'browsercookie', 'pycookiecheat'], 15 | package_data={'video_funnel': ['index.html']}, 16 | 17 | classifiers=[ 18 | 'Development Status :: 3 - Alpha', 19 | 20 | 'License :: OSI Approved :: MIT License', 21 | 22 | 'Programming Language :: Python :: 3.6', 23 | ], 24 | license="MIT", 25 | keywords='Online multi-threaded video play', 26 | entry_points={ 27 | 'console_scripts': [ 28 | 'vf = video_funnel.__main__:main' 29 | ] 30 | } 31 | ) 32 | -------------------------------------------------------------------------------- /tests/test_funnel.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from io import BytesIO 3 | from random import getrandbits 4 | 5 | from aiohttp import web 6 | 7 | from video_funnel.funnel import Funnel 8 | from video_funnel.utils import HttpRange 9 | 10 | 11 | async def test_funnel(tmp_path, aiohttp_client): 12 | tmp_file = tmp_path / 'test' 13 | data = bytes(getrandbits(8) for _ in range(16)) 14 | tmp_file.write_bytes(data) 15 | 16 | async def serve_file(request): 17 | # FileResponse supports range requests. 18 | return web.FileResponse(tmp_file) 19 | 20 | app = web.Application() 21 | app.router.add_get('/', serve_file) 22 | session = await aiohttp_client(app) 23 | 24 | async def test(block_size, piece_size): 25 | r = HttpRange(0, len(data) - 1) 26 | buf = BytesIO() 27 | async with Funnel( 28 | url='/', 29 | range=r, 30 | session=session, 31 | block_size=block_size, 32 | piece_size=piece_size) as funnel: 33 | async for block in funnel: 34 | buf.write(block) 35 | assert buf.getvalue() == data 36 | 37 | tests = [] 38 | for block_size in range(1, len(data) + 1): 39 | for piece_size in range(1, block_size + 1): 40 | tests.append(asyncio.create_task(test(block_size, piece_size))) 41 | await asyncio.gather(*tests) 42 | -------------------------------------------------------------------------------- /video_funnel/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshuaimin/video-funnel/1535f8c75f6c3ced2bc3a1b4eb2a49bc283e26de/video_funnel/__init__.py -------------------------------------------------------------------------------- /video_funnel/__main__.py: -------------------------------------------------------------------------------- 1 | from argparse import ArgumentParser 2 | 3 | from aiohttp import web 4 | 5 | from .server import make_app 6 | 7 | 8 | def make_args(): 9 | ap = ArgumentParser( 10 | description='Video Funnel -- Use multiple connections to request the ' 11 | 'video, then feed the combined data to the player.') 12 | 13 | ap.add_argument( 14 | '--url', 15 | '-u', 16 | help='the video url, omitting it to run VF in WebUI mode ' 17 | '(in this mode, http://127.0.0.1:8080 returns the WebUI ' 18 | 'instead of the video stream)') 19 | ap.add_argument('--port', type=int, default=8080, help='port to listen') 20 | ap.add_argument( 21 | '--block-size', 22 | '-b', 23 | metavar='N', 24 | default='4M', 25 | help='size of one block') 26 | ap.add_argument( 27 | '--piece-size', 28 | '-p', 29 | metavar='N', 30 | default='1M', 31 | help='size of one piece') 32 | ap.add_argument( 33 | '--cookies-from', 34 | '-c', 35 | choices=['chrome', 'chromium', 'firefox'], 36 | help='load browser cookies') 37 | ap.add_argument( 38 | '--use-original-url', 39 | '-g', 40 | action='store_true', 41 | help='always use the original URL ' 42 | '(no optimization for 3XX response code)') 43 | 44 | return ap.parse_args() 45 | 46 | 47 | def main(): 48 | args = make_args() 49 | print(f'* Listening at port {args.port} ...') 50 | web.run_app(make_app(args), print=None, port=args.port) 51 | 52 | 53 | if __name__ == '__main__': 54 | main() 55 | -------------------------------------------------------------------------------- /video_funnel/funnel.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from io import BytesIO 3 | 4 | import aiohttp 5 | from tqdm import tqdm 6 | 7 | from .utils import RangeNotSupportedError, hook_print, retry 8 | 9 | 10 | class Funnel: 11 | def __init__(self, url, range, session, block_size, piece_size): 12 | self.url = url 13 | self.range = range 14 | self.session = session 15 | self.block_size = block_size 16 | self.piece_size = piece_size 17 | self.blocks = asyncio.Queue(maxsize=2) 18 | 19 | async def __aenter__(self): 20 | self.producer = asyncio.ensure_future(self.produce_blocks()) 21 | return self 22 | 23 | async def __aexit__(self, type, value, tb): 24 | self.producer.cancel() 25 | while not self.blocks.empty(): 26 | self.blocks.get_nowait() 27 | await self.producer 28 | 29 | # needs Python 3.6 30 | async def __aiter__(self): 31 | while not (self.producer.done() and self.blocks.empty()): 32 | chunk = await self.blocks.get() 33 | if isinstance(chunk, Exception): 34 | raise chunk 35 | yield chunk 36 | 37 | @retry 38 | async def request_piece(self, range, block_begin, bar): 39 | headers = {'Range': 'bytes={0.begin}-{0.end}'.format(range)} 40 | async with self.session.get(self.url, headers=headers) as resp: 41 | if resp.status != 206: 42 | raise RangeNotSupportedError 43 | async for chunk in resp.content.iter_any(): 44 | # Here multiple request_piece() coroutines collaborate on 45 | # this buffer to generate the *block*, so we need to seek to 46 | # the correct position before writing. 47 | # The range parameter is the offset in the *entire file*, 48 | # so we need to convert it to an offset relative to the block. 49 | self.buffer.seek(range.begin - block_begin) 50 | self.buffer.write(chunk) 51 | bar.update(len(chunk)) 52 | range.begin += len(chunk) 53 | 54 | async def produce_blocks(self): 55 | for nr, block in enumerate(self.range.subranges(self.block_size)): 56 | with tqdm( 57 | desc=f'Block #{nr}', 58 | leave=False, 59 | dynamic_ncols=True, 60 | total=block.size(), 61 | unit='B', 62 | unit_scale=True, 63 | unit_divisor=1024) as bar, hook_print(bar.write): 64 | 65 | self.buffer = BytesIO() 66 | futures = [ 67 | asyncio.ensure_future( 68 | self.request_piece(r, block.begin, bar)) 69 | for r in block.subranges(self.piece_size) 70 | ] 71 | try: 72 | await asyncio.gather(*futures) 73 | await self.blocks.put(self.buffer.getvalue()) 74 | except (asyncio.CancelledError, aiohttp.ClientError) as exc: 75 | for f in futures: 76 | f.cancel() 77 | # Notify the consumer to leave 78 | # -- which is waiting at the end of this queue! 79 | await self.blocks.put(exc) 80 | return 81 | -------------------------------------------------------------------------------- /video_funnel/index.dev.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |