├── constants.py ├── web ├── constants.py ├── queues │ └── .gitignore ├── uploads │ └── .gitignore ├── .env.example ├── app.py ├── views │ ├── __init__.py │ ├── static.py │ ├── api.py │ ├── home.py │ └── manage.py ├── assets │ ├── css │ │ ├── layout.css │ │ └── video-js.min.css │ └── js │ │ ├── utils.js │ │ └── videojs.hotkeys.min.js ├── utils.py ├── schedule.py ├── middleware.py ├── templates │ ├── playlist.html │ ├── tag.html │ ├── login.html │ ├── play.html │ ├── home.html │ ├── videos.html │ ├── offline.html │ ├── tags.html │ └── video.html └── models.py ├── uploader ├── .gitignore ├── local.py └── example.py ├── .gitignore ├── .env.example ├── Dockerfile ├── docker-compose.yml ├── docs └── server-deployment.md ├── .github └── issue_template.md ├── ls.py ├── migration.sql ├── info.py ├── LICENSE ├── README.md ├── test.py ├── utils.py └── up.py /constants.py: -------------------------------------------------------------------------------- 1 | VERSION='20.5.1' 2 | -------------------------------------------------------------------------------- /web/constants.py: -------------------------------------------------------------------------------- 1 | VERSION='20.5.1' 2 | -------------------------------------------------------------------------------- /web/queues/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore -------------------------------------------------------------------------------- /web/uploads/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore -------------------------------------------------------------------------------- /web/.env.example: -------------------------------------------------------------------------------- 1 | CLOSEUI=NO 2 | SECRET=NONE 3 | ENABLE_UPLOAD=NO 4 | -------------------------------------------------------------------------------- /uploader/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | !local.py 4 | !example.py 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | tmp/ 2 | .env* 3 | .idea 4 | .vscode 5 | **/*.db 6 | **/__pycache__ -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | NOSERVER=NO 2 | SECRET=NONE 3 | VCODEC=h264 4 | APIURL=http://your-domain.com 5 | ENCRYPTION=NO 6 | 7 | UPLOAD_DRIVE=example 8 | EXAMPLE_TOKEN= 9 | -------------------------------------------------------------------------------- /web/app.py: -------------------------------------------------------------------------------- 1 | from dotenv import load_dotenv 2 | load_dotenv() 3 | 4 | import schedule 5 | from views import app 6 | 7 | if __name__ == '__main__': 8 | app.run(host='0.0.0.0', port='3395', debug=True) 9 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine 2 | RUN apk update \ 3 | && apk add python3 py3-pip \ 4 | && pip3 install Flask peewee gunicorn python-dotenv 5 | WORKDIR /var/app 6 | COPY . . 7 | WORKDIR /var/app/web 8 | ENTRYPOINT ["gunicorn","app:app","-b","0.0.0.0:3395","--workers=5","--threads=2"] 9 | EXPOSE 3395 10 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | 3 | services: 4 | free-hls: 5 | build: . 6 | container_name: 'free-hls' 7 | volumes: 8 | - app:/var/app 9 | logging: 10 | options: 11 | max-size: '1G' 12 | max-file: '10' 13 | ports: 14 | - '33950:3395' 15 | restart: always 16 | 17 | volumes: 18 | app: 19 | -------------------------------------------------------------------------------- /web/views/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | from flask import Flask 3 | from os import getenv as _ 4 | from constants import VERSION 5 | 6 | app = Flask(__name__, root_path=os.getcwd()) 7 | # app.config['MAX_CONTENT_LENGTH'] = 20 << 20 8 | 9 | app.add_template_global(name='VERSION', f=VERSION) 10 | app.add_template_global(name='SECRET', f=_('SECRET')) 11 | 12 | from . import api, home, manage, static 13 | -------------------------------------------------------------------------------- /docs/server-deployment.md: -------------------------------------------------------------------------------- 1 | # 其它部署方式 2 | 3 | ## Docker Compose 4 | 5 | ```bash 6 | git clone https://github.com/sxyazi/free-hls.git && cd free-hls 7 | docker-compose up -d 8 | ``` 9 | 10 | ## 手动部署 11 | 12 | 安装最新的 Python3,以及必要包: 13 | 14 | ```bash 15 | apt install -y python3 python3-pip 16 | pip3 install Flask peewee gunicorn python-dotenv 17 | ``` 18 | 19 | 启动服务 20 | 21 | ```bash 22 | cd web 23 | gunicorn app:app -b 0.0.0.0:3395 --workers=5 --threads=2 -D 24 | ``` 25 | -------------------------------------------------------------------------------- /web/views/static.py: -------------------------------------------------------------------------------- 1 | from . import app 2 | from flask import abort, make_response, send_from_directory 3 | 4 | @app.route('/favicon.ico') 5 | def favicon(): 6 | return abort(404) 7 | 8 | @app.route('/assets/') 9 | def send_js(path): 10 | return send_from_directory('assets', path) 11 | 12 | @app.route('/uploads/') 13 | def send_file(path): 14 | r = make_response(send_from_directory('uploads', path)) 15 | r.headers.add('Access-Control-Allow-Origin', '*') 16 | return r 17 | -------------------------------------------------------------------------------- /uploader/local.py: -------------------------------------------------------------------------------- 1 | from os import getenv as _ 2 | from utils import api, md5, upload_wrapper 3 | 4 | class Uploader: 5 | MAX_BYTES = 20 << 20 6 | 7 | @classmethod 8 | def params(cls): 9 | return {'padding': 0} 10 | 11 | @classmethod 12 | @upload_wrapper 13 | def handle(cls, file): 14 | file = file.read() 15 | r = api('POST', 'upload', files={ 16 | 'file': (f'{md5(file)}.ts', file, 'video/mp2t') 17 | }) 18 | 19 | if not r: 20 | return None 21 | 22 | return f'{_("APIURL")}/uploads/{r}' 23 | -------------------------------------------------------------------------------- /.github/issue_template.md: -------------------------------------------------------------------------------- 1 | ## 概述 2 | 3 | 这里写你对 BUG 的概述 4 | 5 | 6 | ## 环境信息 7 | 8 | - 系统版本: 9 | - 视频格式: 10 | - 视频编码: 11 | - ffmpeg 版本: 12 | - python 版本: 13 | - tmp/command.sh 内容:`这里写文件内容` 14 | 15 | 16 | ## 重现步骤 17 | 18 | 1. 19 | 2. 20 | 3. 21 | 22 | 23 | ## 附件 24 | 25 | **日志内容:** 26 | 27 | ```bash 28 | 这里写日志内容 29 | ``` 30 | 31 | 32 | **ffmpeg -formats:** 33 | 34 | ```bash 35 | ffmpeg -formats 执行结果: 36 | 37 | ``` 38 | 39 | 40 | **图片(如果有):** 41 | 42 | ```bash 43 | 图片地址: 44 | ``` 45 | 46 | **视频文件(如果有):** 47 | 48 | ```bash 49 | # 推荐使用 https://send.firefox.com 上传视频 50 | 51 | 视频下载地址: 52 | ``` 53 | -------------------------------------------------------------------------------- /ls.py: -------------------------------------------------------------------------------- 1 | from sys import argv 2 | from utils import api 3 | from os import getenv as _ 4 | from datetime import datetime 5 | from dotenv import load_dotenv 6 | load_dotenv() 7 | argv += [''] * 1 8 | 9 | def main(): 10 | try: 11 | page = int(argv[1]) 12 | except: 13 | page = 1 14 | 15 | result = api('GET', f'paginate?page={page}') 16 | for video in result['data']: 17 | link = f'{_("APIURL")}/play/{video["slug"]}' 18 | date = datetime.strptime(video['created_at'], '%a, %d %b %Y %H:%M:%S GMT') 19 | 20 | print(f'{video["title"]}\t{date}\t{link}') 21 | 22 | 23 | if __name__ == '__main__': 24 | main() 25 | -------------------------------------------------------------------------------- /web/assets/css/layout.css: -------------------------------------------------------------------------------- 1 | a { 2 | text-decoration: none; 3 | } 4 | 5 | .paginator { 6 | display: flex; 7 | font-size: 12px; 8 | align-items: center; 9 | justify-content: space-between; 10 | } 11 | .paginator span { 12 | float: left; 13 | } 14 | .paginator a { 15 | padding: 2px 8px; 16 | display: inline-block; 17 | } 18 | .paginator:empty { 19 | display: none; 20 | } 21 | .paginator a:hover { 22 | opacity: .75; 23 | } 24 | .paginator .links { 25 | display: flex; 26 | align-items: center; 27 | } 28 | .paginate-loading { 29 | display: none; 30 | } 31 | 32 | input.sort { 33 | border: none; 34 | outline: none; 35 | width: 100%; 36 | } 37 | -------------------------------------------------------------------------------- /migration.sql: -------------------------------------------------------------------------------- 1 | --- 在执行数据库迁移前请先备份原数据库 2 | 3 | 4 | --- 版本 5 | --- 20.4.19 to 20.5.1 6 | create table videos_dg_tmp 7 | ( 8 | id INTEGER not null 9 | primary key, 10 | slug VARCHAR(255), 11 | code TEXT not null, 12 | tags VARCHAR(255) not null, 13 | title VARCHAR(255) not null, 14 | params TEXT not null, 15 | output TEXT not null, 16 | status INTEGER not null, 17 | created_at DATETIME not null, 18 | updated_at DATETIME 19 | ); 20 | 21 | insert into videos_dg_tmp(id, slug, code, tags, title, params, created_at, updated_at, output, status) select id, slug, code, tags, title, params, created_at, updated_at, '' as output, 0 as status from videos; 22 | 23 | drop table videos; 24 | 25 | alter table videos_dg_tmp rename to videos; 26 | 27 | create unique index video_slug 28 | on videos (slug); 29 | 30 | create index video_status 31 | on videos (status); 32 | 33 | -------------------------------------------------------------------------------- /info.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | import tempfile 4 | from sys import argv 5 | from dotenv import load_dotenv 6 | from utils import exec, uploader, genrepair, bit_rate, maxbit_rate, video_duration 7 | load_dotenv() 8 | 9 | 10 | def main(): 11 | d = tempfile.mkdtemp() 12 | os.chdir(d) 13 | 14 | name = 'video%s' % os.path.splitext(argv[1])[1] 15 | shutil.copyfile(argv[1], name) 16 | 17 | bitrate = bit_rate(name) 18 | maxbitrate = maxbit_rate(name) 19 | duration = video_duration(name) 20 | repaircmd = genrepair(name, name, uploader().MAX_BYTES) 21 | os.system('clear') 22 | 23 | print('\n=================================') 24 | print('file: %s' % argv[1]) 25 | print('size: %s' % os.path.getsize(name)) 26 | print('bitrate: %s' % bitrate) 27 | print('max_bitrate: %s' % maxbitrate) 28 | print('duration: %s' % duration) 29 | print('genrepair: %s' % repaircmd) 30 | print('=================================\n') 31 | 32 | exec(['rm', '-rf', d]) 33 | 34 | 35 | if __name__ == '__main__': 36 | main() 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 sxyazi 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 | -------------------------------------------------------------------------------- /web/utils.py: -------------------------------------------------------------------------------- 1 | import os, json, shutil, hashlib 2 | from os import path 3 | from flask import request 4 | from werkzeug.utils import secure_filename 5 | 6 | def md5(s, short=False): 7 | md5 = hashlib.md5(s.encode('utf-8')).hexdigest() 8 | return md5[8:24] if short else md5 9 | 10 | def validjson(s): 11 | try: 12 | json.loads(s) 13 | return True 14 | except: 15 | return False 16 | 17 | def filtertags(s): 18 | return ','.join(list(dict.fromkeys(filter(None, s.split(','))))) if s else '' 19 | 20 | def saveupload(dir, full=False): 21 | if 'file' not in request.files: 22 | return 0, 'No file part' 23 | 24 | file = request.files['file'] 25 | if not file or file.filename == '': 26 | return 0, 'No selected file' 27 | 28 | path = os.path.join(dir, secure_filename(file.filename)) 29 | file.save(path) 30 | return 1, path if full else os.path.basename(path) 31 | 32 | def cloudconfig(): 33 | root = path.dirname(path.dirname(path.abspath(__file__))) 34 | config = f'{root}/.env.cloud' 35 | shutil.copy(f'{root}/.env', config) 36 | 37 | with open(config, 'a') as f: 38 | f.write('\n') 39 | f.write('\n') 40 | f.write('NOSERVER=YES\n') 41 | 42 | return config 43 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Free-HLS 2 | 3 | 这是一个免费的 HLS 视频解决方案,即所谓的视频床。本项目提供一整套集成化解决方案,囊括了各环节所需的切片、转码、上传、即时分享等套件。让您可以以更方便、更低廉的方式分享您的视频到任意地方。 4 | 5 | 本项目仅供学习交流使用,在使用过程中对您或他人造成的任何损失我们概不负责。 6 | 7 | ## 服务端 8 | 9 | 使用 Docker 一键部署([其它方式](/docs/server-deployment.md)): 10 | 11 | ```bash 12 | docker run --name free-hls -d -p 33950:3395 -v free-hls-data:/var/app sxyazi/free-hls 13 | ``` 14 | 15 | 执行上面指令后,访问 `http://服务器地址:33950` 若看到 Free-HLS 界面,表示部署成功 16 | 17 | ## 客户端 18 | 19 | 客户端,即 `up.py` 入口,提供对视频资源的切片、转码、上传的支持。可以在任意主机使用,只要您安装了必要的依赖和作出了正确的配置。 20 | 21 | ### 配置 22 | 23 | 安装最新的 Python3,以及必要包: 24 | 25 | ```bash 26 | apt install -y ffmpeg python3 python3-pip 27 | pip3 install requests python-dotenv 28 | ``` 29 | 30 | 复制客户端配置文件 `.env.example` 为 `.env`,将 `APIURL` 改为您的服务器域名或 IP 地址,最后修改 `UPLOAD_DRIVE` 为您的 [上传驱动器](https://github.com/sxyazi/free-hls/wiki/%E4%B8%8A%E4%BC%A0%E9%A9%B1%E5%8A%A8%E5%99%A8),其值为 `uploader` 目录中的文件名。 31 | 32 | ### 开始使用 33 | 34 | 准备好目标视频文件,输入如下指令开始切片、上传: 35 | 36 | ```bash 37 | python3 up.py test.mp4 #默认标题 38 | python3 up.py test.mp4 测试标题 #自定义标题 39 | python3 up.py test.mp4 测试标题 5 #自定义分段大小 40 | 41 | python3 ls.py #列出已上传视频 42 | python3 ls.py 3 #列出已上传视频(第3页,50每页) 43 | ``` 44 | 45 | ## 相似服务 46 | 47 | - [https://github.com/sxzz/free-hls.js](https://github.com/sxzz/free-hls.js) 48 | - [https://github.com/sxzz/free-hls-live](https://github.com/sxzz/free-hls-live) 49 | - [https://github.com/MoeClub/Note/tree/master/ffmpeg](https://github.com/MoeClub/Note/tree/master/ffmpeg) 50 | -------------------------------------------------------------------------------- /web/schedule.py: -------------------------------------------------------------------------------- 1 | import os, sys, datetime 2 | import time, threading, subprocess 3 | from models import Video 4 | from utils import md5, cloudconfig 5 | 6 | def cloud(): 7 | while True: 8 | time.sleep(3) 9 | video = Video.select().where(Video.status == 1).first() 10 | if not video: continue 11 | Video.update(status = 2).where(Video.id == video.id).execute() 12 | 13 | root = os.path.dirname(os.getcwd()) 14 | envfile = cloudconfig() 15 | cmd = [sys.executable, f'{root}/up.py', '-c', envfile, f'{os.getcwd()}/queues/{video.id}'] 16 | Video.update({Video.output: f'{" ".join(cmd)}\n'}).where(Video.id == video.id).execute() 17 | 18 | p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) 19 | while True: 20 | line = p.stdout.readline() 21 | if not line: break 22 | Video.update({Video.output: Video.output + line.decode('utf-8')}).where(Video.id == video.id).execute() 23 | 24 | p.wait() 25 | if p.returncode == 1: 26 | Video.update(status = 3).where(Video.id == video.id).execute() 27 | if p.returncode == 2: 28 | Video.update(status = 1).where(Video.id == video.id).execute() 29 | if p.returncode != 0: 30 | continue 31 | 32 | code = open(f'{root}/tmp/out.m3u8', 'r').read() 33 | Video.update( 34 | status = 0, 35 | code = code, 36 | slug = md5(code, True), 37 | params = open(f'{root}/tmp/params.json', 'r').read(), 38 | updated_at = datetime.datetime.now() 39 | ).where(Video.id == video.id).execute() 40 | 41 | 42 | 43 | threading.Thread(target=cloud).start() 44 | -------------------------------------------------------------------------------- /web/middleware.py: -------------------------------------------------------------------------------- 1 | from os import getenv as _ 2 | from functools import wraps 3 | from constants import VERSION 4 | from flask import request, jsonify, redirect 5 | 6 | def same_version(f): 7 | @wraps(f) 8 | def decorated(*args, **kwargs): 9 | version = request.headers.get('API-Version') 10 | if version == VERSION: 11 | return f(*args, **kwargs) 12 | return jsonify([0, 'Version mismatch']) 13 | 14 | return decorated 15 | 16 | def api_authorize(f): 17 | @wraps(f) 18 | def decorated(*args, **kwargs): 19 | token = request.headers.get('API-Token') 20 | if token == 'NONE' or token == _('SECRET'): 21 | return f(*args, **kwargs) 22 | return jsonify([0, 'Authorization failed']) 23 | 24 | return decorated 25 | 26 | def api_response(f): 27 | @wraps(f) 28 | def decorated(*args, **kwargs): 29 | resp = f(*args, **kwargs) 30 | return jsonify(resp) if isinstance(resp, tuple) else resp 31 | 32 | return decorated 33 | 34 | def api_combined(f): 35 | @wraps(f) 36 | @same_version 37 | @api_authorize 38 | @api_response 39 | def decorated(*args, **kwargs): 40 | return f(*args, **kwargs) 41 | 42 | return decorated 43 | 44 | def mng_authorize(f): 45 | @wraps(f) 46 | def decorated(*args, **kwargs): 47 | secret = request.cookies.get('secret') 48 | if secret == _('SECRET'): 49 | return f(*args, **kwargs) 50 | return redirect('/login') 51 | 52 | return decorated 53 | 54 | def mng_combined(f): 55 | @wraps(f) 56 | @mng_authorize 57 | @api_response 58 | def decorated(*args, **kwargs): 59 | return f(*args, **kwargs) 60 | 61 | return decorated 62 | -------------------------------------------------------------------------------- /test.py: -------------------------------------------------------------------------------- 1 | import os, tempfile, time 2 | from os import getenv as _ 3 | from utils import uploader 4 | from dotenv import load_dotenv 5 | load_dotenv() 6 | 7 | os.environ['UPLOAD_DRIVE'] = os.sys.argv[1] 8 | handle, params = uploader().handle, uploader().params() 9 | 10 | def upload(size): 11 | fd, path = tempfile.mkstemp() 12 | with open(path, 'wb') as f: 13 | f.write(os.urandom(size * 1048576 - params['padding'])) 14 | 15 | r = handle(path) 16 | os.close(fd) 17 | os.unlink(path) 18 | return r 19 | 20 | def test(curr, step): 21 | maps = {} 22 | reve = False 23 | reve_inc = None 24 | print(f'Starting test {_("UPLOAD_DRIVE")}:') 25 | 26 | while True: 27 | if curr in maps: 28 | result = maps[curr] 29 | else: 30 | result = maps[curr] = upload(curr) 31 | print('%dM\t%s\t%s' % (curr, 'OK' if result else 'FAIL', result)) 32 | 33 | if not result: 34 | reve = True 35 | if not reve and curr > 20: 36 | step = 20 37 | if not reve and curr > 50: 38 | step = 30 39 | 40 | if not result and not reve_inc == None: 41 | print(f'\n---\nFinally ... {reve_inc}M') 42 | exit(0) 43 | 44 | if reve and result: 45 | reve_inc = curr 46 | curr += 1 47 | elif reve and not result: 48 | if (curr - 1) % 5 == 0: 49 | curr -= 1 50 | else: 51 | step //= 2 52 | curr -= max(1, step) 53 | if curr < 1: 54 | curr = 1 55 | reve_inc = 0 56 | elif not reve: 57 | curr += step 58 | 59 | 60 | if __name__ == '__main__': 61 | 62 | # print(handle('/Users/ika/Desktop/test/9913509E9DE4492E0E903B4C2C66E98D.gif')) 63 | # print(handle('/Users/ika/Desktop/test/ACFC928140EE4FA072F4D6EB7CB35245.jpg')) 64 | # print(handle('/Users/ika/Desktop/test/out00006.ts')) 65 | 66 | test(1, 10) 67 | -------------------------------------------------------------------------------- /uploader/example.py: -------------------------------------------------------------------------------- 1 | from os import getenv as _ 2 | from utils import session, upload_wrapper 3 | 4 | # 上传驱动器类,类名不要改,一般一个驱动器对应一个网站的上传接口。编写完后,修改 .env 的 UPLOAD_DRIVE 为该驱动器的文件名,如“example.py”,则应该 UPLOAD_DRIVE=example 5 | class Uploader: 6 | # 该上传驱动(即该网站)支持的最大文件大小,单位为字节。10 << 20 表示 10M 7 | # 建议先实现 handle 方法,然后执行 `python3 test.py 驱动器名` 自动化测试出最大允许大小 8 | MAX_BYTES = 10 << 20 9 | # 为了防止上传时检测文件内容格式,在文件前面填充的“图片头”,下面是 PNG 的文件头,按需要可以换成其它(比如 GIF),网上查一下就有了 10 | # 小技巧:能不伪造头就不伪造(很多网站是不检查上传“文件内容”的,只检查“文件名”)。如果要伪造,首选 PNG、GIF,因为这些格式一般不会被“有损压缩”,以确保上传后的视频数据不被损坏 11 | _BITS = b'\x89\x50\x4E\x47\x0D\x0A\x1A\x0A\x00\x00\x00\x0D\x49\x48\x44\x52\x00' \ 12 | + b'\x00\x00\x01\x00\x00\x00\x01\x08\x04\x00\x00\x00\xB5\x1C\x0C\x02\x00' \ 13 | + b'\x00\x00\x0B\x49\x44\x41\x54\x78\x9C\x63\xFA\xCF\x00\x00\x02\x07\x01' \ 14 | + b'\x02\x9A\x1C\x31\x71\x00\x00\x00\x00\x49\x45\x4E\x44\xAE\x42\x60\x82' 15 | 16 | @classmethod 17 | def params(cls): 18 | # 返回图片头的“填充长度”信息,因为在视频播放时,需要把图片头去掉,所以要知道你填充了多少字节在前面(见第24~26行) 19 | return {'padding': len(cls._BITS)} # 如果没填充,直接返回 {'padding': 0} 即可 20 | 21 | @classmethod 22 | @upload_wrapper 23 | def handle(cls, file): 24 | # (可选)给切片后的视频段,添加“图片头”,从而绕过某些网站对非图片文件的上传检测 25 | # 大部分网站都可以靠这个绕过,若不需要可以将下面一行注释。其实很多网站只检查文件名而已,因此只需要伪造文件名(见第32行) 26 | file = cls._BITS + file.read() 27 | 28 | try: 29 | # 上传图片 API 接口地址,通过浏览器抓包得到 30 | r = session.post('https://example.com/upload', files={ 31 | # pic 是上传时的参数名,抓包得到 32 | 'pic': ('image.png', file, 'image/png') # 如某网站只允许传 jpg,那就修改为:('image.jpg', file, 'image/jpeg') 33 | }, headers={ 34 | # 有些网站需要登录才能上传,因此需要带 cookie 35 | # _('EXAMPLE_TOKEN') 指读取 .env 配置文件中的“EXAMPLE_TOKEN”变量,当然这个名字可以随便起 36 | 'Cookie': 'token=%s' % _('EXAMPLE_TOKEN') 37 | }).json() 38 | 39 | # 检查上传结果,若成功,则返回图片地址 40 | return r['uploadInfo']['url'] # 上传后的图片地址 41 | except: 42 | # 上传失败,返回 None 就行了 43 | return None 44 | -------------------------------------------------------------------------------- /web/views/api.py: -------------------------------------------------------------------------------- 1 | import os 2 | from . import app 3 | from os import getenv as _ 4 | from flask import request 5 | from models import Video, Secret 6 | from middleware import api_combined 7 | from utils import md5, saveupload 8 | from playhouse.shortcuts import model_to_dict 9 | from playhouse.flask_utils import PaginatedQuery 10 | 11 | @app.route('/key', methods=['POST']) 12 | @api_combined 13 | def key(): 14 | iv = request.form.get('iv') 15 | key = request.form.get('key') 16 | 17 | if len(iv) != 32 or len(key) != 32: 18 | return 0, 'Invalid key or iv' 19 | 20 | return Secret.add(iv, key) 21 | 22 | @app.route('/upload', methods=['POST']) 23 | @api_combined 24 | def upload(): 25 | if not _('ENABLE_UPLOAD') == 'YES': 26 | return 0, 'Upload is not enabled' 27 | return saveupload('uploads') 28 | 29 | @app.route('/queue', methods=['POST']) 30 | @api_combined 31 | def queue(): 32 | ok, file = saveupload('queues', True) 33 | if not ok: 34 | return 0, file 35 | 36 | video = Video.add( 37 | status = 1, 38 | params = '{}', 39 | tags = request.form.get('tags'), 40 | title = request.form.get('title')) 41 | 42 | os.rename(file, f'{os.path.dirname(file)}/{video.id}') 43 | return 1, video.id 44 | 45 | @app.route('/publish', methods=['POST']) 46 | @api_combined 47 | def publish(): 48 | get = request.form.get 49 | 50 | return Video.createOrUpdate( 51 | id = get('id'), 52 | code = get('code'), 53 | tags = get('tags'), 54 | title = get('title'), 55 | params = get('params'), 56 | slug = get('slug') or md5(get('code'), True)) 57 | 58 | @app.route('/paginate') 59 | @api_combined 60 | def paginate(): 61 | pagination = PaginatedQuery(Video.select().order_by(Video.id.desc()), 50) 62 | 63 | return 1, { 64 | 'pre': 50, 65 | 'page': pagination.get_page(), 66 | 'count': Video.select().count(), 67 | 'data': [{**model_to_dict(video), 'code': None} for video in pagination.get_object_list()] 68 | } 69 | -------------------------------------------------------------------------------- /web/views/home.py: -------------------------------------------------------------------------------- 1 | import os 2 | import binascii 3 | from . import app 4 | from os import getenv as _ 5 | from models import Tag, Video, Secret, VideoTag 6 | from flask import abort, jsonify, request, redirect, Response, render_template 7 | 8 | @app.route('/') 9 | def home(): 10 | if _('CLOSEUI') == 'YES': 11 | return 'Hello Free-HLS!' 12 | 13 | total_tags = Tag.select().count() 14 | total_videos = Video.select().count() 15 | latest_tags = {vtag.tag.id: vtag.tag for vtag in VideoTag.select().join(Tag).order_by(VideoTag.id.desc()).group_by(VideoTag.tag).limit(10)} 16 | video_tags = VideoTag.select().join(Tag).switch(VideoTag).join(Video).where(VideoTag.tag << list(latest_tags)).order_by(VideoTag.id.desc()).limit(50) 17 | 18 | tags_videos = {} 19 | for vt in video_tags: 20 | if vt.tag.id not in tags_videos: 21 | tags_videos[vt.tag.id] = [] 22 | tags_videos[vt.tag.id].append(vt.video) 23 | 24 | return render_template('home.html', total_tags=total_tags, total_videos=total_videos, 25 | latest_tags=latest_tags, tags_videos=tags_videos) 26 | 27 | @app.route('/play/') 28 | def play(slug): 29 | real = os.path.splitext(slug)[0] 30 | 31 | try: 32 | if slug[-4:] == '.key': 33 | secret = Secret.get_by_id(real) 34 | r = Response(binascii.unhexlify(secret.key), mimetype='application/octet-stream') 35 | r.headers.add('Access-Control-Allow-Origin', '*') 36 | return r 37 | 38 | video = Video.get(Video.slug == real) 39 | if slug[-5:] == '.m3u8': 40 | r = Response(video.code, mimetype='application/vnd.apple.mpegurl') 41 | r.headers.add('Access-Control-Allow-Origin', '*') 42 | return r 43 | 44 | return render_template('play.html', video=video, notitle=request.args.get('notitle')) 45 | except: 46 | return jsonify({'err': 1, 'message': 'Resource does not exist'}) 47 | 48 | @app.route('/playlist/') 49 | def playlist(tag_id): 50 | if _('CLOSEUI') == 'YES': 51 | return redirect('/') 52 | 53 | tag = Tag.get_by_id(tag_id) 54 | videos = VideoTag.blend(tag) 55 | watch = request.args.get('watch') or videos[0]['slug'] 56 | 57 | return render_template('playlist.html', tag=tag, watch=watch, videos=videos) 58 | -------------------------------------------------------------------------------- /web/templates/playlist.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 播放列表 7 | 8 | 9 | 10 |
11 |
12 | FreeHLS 13 | 播放列表 14 |
15 | search 16 |
17 |
18 | 19 |
20 | 21 | 22 |
23 |
24 |

