惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

MyScale Blog
MyScale Blog
F
Fortinet All Blogs
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
D
Docker
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
爱范儿
爱范儿
V
Visual Studio Blog
Last Week in AI
Last Week in AI
WordPress大学
WordPress大学
aimingoo的专栏
aimingoo的专栏
小众软件
小众软件
L
LangChain Blog
Vercel News
Vercel News
阮一峰的网络日志
阮一峰的网络日志
IT之家
IT之家
P
Proofpoint News Feed
博客园_首页
D
DataBreaches.Net
T
The Blog of Author Tim Ferriss
The GitHub Blog
The GitHub Blog
酷 壳 – CoolShell
酷 壳 – CoolShell
C
Check Point Blog
Engineering at Meta
Engineering at Meta
Microsoft Azure Blog
Microsoft Azure Blog

又耳笔记

用AI挣钱之AI建站:二维码生成器工具 用AI挣钱之AI建站:https://markdowntopdf.top Cloudflare Workers实战番外一:用Static Assets托管网站,告别传统方案 Cloudflare Workers实战(五):不止JavaScript,拥抱Python与Rust Cloudflare Workers实战(四):托管和分发静态文件 Cloudflare Workers实战(三):实现认证、重定向与缓存 Cloudflare Workers实战(二):动态修改后端响应 Cloudflare Workers实战(一):随心所欲操作客户端请求 Cloudflare workers不完全指南 使用n8n创作短篇小说 水果风波:信任的代价 用Python将PDF文件转换成图片 rust网络框架Pingora源码阅读3 rust网络框架Pingora源码阅读2 rust网络框架Pingora源码阅读1 Pingora快速入门教程1之总览 Rust模板引擎askama快速入门引擎 使用Loco快速搭建自己的后台系统 用Rust发一封图文并茂的邮件 Rust小项目: 写一个简单的恶意流量阻断器 Rust小项目: 写一个简单的网页爬虫 Rust命令行库Clap快速入门教程 Rust小项目:用Rust写一个端口扫描器 Rust真全栈开发快速入门 rust声明宏快速入门教程 Rust文本处理快速入门教程 RUST web框架axum快速入门教程6之测试 白嫖免费的Rust在线运行时shuttle RUST web框架axum快速入门教程5之中间件 用Rust来做以太坊开发5之事件日志及签名
写一个h5视频网站
About 又耳宁 关于技术与股票的一些零碎想法 · 2021-09-07 · via 又耳笔记

做一个h5视频网站应该最重要的就是视频源,至于视频源从哪里来这是一个问题,不过这里提供一个很小的数据集,不要问我怎么来的,问就是网上来的^_^。

Deploy

所有资源来源于网路,仅做技术交流,如有侵权,请联系我删除。

技术栈如下:

  • 后端: Python + FastAPI

  • 前端: uniapp + videojs

  • 部署: deta/docker-compose+traefik

在线地址:

截图:

image-20210907144605776image-20210907145046149

数据集

这里假设已经有一个数据源(test.json),结果大致如下

