Jeffrey Wang
文章85
标签144
分类12
使用PHP读取视频流,IOS系统无法观看的问题解决方法

使用PHP读取视频流,IOS系统无法观看的问题解决方法

背景

项目中遇到了付费视频资源的问题,为隐藏视频真实地址并对观看视频的用户做权限限制,于是就想到了使用php判断当前用户权限读取视频文件,客户端视频地址指向写好的php就好,但是经过简单的代码处理后,电脑和Android并没有发现问题,但是IOS系统播放的时候死活播放不了,进行一番研究之后,得出了解决方案。

场景复现

假设我们要做一个付费视频课程的项目,设计数据库数据结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
资源表,包含资源名称、购买金额、下载量等
*/
CREATE TALBE resource(
`rid` INT(11) PRIMARY KEY AUTO_INCREMENT,
`rname` CHAR(40) NOT NULL COMMENT '资源名称',
`raddr` CHAR(60) NOT NULL COMMENT '资源在服务器上实际地址',
`rdownload` INT(11) NOT NULL DEFAULT 0 COMMENT '资源下载量',
`rmoney` INT(11) NOT NULL COMMENT '资源费用',
INDEX(`rname`) COMMENT '搜索资源标题时使用索引'
)ENGINE=INNODB;
/**
资源购买记录表,检查uid和rid在此表中有记录则表示已购买
*/
CREATE TABLE resource_buy(
`rbid` INT(11) PRIMARY KEY AUTO_INCREMENT,
`rid` INT(11) NOT NULL COMMENT '购买资源id',
`uid` INT(11) NOT NULL COMMENT '购买用户id',
`create_at` DATETIME NOT NULL COMMENT '购买时间',
INDEX(`rid`, `uid`)
)ENGINE=INNODB;

然后新增一个页面(_getresource.php_)(在框架中可用 控制器/方法 代替) 这是比较常规的做法,使用file_get_contents()或者readfile();直接将文件读取出来,前端使用video标签读取视频文件并播放。

1
<video src="getresource.php" contorls="controls">
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
<?php
// 此处理仅供参考,此处出于照顾对框架不熟悉的人,使用的原生php书写,没有做防sql注入等安全措施,建议使用TP、Laravel等框架的 模型/ORM实现数据库增删改查。
$rid = intval($_GET['rid']);
$uid = $_SESSION['uid'];

$con = mysql_connect('127.0.0.1', 'root', 'root');
// 资源有效性
$res = mysql_query('SELECT rid, rname, raddr FROM resource WHERE rid = '.$rid);
$resource = mysql_fetch_array($res);
if($resource){
// 检测是否购买
$res = mysql_query('SELECT rbid FROM resource_buy WHERE rid = '.$rid.' AND uid = '.$uid.' LIMIT 1');
$buydata = mysql_fetch_array($res);
if($buydata){
// 已购买资源,返回资源内容
// 检测资源是否存在等操作,存在则读取文件
if(file_exists($resource['raddr'])){
// 判断文件类型,返回相应的Content-Type,推荐使用fileinfo()
$fileinfo = finfo_open(FILEINFO_MIME);
$mime = file_info($fileinfo, $resource['raddr']);
finfo_close($fileinfo);
header('Content-Type:'.$mime);
// 读取文件,不考虑视频分段,使用readfile()/file_get_content()均可
readfile($resource['raddr']);
} else {
echo '资源地址无效,请联系管理员';
}
} else {
echo '请购买后重试';
}
} else {
echo '资源不存在';
}
mysql_close($con);

我最开始的想法就是这样,然后使用Chrome、Android进行测试也并没有发现问题,直到有一天,我拿出了IOS。。。点击 播放 ,然后出现了一个小叉叉,视频无法加载。 当时的我百思不得其解,于是开始在全网搜集资料,然后我发现并不是我一个人遇到了这个问题,经过搜索,最终我锁定了一片知乎上的帖子,根据答主所述原理,成功使用PHP解决了这个问题。 吃水不忘挖井人,原帖奉上:video标签在iPhone手机上不能播放?万能的知乎大神请指引我,实在没办法了

问题成因

chrome对视频的兼容性比较好,有时候请求资源,header里边的”Range”是”0-“,意思是请求获取该视频全部文件,而我们恰好将文件全部返回了,所以Chrome中可以正常使用。 但是在IOS中,无论是自带的safari还是IOS的微信浏览器都是用的safari内核,请求视频资源时都会先发起一个头部包含”Range:0-1” 的请求,目的是为了获取视频文件大小,如果用户不点开视频可以节省流量。 chorme的video请求: IOS的video请求日志记录 透过日志可以看到,IOS观看视频时,会先请求一个range:0-1, 此时我回一个length:2 ,bytes=0-1,IOS将会获取到视频大小等信息,以便进行分段请求。

解决问题