{{ tag.name }}

25 |
26 |
27 | 28 | 29 |
30 |
31 | 32 |
33 |
34 | 35 | 36 |
37 |
38 |

视频列表

39 |
    40 | {% for video in videos %} 41 |
  • 42 | play_arrow 43 |
    44 |
    {{ video.title }}
    45 |
    发布于 {{ video.created_at.strftime('%Y-%m-%d %H:%M:%S') }}
    46 |
    47 |
  • 48 | {% endfor %} 49 |
50 |
51 |
52 |
53 | 54 | 55 | 56 | 71 | -------------------------------------------------------------------------------- /web/views/manage.py: -------------------------------------------------------------------------------- 1 | import json 2 | from . import app 3 | from os import getenv as _ 4 | from utils import md5, validjson 5 | from models import Tag, Video, VideoTag 6 | from playhouse.shortcuts import model_to_dict 7 | from playhouse.flask_utils import PaginatedQuery 8 | from middleware import mng_combined, api_response 9 | from flask import request, redirect, make_response, render_template 10 | 11 | 12 | @app.route('/tag', methods=['GET', 'POST']) 13 | @app.route('/tag/', methods=['GET', 'POST']) 14 | @mng_combined 15 | def tag(id = 0): 16 | if request.method == 'POST': 17 | return Tag.edit(id, name=request.form.get('name')) 18 | 19 | tag = Tag.get_by_id(id) 20 | return render_template('tag.html', tag=tag) 21 | 22 | @app.route('/tags') 23 | @mng_combined 24 | def tags(): 25 | if 'q' in request.args: 26 | q = f'%{request.args["q"]}%' 27 | return 1, [model_to_dict(tag) for tag in Tag.select().where(Tag.name ** q).limit(10)] 28 | 29 | if 'list' in request.args: 30 | pagination = PaginatedQuery(Tag.select().order_by(Tag.id.desc()), 50) 31 | return 1, { 32 | 'pre': 50, 33 | 'page': pagination.get_page(), 34 | 'count': Tag.select().count(), 35 | 'data': [model_to_dict(video) for video in pagination.get_object_list()] 36 | } 37 | 38 | return render_template('tags.html') 39 | 40 | 41 | @app.route('/video', methods=['GET', 'POST']) 42 | @app.route('/video/', methods=['GET', 'POST']) 43 | @mng_combined 44 | def video(id = 0): 45 | if 'remove' in request.form: 46 | return Video.remove(id) 47 | 48 | video = Video.get_by_id(id) if id else {} 49 | return render_template('video.html', video=video) 50 | 51 | @app.route('/videos') 52 | @mng_combined 53 | def videos(): 54 | if 'tag' in request.args: 55 | return 1, VideoTag.blend(request.args['tag']) 56 | 57 | return render_template('videos.html') 58 | 59 | 60 | @app.route('/offline', methods=['GET']) 61 | @mng_combined 62 | def offline(id = 0): 63 | return render_template('offline.html') 64 | 65 | 66 | @app.route('/tag_videos', methods=['GET', 'POST']) 67 | @app.route('/tag_videos/', methods=['GET', 'POST']) 68 | @mng_combined 69 | def tag_videos(id = 0): 70 | if 'videos' in request.form: 71 | if not validjson(request.form['videos']): 72 | return 0, 'Videos parame is not a valid JSON' 73 | 74 | return VideoTag.save_videos(id, json.loads(request.form['videos'])) 75 | 76 | return render_template('videos.html') 77 | 78 | 79 | @app.route('/login', methods=['GET', 'POST']) 80 | @api_response 81 | def login(): 82 | if 'auth' in request.args: 83 | if request.args['auth'] != md5(_('SECRET')): 84 | return 0, '无效的 Token' 85 | 86 | resp = make_response(redirect(request.args.get('goto'))) 87 | resp.set_cookie('secret', _('SECRET')) 88 | return resp 89 | 90 | if 'secret' in request.form: 91 | secret = request.form['secret'] 92 | if not _('SECRET') == secret: 93 | return 0, '登录失败' 94 | 95 | resp = make_response('[1, "OK"]') 96 | resp.set_cookie('secret', secret) 97 | return resp 98 | 99 | resp = make_response(render_template('login.html')) 100 | resp.delete_cookie('secret') 101 | return resp 102 | -------------------------------------------------------------------------------- /web/templates/tag.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 更新标签 7 | 8 | 9 | 10 | 12 |
13 |
14 | FreeHLS 15 | 更新标签 16 |
17 | search 18 |
19 |
20 | 21 |
22 | 40 |
41 | 42 |
43 |
44 |
45 |
46 |
47 |
标签信息
48 |
Information
49 |
50 |
51 | 54 |
55 |
56 |
57 |
58 | 59 | 60 |
61 | 62 | 63 |
64 |
65 |
66 |
67 |
68 |
69 | 70 | 71 | 72 | 73 | 85 | -------------------------------------------------------------------------------- /web/assets/js/utils.js: -------------------------------------------------------------------------------- 1 | function api(method, url, data, callback) { 2 | return $.ajax({ 3 | url: '/' + url, 4 | data: data, 5 | type: method, 6 | processData: !(data instanceof FormData), 7 | contentType: data instanceof FormData ? false : 'application/x-www-form-urlencoded', 8 | beforeSend: function (xhr) { 9 | xhr.setRequestHeader('API-Token', window.SECRET || ''); 10 | xhr.setRequestHeader('API-Version', window.VERSION || ''); 11 | }, 12 | success: function(r) { 13 | var ok = r[0], data = r[1]; 14 | if (ok) { 15 | return callback(data) 16 | } 17 | 18 | snackbar(data) 19 | }, 20 | error: function () { 21 | snackbar('Request failed: connection error') 22 | } 23 | }); 24 | } 25 | 26 | function snackbar(msg, callback) { 27 | mdui.snackbar({ 28 | message: msg, 29 | position: 'right-top', 30 | onClosed: function () { 31 | callback && callback() 32 | } 33 | }) 34 | } 35 | 36 | function pagination(elem, callback, page) { 37 | var p = elem.attr('pagination').split(':'), method = p[0], url = p[1]; 38 | var rel = elem.attr('index') ? '[index="' + elem.attr('index') + '"]' : ''; 39 | url += (url.indexOf('?') == -1 ? '?' : '&') + 'page=' + (page || 1); 40 | 41 | $('.paginate-loading' + rel).show(); 42 | api(method, url, {}, function (r) { 43 | var pre = r.pre || r.length || 0, 44 | cur = r.page || 1, 45 | count = r.count || r.length || 0, 46 | pages = Math.ceil(count / pre) || 1, 47 | paginator = '共 ' + count + ' 项'); 89 | $('.paginator[switch]' + rel).click(function () { 90 | pagination(elem, callback, $(this).attr('switch')); 91 | }); 92 | }); 93 | 94 | return function () { pagination(elem, callback, page); } 95 | } 96 | 97 | function dateformat(date) { 98 | var date = new Date(date); 99 | var pad = function (s) { return ('0' + s).substr(-2); } 100 | return date.getFullYear() + '-' + pad(date.getMonth()) + '-' + pad(date.getDate()) + ' ' + pad(date.getHours()) + ':' + pad(date.getMinutes()) 101 | } 102 | -------------------------------------------------------------------------------- /web/templates/login.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 登录 7 | 8 | 9 | 10 | 12 |
13 |
14 | FreeHLS 15 | 登录 16 |
17 | search 18 |
19 |
20 | 21 |
22 | 44 |
45 | 46 |
47 |
48 |
49 |
50 |
51 |
登录
52 |
Login
53 |
54 |
55 | 58 |
59 |
60 |
61 |
62 | 63 | 64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 | 72 | 73 | 74 | 75 | 85 | -------------------------------------------------------------------------------- /web/templates/play.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {{ video.title }} 6 | 7 | 8 | 9 | 63 | 64 | 65 | 66 | {% if not notitle %} 67 | 68 |