[{'vod_id': '49069',
  'vod_cid': '36',
  'vod_name': '新双生花普通话',
  'vod_title': '',
  'vod_type': '',
  'vod_keywords': '',
  'vod_actor': '金伯莉·安妮·田舍利,詹姆斯·马',
  'vod_director': '内详',
  'vod_content': '内详',
  'vod_pic': 'https://img.52swat.cn/upload/vod/20210507-1/10b8897d7d7b6c2a5304a8867bb6bd4f.jpg',
  'vod_area': '泰国',
  'vod_language': '泰语',
  'vod_year': '2020',
  'vod_addtime': '2021-08-08 13:12:33',
  'vod_filmtime': 0,
  'vod_server': '',
  'vod_play': 'dbm3u8',
  'vod_url': '第01集$https://vod3.bdzybf3.com/20210507/VrRDCdH3/index.m3u8\r\n第02集$https://vod3.bdzybf3.com/20210507/yh8w9Xqs/index.m3u8\r\n第03集$https://vod3.bdzybf3.com/20210507/om8aOzQA/index.m3u8\r\n第04集$https://vod3.bdzybf3.com/20210507/NCclbdqE/index.m3u8\r\n第05集$https://vod3.bdzybf3.com/20210513/Kz2cHMtz/index.m3u8\r\n第06集$https://vod3.bdzybf3.com/20210513/UneQkeSi/index.m3u8\r\n第07集$https://vod3.bdzybf3.com/20210513/8jIVTzNl/index.m3u8\r\n第08集$https://vod3.bdzybf3.com/20210513/aFzZIwjK/index.m3u8\r\n第09集$https://vod3.bdzybf3.com/20210520/bc0eJaNM/index.m3u8\r\n第10集$https://vod3.bdzybf3.com/20210520/JGGTdkPl/index.m3u8\r\n第11集$https://vod3.bdzybf3.com/20210520/A9XEbzJC/index.m3u8\r\n第12集$https://vod3.bdzybf3.com/20210520/x5WeVe2s/index.m3u8\r\n第13集$https://vod8.wenshibaowenbei.com/20210526/81MdaJUe/index.m3u8\r\n 第14集$https://vod8.wenshibaowenbei.com/20210526/jxc2hQBx/index.m3u8\r\n第15集$https://vod8.wenshibaowenbei.com/20210603/D1UTn0YY/index.m3u8\r\n第16集$https://vod8.wenshibaowenbei.com/20210603/VCf6NvbC/index.m3u8\r\n第17集$https://vod8.wenshibaowenbei.com/20210604/afKgBDKk/index.m3u8\r\n第18集$https://vod8.wenshibaowenbei.com/20210604/7JHjaJ1B/index.m3u8\r\n第19集$https://vod8.wenshibaowenbei.com/20210609/vXi4i7LW/index.m3u8\r\n第20集$https://vod8.wenshibaowenbei.com/20210609/KibQMNAi/index.m3u8\r\n第21集$https://vod8.wenshibaowenbei.com/20210610/t7x2SAGV/index.m3u8\r\n第22集$https://vod8.wenshibaowenbei.com/20210610/PU5Nt4VD/index.m3u8\r\n第23集$https://vod8.wenshibaowenbei.com/20210617/anK2hTDK/index.m3u8\r\n第24集$https://vod8.wenshibaowenbei.com/20210617/iSj9E0ke/index.m3u8\r\n第25集$https://vod8.wenshibaowenbei.com/20210618/10teWp7R/index.m3u8\r\n第26集$https://vod8.wenshibaowenbei.com/20210618/38FYcSAt/index.m3u8\r\n第27集$https://vod8.wenshibaowenbei.com/20210624/yEnElEJb/index.m3u8\r\n第28集$https://vod8.wenshibaowenbei.com/20210624/PpOJ9Q1X/index.m3u8\r\n第29集$https://vod8.wenshibaowenbei.com/20210625/ibLJ8BVi/index.m3u8\r\n第30集$https://vod8.wenshibaowenbei.com/20210625/kR5MGUYa/index.m3u8\r\n第31集$https://vod8.wenshibaowenbei.com/20210701/zO7e308g/index.m3u8\r\n第32集$https://vod8.wenshibaowenbei.com/20210701/Ud62RiQr/index.m3u8\r\n第33集$https://vod8.wenshibaowenbei.com/20210701/wzi6elaq/index.m3u8\r\n第34集$https://vod8.wenshibaowenbei.com/20210701/4knnVJj7/index.m3u8\r\n第35集$https://vod8.wenshibaowenbei.com/20210708/cYqqEQl6/index.m3u8\r\n第36集$https://vod8.wenshibaowenbei.com/20210708/8WBVoFhM/index.m3u8',
  'vod_inputer': None,
  'vod_reurl': 'http://www.605zyw.net//?m=vod-detail-id-49069.html',
  'vod_length': 0,
  'vod_weekday': None,
  'vod_copyright': 0,
  'vod_state': '',
  'vod_version': '',
  'vod_tv': '',
  'vod_total': 0,
  'vod_continu': '共36集,完结',
  'vod_status': 1,
  'vod_stars': 0,
  'vod_hits': None,
  'vod_isend': 1,
  'vod_douban_id': 0,
  'vod_series': '',
  'list_name': '海外剧'}]

所有资源来源于网路,仅做技术交流,如有侵权,请联系我删除。

后端

因为我要做的是一个非常简单的视频网站,所以只需要提供几个接口就可以了

# -*- coding: UTF-8 -*-
# @author youerning

import logging
import json
from typing import List

from fastapi import FastAPI, Query, Body
from  fastapi.staticfiles import StaticFiles
from fastapi.responses import JSONResponse, HTMLResponse
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel


LOG_LEVEL = logging.DEBUG
logger = logging.getLogger(__name__)
fmt = logging.Formatter("%(asctime)s - %(filename)s[line:%(lineno)d] - %(levelname)s: %(message)s")
console_handler = logging.StreamHandler()
console_handler.setFormatter(fmt)
logger.addHandler(console_handler)