解决这个问题,其实只需要解析header里边的Range就可以了,比如Range:start-end ,表示获取start-end的所有数据,假设这个文件有 255个字节并且请求header中的Range: 0-,那么我们应该返回Content-Range:bytes 0-254/255。 特别注意:返回的end是广义的end,255个字节,end最大为254,如果请求Range:0-1, 那么我们应该返回前两个字节,而不是1个。 所以,根据规律,start和end都可以根据正则表达式匹配Header的Range可以获取到,读取长度 = end - start + 1,所以,可以将文件读取的部分修改为如下的样子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
<?php
// 假设文件真实地址之前已经获取到
$path = '....';
// 文件大小以及读取大小
$file_size = filesize($path);
$start = 0;
$end = $file_size - 1;
$range_str = $_SERVER['HTTP_RANGE'];
preg_match('/^bytes=(\d+)-(\d+)?$/U', $range_str, $matches);
// print_r($matches);
// \Think\Log::write('matches:'.json_encode($matches));
if($matches){
$start = $matches[1];
if(!empty($matches[2])){
$end = $matches[2];
}
}
$length = $end - $start + 1;
// $header专指之前设置的Content-Type或者下载文件的filename等其他header。
$header = [];
// 合并现有header
$header = array_merge(['Content-Range:bytes '.$start.'-'.$end.'/'.$file_size, 'Content-Length:'.$length], $header);
// print_r($header);
// return false;
\Think\Log::write('range:'.$start.'-'.$end.' size:'.$file_size.' length:'.$length.' client:'.$range_str);
if($header){
foreach($header as $key => $value){
header($value);
}
}
// $content = file_get_contents($path);
// echo $content;
// readfile($path);
$fp = fopen($path, 'rb');
set_time_limit(0);
fseek($fp, $start);
while(!feof($fp)){
echo fread($fp, $length);
flush();
}

基于TP封装的获取资源类

系统会根据method的不同而调用AssetLogic不同的方法,AssetLogic中可以进行权限判断以及返回视频/其他资源的真实地址、定制header等。 并且针对微信浏览器不能调起下载链接的问题,弄了个100s有效的下载码,提示用户从浏览器打开,打开后即可下载到相关资源。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
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
107
108
109
110
111
<?php

/**
* @Author: JeffreyWang
* @Date: 2018-03-04 01:01:13
* @Last Modified by: JeffreyWang
* @Last Modified time: 2018-03-06 08:55:21
*/
namespace Lesson\Controller;
use Think\Controller;
/**
* AssetController.class.php
* 此类用于获取资源时候的回调类,可用于隐藏真实播放地址
*/
class AssetController extends Controller
{
public function _initialize(){
parent::_initialize();
if(!$this->is_login() && empty(I('get.code'))){
$this->error('您还没有登陆,请登陆后重试');
}
}
public function getasset(){
$method = I('get.method', '');
$code = I('get.code', '');
$isdownload = I('get.download', 0, 'intval');
// 检测方法合法性
if(!in_array($method, ['lesson', 'resource', 'material'])){
$this->error('非法的方法');
}
// 检测是否是微信以及下载码
if(sp_is_weixin() && $isdownload && $this->is_login() && empty(S('LESSON_ASSET_'.$code))){
$param = $_GET;
$param['code'] = sp_random_string(16);
// 下载码有效期100s,存储日期
S('LESSON_ASSET_'.$param['code'], $this->user, 100);
// print_r(S('LESSON_ASSET_'.$param['code']));
redirect(UU('Lesson/Asset/getasset', $param));
return false;
}
// 如果是微信浏览器且有下载码,则显示提示图片提示在浏览器打开
if(sp_is_weixin() && !empty($code)){
echo '<img src="/public/images/wechat_prompt.jpg" style="width:100%;">';
return false;
}
// 有下载码则检测下载码正确性
if(!empty($code)){
$user = S('LESSON_ASSET_'.$code);
// var_dump($user);
// 下载码无效如果处于登陆态则重新生成
if(empty($user)){
$this->error('下载校验错误,请刷新资源页重试');
}
// 用户信息
empty($this->user) && $this->user = $user;
}
// 执行相应逻辑
if(file_exists(APP_PATH.'Lesson/Logic/AssetLogic.class.php') && method_exists(D('Lesson/Asset', 'Logic'), $method)){
$logic = D('Lesson/Asset', 'Logic');
$logic->init($this->user);
$ans = $logic->$method();
if($ans['status']){
$path = $ans['data'];
if(file_exists($path)){
// 文件大小以及读取大小
$file_size = filesize($path);
$start = 0;
$end = $file_size - 1;
$range_str = $_SERVER['HTTP_RANGE'];
preg_match('/^bytes=(\d+)-(\d+)?$/U', $range_str, $matches);
// print_r($matches);
// \Think\Log::write('matches:'.json_encode($matches));
if($matches){
$start = $matches[1];
if(!empty($matches[2])){
$end = $matches[2];
}
}
$length = $end - $start + 1;
$header = $ans['header'];
$header = array_merge(['Content-Range:bytes '.$start.'-'.$end.'/'.$file_size, 'Content-Length:'.$length], $header);
// print_r($header);
// return false;
// \Think\Log::write('range:'.$start.'-'.$end.' size:'.$file_size.' length:'.$length.' client:'.$range_str);
if($header){
foreach($header as $key => $value){
header($value);
}
}
// $content = file_get_contents($path);
// echo $content;
// readfile($path);
$fp = fopen($path, 'rb');
set_time_limit(0);
fseek($fp, $start);
while(!feof($fp)){
echo fread($fp, $length);
flush();
}
} else {
$this->error('资源文件不存在');
}
} else {
$this->error($ans['info']);
}
} else {
$this->error('回调不存在');
}
}
}

本文作者:Jeffrey Wang
本文链接:https://blog.wj2015.com/2018/03/10/%E4%BD%BF%E7%94%A8php%E8%AF%BB%E5%8F%96%E8%A7%86%E9%A2%91%E6%B5%81%EF%BC%8Cios%E7%B3%BB%E7%BB%9F%E6%97%A0%E6%B3%95%E8%A7%82%E7%9C%8B%E7%9A%84%E9%97%AE%E9%A2%98%E8%A7%A3%E5%86%B3%E6%96%B9%E6%B3%95/
版权声明:本文采用 CC BY-NC-SA 3.0 CN 协议进行许可
×