LAST UPDATE: July 23rd 2018, 5:54:46 AM

想搞个本地服务器让电脑里的视频能直接在网页播放,方便瘫在床上的时候看
(顺便拿 flask 练练手

idea 前的尝试

http.server: 体验不佳,不能拖进度,有些不能播放而是直接下载,还会报奇奇怪怪的错误
MX Player : 用软解可以播,但是麻烦,要去复制链接

第一次尝试

距离第一次用 flask 已经有点年头了,手生


有些视频是跟字幕文件一起放在一个文件夹,有些自带字幕,不想给每个视频都对应建文件夹,选择用生成器递归遍历文件夹获取视频文件,外层一个get_video_list函数调用生成器,处理后给模板提供数据

def flatten_dir(dir: str):
    for item in pathlib.Path(dir).iterdir():
        if pathlib.Path(item).is_dir():
            yield from flatten_dir(str(item))
        else:
            yield item

def get_video_file(dir: str):
    if not pathlib.Path(dir).is_dir():
        raise ValueError(f"{dir} is not a directory.")
        return
    for item in flatten_dir(dir):
        yield item.stem

...

@app.route('/')
def index():
    return render_template('index.html', videos=get_video_file(VIDEO_DIR))

于是可以把其他文件夹链接到一个,虽然这样的结构好丑,但是将就一下先,重点是播放


单纯嵌入 video 标签中,出现以下情况

  • 有些是直接下载
  • 有些直接可以播放
  • 有些要等很久才能播放

查了一下发现在浏览器用原生 video 标签直接播放视频有视频格式和音频格式要求:

  • 都不符合的直接下载
  • 部分符合的可以播放,但是不符合的部分会有问题
  • 完全符合的直接播放

从浏览器兼容方面考虑,最终选择了视频格式 h264、音频格式 aac 作为每个视频的格式


接下来这段是本次尝试的核心,以及最后失败

结合以上内容,对于每个不符合要求的视频,用 ffmpeg 每次切10秒转码后传到客户端
但是失败了……只能播放第一个10秒,毕竟转出来的视频只有这部分信息
要么就得每次用js慢慢替换,但是会有明显的延迟(体验极差.jpg)

第二次尝试

大概有3/4的视频、音频格式是正确的,所以换个做法:

  • 用 ffprobe 筛出格式正确的,网页上只显示正确的视频
  • 格式不正确的视频传到另外的程序处理,用 gpu 加持的 ffmpeg 一次性转码
  • 没字幕的加上字幕
  • 前端是时候修饰一下了

前端选用了 semantic ui
播放器用了 video.js

在找上面这两个的过程中了解到了一个可以在 h5 播放器添加 ass 字幕的库 ass.js
但是尝试后部分字幕效果不佳,放弃


@app.route('/')
def index():
    return render_template('index.html', videos=get_video_file(VIDEO_DIR))

传进模板的生成器每次都需要运行,导致访问首页等很久
改成了在运行绑定监听端口之前先获取需要传进去的列表,每次请求只需把提前获取的列表传进去
但当有新文件或其他修改的时候,这个列表并没有修改


check_video_audio_format = lambda video, audio: video == 'h264' and audio == 'aac'
def get_video_list(dirs: list):
    for dir in dirs:
        if not pathlib.Path(dir).is_dir():
            app.logger.warning(f"{dir} is not a directory.")
            continue
        for item in flatten_dir(dir):
            if item.name in IGNORES:
                continue
            FILE_PATH[item.stem] = FILE_PATH.get(item.stem, {
                'video': {
                    'path': None,
                    'info': {},
                    'mime': None
                },
                'tracker': None
            })
            if magic.from_file(str(item), mime=True).startswith('video'):
                FILE_PATH[item.stem]['video']['path'] = str(item)
                FILE_PATH[item.stem]['video']['info'] = get_video_info(item.stem)
                FILE_PATH[item.stem]['video']['mime'] = magic.from_file(str(item), mime=True)
                if check_video_audio_format(**FILE_PATH[item.stem]['video']['info']['codec']):
                    yield {'video': item.stem, 'size': FILE_PATH[item.stem]['video']['info']['size']}
            if item.suffix == '.vtt':
                FILE_PATH[item.stem]['tracker'] = str(item)


bytes_to_gigab = lambda s: f'{float(s) / 1024 / 1024 / 1024:.3} G'
def get_video_info(video: str):
    raw_video_info = json.loads(subprocess.check_output([
        'ffprobe',
            '-v', 'quiet',
            '-print_format', 'json',
            '-show_format',
            '-show_streams',
            '-i', FILE_PATH[video]['video']['path']
    ]))
    video_info = {
        'size': 0,
        'codec': {
            'video': None,
            'audio': None
        }
    }
    video_info['size'] = bytes_to_gigab(raw_video_info['format']['size'])
    for info in raw_video_info['streams']:
        if info['codec_type'] in {'video', 'audio'}:
            codec = video_info['codec'][info['codec_type']] or info['codec_name']
            video_info['codec'][info['codec_type']] = codec
    return video_info

send_file 很方便啊,了解了,视频、下载、字幕、图片都用这个函数

@app.route('/v/<string:video>')
def send_video(video: str):
    return send_file(FILE_PATH[video]['video']['path'])


@app.route('/d/<string:video>')
def download_vidoe(video: str):
    return send_file(FILE_PATH[video]['video']['path'], as_attachment=True)

@app.route('/t/<string:video>')
def send_tracker(video: str):
    return send_file(FILE_PATH[video]['tracker'])


@app.route('/p/<string:video>')
def send_poster(video: str):
    return send_file(POSTER_DIR + video)

但是这个时候拖动进度条会导致视频从头开始播放
抓了一下B站的视频发现发出的请求中有个range字段,响应中有Content-Range字段
查了一下这个叫 range request

  1. 服务端需要用Accept-Ranges: bytes告知客户端该服务器支持范围请求
  2. 然后客户端可以用range字段进行请求
  3. 服务端发出的响应中,状态码为206 Partial Content,包含Content-Range字段,格式为
    Content-Range: bytes (unit first byte pos) - [last byte pos]/[entity length]

于是用app.after_request为每个response都加了这个header:

@app.after_request
def after_request(response: Response):
    response.headers.add('Accept-Ranges', 'bytes')
    return response

尝试拖动进度条后发现进度条没有跳回起点,但是等待的时间与跳过的进度长度成正比
查看浏览器网络工具发现拖动进度条后必须连续加载到当前点才会继续播放

查阅flask文档发现send_file函数中还必须设定参数conditional=True,才能真正支持range request

If conditional=True and filename is provided, this method will try to upgrade the response stream to support range requests. This will allow the request to be answered with partial content response.

@app.route('/v/<string:video>')
def send_video(video: str):
    return send_file(FILE_PATH[video]['video']['path'], conditional=True)

TODO

  1. 拆分功能,把维护视频信息拆给负责转码的程序
  2. 视频信息存储持久化,由转码服务负责维护,流媒体服务初始化时读取
  3. 流媒体服务和转码服务之间消息通信,有新可用视频时由转码服务通知流媒体服务
  4. 转码 ffmpeg with gpu
  5. docker