{{ video.title }}

69 | {% endif %} 70 | 71 |
72 | 75 |
76 | 77 | 111 | 112 | 113 | 114 | 148 | 149 | 150 | 151 | -------------------------------------------------------------------------------- /web/assets/js/videojs.hotkeys.min.js: -------------------------------------------------------------------------------- 1 | /* videojs-hotkeys v0.2.25 - https://github.com/ctd1500/videojs-hotkeys */ 2 | !function(e,n){"undefined"!=typeof window&&window.videojs?n(window.videojs):"function"==typeof define&&define.amd?define("videojs-hotkeys",["video.js"],function(e){return n(e.default||e)}):"undefined"!=typeof module&&module.exports&&(module.exports=n(require("video.js")))}(0,function(e){"use strict";"undefined"!=typeof window&&(window.videojs_hotkeys={version:"0.2.25"});(e.registerPlugin||e.plugin)("hotkeys",function(n){function t(e){return"function"==typeof s?s(e):s}function r(e){null!=e&&"function"==typeof e.then&&e.then(null,function(e){})}var o=this,u=o.el(),l=document,i={volumeStep:.1,seekStep:5,enableMute:!0,enableVolumeScroll:!0,enableHoverScroll:!1,enableFullscreen:!0,enableNumbers:!0,enableJogStyle:!1,alwaysCaptureHotkeys:!1,enableModifiersForNumbers:!0,enableInactiveFocus:!0,skipInitialFocus:!1,playPauseKey:function(e){return 32===e.which||179===e.which},rewindKey:function(e){return 37===e.which||177===e.which},forwardKey:function(e){return 39===e.which||176===e.which},volumeUpKey:function(e){return 38===e.which},volumeDownKey:function(e){return 40===e.which},muteKey:function(e){return 77===e.which},fullscreenKey:function(e){return 70===e.which},customKeys:{}},c=e.mergeOptions||e.util.mergeOptions,a=(n=c(i,n||{})).volumeStep,s=n.seekStep,m=n.enableMute,f=n.enableVolumeScroll,y=n.enableHoverScroll,v=n.enableFullscreen,d=n.enableNumbers,p=n.enableJogStyle,b=n.alwaysCaptureHotkeys,h=n.enableModifiersForNumbers,w=n.enableInactiveFocus,k=n.skipInitialFocus,S=e.VERSION;u.hasAttribute("tabIndex")||u.setAttribute("tabIndex","-1"),u.style.outline="none",!b&&o.autoplay()||k||o.one("play",function(){u.focus()}),w&&o.on("userinactive",function(){var e=function(){clearTimeout(n)},n=setTimeout(function(){o.off("useractive",e);var n=l.activeElement,t=u.querySelector(".vjs-control-bar");n&&n.parentElement==t&&u.focus()},10);o.one("useractive",e)}),o.on("play",function(){var e=u.querySelector(".iframeblocker");e&&""===e.style.display&&(e.style.display="block",e.style.bottom="39px")});var K=!1,q=u.querySelector(".vjs-volume-menu-button")||u.querySelector(".vjs-volume-panel");null!=q&&(q.onmouseover=function(){K=!0},q.onmouseout=function(){K=!1});var j=function(e){if(y)n=0;else var n=l.activeElement;if(o.controls()&&(b||n==u||n==u.querySelector(".vjs-tech")||n==u.querySelector(".iframeblocker")||n==u.querySelector(".vjs-control-bar")||K)&&f){e=window.event||e;var t=Math.max(-1,Math.min(1,e.wheelDelta||-e.detail));e.preventDefault(),1==t?o.volume(o.volume()+a):-1==t&&o.volume(o.volume()-a)}},F=function(e,t){return n.playPauseKey(e,t)?1:n.rewindKey(e,t)?2:n.forwardKey(e,t)?3:n.volumeUpKey(e,t)?4:n.volumeDownKey(e,t)?5:n.muteKey(e,t)?6:n.fullscreenKey(e,t)?7:void 0};return o.on("keydown",function(e){var i,c,s=e.which,f=e.preventDefault,y=o.duration();if(o.controls()){var w=l.activeElement;if(b||w==u||w==u.querySelector(".vjs-tech")||w==u.querySelector(".vjs-control-bar")||w==u.querySelector(".iframeblocker"))switch(F(e,o)){case 1:f(),b&&e.stopPropagation(),o.paused()?r(o.play()):o.pause();break;case 2:i=!o.paused(),f(),i&&o.pause(),(c=o.currentTime()-t(e))<=0&&(c=0),o.currentTime(c),i&&r(o.play());break;case 3:i=!o.paused(),f(),i&&o.pause(),(c=o.currentTime()+t(e))>=y&&(c=i?y-.001:y),o.currentTime(c),i&&r(o.play());break;case 5:f(),p?(c=o.currentTime()-1,o.currentTime()<=1&&(c=0),o.currentTime(c)):o.volume(o.volume()-a);break;case 4:f(),p?((c=o.currentTime()+1)>=y&&(c=y),o.currentTime(c)):o.volume(o.volume()+a);break;case 6:m&&o.muted(!o.muted());break;case 7:v&&(o.isFullscreen()?o.exitFullscreen():o.requestFullscreen());break;default:if((s>47&&s<59||s>95&&s<106)&&(h||!(e.metaKey||e.ctrlKey||e.altKey))&&d){var k=48;s>95&&(k=96);var S=s-k;f(),o.currentTime(o.duration()*S*.1)}for(var K in n.customKeys){var q=n.customKeys[K];q&&q.key&&q.handler&&q.key(e)&&(f(),q.handler(o,n,e))}}}}),o.on("dblclick",function(e){if(null!=S&&S<="7.1.0"&&o.controls()){var n=e.relatedTarget||e.toElement||l.activeElement;n!=u&&n!=u.querySelector(".vjs-tech")&&n!=u.querySelector(".iframeblocker")||v&&(o.isFullscreen()?o.exitFullscreen():o.requestFullscreen())}}),o.on("mousewheel",j),o.on("DOMMouseScroll",j),this})}); 3 | //# sourceMappingURL=videojs.hotkeys.min.js.map -------------------------------------------------------------------------------- /utils.py: -------------------------------------------------------------------------------- 1 | import os, re, glob, math 2 | import requests, importlib 3 | import shutil, hashlib, subprocess 4 | from os import getenv as _ 5 | from functools import wraps 6 | from constants import VERSION 7 | from requests.utils import requote_uri 8 | 9 | def api(method, url, **kwargs): 10 | if method == 'POST': 11 | fn = requests.post 12 | else: 13 | fn = requests.get 14 | try: 15 | ok, data = fn('%s/%s' % (_('APIURL'), url), **kwargs, timeout=10, headers={ 16 | 'API-Token': _('SECRET'), 17 | 'API-Version': VERSION}).json() 18 | 19 | if ok: return data 20 | print('Request failed: %s' % data) 21 | 22 | except: 23 | print('Request failed: connection error') 24 | 25 | def md5(b): 26 | return hashlib.md5(b).hexdigest() 27 | 28 | def exec(cmd, timeout=None, **kwargs): 29 | p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) 30 | 31 | communicate_kwargs = {} 32 | if timeout is not None: 33 | communicate_kwargs['timeout'] = timeout 34 | 35 | out, err = p.communicate(**communicate_kwargs) 36 | if p.returncode != 0: 37 | raise Exception(cmd, out, err.decode('utf-8')) 38 | 39 | return out 40 | 41 | def execstr(*args, **kwargs): 42 | return exec(*args, **kwargs).decode('utf-8').strip() 43 | 44 | def tsfiles(m3u8): 45 | return re.findall(r'^(?:enc\.)?(?:rep\.)?out\d+\.ts$', m3u8, re.M) 46 | 47 | def safename(file): 48 | return '"' + file.replace('"', '\\"') + '"' 49 | 50 | def sameparams(dir, command): 51 | if not os.path.isdir(dir): 52 | return False 53 | 54 | try: 55 | if open('%s/command.sh' % dir, 'r').read() != command: 56 | shutil.rmtree(dir) 57 | return False 58 | except: 59 | shutil.rmtree(dir) 60 | return False 61 | 62 | return True 63 | 64 | def uploader(): 65 | return importlib.import_module('uploader.' + _('UPLOAD_DRIVE')).Uploader 66 | 67 | def upload_wrapper(f): 68 | @wraps(f) 69 | def decorated(cls, file): 70 | with open(file, 'rb+') as g: 71 | return f(cls, g) 72 | 73 | return decorated 74 | 75 | def manageurl(path): 76 | return '%s/login?auth=%s&goto=%s' % (_('APIURL'), md5(_('SECRET').encode('utf-8')), requote_uri(path if '://' in path else '/' + path)) 77 | 78 | def bit_rate(file): 79 | return int(execstr(['ffprobe','-v','error','-show_entries','format=bit_rate','-of','default=noprint_wrappers=1:nokey=1',file])) 80 | 81 | def maxbit_rate(file): 82 | name = os.path.splitext(file)[0] 83 | os.system('ffmpeg -y -i %s -c copy -map 0:v:0 -f segment -segment_time 1 -break_non_keyframes 1 %s.seg%%05d.ts' % (file, name)) 84 | vrate = bit_rate(sorted(glob.glob('%s.seg*.ts' % name), key=os.path.getsize)[-1]) 85 | 86 | list(map(os.remove, glob.glob('*.seg*.ts'))) 87 | return vrate 88 | 89 | def video_codec(file): 90 | codecs = execstr(['ffprobe','-v','error','-select_streams','v:0','-show_entries','stream=codec_name','-of','default=noprint_wrappers=1:nokey=1',file]) 91 | return _('VCODEC') if set(codecs.split('\n')).difference({'h264'}) else 'copy' 92 | 93 | def video_duration(file): 94 | return float(execstr(['ffprobe','-v','error','-show_entries','format=duration','-of','default=noprint_wrappers=1:nokey=1',file])) 95 | 96 | def genslice(file, time): 97 | sub = '' 98 | rate = bit_rate(file) 99 | vcodec = video_codec(file) 100 | max_bits = uploader().MAX_BYTES * 8 101 | segment_time = min(20, int(max_bits / (rate * 1.35))) 102 | 103 | #SEGMENT_TIME 104 | sub += ' -segment_time %d' % (time or segment_time) 105 | 106 | return 'ffmpeg -y -i %s -c:v %s -c:a aac -bsf:v h264_mp4toannexb -map 0:v:0 -map 0:a? -f segment -segment_list out.m3u8 %s out%%05d.ts' % (safename(file), vcodec, sub) 107 | 108 | def genrepair(file, newfile, maxbits): 109 | maxrate = maxbits / math.ceil(video_duration(file)) 110 | subcmd = 'ffmpeg -y -i %s -copyts -vsync 0 -muxdelay 0 -c:v %s -c:a copy -bsf:v h264_mp4toannexb -b:v %s -pass %s' % (_('VCODEC'), file, maxrate*0.9, newfile) 111 | return '%s && %s' % (subcmd.replace('-pass', '-pass 1'), subcmd.replace('-pass', '-pass 2')) 112 | 113 | session = requests.Session() 114 | session.headers.update({'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 12_3_1 like Mac OS X) AppleWebKit/537.51.1 (KHTML, like Gecko) Mobile/11A465 QQLiveBrowser/7.0.8 WebKitCore/UIWebView'}) 115 | -------------------------------------------------------------------------------- /up.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import os, re, json 3 | from os import path 4 | from os import getenv as _ 5 | from dotenv import load_dotenv 6 | from concurrent.futures import ThreadPoolExecutor, as_completed 7 | from utils import (api, exec, execstr, tsfiles, uploader, 8 | manageurl, sameparams, genslice, genrepair) 9 | 10 | def encrypt(code): 11 | if not _('ENCRYPTION') == 'YES': 12 | return code 13 | 14 | for file in tsfiles(code): 15 | if file.startswith('enc.'): 16 | continue 17 | 18 | print(f'Encrypting {file} to enc.{file} ... ', end='') 19 | key = exec(['openssl','rand','16']).hex() 20 | iv = execstr(['openssl','rand','-hex','16']) 21 | exec(['openssl','aes-128-cbc','-e','-in',file,'-out','enc.%s' % file,'-p','-nosalt','-iv',iv,'-K',key]) 22 | 23 | key_id = api('POST', 'key', data={'iv': iv, 'key': key}) 24 | if not key_id: 25 | open('out.m3u8', 'w').write(code) 26 | print('failed') 27 | exit(1) 28 | 29 | print('done') 30 | code = re.sub(f'(#EXTINF:.+$[\\r\\n]+^{file}$)', '#EXT-X-KEY:METHOD=AES-128,URI="%s/play/%s.key",IV=0x%s\n\\1' % (_('APIURL'), key_id, iv), code, 1, re.M) 31 | code = code.replace(file, f'enc.{file}') 32 | 33 | open('out.m3u8', 'w').write(code) 34 | return code 35 | 36 | def publish(code, title=None): 37 | if _('NOSERVER') == 'YES': 38 | return print('The m3u8 file has been dumped to tmp/out.m3u8') 39 | 40 | r = api('POST', 'publish', data={'code': code, 'title': title, 41 | 'params': json.dumps(uploader().params())}) 42 | if r: 43 | url = '%s/play/%s' % (_('APIURL'), r['slug']) 44 | print(f'This video has been published to: {url}') 45 | print(f'You can also download it directly: {url}.m3u8') 46 | print('---') 47 | print('Click here to edit the information for this video:\n%s' % manageurl(f'video/{r["id"]}')) 48 | 49 | def repairer(code): 50 | limit = uploader().MAX_BYTES 51 | 52 | for file in tsfiles(code): 53 | if path.getsize(file) > limit: 54 | tmp = 'rep.%s' % file 55 | os.system(genrepair(file, tmp, limit * 8)) 56 | os.rename(tmp, file) 57 | 58 | if path.getsize(file) > limit: 59 | open('out.m3u8', 'w').write(code) 60 | print(f'File too large: tmp/{file}') 61 | print('Adjust parameters or continue execution with the same parameters') 62 | exit(2) 63 | 64 | open('out.m3u8', 'w').write(code) 65 | return code 66 | 67 | 68 | def main(): 69 | parser = argparse.ArgumentParser() 70 | parser.add_argument('file', type=str, help='video file') 71 | parser.add_argument('title', type=str, nargs='?', help='post title') 72 | parser.add_argument('time', type=int, nargs='?', help='time for pre segment', default=0) 73 | parser.add_argument('-c, --config', type=str, dest='config', help='change the configuration file path') 74 | args = parser.parse_args() 75 | 76 | load_dotenv(args.config) 77 | tmpdir = path.dirname(path.abspath(__file__)) + '/tmp' 78 | command = genslice(path.abspath(args.file), args.time) 79 | 80 | if sameparams(tmpdir, command): 81 | os.chdir(tmpdir) 82 | else: 83 | os.mkdir(tmpdir) 84 | os.chdir(tmpdir) 85 | os.system(command) 86 | open('command.sh', 'w').write(command) 87 | 88 | failures, completions = 0, 0 89 | lines = encrypt(repairer(open('out.m3u8', 'r').read())) 90 | executor = ThreadPoolExecutor(max_workers=15) 91 | futures = {executor.submit(uploader().handle, chunk): chunk for chunk in tsfiles(lines)} 92 | 93 | for future in as_completed(futures): 94 | completions += 1 95 | result = future.result() 96 | 97 | if not result: 98 | failures += 1 99 | print('[%s/%s] Uploaded failed: %s' % (completions, len(futures), futures[future])) 100 | continue 101 | 102 | lines = lines.replace(futures[future], result) 103 | print('[%s/%s] Uploaded %s to %s' % (completions, len(futures), futures[future], result)) 104 | 105 | #Write to file 106 | open('out.m3u8', 'w').write(lines) 107 | open('params.json', 'w').write(json.dumps(uploader().params())) 108 | 109 | if failures: 110 | print('Partially successful: %d/%d' % (completions-failures, completions)) 111 | print('You can re-execute this program with the same parameters') 112 | exit(2) 113 | 114 | publish(lines, args.title or path.splitext(path.basename(args.file))[0]) 115 | 116 | 117 | if __name__ == '__main__': 118 | main() 119 | -------------------------------------------------------------------------------- /web/templates/home.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Hello, FreeHLS! 7 | 8 | 9 | 10 |
11 |
12 | FreeHLS 13 | 首页 14 |
15 | search 16 |
17 |
18 | 19 |
20 | 21 |
22 |
23 |