logger.setLevel(LOG_LEVEL)
console_handler.setLevel(LOG_LEVEL)

# 加载本地数据源
with open("test.json", encoding="utf8") as rf:
    db = json.load(rf)


with open("index.html", encoding="utf8") as rf:
    html = rf.read()

app = FastAPI(
    title="Simple Video APP Backend",
    version="1.0.0",
)

app.mount("/static", StaticFiles(directory="static"), name="static")

origins = [
    "http://localhost",
    "http://localhost:8080",
]

app.add_middleware(
    CORSMiddleware,
    allow_origins=origins,
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)


# 响应数据结构
class ResponseModel(BaseModel):
    data: list

class SuccessResponseModel(ResponseModel):
    status: str = "ok"

class ErrorResponseModel(BaseModel):
    status: str = "error"
    msg: str


def parse_vod_urls(video: dict) -> list:
    vod_url = video.pop("vod_url", "")
    vod_links = []

    if not vod_url:
        logger.warning("the content of video[{}] is not vaild".format(video["_id"]))
        return vod_links

    name_urls = vod_url.split("\r\n")
    for name_url in name_urls:
        name, url = name_url.split("$")
        vod_links.append({"name": name, "url": url})

    return vod_links


@app.get("/", response_class=HTMLResponse)
def index_page():
    return html


@app.get("/api/search",
    status_code=200,
    response_model=SuccessResponseModel,
    response_model_exclude_none=True,
    responses={501: {"model": ErrorResponseModel}})
async def search(q: str = Query(..., title="query", description="The name of the video you want to search", max_length=30)):
    data = [item for item in db if q in item["vod_name"]]
    for video in data:
        video["vod_links"] = parse_vod_urls(video)
    return SuccessResponseModel(data=data)


@app.post("/api/videos/",
    status_code=200,
    response_model=SuccessResponseModel,
    response_model_exclude_none=True,
    responses={501: {"model": ErrorResponseModel}})
async def search_videos(video_ids: List[str] = Body(..., embed=True)):
    video_ids = list(set(video_ids))[:10]
    data = [item for item in db if item["vod_id"] in video_ids]
    for video in data:
        video["vod_links"] = parse_vod_urls(video)
    return SuccessResponseModel(data=data)


@app.get("/api/videos/{video_id}",
    status_code=200,
    response_model=SuccessResponseModel,
    response_model_exclude_none=True,
    responses={501: {"model": ErrorResponseModel}, 400: {"model": ErrorResponseModel}})
async def search_video(video_id: str):
    data = [item for item in db if item["vod_id"] == video_id]
    if not data:
        return JSONResponse(status_code=400, content={"msg": "video id do not exists"})

    data = data[0]
    data["vod_links"] = parse_vod_urls(data)
    return SuccessResponseModel(data=[data])

我这里是直接加载的json文件,现实情况当然是使用数据库比如mysql或者mongodb,又或者全文搜索引擎如elasticsearch, meilisearch, typesense。

前端

前端不用太复杂,只要两个页面即可,一个是搜索页面,一个是播放页面.

说实话,前端也不够好

目录结构如下:

pages
└─index
      index.vue
    └─detail
            detail.vue

index.vue 代码如下:

<template>
	<view class="content">
		<uni-search-bar placeholder="搜索" @confirm="search"></uni-search-bar>
		<view>
			<scroll-view scroll-y="true" class="scroll-Y" @scrolltoupper="upper" @scrolltolower="lower"
			@scroll="scroll">
				<block v-for="item in data" >
					<uni-card
						:title="item.vod_name"
						:key="item.vod_id"
						:is-shadow="true"
						:extra="item.vod_addtime"
						:note="item.vod_continu"
						>
						<view class="detail" @click="() => gotoDetail(item.vod_id)">
							<image :src="item.vod_pic" mode="widthFix" class="index-image"></image>
							<text>{{"演员: " + item.vod_actor.slice(0, 30) + "..." + "\n\n"}}  {{item.vod_content}}</text>
						</view>
					</uni-card>
				</block>
			</scroll-view>
		</view>
	</view>
</template>

<script>
	export default {
		data() {
			return {
				video_ids: [],
				data: []
			}
		},
		onLoad() {
			var that = this
			uni.getStorage({
			    key: 'video_ids',
			    success: function (res) {
					this.video_ids = res.data
					console.log("获取缓存数据", this.video_ids)
					
					if (this.video_ids.length >= 1) {
						uni.request({
							url: "/api/videos",
							data: {
								video_ids: this.video_ids
							},
							method:"POST",
							success: (res) => {
								that.data = res.data.data
								// console.log(that.data)
							}
						})
					}
			    },
				fail: function(err) {
					// console.log(err)
					uni.setStorage({
					    key: 'video_ids',
					    data: [],
					    success: function () {
					        console.log('将历史记录重置为空数组');
					    }
					});
				}
			});
		},
		methods: {
			gotoDetail(vod_id) {
				console.log("click ", vod_id)
				uni.navigateTo({url: "/pages/index/detail/detail?vod_id=" + vod_id})
			},
			search(value) {
				// console.log(value)
				uni.request({
					url: '/api/search?q=' + value.value,
				}).then((res) => {
					this.data = res[1].data.data
					console.log(this.data)
					// console.log(res[])
				}).catch((err) => {
					uni.showToast({
					    title: '请求失败',
					    duration: 2000,
						icon: none
					});
				})
			}
		}
	}
</script>

<style>
	/* .content {
		padding:15upx 50upx 0upx 50upx;
	} */ 
	.detail {
		font-size: 30rpx;
		height: 350rpx;
		display: -webkit-flex;
		display: flex;
		align-items: flex-start;
		justify-content: space-around;
		flex-flow: row nowrap;
	}
	
	.detail image {
		width: 270rpx;
	}
	
	.detail text {
		padding-left: 10rpx;
		width: 450rpx;
		display: -webkit-box;
		overflow: hidden;
		text-overflow: ellipsis;
		word-wrap: break-word;
		white-space: normal !important;
		-webkit-line-clamp: 8;
		-webkit-box-orient: vertical;
	}
</style>

detail.vue代码如下:

<template>
	<view class="content">
		<text class="title">{{data.vod_name}}{{current_video.name? " - " + current_video.name : ""}}</text>
		<view class="videocontent">
			<view class="video-js" ref="video" />
		</view>
		<view class="title">播放列表</view>
		<!-- <view class="series">
			<button v-for="video_data in data.vod_links"
				:type="video_data.url == current_video.url ? 'primary' : 'defalut'"
				@click="() => changeUrl(video_data)"
				plain="true">{{video_data.name}}</button>
		</view>
		 -->
		<view class="grid col-5 padding-sm">
			<button v-for="video_data in data.vod_links"
				:type="video_data.url == current_video.url ? 'primary' : 'defalut'"
				@click="() => changeUrl(video_data)"
				plain="true"
				>{{video_data.name}}</button>
		</view>
	</view>
</template>

<script>
	export default {
		data() {
			return {
				current_video: {
					name: "",
					url: ""
				},
				data: {}
			}
		},
		methods: {
			changeUrl(video_data) {
				this.current_video = video_data
				var v = videojs('video')
				v.src([
					{type: "application/x-mpegURL", src: video_data.url},
				])
				v.play()
			}
		},
		mounted() {
			// this.current_video = this.data.vod_urls[0]
			var video = document.createElement('video')  
			this.video = video
			this.video.id = 'video'
			// video.style = 'width: 300px;height: 150px;'  
			this.video.controls = true  
			// var source = document.createElement('source')
			// source.src = this.current_video.url
			// this.video.appendChild(source)  
			this.$refs.video.$el.appendChild(video)  
			videojs('video')
		},
		onLoad: function(option) {
			// console.log("recive vod_id: ", option)
			this.video_id = option.vod_id
			uni.request({
				url: "/api/videos/" + option.vod_id
			})
			.then(res => {
				// console.log(res[1].data.data)
				this.data = res[1].data.data[0]
				// console.log(this.data)
				this.current_video = this.data.vod_links[0]
				this.changeUrl(this.current_video)
				
				// 缓存点击多的video id
				uni.getStorage({
				    key: 'video_ids',
				    success: function (res) {
						console.log("获取缓存数据", res)
						const video_ids = res.data.filter((val) => {
							if (val && val != this.video_id) {
								return true
							}
						})
						
						this.video_ids = [option.vod_id].concat(video_ids)
						console.log("缓存数据: ", this.video_ids)
						uni.setStorage({
						    key: 'video_ids',
						    data: this.video_ids,
						});	
				    }
				})
			})
			.catch(err => {
				console.log(err)
				uni.showToast({
					icon: "none",
					duration: 1500,
					title: "没有找到对应的视频内容"
				})
			})
			
		}
	}
</script>