最新标签

24 |
25 | {% for tag in latest_tags.values() %} 26 | 27 | {{ tag.name }} 28 | 29 | {% endfor %} 30 | 33 |
34 |
35 |
36 | 37 | 38 |
39 |
40 |
41 |
42 |
站点状况
43 |
Situation
44 |
45 | 46 |
47 |

公告板:欢迎使用 Free-HLS 系统

48 |

系统运行正常。迄今为止共创建标签 {{ total_tags }} 个,发布视频 {{ total_videos }}

49 |
50 |
51 |
52 |
53 | 54 | 55 |
56 |
57 | {% for tagid, videos in tags_videos.items() %} 58 | 76 | {% endfor %} 77 |
78 |
79 |
80 | 81 | 99 | 100 | 101 | -------------------------------------------------------------------------------- /web/templates/videos.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 视频管理 7 | 8 | 9 | 10 | 12 |
13 |
14 | FreeHLS 15 | 视频管理 16 |
17 | search 18 |
19 |
20 | 21 |
22 | 44 |
45 | 46 |
47 |
48 |
49 |

视频列表

50 |
51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 |
ID标题状态标签发布时间操作
65 |
数据加载中……
66 |
67 |
68 |
69 |
70 |
71 | 72 | 73 | 74 | 75 | 110 | -------------------------------------------------------------------------------- /web/models.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from peewee import * 3 | from utils import md5, validjson, filtertags 4 | from playhouse.shortcuts import model_to_dict 5 | db = SqliteDatabase('data.db', pragmas={'foreign_keys': 1}) 6 | 7 | class Video(Model): 8 | slug = CharField(unique=True, null=True) 9 | code = TextField(default='') 10 | tags = CharField() 11 | title = CharField() 12 | params = TextField() 13 | output = TextField(default='') 14 | # 0.已发布, 1.队列中,2.处理中,3.发布失败 15 | status = IntegerField(default=0, index=True) 16 | created_at = DateTimeField(default=datetime.datetime.now) 17 | updated_at = DateTimeField(null=True) 18 | 19 | class Meta: 20 | database = db 21 | db_table = 'videos' 22 | 23 | @classmethod 24 | def add(cls, **kwargs): 25 | kwargs['tags'] = filtertags(kwargs['tags']) or '未标记' 26 | with db.atomic(): 27 | video = cls.create(**kwargs) 28 | Tag.add(kwargs['tags'], video.id) 29 | return video 30 | 31 | @classmethod 32 | def remove(cls, id): 33 | with db.atomic(): 34 | for tag in VideoTag.tags(id): 35 | Tag.unlink(tag, id) 36 | 37 | cls.delete().where(cls.id == id).execute() 38 | 39 | return 1, id 40 | 41 | @classmethod 42 | def createOrUpdate(cls, **kwargs): 43 | if not kwargs['slug']: 44 | return 0, 'Slug cannot be empty' 45 | if not kwargs['code']: 46 | return 0, 'Code cannot be empty' 47 | elif len(kwargs['code']) > 500*1024: 48 | return 0, 'Code size cannot exceed 500K' 49 | elif not validjson(kwargs['params']): 50 | return 0, 'Invalid params' 51 | kwargs['tags'] = filtertags(kwargs['tags']) or '未标记' 52 | 53 | with db.atomic(): 54 | id = kwargs.pop('id') if 'id' in kwargs else 0 55 | if id: 56 | cls.update(**kwargs).where(cls.id == id).execute() 57 | video = cls.get_by_id(id) 58 | elif cls.select().where(cls.slug == kwargs['slug']).exists(): 59 | cls.update(**kwargs).where(cls.slug == kwargs['slug']).execute() 60 | video = cls.get(cls.slug == kwargs['slug']) 61 | else: 62 | video = cls.create(**kwargs) 63 | Tag.add(kwargs['tags'], video.id) 64 | 65 | return 1, model_to_dict(video) 66 | 67 | 68 | class Tag(Model): 69 | name = CharField(unique=True) 70 | created_at = DateTimeField(default=datetime.datetime.now) 71 | 72 | class Meta: 73 | database = db 74 | db_table = 'tags' 75 | 76 | @classmethod 77 | def add(cls, tags, video_id): 78 | tags = tags.split(',') if tags else [] 79 | all_tags = [tag.name for tag in cls.select().where(cls.name << tags)] 80 | video_tags = [tag.name for tag in VideoTag.tags(video_id)] 81 | 82 | for tag in set(tags) - set(all_tags): 83 | cls.create(name = tag) 84 | for tag in set(video_tags) - set(tags): 85 | cls.unlink(tag, video_id) 86 | 87 | cls.relink(tags, video_id) 88 | 89 | @classmethod 90 | def edit(cls, tag_id, **kwargs): 91 | tag = cls.get_by_id(tag_id) 92 | if 'name' in kwargs and not kwargs['name']: 93 | return 0, 'Name cannot be empty' 94 | 95 | with db.atomic(): 96 | cls.update(**kwargs).where(cls.id == tag).execute() 97 | 98 | # Replace videos `tags` attr 99 | for video in VideoTag.videos(tag): 100 | video.tags = ','.join([kwargs['name'] if t == tag.name else t for t in filtertags(video.tags).split(',')]) 101 | video.save() 102 | 103 | return 1, tag.id 104 | 105 | @classmethod 106 | def unlink(cls, tag, video_id): 107 | if isinstance(tag, str): 108 | tag = cls.get(cls.name == tag) 109 | if not VideoTag.select().where(VideoTag.tag == tag, VideoTag.video != video_id).exists(): 110 | tag.delete_instance() 111 | 112 | @classmethod 113 | def relink(cls, tags, video_id): 114 | VideoTag.delete().where(VideoTag.video == video_id).execute() 115 | 116 | _tags = {tag.name: tag for tag in cls.select().where(cls.name << tags)} 117 | for tag in tags: 118 | VideoTag.create(tag = _tags[tag].id, video = video_id) 119 | 120 | 121 | class VideoTag(Model): 122 | tag = ForeignKeyField(Tag, backref='tags', on_delete='CASCADE') 123 | video = ForeignKeyField(Video, on_delete='CASCADE') 124 | sort = IntegerField(default=0, index=True) 125 | created_at = DateTimeField(default=datetime.datetime.now) 126 | 127 | class Meta: 128 | database = db 129 | db_table = 'video_tags' 130 | indexes = ( 131 | (('tag', 'video'), True), 132 | ) 133 | 134 | @classmethod 135 | def tags(cls, video): 136 | return [vtag.tag for vtag in cls.select().join(Tag).where(cls.video == video)] 137 | 138 | @classmethod 139 | def videos(cls, tag): 140 | return [vtag.video for vtag in cls.select().join(Video).where(cls.tag == tag)] 141 | 142 | @classmethod 143 | def blend(cls, tag): 144 | return [{**model_to_dict(vtag.video), 'sort': vtag.sort, 'code': None} 145 | for vtag in cls.select().join(Tag).switch(cls).join(Video).order_by(cls.sort, cls.id).where(cls.tag == tag)] 146 | 147 | @classmethod 148 | def save_videos(cls, tag, videos): 149 | with db.atomic(): 150 | for video in videos: 151 | video_id = video.pop('id') 152 | cls.update({**video}).where(cls.tag == tag, cls.video == video_id).execute() 153 | 154 | return 1, 'OK' 155 | 156 | class Secret(Model): 157 | iv = CharField() 158 | key = TextField() 159 | created_at = DateTimeField(default=datetime.datetime.now) 160 | 161 | class Meta: 162 | database = db 163 | db_table = 'secrets' 164 | 165 | @classmethod 166 | def add(cls, iv, key): 167 | secret = cls.create(iv=iv, key=key) 168 | return 1, secret.id 169 | 170 | 171 | 172 | 173 | db.create_tables([Tag, Video, Secret, VideoTag], safe=True) 174 | -------------------------------------------------------------------------------- /web/templates/offline.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 离线切片 7 | 8 | 9 | 10 | 29 | 30 | 32 |
33 |
34 | FreeHLS 35 | 离线切片 36 |
37 | search 38 |
39 |
40 | 41 |
42 | 64 |
65 | 66 |
67 |
68 |
69 |
70 |
71 |
上传视频
72 |
Upload
73 |
74 |
75 | 78 |
79 |
80 |
81 |
82 | 83 | 84 |
85 | 86 |
87 | 88 | 89 |
90 | 91 |
92 | 93 | 94 |
95 |
96 |
97 |
98 |
99 |
100 |
101 | 102 | 103 | 104 | 105 | 106 | 150 | -------------------------------------------------------------------------------- /web/templates/tags.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 标签管理 7 | 8 | 9 | 10 | 12 |
13 |
14 | FreeHLS 15 | 标签管理 16 |
17 | search 18 |
19 |
20 | 21 |
22 | 44 |
45 | 46 |
47 |
48 |
49 |