<style>
	.content {
		padding: 0 30rpx;
	}
	
	.title {
		display: -webkit-box;
		padding: 20rpx 0;
		font-size: 24rpx;
		color: #434343;
	}
	
	.videocontent {
		display: flex;
		height: 400rpx;
		display: -webkit-flex;
		display: flex;
		align-items: center;
		justify-content: center;
		flex-flow:row nowrap;
	}
	
	.videocontent view{
		width: 100%;
		height: 100%;
	}
	.series {
		display: flex;
		justify-content: flex-start;
		flex-flow:row wrap;
		display: -webkit-flex;
	}
	
	.series button {
		width: 180rpx;
	}
</style>

index.html代码如下:

<!DOCTYPE html>  
<html lang="zh-CN">  
    <head>  
        <meta charset="utf-8">  
        <meta http-equiv="X-UA-Compatible" content="IE=edge">  
        <meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">  
        <title>  
            <%= htmlWebpackPlugin.options.title %>  
        </title>  
        <script>  
            document.addEventListener('DOMContentLoaded', function() {  
                document.documentElement.style.fontSize = document.documentElement.clientWidth / 20 + 'px'  
            })  
        </script>  
        <link rel="stylesheet" href="<%= htmlWebpackPlugin.options.baseUrl %>static/index.css" />  
        <link href="https://cdn.bootcdn.net/ajax/libs/video.js/7.14.2/alt/video-js-cdn.min.css" rel="stylesheet">  
        <script src="https://cdn.bootcdn.net/ajax/libs/video.js/7.14.2/alt/video.core.min.js"></script>  
        <script src="https://cdn.bootcdn.net/ajax/libs/videojs-contrib-hls/5.9.0/videojs-contrib-hls.js"></script>  
    </head>  
    <body>  
        <noscript>  
            <strong>Please enable JavaScript to continue.</strong>  
        </noscript>  
        <div id="app"></div>  
        <!-- built files will be auto injected -->  
    </body>  
</html>

部署

这里用两种方式部署

  • deta: 一个免费的应用云平台,可以部署自己服务,支持自定义子域名或者使用自己的域名
  • docker-compose + traefik

Deta

代码请参考deta目录

非常推荐的一个小项目的部署方案,因为免费版本也就内存128mb, 但是做一个demo还是不错的。

注册登录

注册登录之后会一个账号,还有一个project key,这里暂时用不到

安装deta

参考 https://docs.deta.sh/docs/cli/install

创建项目

deta new video_site --python

创建完成之后会给一个自动生成的域名

Successfully created a new micro
{
        "name": "video_site",
        "runtime": "python3.7",
        "endpoint": "https://u8hfyf.deta.dev",
        "visor": "enabled",
        "http_auth": "disabled"
}

目录结构

video_site
│  index.html
│  main.py
│  requirements.txt
│  test.json
│
├─.deta
│      prog_info
│
└─static
    └─static
        │  index.css
        │  logo.png
        │
        ├─fonts
        │      uni.75745d34.ttf
        │
        └─js
                chunk-vendors.7b2b4d3a.js
                index.d88f037c.js
                pages-index-detail-detail.c3068165.js
                pages-index-index.290eaeda.js

每个deta项目(Python)需要一个main.py以及requirements.txt

部署

最后在项目工作目录执行

小结

使用deta作为demo地址还是不错的,并且还有其他服务作为交互,比如deta base可以作为一个类redis key-value数据库使用,而deta drive可以作为一个网络空间使用。

大家多多注册吧,我怕它经营不善要倒闭 (捂脸)

docker-compose + traefik

配置文件太多就不粘贴了

构建镜像

mkdir -p app
cp ../backend/* app/
cp ../frontend/app/unpackage/dist/build/h5/index.html app/
cp ../frontend/app/unpackage/dist/build/h5/static app
sed -i "s/127.0.0.1/db/" app/app.yaml

docker build -t video .

启动容器

小结

有了docker或者说容器是的部署的环境问题得到很大的改善,环境一致干净,但是大规模的环境就需要编排工具,比如k8s,虽然编排的很快,但是网络流量处理是一个让人头疼的问题,所以Traefik应运而生,让配置反向代理变得有趣且简单,世界又变得美好了,而且Traefik支持的provider很多,比如docker,这样对于个人用户还是很友好的,如果有多个应用需要维护,可以很方便的维护这个统一的入口。

最后,Traefik跟Let’s Encrypt的配合,使得ssl证书可以自动获得,甚至是通配符ssl证书,世界变得更美好了^_^

代码地址

https://github.com/youerning/blog/tree/master/video_site

总结

见小结。