标签列表

50 |
51 |
52 |
53 |
54 |
55 |
56 | 57 | 58 | 59 | 60 | 145 | -------------------------------------------------------------------------------- /web/templates/video.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {{ '更新' if video else '发布' }}视频 7 | 8 | 9 | 10 | 29 | 30 | 32 |
33 | 39 |
40 | 41 |
42 | 64 |
65 | 66 |
67 |
68 |
69 |
70 |
71 |
视频信息
72 |
Information
73 |
74 |
75 | 78 | {% if video %} 79 | 82 | {% endif %} 83 |
84 |
85 |
86 |
87 | 88 | 89 |
90 | 91 | {% if video and video.status < 1 %} 92 |
93 | 94 | 95 |
96 | {% endif %} 97 | 98 |
99 | 100 | 101 |
102 | 103 |
104 | 105 | 106 |
107 | 108 |
109 | 110 | 111 |
112 | 113 | {% if video and video.output %} 114 |
115 | 116 | 117 |
118 | {% endif %} 119 | 120 | 121 |
122 |
123 |
124 |
125 |
126 |
127 | 128 | 129 | 130 | 131 | 132 | 187 | -------------------------------------------------------------------------------- /web/assets/css/video-js.min.css: -------------------------------------------------------------------------------- 1 | @charset "UTF-8";.video-js .vjs-big-play-button .vjs-icon-placeholder:before,.video-js .vjs-modal-dialog,.vjs-button>.vjs-icon-placeholder:before,.vjs-modal-dialog .vjs-modal-dialog-content{position:absolute;top:0;left:0;width:100%;height:100%}.video-js .vjs-big-play-button .vjs-icon-placeholder:before,.vjs-button>.vjs-icon-placeholder:before{text-align:center}@font-face{font-family:VideoJS;src:url(data:application/font-woff;charset=utf-8;base64,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) format("woff");font-weight:400;font-style:normal}.video-js .vjs-big-play-button .vjs-icon-placeholder:before,.video-js .vjs-play-control .vjs-icon-placeholder,.vjs-icon-play{font-family:VideoJS;font-weight:400;font-style:normal}.video-js .vjs-big-play-button .vjs-icon-placeholder:before,.video-js .vjs-play-control .vjs-icon-placeholder:before,.vjs-icon-play:before{content:"\f101"}.vjs-icon-play-circle{font-family:VideoJS;font-weight:400;font-style:normal}.vjs-icon-play-circle:before{content:"\f102"}.video-js .vjs-play-control.vjs-playing .vjs-icon-placeholder,.vjs-icon-pause{font-family:VideoJS;font-weight:400;font-style:normal}.video-js .vjs-play-control.vjs-playing .vjs-icon-placeholder:before,.vjs-icon-pause:before{content:"\f103"}.video-js .vjs-mute-control.vjs-vol-0 .vjs-icon-placeholder,.vjs-icon-volume-mute{font-family:VideoJS;font-weight:400;font-style:normal}.video-js .vjs-mute-control.vjs-vol-0 .vjs-icon-placeholder:before,.vjs-icon-volume-mute:before{content:"\f104"}.video-js .vjs-mute-control.vjs-vol-1 .vjs-icon-placeholder,.vjs-icon-volume-low{font-family:VideoJS;font-weight:400;font-style:normal}.video-js .vjs-mute-control.vjs-vol-1 .vjs-icon-placeholder:before,.vjs-icon-volume-low:before{content:"\f105"}.video-js .vjs-mute-control.vjs-vol-2 .vjs-icon-placeholder,.vjs-icon-volume-mid{font-family:VideoJS;font-weight:400;font-style:normal}.video-js .vjs-mute-control.vjs-vol-2 .vjs-icon-placeholder:before,.vjs-icon-volume-mid:before{content:"\f106"}.video-js .vjs-mute-control .vjs-icon-placeholder,.vjs-icon-volume-high{font-family:VideoJS;font-weight:400;font-style:normal}.video-js .vjs-mute-control .vjs-icon-placeholder:before,.vjs-icon-volume-high:before{content:"\f107"}.video-js .vjs-fullscreen-control .vjs-icon-placeholder,.vjs-icon-fullscreen-enter{font-family:VideoJS;font-weight:400;font-style:normal}.video-js .vjs-fullscreen-control .vjs-icon-placeholder:before,.vjs-icon-fullscreen-enter:before{content:"\f108"}.video-js.vjs-fullscreen .vjs-fullscreen-control .vjs-icon-placeholder,.vjs-icon-fullscreen-exit{font-family:VideoJS;font-weight:400;font-style:normal}.video-js.vjs-fullscreen .vjs-fullscreen-control .vjs-icon-placeholder:before,.vjs-icon-fullscreen-exit:before{content:"\f109"}.vjs-icon-square{font-family:VideoJS;font-weight:400;font-style:normal}.vjs-icon-square:before{content:"\f10a"}.vjs-icon-spinner{font-family:VideoJS;font-weight:400;font-style:normal}.vjs-icon-spinner:before{content:"\f10b"}.video-js .vjs-subs-caps-button .vjs-icon-placeholder,.video-js .vjs-subtitles-button .vjs-icon-placeholder,.video-js.video-js:lang(en-AU) .vjs-subs-caps-button .vjs-icon-placeholder,.video-js.video-js:lang(en-GB) .vjs-subs-caps-button .vjs-icon-placeholder,.video-js.video-js:lang(en-IE) .vjs-subs-caps-button .vjs-icon-placeholder,.video-js.video-js:lang(en-NZ) .vjs-subs-caps-button .vjs-icon-placeholder,.vjs-icon-subtitles{font-family:VideoJS;font-weight:400;font-style:normal}.video-js .vjs-subs-caps-button .vjs-icon-placeholder:before,.video-js .vjs-subtitles-button .vjs-icon-placeholder:before,.video-js.video-js:lang(en-AU) .vjs-subs-caps-button .vjs-icon-placeholder:before,.video-js.video-js:lang(en-GB) .vjs-subs-caps-button .vjs-icon-placeholder:before,.video-js.video-js:lang(en-IE) .vjs-subs-caps-button .vjs-icon-placeholder:before,.video-js.video-js:lang(en-NZ) .vjs-subs-caps-button .vjs-icon-placeholder:before,.vjs-icon-subtitles:before{content:"\f10c"}.video-js .vjs-captions-button .vjs-icon-placeholder,.video-js:lang(en) .vjs-subs-caps-button .vjs-icon-placeholder,.video-js:lang(fr-CA) .vjs-subs-caps-button .vjs-icon-placeholder,.vjs-icon-captions{font-family:VideoJS;font-weight:400;font-style:normal}.video-js .vjs-captions-button .vjs-icon-placeholder:before,.video-js:lang(en) .vjs-subs-caps-button .vjs-icon-placeholder:before,.video-js:lang(fr-CA) .vjs-subs-caps-button .vjs-icon-placeholder:before,.vjs-icon-captions:before{content:"\f10d"}.video-js .vjs-chapters-button .vjs-icon-placeholder,.vjs-icon-chapters{font-family:VideoJS;font-weight:400;font-style:normal}.video-js .vjs-chapters-button .vjs-icon-placeholder:before,.vjs-icon-chapters:before{content:"\f10e"}.vjs-icon-share{font-family:VideoJS;font-weight:400;font-style:normal}.vjs-icon-share:before{content:"\f10f"}.vjs-icon-cog{font-family:VideoJS;font-weight:400;font-style:normal}.vjs-icon-cog:before{content:"\f110"}.video-js .vjs-play-progress,.video-js .vjs-volume-level,.vjs-icon-circle,.vjs-seek-to-live-control .vjs-icon-placeholder{font-family:VideoJS;font-weight:400;font-style:normal}.video-js .vjs-play-progress:before,.video-js .vjs-volume-level:before,.vjs-icon-circle:before,.vjs-seek-to-live-control .vjs-icon-placeholder:before{content:"\f111"}.vjs-icon-circle-outline{font-family:VideoJS;font-weight:400;font-style:normal}.vjs-icon-circle-outline:before{content:"\f112"}.vjs-icon-circle-inner-circle{font-family:VideoJS;font-weight:400;font-style:normal}.vjs-icon-circle-inner-circle:before{content:"\f113"}.vjs-icon-hd{font-family:VideoJS;font-weight:400;font-style:normal}.vjs-icon-hd:before{content:"\f114"}.video-js .vjs-control.vjs-close-button .vjs-icon-placeholder,.vjs-icon-cancel{font-family:VideoJS;font-weight:400;font-style:normal}.video-js .vjs-control.vjs-close-button .vjs-icon-placeholder:before,.vjs-icon-cancel:before{content:"\f115"}.video-js .vjs-play-control.vjs-ended .vjs-icon-placeholder,.vjs-icon-replay{font-family:VideoJS;font-weight:400;font-style:normal}.video-js .vjs-play-control.vjs-ended .vjs-icon-placeholder:before,.vjs-icon-replay:before{content:"\f116"}.vjs-icon-facebook{font-family:VideoJS;font-weight:400;font-style:normal}.vjs-icon-facebook:before{content:"\f117"}.vjs-icon-gplus{font-family:VideoJS;font-weight:400;font-style:normal}.vjs-icon-gplus:before{content:"\f118"}.vjs-icon-linkedin{font-family:VideoJS;font-weight:400;font-style:normal}.vjs-icon-linkedin:before{content:"\f119"}.vjs-icon-twitter{font-family:VideoJS;font-weight:400;font-style:normal}.vjs-icon-twitter:before{content:"\f11a"}.vjs-icon-tumblr{font-family:VideoJS;font-weight:400;font-style:normal}.vjs-icon-tumblr:before{content:"\f11b"}.vjs-icon-pinterest{font-family:VideoJS;font-weight:400;font-style:normal}.vjs-icon-pinterest:before{content:"\f11c"}.video-js .vjs-descriptions-button .vjs-icon-placeholder,.vjs-icon-audio-description{font-family:VideoJS;font-weight:400;font-style:normal}.video-js .vjs-descriptions-button .vjs-icon-placeholder:before,.vjs-icon-audio-description:before{content:"\f11d"}.video-js .vjs-audio-button .vjs-icon-placeholder,.vjs-icon-audio{font-family:VideoJS;font-weight:400;font-style:normal}.video-js .vjs-audio-button .vjs-icon-placeholder:before,.vjs-icon-audio:before{content:"\f11e"}.vjs-icon-next-item{font-family:VideoJS;font-weight:400;font-style:normal}.vjs-icon-next-item:before{content:"\f11f"}.vjs-icon-previous-item{font-family:VideoJS;font-weight:400;font-style:normal}.vjs-icon-previous-item:before{content:"\f120"}.video-js .vjs-picture-in-picture-control .vjs-icon-placeholder,.vjs-icon-picture-in-picture-enter{font-family:VideoJS;font-weight:400;font-style:normal}.video-js .vjs-picture-in-picture-control .vjs-icon-placeholder:before,.vjs-icon-picture-in-picture-enter:before{content:"\f121"}.video-js.vjs-picture-in-picture .vjs-picture-in-picture-control .vjs-icon-placeholder,.vjs-icon-picture-in-picture-exit{font-family:VideoJS;font-weight:400;font-style:normal}.video-js.vjs-picture-in-picture .vjs-picture-in-picture-control .vjs-icon-placeholder:before,.vjs-icon-picture-in-picture-exit:before{content:"\f122"}.video-js{display:block;vertical-align:top;box-sizing:border-box;color:#fff;background-color:#000;position:relative;padding:0;font-size:10px;line-height:1;font-weight:400;font-style:normal;font-family:Arial,Helvetica,sans-serif;word-break:initial}.video-js:-moz-full-screen{position:absolute}.video-js:-webkit-full-screen{width:100%!important;height:100%!important}.video-js[tabindex="-1"]{outline:0}.video-js *,.video-js :after,.video-js :before{box-sizing:inherit}.video-js ul{font-family:inherit;font-size:inherit;line-height:inherit;list-style-position:outside;margin-left:0;margin-right:0;margin-top:0;margin-bottom:0}.video-js.vjs-16-9,.video-js.vjs-4-3,.video-js.vjs-fluid{width:100%;max-width:100%;height:0}.video-js.vjs-16-9{padding-top:56.25%}.video-js.vjs-4-3{padding-top:75%}.video-js.vjs-fill{width:100%;height:100%}.video-js .vjs-tech{position:absolute;top:0;left:0;width:100%;height:100%}body.vjs-full-window{padding:0;margin:0;height:100%}.vjs-full-window .video-js.vjs-fullscreen{position:fixed;overflow:hidden;z-index:1000;left:0;top:0;bottom:0;right:0}.video-js.vjs-fullscreen{width:100%!important;height:100%!important;padding-top:0!important}.video-js.vjs-fullscreen.vjs-user-inactive{cursor:none}.vjs-hidden{display:none!important}.vjs-disabled{opacity:.5;cursor:default}.video-js .vjs-offscreen{height:1px;left:-9999px;position:absolute;top:0;width:1px}.vjs-lock-showing{display:block!important;opacity:1;visibility:visible}.vjs-no-js{padding:20px;color:#fff;background-color:#000;font-size:18px;font-family:Arial,Helvetica,sans-serif;text-align:center;width:300px;height:150px;margin:0 auto}.vjs-no-js a,.vjs-no-js a:visited{color:#66a8cc}.video-js .vjs-big-play-button{font-size:3em;line-height:1.5em;height:1.63332em;width:3em;display:block;position:absolute;top:10px;left:10px;padding:0;cursor:pointer;opacity:1;border:.06666em solid #fff;background-color:#2b333f;background-color:rgba(43,51,63,.7);border-radius:.3em;transition:all .4s}.vjs-big-play-centered .vjs-big-play-button{top:50%;left:50%;margin-top:-.81666em;margin-left:-1.5em}.video-js .vjs-big-play-button:focus,.video-js:hover .vjs-big-play-button{border-color:#fff;background-color:#73859f;background-color:rgba(115,133,159,.5);transition:all 0s}.vjs-controls-disabled .vjs-big-play-button,.vjs-error .vjs-big-play-button,.vjs-has-started .vjs-big-play-button,.vjs-using-native-controls .vjs-big-play-button{display:none}.vjs-has-started.vjs-paused.vjs-show-big-play-button-on-pause .vjs-big-play-button{display:block}.video-js button{background:0 0;border:none;color:inherit;display:inline-block;font-size:inherit;line-height:inherit;text-transform:none;text-decoration:none;transition:none;-webkit-appearance:none;-moz-appearance:none;appearance:none}.vjs-control .vjs-button{width:100%;height:100%}.video-js .vjs-control.vjs-close-button{cursor:pointer;height:3em;position:absolute;right:0;top:.5em;z-index:2}.video-js .vjs-modal-dialog{background:rgba(0,0,0,.8);background:linear-gradient(180deg,rgba(0,0,0,.8),rgba(255,255,255,0));overflow:auto}.video-js .vjs-modal-dialog>*{box-sizing:border-box}.vjs-modal-dialog .vjs-modal-dialog-content{font-size:1.2em;line-height:1.5;padding:20px 24px;z-index:1}.vjs-menu-button{cursor:pointer}.vjs-menu-button.vjs-disabled{cursor:default}.vjs-workinghover .vjs-menu-button.vjs-disabled:hover .vjs-menu{display:none}.vjs-menu .vjs-menu-content{display:block;padding:0;margin:0;font-family:Arial,Helvetica,sans-serif;overflow:auto}.vjs-menu .vjs-menu-content>*{box-sizing:border-box}.vjs-scrubbing .vjs-control.vjs-menu-button:hover .vjs-menu{display:none}.vjs-menu li{list-style:none;margin:0;padding:.2em 0;line-height:1.4em;font-size:1.2em;text-align:center;text-transform:lowercase}.js-focus-visible .vjs-menu li.vjs-menu-item:hover,.vjs-menu li.vjs-menu-item:focus,.vjs-menu li.vjs-menu-item:hover{background-color:#73859f;background-color:rgba(115,133,159,.5)}.js-focus-visible .vjs-menu li.vjs-selected:hover,.vjs-menu li.vjs-selected,.vjs-menu li.vjs-selected:focus,.vjs-menu li.vjs-selected:hover{background-color:#fff;color:#2b333f}.vjs-menu li.vjs-menu-title{text-align:center;text-transform:uppercase;font-size:1em;line-height:2em;padding:0;margin:0 0 .3em 0;font-weight:700;cursor:default}.vjs-menu-button-popup .vjs-menu{display:none;position:absolute;bottom:0;width:10em;left:-3em;height:0;margin-bottom:1.5em;border-top-color:rgba(43,51,63,.7)}.vjs-menu-button-popup .vjs-menu .vjs-menu-content{background-color:#2b333f;background-color:rgba(43,51,63,.7);position:absolute;width:100%;bottom:1.5em;max-height:15em}.vjs-layout-tiny .vjs-menu-button-popup .vjs-menu .vjs-menu-content,.vjs-layout-x-small .vjs-menu-button-popup .vjs-menu .vjs-menu-content{max-height:5em}.vjs-layout-small .vjs-menu-button-popup .vjs-menu .vjs-menu-content{max-height:10em}.vjs-layout-medium .vjs-menu-button-popup .vjs-menu .vjs-menu-content{max-height:14em}.vjs-layout-huge .vjs-menu-button-popup .vjs-menu .vjs-menu-content,.vjs-layout-large .vjs-menu-button-popup .vjs-menu .vjs-menu-content,.vjs-layout-x-large .vjs-menu-button-popup .vjs-menu .vjs-menu-content{max-height:25em}.vjs-menu-button-popup .vjs-menu.vjs-lock-showing,.vjs-workinghover .vjs-menu-button-popup.vjs-hover .vjs-menu{display:block}.video-js .vjs-menu-button-inline{transition:all .4s;overflow:hidden}.video-js .vjs-menu-button-inline:before{width:2.222222222em}.video-js .vjs-menu-button-inline.vjs-slider-active,.video-js .vjs-menu-button-inline:focus,.video-js .vjs-menu-button-inline:hover,.video-js.vjs-no-flex .vjs-menu-button-inline{width:12em}.vjs-menu-button-inline .vjs-menu{opacity:0;height:100%;width:auto;position:absolute;left:4em;top:0;padding:0;margin:0;transition:all .4s}.vjs-menu-button-inline.vjs-slider-active .vjs-menu,.vjs-menu-button-inline:focus .vjs-menu,.vjs-menu-button-inline:hover .vjs-menu{display:block;opacity:1}.vjs-no-flex .vjs-menu-button-inline .vjs-menu{display:block;opacity:1;position:relative;width:auto}.vjs-no-flex .vjs-menu-button-inline.vjs-slider-active .vjs-menu,.vjs-no-flex .vjs-menu-button-inline:focus .vjs-menu,.vjs-no-flex .vjs-menu-button-inline:hover .vjs-menu{width:auto}.vjs-menu-button-inline .vjs-menu-content{width:auto;height:100%;margin:0;overflow:hidden}.video-js .vjs-control-bar{display:none;width:100%;position:absolute;bottom:0;left:0;right:0;height:3em;background-color:#2b333f;background-color:rgba(43,51,63,.7)}.vjs-has-started .vjs-control-bar{display:flex;visibility:visible;opacity:1;transition:visibility .1s,opacity .1s}.vjs-has-started.vjs-user-inactive.vjs-playing .vjs-control-bar{visibility:visible;opacity:0;transition:visibility 1s,opacity 1s}.vjs-controls-disabled .vjs-control-bar,.vjs-error .vjs-control-bar,.vjs-using-native-controls .vjs-control-bar{display:none!important}.vjs-audio.vjs-has-started.vjs-user-inactive.vjs-playing .vjs-control-bar{opacity:1;visibility:visible}.vjs-has-started.vjs-no-flex .vjs-control-bar{display:table}.video-js .vjs-control{position:relative;text-align:center;margin:0;padding:0;height:100%;width:4em;flex:none}.vjs-button>.vjs-icon-placeholder:before{font-size:1.8em;line-height:1.67}.video-js .vjs-control:focus,.video-js .vjs-control:focus:before,.video-js .vjs-control:hover:before{text-shadow:0 0 1em #fff}.video-js .vjs-control-text{border:0;clip:rect(0 0 0 0);height:1px;overflow:hidden;padding:0;position:absolute;width:1px}.vjs-no-flex .vjs-control{display:table-cell;vertical-align:middle}.video-js .vjs-custom-control-spacer{display:none}.video-js .vjs-progress-control{cursor:pointer;flex:auto;display:flex;align-items:center;min-width:4em;touch-action:none}.video-js .vjs-progress-control.disabled{cursor:default}.vjs-live .vjs-progress-control{display:none}.vjs-liveui .vjs-progress-control{display:flex;align-items:center}.vjs-no-flex .vjs-progress-control{width:auto}.video-js .vjs-progress-holder{flex:auto;transition:all .2s;height:.3em}.video-js .vjs-progress-control .vjs-progress-holder{margin:0 10px}.video-js .vjs-progress-control:hover .vjs-progress-holder{font-size:1.6666666667em}.video-js .vjs-progress-control:hover .vjs-progress-holder.disabled{font-size:1em}.video-js .vjs-progress-holder .vjs-load-progress,.video-js .vjs-progress-holder .vjs-load-progress div,.video-js .vjs-progress-holder .vjs-play-progress{position:absolute;display:block;height:100%;margin:0;padding:0;width:0}.video-js .vjs-play-progress{background-color:#fff}.video-js .vjs-play-progress:before{font-size:.9em;position:absolute;right:-.5em;top:-.3333333333em;z-index:1}.video-js .vjs-load-progress{background:rgba(115,133,159,.5)}.video-js .vjs-load-progress div{background:rgba(115,133,159,.75)}.video-js .vjs-time-tooltip{background-color:#fff;background-color:rgba(255,255,255,.8);border-radius:.3em;color:#000;float:right;font-family:Arial,Helvetica,sans-serif;font-size:1em;padding:6px 8px 8px 8px;pointer-events:none;position:absolute;top:-3.4em;visibility:hidden;z-index:1}.video-js .vjs-progress-holder:focus .vjs-time-tooltip{display:none}.video-js .vjs-progress-control:hover .vjs-progress-holder:focus .vjs-time-tooltip,.video-js .vjs-progress-control:hover .vjs-time-tooltip{display:block;font-size:.6em;visibility:visible}.video-js .vjs-progress-control.disabled:hover .vjs-time-tooltip{font-size:1em}.video-js .vjs-progress-control .vjs-mouse-display{display:none;position:absolute;width:1px;height:100%;background-color:#000;z-index:1}.vjs-no-flex .vjs-progress-control .vjs-mouse-display{z-index:0}.video-js .vjs-progress-control:hover .vjs-mouse-display{display:block}.video-js.vjs-user-inactive .vjs-progress-control .vjs-mouse-display{visibility:hidden;opacity:0;transition:visibility 1s,opacity 1s}.video-js.vjs-user-inactive.vjs-no-flex .vjs-progress-control .vjs-mouse-display{display:none}.vjs-mouse-display .vjs-time-tooltip{color:#fff;background-color:#000;background-color:rgba(0,0,0,.8)}.video-js .vjs-slider{position:relative;cursor:pointer;padding:0;margin:0 .45em 0 .45em;-webkit-touch-callout:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;background-color:#73859f;background-color:rgba(115,133,159,.5)}.video-js .vjs-slider.disabled{cursor:default}.video-js .vjs-slider:focus{text-shadow:0 0 1em #fff;box-shadow:0 0 1em #fff}.video-js .vjs-mute-control{cursor:pointer;flex:none}.video-js .vjs-volume-control{cursor:pointer;margin-right:1em;display:flex}.video-js .vjs-volume-control.vjs-volume-horizontal{width:5em}.video-js .vjs-volume-panel .vjs-volume-control{visibility:visible;opacity:0;width:1px;height:1px;margin-left:-1px}.video-js .vjs-volume-panel{transition:width 1s}.video-js .vjs-volume-panel .vjs-volume-control.vjs-slider-active,.video-js .vjs-volume-panel .vjs-volume-control:active,.video-js .vjs-volume-panel.vjs-hover .vjs-mute-control~.vjs-volume-control,.video-js .vjs-volume-panel.vjs-hover .vjs-volume-control,.video-js .vjs-volume-panel:active .vjs-volume-control,.video-js .vjs-volume-panel:focus .vjs-volume-control{visibility:visible;opacity:1;position:relative;transition:visibility .1s,opacity .1s,height .1s,width .1s,left 0s,top 0s}.video-js .vjs-volume-panel .vjs-volume-control.vjs-slider-active.vjs-volume-horizontal,.video-js .vjs-volume-panel .vjs-volume-control:active.vjs-volume-horizontal,.video-js .vjs-volume-panel.vjs-hover .vjs-mute-control~.vjs-volume-control.vjs-volume-horizontal,.video-js .vjs-volume-panel.vjs-hover .vjs-volume-control.vjs-volume-horizontal,.video-js .vjs-volume-panel:active .vjs-volume-control.vjs-volume-horizontal,.video-js .vjs-volume-panel:focus .vjs-volume-control.vjs-volume-horizontal{width:5em;height:3em;margin-right:0}.video-js .vjs-volume-panel .vjs-volume-control.vjs-slider-active.vjs-volume-vertical,.video-js .vjs-volume-panel .vjs-volume-control:active.vjs-volume-vertical,.video-js .vjs-volume-panel.vjs-hover .vjs-mute-control~.vjs-volume-control.vjs-volume-vertical,.video-js .vjs-volume-panel.vjs-hover .vjs-volume-control.vjs-volume-vertical,.video-js .vjs-volume-panel:active .vjs-volume-control.vjs-volume-vertical,.video-js .vjs-volume-panel:focus .vjs-volume-control.vjs-volume-vertical{left:-3.5em;transition:left 0s}.video-js .vjs-volume-panel.vjs-volume-panel-horizontal.vjs-hover,.video-js .vjs-volume-panel.vjs-volume-panel-horizontal.vjs-slider-active,.video-js .vjs-volume-panel.vjs-volume-panel-horizontal:active{width:10em;transition:width .1s}.video-js .vjs-volume-panel.vjs-volume-panel-horizontal.vjs-mute-toggle-only{width:4em}.video-js .vjs-volume-panel .vjs-volume-control.vjs-volume-vertical{height:8em;width:3em;left:-3000em;transition:visibility 1s,opacity 1s,height 1s 1s,width 1s 1s,left 1s 1s,top 1s 1s}.video-js .vjs-volume-panel .vjs-volume-control.vjs-volume-horizontal{transition:visibility 1s,opacity 1s,height 1s 1s,width 1s,left 1s 1s,top 1s 1s}.video-js.vjs-no-flex .vjs-volume-panel .vjs-volume-control.vjs-volume-horizontal{width:5em;height:3em;visibility:visible;opacity:1;position:relative;transition:none}.video-js.vjs-no-flex .vjs-volume-control.vjs-volume-vertical,.video-js.vjs-no-flex .vjs-volume-panel .vjs-volume-control.vjs-volume-vertical{position:absolute;bottom:3em;left:.5em}.video-js .vjs-volume-panel{display:flex}.video-js .vjs-volume-bar{margin:1.35em .45em}.vjs-volume-bar.vjs-slider-horizontal{width:5em;height:.3em}.vjs-volume-bar.vjs-slider-vertical{width:.3em;height:5em;margin:1.35em auto}.video-js .vjs-volume-level{position:absolute;bottom:0;left:0;background-color:#fff}.video-js .vjs-volume-level:before{position:absolute;font-size:.9em}.vjs-slider-vertical .vjs-volume-level{width:.3em}.vjs-slider-vertical .vjs-volume-level:before{top:-.5em;left:-.3em}.vjs-slider-horizontal .vjs-volume-level{height:.3em}.vjs-slider-horizontal .vjs-volume-level:before{top:-.3em;right:-.5em}.video-js .vjs-volume-panel.vjs-volume-panel-vertical{width:4em}.vjs-volume-bar.vjs-slider-vertical .vjs-volume-level{height:100%}.vjs-volume-bar.vjs-slider-horizontal .vjs-volume-level{width:100%}.video-js .vjs-volume-vertical{width:3em;height:8em;bottom:8em;background-color:#2b333f;background-color:rgba(43,51,63,.7)}.video-js .vjs-volume-horizontal .vjs-menu{left:-2em}.vjs-poster{display:inline-block;vertical-align:middle;background-repeat:no-repeat;background-position:50% 50%;background-size:contain;background-color:#000;cursor:pointer;margin:0;padding:0;position:absolute;top:0;right:0;bottom:0;left:0;height:100%}.vjs-has-started .vjs-poster{display:none}.vjs-audio.vjs-has-started .vjs-poster{display:block}.vjs-using-native-controls .vjs-poster{display:none}.video-js .vjs-live-control{display:flex;align-items:flex-start;flex:auto;font-size:1em;line-height:3em}.vjs-no-flex .vjs-live-control{display:table-cell;width:auto;text-align:left}.video-js.vjs-liveui .vjs-live-control,.video-js:not(.vjs-live) .vjs-live-control{display:none}.video-js .vjs-seek-to-live-control{cursor:pointer;flex:none;display:inline-flex;height:100%;padding-left:.5em;padding-right:.5em;font-size:1em;line-height:3em;width:auto;min-width:4em}.vjs-no-flex .vjs-seek-to-live-control{display:table-cell;width:auto;text-align:left}.video-js.vjs-live:not(.vjs-liveui) .vjs-seek-to-live-control,.video-js:not(.vjs-live) .vjs-seek-to-live-control{display:none}.vjs-seek-to-live-control.vjs-control.vjs-at-live-edge{cursor:auto}.vjs-seek-to-live-control .vjs-icon-placeholder{margin-right:.5em;color:#888}.vjs-seek-to-live-control.vjs-control.vjs-at-live-edge .vjs-icon-placeholder{color:red}.video-js .vjs-time-control{flex:none;font-size:1em;line-height:3em;min-width:2em;width:auto;padding-left:1em;padding-right:1em}.vjs-live .vjs-time-control{display:none}.video-js .vjs-current-time,.vjs-no-flex .vjs-current-time{display:none}.video-js .vjs-duration,.vjs-no-flex .vjs-duration{display:none}.vjs-time-divider{display:none;line-height:3em}.vjs-live .vjs-time-divider{display:none}.video-js .vjs-play-control{cursor:pointer}.video-js .vjs-play-control .vjs-icon-placeholder{flex:none}.vjs-text-track-display{position:absolute;bottom:3em;left:0;right:0;top:0;pointer-events:none}.video-js.vjs-user-inactive.vjs-playing .vjs-text-track-display{bottom:1em}.video-js .vjs-text-track{font-size:1.4em;text-align:center;margin-bottom:.1em}.vjs-subtitles{color:#fff}.vjs-captions{color:#fc6}.vjs-tt-cue{display:block}video::-webkit-media-text-track-display{transform:translateY(-3em)}.video-js.vjs-user-inactive.vjs-playing video::-webkit-media-text-track-display{transform:translateY(-1.5em)}.video-js .vjs-picture-in-picture-control{cursor:pointer;flex:none}.video-js .vjs-fullscreen-control{cursor:pointer;flex:none}.vjs-playback-rate .vjs-playback-rate-value,.vjs-playback-rate>.vjs-menu-button{position:absolute;top:0;left:0;width:100%;height:100%}.vjs-playback-rate .vjs-playback-rate-value{pointer-events:none;font-size:1.5em;line-height:2;text-align:center}.vjs-playback-rate .vjs-menu{width:4em;left:0}.vjs-error .vjs-error-display .vjs-modal-dialog-content{font-size:1.4em;text-align:center}.vjs-error .vjs-error-display:before{color:#fff;content:"X";font-family:Arial,Helvetica,sans-serif;font-size:4em;left:0;line-height:1;margin-top:-.5em;position:absolute;text-shadow:.05em .05em .1em #000;text-align:center;top:50%;vertical-align:middle;width:100%}.vjs-loading-spinner{display:none;position:absolute;top:50%;left:50%;margin:-25px 0 0 -25px;opacity:.85;text-align:left;border:6px solid rgba(43,51,63,.7);box-sizing:border-box;background-clip:padding-box;width:50px;height:50px;border-radius:25px;visibility:hidden}.vjs-seeking .vjs-loading-spinner,.vjs-waiting .vjs-loading-spinner{display:block;-webkit-animation:vjs-spinner-show 0s linear .3s forwards;animation:vjs-spinner-show 0s linear .3s forwards}.vjs-loading-spinner:after,.vjs-loading-spinner:before{content:"";position:absolute;margin:-6px;box-sizing:inherit;width:inherit;height:inherit;border-radius:inherit;opacity:1;border:inherit;border-color:transparent;border-top-color:#fff}.vjs-seeking .vjs-loading-spinner:after,.vjs-seeking .vjs-loading-spinner:before,.vjs-waiting .vjs-loading-spinner:after,.vjs-waiting .vjs-loading-spinner:before{-webkit-animation:vjs-spinner-spin 1.1s cubic-bezier(.6,.2,0,.8) infinite,vjs-spinner-fade 1.1s linear infinite;animation:vjs-spinner-spin 1.1s cubic-bezier(.6,.2,0,.8) infinite,vjs-spinner-fade 1.1s linear infinite}.vjs-seeking .vjs-loading-spinner:before,.vjs-waiting .vjs-loading-spinner:before{border-top-color:#fff}.vjs-seeking .vjs-loading-spinner:after,.vjs-waiting .vjs-loading-spinner:after{border-top-color:#fff;-webkit-animation-delay:.44s;animation-delay:.44s}@keyframes vjs-spinner-show{to{visibility:visible}}@-webkit-keyframes vjs-spinner-show{to{visibility:visible}}@keyframes vjs-spinner-spin{100%{transform:rotate(360deg)}}@-webkit-keyframes vjs-spinner-spin{100%{-webkit-transform:rotate(360deg)}}@keyframes vjs-spinner-fade{0%{border-top-color:#73859f}20%{border-top-color:#73859f}35%{border-top-color:#fff}60%{border-top-color:#73859f}100%{border-top-color:#73859f}}@-webkit-keyframes vjs-spinner-fade{0%{border-top-color:#73859f}20%{border-top-color:#73859f}35%{border-top-color:#fff}60%{border-top-color:#73859f}100%{border-top-color:#73859f}}.vjs-chapters-button .vjs-menu ul{width:24em}.video-js .vjs-subs-caps-button+.vjs-menu .vjs-captions-menu-item .vjs-menu-item-text .vjs-icon-placeholder{vertical-align:middle;display:inline-block;margin-bottom:-.1em}.video-js .vjs-subs-caps-button+.vjs-menu .vjs-captions-menu-item .vjs-menu-item-text .vjs-icon-placeholder:before{font-family:VideoJS;content:"";font-size:1.5em;line-height:inherit}.video-js .vjs-audio-button+.vjs-menu .vjs-main-desc-menu-item .vjs-menu-item-text .vjs-icon-placeholder{vertical-align:middle;display:inline-block;margin-bottom:-.1em}.video-js .vjs-audio-button+.vjs-menu .vjs-main-desc-menu-item .vjs-menu-item-text .vjs-icon-placeholder:before{font-family:VideoJS;content:" ";font-size:1.5em;line-height:inherit}.video-js:not(.vjs-fullscreen).vjs-layout-small .vjs-audio-button,.video-js:not(.vjs-fullscreen).vjs-layout-small .vjs-captions-button,.video-js:not(.vjs-fullscreen).vjs-layout-small .vjs-chapters-button,.video-js:not(.vjs-fullscreen).vjs-layout-small .vjs-current-time,.video-js:not(.vjs-fullscreen).vjs-layout-small .vjs-descriptions-button,.video-js:not(.vjs-fullscreen).vjs-layout-small .vjs-duration,.video-js:not(.vjs-fullscreen).vjs-layout-small .vjs-playback-rate,.video-js:not(.vjs-fullscreen).vjs-layout-small .vjs-remaining-time,.video-js:not(.vjs-fullscreen).vjs-layout-small .vjs-subtitles-button,.video-js:not(.vjs-fullscreen).vjs-layout-small .vjs-time-divider,.video-js:not(.vjs-fullscreen).vjs-layout-small .vjs-volume-control,.video-js:not(.vjs-fullscreen).vjs-layout-tiny .vjs-audio-button,.video-js:not(.vjs-fullscreen).vjs-layout-tiny .vjs-captions-button,.video-js:not(.vjs-fullscreen).vjs-layout-tiny .vjs-chapters-button,.video-js:not(.vjs-fullscreen).vjs-layout-tiny .vjs-current-time,.video-js:not(.vjs-fullscreen).vjs-layout-tiny .vjs-descriptions-button,.video-js:not(.vjs-fullscreen).vjs-layout-tiny .vjs-duration,.video-js:not(.vjs-fullscreen).vjs-layout-tiny .vjs-playback-rate,.video-js:not(.vjs-fullscreen).vjs-layout-tiny .vjs-remaining-time,.video-js:not(.vjs-fullscreen).vjs-layout-tiny .vjs-subtitles-button,.video-js:not(.vjs-fullscreen).vjs-layout-tiny .vjs-time-divider,.video-js:not(.vjs-fullscreen).vjs-layout-tiny .vjs-volume-control,.video-js:not(.vjs-fullscreen).vjs-layout-x-small .vjs-audio-button,.video-js:not(.vjs-fullscreen).vjs-layout-x-small .vjs-captions-button,.video-js:not(.vjs-fullscreen).vjs-layout-x-small .vjs-chapters-button,.video-js:not(.vjs-fullscreen).vjs-layout-x-small .vjs-current-time,.video-js:not(.vjs-fullscreen).vjs-layout-x-small .vjs-descriptions-button,.video-js:not(.vjs-fullscreen).vjs-layout-x-small .vjs-duration,.video-js:not(.vjs-fullscreen).vjs-layout-x-small .vjs-playback-rate,.video-js:not(.vjs-fullscreen).vjs-layout-x-small .vjs-remaining-time,.video-js:not(.vjs-fullscreen).vjs-layout-x-small .vjs-subtitles-button,.video-js:not(.vjs-fullscreen).vjs-layout-x-small .vjs-time-divider,.video-js:not(.vjs-fullscreen).vjs-layout-x-small .vjs-volume-control{display:none}.video-js:not(.vjs-fullscreen).vjs-layout-small .vjs-volume-panel.vjs-volume-panel-horizontal.vjs-slider-active,.video-js:not(.vjs-fullscreen).vjs-layout-small .vjs-volume-panel.vjs-volume-panel-horizontal:active,.video-js:not(.vjs-fullscreen).vjs-layout-small .vjs-volume-panel.vjs-volume-panel-horizontal:hover,.video-js:not(.vjs-fullscreen).vjs-layout-tiny .vjs-volume-panel.vjs-volume-panel-horizontal.vjs-slider-active,.video-js:not(.vjs-fullscreen).vjs-layout-tiny .vjs-volume-panel.vjs-volume-panel-horizontal:active,.video-js:not(.vjs-fullscreen).vjs-layout-tiny .vjs-volume-panel.vjs-volume-panel-horizontal:hover,.video-js:not(.vjs-fullscreen).vjs-layout-x-small .vjs-volume-panel.vjs-volume-panel-horizontal.vjs-slider-active,.video-js:not(.vjs-fullscreen).vjs-layout-x-small .vjs-volume-panel.vjs-volume-panel-horizontal:active,.video-js:not(.vjs-fullscreen).vjs-layout-x-small .vjs-volume-panel.vjs-volume-panel-horizontal:hover{width:auto;width:initial}.video-js:not(.vjs-fullscreen).vjs-layout-tiny .vjs-subs-caps-button,.video-js:not(.vjs-fullscreen).vjs-layout-x-small:not(.vjs-live) .vjs-subs-caps-button,.video-js:not(.vjs-fullscreen).vjs-layout-x-small:not(.vjs-liveui) .vjs-subs-caps-button{display:none}.video-js:not(.vjs-fullscreen).vjs-layout-tiny .vjs-custom-control-spacer,.video-js:not(.vjs-fullscreen).vjs-layout-x-small.vjs-liveui .vjs-custom-control-spacer{flex:auto;display:block}.video-js:not(.vjs-fullscreen).vjs-layout-tiny.vjs-no-flex .vjs-custom-control-spacer,.video-js:not(.vjs-fullscreen).vjs-layout-x-small.vjs-liveui.vjs-no-flex .vjs-custom-control-spacer{width:auto}.video-js:not(.vjs-fullscreen).vjs-layout-tiny .vjs-progress-control,.video-js:not(.vjs-fullscreen).vjs-layout-x-small.vjs-liveui .vjs-progress-control{display:none}.vjs-modal-dialog.vjs-text-track-settings{background-color:#2b333f;background-color:rgba(43,51,63,.75);color:#fff;height:70%}.vjs-text-track-settings .vjs-modal-dialog-content{display:table}.vjs-text-track-settings .vjs-track-settings-colors,.vjs-text-track-settings .vjs-track-settings-controls,.vjs-text-track-settings .vjs-track-settings-font{display:table-cell}.vjs-text-track-settings .vjs-track-settings-controls{text-align:right;vertical-align:bottom}@supports (display:grid){.vjs-text-track-settings .vjs-modal-dialog-content{display:grid;grid-template-columns:1fr 1fr;grid-template-rows:1fr;padding:20px 24px 0 24px}.vjs-track-settings-controls .vjs-default-button{margin-bottom:20px}.vjs-text-track-settings .vjs-track-settings-controls{grid-column:1/-1}.vjs-layout-small .vjs-text-track-settings .vjs-modal-dialog-content,.vjs-layout-tiny .vjs-text-track-settings .vjs-modal-dialog-content,.vjs-layout-x-small .vjs-text-track-settings .vjs-modal-dialog-content{grid-template-columns:1fr}}.vjs-track-setting>select{margin-right:1em;margin-bottom:.5em}.vjs-text-track-settings fieldset{margin:5px;padding:3px;border:none}.vjs-text-track-settings fieldset span{display:inline-block}.vjs-text-track-settings fieldset span>select{max-width:7.3em}.vjs-text-track-settings legend{color:#fff;margin:0 0 5px 0}.vjs-text-track-settings .vjs-label{position:absolute;clip:rect(1px 1px 1px 1px);clip:rect(1px,1px,1px,1px);display:block;margin:0 0 5px 0;padding:0;border:0;height:1px;width:1px;overflow:hidden}.vjs-track-settings-controls button:active,.vjs-track-settings-controls button:focus{outline-style:solid;outline-width:medium;background-image:linear-gradient(0deg,#fff 88%,#73859f 100%)}.vjs-track-settings-controls button:hover{color:rgba(43,51,63,.75)}.vjs-track-settings-controls button{background-color:#fff;background-image:linear-gradient(-180deg,#fff 88%,#73859f 100%);color:#2b333f;cursor:pointer;border-radius:2px}.vjs-track-settings-controls .vjs-default-button{margin-right:1em}@media print{.video-js>:not(.vjs-tech):not(.vjs-poster){visibility:hidden}}.vjs-resize-manager{position:absolute;top:0;left:0;width:100%;height:100%;border:none;z-index:-1000}.js-focus-visible .video-js :focus:not(.focus-visible){outline:0;background:0 0}.video-js .vjs-menu :focus:not(:focus-visible),.video-js :focus:not(:focus-visible){outline:0;background:0 0} --------------------------------------------------------------------------------