Add 'go-vod/' from commit 'a37c11daf8c4aa207186c4ef030d8a35f251d433'
git-subtree-dir: go-vod git-subtree-mainline:monorepoa41998f0f0
git-subtree-split:a37c11daf8
commit
4df1ce40f3
|
@ -0,0 +1 @@
|
||||||
|
Dockerfile
|
|
@ -0,0 +1,68 @@
|
||||||
|
name: release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "*"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
binary:
|
||||||
|
name: Binary
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
container:
|
||||||
|
image: golang:1.20-bullseye
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: |
|
||||||
|
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -buildvcs=false -ldflags="-s -w" -o go-vod-amd64
|
||||||
|
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -buildvcs=false -ldflags="-s -w" -o go-vod-arm64
|
||||||
|
|
||||||
|
- name: Upload to releases
|
||||||
|
uses: svenstaro/upload-release-action@v2
|
||||||
|
id: attach_to_release
|
||||||
|
with:
|
||||||
|
file: go-vod-*
|
||||||
|
file_glob: true
|
||||||
|
tag: ${{ github.ref }}
|
||||||
|
overwrite: true
|
||||||
|
|
||||||
|
docker:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
name: Docker
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Check out the repo
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Login to DockerHub
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Get image label
|
||||||
|
id: image_label
|
||||||
|
run: echo "label=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Build container image
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
push: true
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
context: './'
|
||||||
|
no-cache: true
|
||||||
|
file: 'Dockerfile'
|
||||||
|
tags: radialapps/go-vod:${{ steps.image_label.outputs.label }} , radialapps/go-vod:latest
|
||||||
|
provenance: false
|
|
@ -0,0 +1,22 @@
|
||||||
|
# If you prefer the allow list template instead of the deny list, see community template:
|
||||||
|
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
|
||||||
|
#
|
||||||
|
# Binaries for programs and plugins
|
||||||
|
*.exe
|
||||||
|
*.exe~
|
||||||
|
*.dll
|
||||||
|
*.so
|
||||||
|
*.dylib
|
||||||
|
go-vod
|
||||||
|
|
||||||
|
# Test binary, built with `go test -c`
|
||||||
|
*.test
|
||||||
|
|
||||||
|
# Output of the go coverage tool, specifically when used with LiteIDE
|
||||||
|
*.out
|
||||||
|
|
||||||
|
# Dependency directories (remove the comment below to include it)
|
||||||
|
# vendor/
|
||||||
|
|
||||||
|
# Go workspace file
|
||||||
|
go.work
|
|
@ -0,0 +1,7 @@
|
||||||
|
FROM linuxserver/ffmpeg:latest
|
||||||
|
|
||||||
|
COPY run.sh /go-vod.sh
|
||||||
|
|
||||||
|
EXPOSE 47788
|
||||||
|
|
||||||
|
ENTRYPOINT ["/go-vod.sh"]
|
|
@ -0,0 +1,202 @@
|
||||||
|
|
||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following
|
||||||
|
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. We also recommend that a
|
||||||
|
file or class name and description of purpose be included on the
|
||||||
|
same "printed page" as the copyright notice for easier
|
||||||
|
identification within third-party archives.
|
||||||
|
|
||||||
|
Copyright [yyyy] [name of copyright owner]
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
|
@ -0,0 +1,26 @@
|
||||||
|
# go-vod
|
||||||
|
|
||||||
|
Extremely minimal on-demand video transcoding server in go. Used by the FOSS photos app, [Memories](https://github.com/pulsejet/memories).
|
||||||
|
|
||||||
|
## Filing Issues
|
||||||
|
|
||||||
|
Please file issues at the [Memories](https://github.com/pulsejet/memories) repository.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
Note: this package provides bespoke functionality for Memories. As such it is not intended to be used as a library.
|
||||||
|
|
||||||
|
You need go and ffmpeg/ffprobe installed
|
||||||
|
|
||||||
|
```bash
|
||||||
|
CGO_ENABLED=0 go build -ldflags="-s -w"
|
||||||
|
./go-vod
|
||||||
|
```
|
||||||
|
|
||||||
|
The server exposes all files as HLS streams, at the URL
|
||||||
|
```
|
||||||
|
http://localhost:47788/player-id/path/to/file/index.m3u8
|
||||||
|
```
|
||||||
|
|
||||||
|
## Thanks
|
||||||
|
Partially inspired from [go-transcode](https://github.com/m1k1o/go-transcode). The projects use different approaches for segmenting the transcodes.
|
|
@ -0,0 +1,39 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# This script is intended for bare-metal installations.
|
||||||
|
# It builds ffmpeg and NVENC drivers from source.
|
||||||
|
|
||||||
|
apt-get remove -y ffmpeg
|
||||||
|
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y \
|
||||||
|
sudo curl wget \
|
||||||
|
autoconf libtool libdrm-dev xorg xorg-dev openbox \
|
||||||
|
libx11-dev libgl1-mesa-glx libgl1-mesa-dev \
|
||||||
|
xcb libxcb-xkb-dev x11-xkb-utils libx11-xcb-dev \
|
||||||
|
libxkbcommon-x11-dev libxcb-dri3-dev \
|
||||||
|
cmake git nasm build-essential \
|
||||||
|
libx264-dev \
|
||||||
|
libffmpeg-nvenc-dev clang
|
||||||
|
|
||||||
|
git clone --branch sdk/11.1 https://git.videolan.org/git/ffmpeg/nv-codec-headers.git
|
||||||
|
cd nv-codec-headers
|
||||||
|
sudo make install
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
git clone --depth 1 --branch n5.1.3 https://github.com/FFmpeg/FFmpeg
|
||||||
|
cd FFmpeg
|
||||||
|
./configure \
|
||||||
|
--enable-nonfree \
|
||||||
|
--enable-gpl \
|
||||||
|
--enable-libx264 \
|
||||||
|
--enable-nvenc \
|
||||||
|
--enable-ffnvcodec \
|
||||||
|
--enable-cuda-llvm
|
||||||
|
|
||||||
|
make -j"$(nproc)"
|
||||||
|
sudo make install
|
||||||
|
sudo ldconfig
|
||||||
|
cd ..
|
|
@ -0,0 +1,62 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# This script is intended for bare-metal installations.
|
||||||
|
# It builds ffmpeg and VA-API drivers from source.
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
apt-get remove -y libva ffmpeg
|
||||||
|
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y \
|
||||||
|
sudo curl wget \
|
||||||
|
autoconf libtool libdrm-dev xorg xorg-dev openbox \
|
||||||
|
libx11-dev libgl1-mesa-glx libgl1-mesa-dev \
|
||||||
|
xcb libxcb-xkb-dev x11-xkb-utils libx11-xcb-dev \
|
||||||
|
libxkbcommon-x11-dev libxcb-dri3-dev \
|
||||||
|
cmake git nasm build-essential \
|
||||||
|
libx264-dev
|
||||||
|
|
||||||
|
mkdir qsvbuild
|
||||||
|
cd qsvbuild
|
||||||
|
|
||||||
|
git clone --depth 1 --branch 2.18.0 https://github.com/intel/libva
|
||||||
|
cd libva
|
||||||
|
./autogen.sh
|
||||||
|
make
|
||||||
|
sudo make install
|
||||||
|
sudo ldconfig
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
git clone --depth 1 --branch intel-gmmlib-22.3.5 https://github.com/intel/gmmlib
|
||||||
|
cd gmmlib
|
||||||
|
mkdir build && cd build
|
||||||
|
cmake ..
|
||||||
|
make -j"$(nproc)"
|
||||||
|
sudo make install
|
||||||
|
sudo ldconfig
|
||||||
|
cd ../..
|
||||||
|
|
||||||
|
git clone --depth 1 --branch intel-media-23.1.6 https://github.com/intel/media-driver
|
||||||
|
mkdir -p build_media
|
||||||
|
cd build_media
|
||||||
|
cmake ../media-driver
|
||||||
|
make -j"$(nproc)"
|
||||||
|
sudo make install
|
||||||
|
sudo ldconfig
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
git clone --depth 1 --branch n6.0 https://github.com/FFmpeg/FFmpeg
|
||||||
|
cd FFmpeg
|
||||||
|
./configure \
|
||||||
|
--enable-nonfree \
|
||||||
|
--enable-gpl \
|
||||||
|
--enable-libx264
|
||||||
|
|
||||||
|
make -j"$(nproc)"
|
||||||
|
sudo make install
|
||||||
|
sudo ldconfig
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
cd ..
|
||||||
|
rm -rf qsvbuild
|
|
@ -0,0 +1,12 @@
|
||||||
|
FROM golang:bullseye AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY . .
|
||||||
|
RUN CGO_ENABLED=0 go build -buildvcs=false -ldflags="-s -w"
|
||||||
|
|
||||||
|
FROM linuxserver/ffmpeg:latest
|
||||||
|
|
||||||
|
COPY --from=builder /app/go-vod .
|
||||||
|
|
||||||
|
EXPOSE 47788
|
||||||
|
|
||||||
|
ENTRYPOINT ["/go-vod"]
|
|
@ -0,0 +1,3 @@
|
||||||
|
module github.com/pulsejet/go-vod
|
||||||
|
|
||||||
|
go 1.16
|
|
@ -0,0 +1,48 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/pulsejet/go-vod/transcoder"
|
||||||
|
)
|
||||||
|
|
||||||
|
const VERSION = "0.1.28"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Build initial configuration
|
||||||
|
c := &transcoder.Config{
|
||||||
|
VersionMonitor: false,
|
||||||
|
Version: VERSION,
|
||||||
|
Bind: ":47788",
|
||||||
|
ChunkSize: 3,
|
||||||
|
LookBehind: 3,
|
||||||
|
GoalBufferMin: 1,
|
||||||
|
GoalBufferMax: 4,
|
||||||
|
StreamIdleTime: 60,
|
||||||
|
ManagerIdleTime: 60,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse arguments
|
||||||
|
for _, arg := range os.Args[1:] {
|
||||||
|
if arg == "-version-monitor" {
|
||||||
|
c.VersionMonitor = true
|
||||||
|
} else if arg == "-version" {
|
||||||
|
fmt.Print("go-vod " + VERSION)
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
c.FromFile(arg) // config file
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto detect ffmpeg and ffprobe
|
||||||
|
c.AutoDetect()
|
||||||
|
|
||||||
|
// Start server
|
||||||
|
code := transcoder.NewHandler(c).Start()
|
||||||
|
|
||||||
|
// Exit
|
||||||
|
log.Println("Exiting go-vod with status code", code)
|
||||||
|
os.Exit(code)
|
||||||
|
}
|
|
@ -0,0 +1,67 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# This script fetches the current version of go-vod from Nextcloud
|
||||||
|
# to the working directory and runs it. If go-vod exits with a restart
|
||||||
|
# code, the script will restart it.
|
||||||
|
|
||||||
|
# This script is intended to be run by systemd if running on bare metal.
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
HOST=$NEXTCLOUD_HOST
|
||||||
|
ALLOW_INSECURE=$NEXTCLOUD_ALLOW_INSECURE
|
||||||
|
|
||||||
|
# check if host is set
|
||||||
|
if [[ -z $HOST ]]; then
|
||||||
|
echo "fatal: NEXTCLOUD_HOST is not set"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# check if scheme is set
|
||||||
|
if [[ ! $HOST == http://* ]] && [[ ! $HOST == https://* ]]; then
|
||||||
|
echo "fatal: NEXTCLOUD_HOST must start with http:// or https://"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# check if scheme is http and allow_insecure is not set
|
||||||
|
if [[ $HOST == http://* ]] && [[ -z $ALLOW_INSECURE ]]; then
|
||||||
|
echo "fatal: NEXTCLOUD_HOST is set to http:// but NEXTCLOUD_ALLOW_INSECURE is not set"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# build URL to fetch binary from Nextcloud
|
||||||
|
ARCH=$(uname -m)
|
||||||
|
URL="$HOST/index.php/apps/memories/static/go-vod?arch=$ARCH"
|
||||||
|
|
||||||
|
# set the -k option in curl if allow_insecure is set
|
||||||
|
EXTRA_CURL_ARGS=""
|
||||||
|
if [[ $ALLOW_INSECURE == true ]]; then
|
||||||
|
EXTRA_CURL_ARGS="$EXTRA_CURL_ARGS -k"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# fetch binary, sleeping 10 seconds between retries
|
||||||
|
function fetch_binary {
|
||||||
|
while true; do
|
||||||
|
rm -f go-vod
|
||||||
|
curl $EXTRA_CURL_ARGS -L -f -m 10 -s -o go-vod $URL
|
||||||
|
if [[ $? == 0 ]]; then
|
||||||
|
chmod +x go-vod
|
||||||
|
echo "Fetched $URL successfully!"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
echo "Failed to fetch $URL"
|
||||||
|
echo "Are you sure the host is reachable and running Memories v6+?"
|
||||||
|
echo "Retrying in 10 seconds..."
|
||||||
|
sleep 10
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# infinite loop
|
||||||
|
while true; do
|
||||||
|
fetch_binary
|
||||||
|
./go-vod -version-monitor
|
||||||
|
if [[ $? != 12 ]]; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
|
||||||
|
sleep 3 # throttle
|
||||||
|
done
|
|
@ -0,0 +1,15 @@
|
||||||
|
package transcoder
|
||||||
|
|
||||||
|
type Chunk struct {
|
||||||
|
id int
|
||||||
|
done bool
|
||||||
|
notifs []chan bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewChunk(id int) *Chunk {
|
||||||
|
return &Chunk{
|
||||||
|
id: id,
|
||||||
|
done: false,
|
||||||
|
notifs: make([]chan bool, 0),
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,111 @@
|
||||||
|
package transcoder
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io/ioutil"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
// Current version of go-vod
|
||||||
|
Version string
|
||||||
|
|
||||||
|
// Is this server configured?
|
||||||
|
Configured bool
|
||||||
|
|
||||||
|
// Restart the server if incorrect version detected
|
||||||
|
VersionMonitor bool
|
||||||
|
|
||||||
|
// Bind address
|
||||||
|
Bind string `json:"bind"`
|
||||||
|
|
||||||
|
// FFmpeg binary
|
||||||
|
FFmpeg string `json:"ffmpeg"`
|
||||||
|
// FFprobe binary
|
||||||
|
FFprobe string `json:"ffprobe"`
|
||||||
|
// Temp files directory
|
||||||
|
TempDir string `json:"tempdir"`
|
||||||
|
|
||||||
|
// Size of each chunk in seconds
|
||||||
|
ChunkSize int `json:"chunkSize"`
|
||||||
|
// How many *chunks* to look behind before restarting transcoding
|
||||||
|
LookBehind int `json:"lookBehind"`
|
||||||
|
// Number of chunks in goal to restart encoding
|
||||||
|
GoalBufferMin int `json:"goalBufferMin"`
|
||||||
|
// Number of chunks in goal to stop encoding
|
||||||
|
GoalBufferMax int `json:"goalBufferMax"`
|
||||||
|
|
||||||
|
// Number of seconds to wait before shutting down encoding
|
||||||
|
StreamIdleTime int `json:"streamIdleTime"`
|
||||||
|
// Number of seconds to wait before shutting down a client
|
||||||
|
ManagerIdleTime int `json:"managerIdleTime"`
|
||||||
|
|
||||||
|
// Quality Factor (e.g. CRF / global_quality)
|
||||||
|
QF int `json:"qf"`
|
||||||
|
|
||||||
|
// Hardware acceleration configuration
|
||||||
|
|
||||||
|
// VA-API
|
||||||
|
VAAPI bool `json:"vaapi"`
|
||||||
|
VAAPILowPower bool `json:"vaapiLowPower"`
|
||||||
|
|
||||||
|
// NVENC
|
||||||
|
NVENC bool `json:"nvenc"`
|
||||||
|
NVENCTemporalAQ bool `json:"nvencTemporalAQ"`
|
||||||
|
NVENCScale string `json:"nvencScale"` // cuda, npp
|
||||||
|
|
||||||
|
// Use transpose workaround for streaming (VA-API)
|
||||||
|
UseTranspose bool `json:"useTranspose"`
|
||||||
|
|
||||||
|
// Use GOP size workaround for streaming (NVENC)
|
||||||
|
UseGopSize bool `json:"useGopSize"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) FromFile(path string) {
|
||||||
|
// load json config
|
||||||
|
content, err := ioutil.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("Error when opening file: ", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = json.Unmarshal(content, &c)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("Error loading config file", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set config as loaded
|
||||||
|
c.Configured = true
|
||||||
|
c.Print()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) AutoDetect() {
|
||||||
|
// Auto-detect ffmpeg and ffprobe paths
|
||||||
|
if c.FFmpeg == "" || c.FFprobe == "" {
|
||||||
|
ffmpeg, err := exec.LookPath("ffmpeg")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("Could not find ffmpeg")
|
||||||
|
}
|
||||||
|
|
||||||
|
ffprobe, err := exec.LookPath("ffprobe")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("Could not find ffprobe")
|
||||||
|
}
|
||||||
|
|
||||||
|
c.FFmpeg = ffmpeg
|
||||||
|
c.FFprobe = ffprobe
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-choose tempdir
|
||||||
|
if c.TempDir == "" {
|
||||||
|
c.TempDir = os.TempDir() + "/go-vod"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print updated config
|
||||||
|
c.Print()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) Print() {
|
||||||
|
log.Printf("%+v\n", c)
|
||||||
|
}
|
|
@ -0,0 +1,239 @@
|
||||||
|
package transcoder
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io/ioutil"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Handler struct {
|
||||||
|
c *Config
|
||||||
|
server *http.Server
|
||||||
|
managers map[string]*Manager
|
||||||
|
mutex sync.RWMutex
|
||||||
|
close chan string
|
||||||
|
exitCode int
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandler(c *Config) *Handler {
|
||||||
|
h := &Handler{
|
||||||
|
c: c,
|
||||||
|
managers: make(map[string]*Manager),
|
||||||
|
close: make(chan string),
|
||||||
|
exitCode: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recreate tempdir
|
||||||
|
os.RemoveAll(c.TempDir)
|
||||||
|
os.MkdirAll(c.TempDir, 0755)
|
||||||
|
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Check version if monitoring is enabled
|
||||||
|
if h.c.VersionMonitor && !h.versionOk(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
url := r.URL.Path
|
||||||
|
parts := make([]string, 0)
|
||||||
|
|
||||||
|
// log.Println("Serving", url)
|
||||||
|
|
||||||
|
// Break url into parts
|
||||||
|
for _, part := range strings.Split(url, "/") {
|
||||||
|
if part != "" {
|
||||||
|
parts = append(parts, part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serve actual file from manager
|
||||||
|
if len(parts) < 3 {
|
||||||
|
log.Println("Invalid URL", url)
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get streamid and chunk
|
||||||
|
streamid := parts[0]
|
||||||
|
path := "/" + strings.Join(parts[1:len(parts)-1], "/")
|
||||||
|
chunk := parts[len(parts)-1]
|
||||||
|
|
||||||
|
// Check if POST request to create temp file
|
||||||
|
if r.Method == "POST" && len(parts) >= 2 && parts[1] == "create" {
|
||||||
|
var err error
|
||||||
|
path, err = h.createTempFile(w, r, parts)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if test request
|
||||||
|
if chunk == "test" {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
// check if test file is readable
|
||||||
|
size := 0
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err == nil {
|
||||||
|
size = int(info.Size())
|
||||||
|
}
|
||||||
|
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"version": h.c.Version,
|
||||||
|
"size": size,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if configuration request
|
||||||
|
if r.Method == "POST" && chunk == "config" {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
// read new config
|
||||||
|
body, err := ioutil.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("Error reading body", err)
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unmarshal config
|
||||||
|
if err := json.Unmarshal(body, h.c); err != nil {
|
||||||
|
log.Println("Error unmarshaling config", err)
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set config as loaded
|
||||||
|
h.c.Configured = true
|
||||||
|
|
||||||
|
// Print loaded config
|
||||||
|
log.Printf("%+v\n", h.c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if configured
|
||||||
|
if !h.c.Configured {
|
||||||
|
w.WriteHeader(http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if valid
|
||||||
|
if streamid == "" || path == "" {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get existing manager or create new one
|
||||||
|
manager := h.getManager(path, streamid)
|
||||||
|
if manager == nil {
|
||||||
|
manager = h.createManager(path, streamid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Failed to create manager
|
||||||
|
if manager == nil {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serve chunk if asked for
|
||||||
|
if chunk != "" && chunk != "ignore" {
|
||||||
|
manager.ServeHTTP(w, r, chunk)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) versionOk(w http.ResponseWriter, r *http.Request) bool {
|
||||||
|
expected := r.Header.Get("X-Go-Vod-Version")
|
||||||
|
if len(expected) > 0 && expected != h.c.Version {
|
||||||
|
log.Println("Version mismatch", expected, h.c.Version)
|
||||||
|
|
||||||
|
// Try again in some time
|
||||||
|
w.WriteHeader(http.StatusServiceUnavailable)
|
||||||
|
|
||||||
|
// Exit with status code 12
|
||||||
|
h.exitCode = 12
|
||||||
|
h.Close()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) getManager(path string, streamid string) *Manager {
|
||||||
|
h.mutex.RLock()
|
||||||
|
defer h.mutex.RUnlock()
|
||||||
|
|
||||||
|
m := h.managers[streamid]
|
||||||
|
if m == nil || m.path != path {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) createManager(path string, streamid string) *Manager {
|
||||||
|
manager, err := NewManager(h.c, path, streamid, h.close)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("Error creating manager", err)
|
||||||
|
freeIfTemp(path)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
h.mutex.Lock()
|
||||||
|
defer h.mutex.Unlock()
|
||||||
|
|
||||||
|
old := h.managers[streamid]
|
||||||
|
if old != nil {
|
||||||
|
old.Destroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
h.managers[streamid] = manager
|
||||||
|
return manager
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) removeManager(streamid string) {
|
||||||
|
h.mutex.Lock()
|
||||||
|
defer h.mutex.Unlock()
|
||||||
|
delete(h.managers, streamid)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Start() int {
|
||||||
|
log.Println("Starting go-vod " + h.c.Version + " on " + h.c.Bind)
|
||||||
|
h.server = &http.Server{Addr: h.c.Bind, Handler: h}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
err := h.server.ListenAndServe()
|
||||||
|
if err == http.ErrServerClosed {
|
||||||
|
log.Println("HTTP server closed")
|
||||||
|
} else if err != nil {
|
||||||
|
log.Fatal("Error starting server: ", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
for {
|
||||||
|
id := <-h.close
|
||||||
|
if id == "" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
h.removeManager(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop server
|
||||||
|
log.Println("Shutting down HTTP server")
|
||||||
|
ctx, cancel := context.WithDeadline(context.TODO(), time.Now().Add(5*time.Second))
|
||||||
|
defer cancel()
|
||||||
|
h.server.Shutdown(ctx)
|
||||||
|
|
||||||
|
// Return status code
|
||||||
|
return h.exitCode
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Close() {
|
||||||
|
h.close <- ""
|
||||||
|
}
|
|
@ -0,0 +1,375 @@
|
||||||
|
package transcoder
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"hash/fnv"
|
||||||
|
"log"
|
||||||
|
"math"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Manager struct {
|
||||||
|
c *Config
|
||||||
|
|
||||||
|
path string
|
||||||
|
tempDir string
|
||||||
|
id string
|
||||||
|
close chan string
|
||||||
|
inactive int
|
||||||
|
|
||||||
|
probe *ProbeVideoData
|
||||||
|
numChunks int
|
||||||
|
|
||||||
|
streams map[string]*Stream
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProbeVideoData struct {
|
||||||
|
Width int
|
||||||
|
Height int
|
||||||
|
Duration time.Duration
|
||||||
|
FrameRate int
|
||||||
|
CodecName string
|
||||||
|
BitRate int
|
||||||
|
Rotation int
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewManager(c *Config, path string, id string, close chan string) (*Manager, error) {
|
||||||
|
m := &Manager{c: c, path: path, id: id, close: close}
|
||||||
|
m.streams = make(map[string]*Stream)
|
||||||
|
|
||||||
|
h := fnv.New32a()
|
||||||
|
h.Write([]byte(path))
|
||||||
|
ph := fmt.Sprint(h.Sum32())
|
||||||
|
m.tempDir = fmt.Sprintf("%s/%s-%s", m.c.TempDir, id, ph)
|
||||||
|
|
||||||
|
// Delete temp dir if exists
|
||||||
|
os.RemoveAll(m.tempDir)
|
||||||
|
os.MkdirAll(m.tempDir, 0755)
|
||||||
|
|
||||||
|
if err := m.ffprobe(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
m.numChunks = int(math.Ceil(m.probe.Duration.Seconds() / float64(c.ChunkSize)))
|
||||||
|
|
||||||
|
// Possible streams
|
||||||
|
m.streams["480p"] = &Stream{c: c, m: m, quality: "480p", height: 480, width: 854, bitrate: 400}
|
||||||
|
m.streams["720p"] = &Stream{c: c, m: m, quality: "720p", height: 720, width: 1280, bitrate: 700}
|
||||||
|
m.streams["1080p"] = &Stream{c: c, m: m, quality: "1080p", height: 1080, width: 1920, bitrate: 1000}
|
||||||
|
m.streams["1440p"] = &Stream{c: c, m: m, quality: "1440p", height: 1440, width: 2560, bitrate: 1400}
|
||||||
|
m.streams["2160p"] = &Stream{c: c, m: m, quality: "2160p", height: 2160, width: 3840, bitrate: 3000}
|
||||||
|
|
||||||
|
// height is our primary dimension for scaling
|
||||||
|
// using the probed size, we adjust the width of the stream
|
||||||
|
// the smaller dimemension of the output should match the height here
|
||||||
|
smDim, lgDim := m.probe.Height, m.probe.Width
|
||||||
|
if m.probe.Height > m.probe.Width {
|
||||||
|
smDim, lgDim = lgDim, smDim
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the reference bitrate. This is the same as the current bitrate
|
||||||
|
// if the video is H.264, otherwise use double the current bitrate.
|
||||||
|
refBitrate := int(float64(m.probe.BitRate) / 2.0)
|
||||||
|
if m.probe.CodecName != CODEC_H264 {
|
||||||
|
refBitrate *= 2
|
||||||
|
}
|
||||||
|
|
||||||
|
// If bitrate could not be read, use 10Mbps
|
||||||
|
if refBitrate == 0 {
|
||||||
|
refBitrate = 10000000
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the multiplier for the reference bitrate.
|
||||||
|
// For this get the nearest stream size to the original.
|
||||||
|
origPixels := float64(m.probe.Height * m.probe.Width)
|
||||||
|
nearestPixels := float64(0)
|
||||||
|
nearestStream := ""
|
||||||
|
for key, stream := range m.streams {
|
||||||
|
streamPixels := float64(stream.height * stream.width)
|
||||||
|
if nearestPixels == 0 || math.Abs(origPixels-streamPixels) < math.Abs(origPixels-nearestPixels) {
|
||||||
|
nearestPixels = streamPixels
|
||||||
|
nearestStream = key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the bitrate multiplier. This is the ratio of the reference
|
||||||
|
// bitrate to the nearest stream bitrate, so we can scale all streams.
|
||||||
|
bitrateMultiplier := 1.0
|
||||||
|
if nearestStream != "" {
|
||||||
|
bitrateMultiplier = float64(refBitrate) / float64(m.streams[nearestStream].bitrate)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only keep streams that are smaller than the video
|
||||||
|
for k, stream := range m.streams {
|
||||||
|
stream.order = 0
|
||||||
|
|
||||||
|
// scale bitrate using the multiplier
|
||||||
|
stream.bitrate = int(math.Ceil(float64(stream.bitrate) * bitrateMultiplier))
|
||||||
|
|
||||||
|
// now store the width of the stream as the larger dimension
|
||||||
|
stream.width = int(math.Ceil(float64(lgDim) * float64(stream.height) / float64(smDim)))
|
||||||
|
|
||||||
|
// remove invalid streams
|
||||||
|
if (stream.height >= smDim || stream.width >= lgDim) || // no upscaling; we're not AI
|
||||||
|
(float64(stream.bitrate) > float64(m.probe.BitRate)*0.8) || // no more than 80% of original bitrate
|
||||||
|
(stream.height%2 != 0 || stream.width%2 != 0) { // no odd dimensions
|
||||||
|
|
||||||
|
// remove stream
|
||||||
|
delete(m.streams, k)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Original stream
|
||||||
|
m.streams[QUALITY_MAX] = &Stream{
|
||||||
|
c: c, m: m,
|
||||||
|
quality: QUALITY_MAX,
|
||||||
|
height: m.probe.Height,
|
||||||
|
width: m.probe.Width,
|
||||||
|
bitrate: refBitrate,
|
||||||
|
order: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start all streams
|
||||||
|
for _, stream := range m.streams {
|
||||||
|
go stream.Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("%s: new manager for %s", m.id, m.path)
|
||||||
|
|
||||||
|
// Check for inactivity
|
||||||
|
go func() {
|
||||||
|
t := time.NewTicker(5 * time.Second)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
<-t.C
|
||||||
|
|
||||||
|
if m.inactive == -1 {
|
||||||
|
t.Stop()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
m.inactive++
|
||||||
|
|
||||||
|
// Check if any stream is active
|
||||||
|
for _, stream := range m.streams {
|
||||||
|
if stream.coder != nil {
|
||||||
|
m.inactive = 0
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing done for 5 minutes
|
||||||
|
if m.inactive >= m.c.ManagerIdleTime/5 {
|
||||||
|
t.Stop()
|
||||||
|
m.Destroy()
|
||||||
|
m.close <- m.id
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Destroys streams. DOES NOT emit on the close channel.
|
||||||
|
func (m *Manager) Destroy() {
|
||||||
|
log.Printf("%s: destroying manager", m.id)
|
||||||
|
m.inactive = -1
|
||||||
|
|
||||||
|
for _, stream := range m.streams {
|
||||||
|
stream.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete temp dir
|
||||||
|
os.RemoveAll(m.tempDir)
|
||||||
|
|
||||||
|
// Delete file if temp
|
||||||
|
freeIfTemp(m.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) ServeHTTP(w http.ResponseWriter, r *http.Request, chunk string) error {
|
||||||
|
// Master list
|
||||||
|
if chunk == "index.m3u8" {
|
||||||
|
return m.ServeIndex(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stream list
|
||||||
|
m3u8Sfx := ".m3u8"
|
||||||
|
if strings.HasSuffix(chunk, m3u8Sfx) {
|
||||||
|
quality := strings.TrimSuffix(chunk, m3u8Sfx)
|
||||||
|
if stream, ok := m.streams[quality]; ok {
|
||||||
|
return stream.ServeList(w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stream chunk
|
||||||
|
tsSfx := ".ts"
|
||||||
|
if strings.HasSuffix(chunk, tsSfx) {
|
||||||
|
parts := strings.Split(chunk, "-")
|
||||||
|
if len(parts) != 2 {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
quality := parts[0]
|
||||||
|
chunkIdStr := strings.TrimSuffix(parts[1], tsSfx)
|
||||||
|
chunkId, err := strconv.Atoi(chunkIdStr)
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if stream, ok := m.streams[quality]; ok {
|
||||||
|
return stream.ServeChunk(w, chunkId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stream full video
|
||||||
|
mp4Sfx := ".mp4"
|
||||||
|
if strings.HasSuffix(chunk, mp4Sfx) {
|
||||||
|
quality := strings.TrimSuffix(chunk, mp4Sfx)
|
||||||
|
if stream, ok := m.streams[quality]; ok {
|
||||||
|
return stream.ServeFullVideo(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to original
|
||||||
|
return m.streams[QUALITY_MAX].ServeFullVideo(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) ServeIndex(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
WriteM3U8ContentType(w)
|
||||||
|
w.Write([]byte("#EXTM3U\n"))
|
||||||
|
|
||||||
|
// get sorted streams by bitrate
|
||||||
|
streams := make([]*Stream, 0)
|
||||||
|
for _, stream := range m.streams {
|
||||||
|
streams = append(streams, stream)
|
||||||
|
}
|
||||||
|
sort.Slice(streams, func(i, j int) bool {
|
||||||
|
return streams[i].order < streams[j].order ||
|
||||||
|
(streams[i].order == streams[j].order && streams[i].bitrate < streams[j].bitrate)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Write all streams
|
||||||
|
query := GetQueryString(r)
|
||||||
|
for _, stream := range streams {
|
||||||
|
s := fmt.Sprintf("#EXT-X-STREAM-INF:BANDWIDTH=%d,RESOLUTION=%dx%d,FRAME-RATE=%d\n%s.m3u8%s\n", stream.bitrate, stream.width, stream.height, m.probe.FrameRate, stream.quality, query)
|
||||||
|
w.Write([]byte(s))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) ffprobe() error {
|
||||||
|
args := []string{
|
||||||
|
// Hide debug information
|
||||||
|
"-v", "error",
|
||||||
|
|
||||||
|
// Show everything
|
||||||
|
"-show_entries", "format:stream",
|
||||||
|
"-select_streams", "v", // Video stream only, we're not interested in audio
|
||||||
|
|
||||||
|
"-of", "json",
|
||||||
|
m.path,
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithDeadline(context.TODO(), time.Now().Add(5*time.Second))
|
||||||
|
defer cancel()
|
||||||
|
cmd := exec.CommandContext(ctx, m.c.FFprobe, args...)
|
||||||
|
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
cmd.Stdout = &stdout
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
|
||||||
|
err := cmd.Run()
|
||||||
|
if err != nil {
|
||||||
|
log.Println(stderr.String())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
out := struct {
|
||||||
|
Streams []struct {
|
||||||
|
Width int `json:"width"`
|
||||||
|
Height int `json:"height"`
|
||||||
|
Duration string `json:"duration"`
|
||||||
|
FrameRate string `json:"avg_frame_rate"`
|
||||||
|
CodecName string `json:"codec_name"`
|
||||||
|
BitRate string `json:"bit_rate"`
|
||||||
|
SideDataList []struct {
|
||||||
|
SideDataType string `json:"side_data_type"`
|
||||||
|
Rotation int `json:"rotation"`
|
||||||
|
} `json:"side_data_list"`
|
||||||
|
} `json:"streams"`
|
||||||
|
Format struct {
|
||||||
|
Duration string `json:"duration"`
|
||||||
|
} `json:"format"`
|
||||||
|
}{}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(stdout.Bytes(), &out); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(out.Streams) == 0 {
|
||||||
|
return errors.New("no video streams found")
|
||||||
|
}
|
||||||
|
|
||||||
|
var duration time.Duration
|
||||||
|
if out.Streams[0].Duration != "" {
|
||||||
|
duration, _ = time.ParseDuration(out.Streams[0].Duration + "s")
|
||||||
|
} else if out.Format.Duration != "" {
|
||||||
|
duration, _ = time.ParseDuration(out.Format.Duration + "s")
|
||||||
|
}
|
||||||
|
|
||||||
|
// FrameRate is a fraction string
|
||||||
|
frac := strings.Split(out.Streams[0].FrameRate, "/")
|
||||||
|
if len(frac) != 2 {
|
||||||
|
frac = []string{"30", "1"}
|
||||||
|
}
|
||||||
|
num, e1 := strconv.Atoi(frac[0])
|
||||||
|
den, e2 := strconv.Atoi(frac[1])
|
||||||
|
if e1 != nil || e2 != nil {
|
||||||
|
num = 30
|
||||||
|
den = 1
|
||||||
|
}
|
||||||
|
frameRate := float64(num) / float64(den)
|
||||||
|
|
||||||
|
// BitRate is a string
|
||||||
|
bitRate, err := strconv.Atoi(out.Streams[0].BitRate)
|
||||||
|
if err != nil {
|
||||||
|
bitRate = 5000000
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get rotation from side data
|
||||||
|
rotation := 0
|
||||||
|
for _, sideData := range out.Streams[0].SideDataList {
|
||||||
|
if sideData.SideDataType == "Display Matrix" {
|
||||||
|
rotation = sideData.Rotation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
m.probe = &ProbeVideoData{
|
||||||
|
Width: out.Streams[0].Width,
|
||||||
|
Height: out.Streams[0].Height,
|
||||||
|
Duration: duration,
|
||||||
|
FrameRate: int(frameRate),
|
||||||
|
CodecName: out.Streams[0].CodecName,
|
||||||
|
BitRate: bitRate,
|
||||||
|
Rotation: rotation,
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
|
@ -0,0 +1,704 @@
|
||||||
|
package transcoder
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
ENCODER_COPY = "copy"
|
||||||
|
ENCODER_X264 = "libx264"
|
||||||
|
ENCODER_VAAPI = "h264_vaapi"
|
||||||
|
ENCODER_NVENC = "h264_nvenc"
|
||||||
|
|
||||||
|
QUALITY_MAX = "max"
|
||||||
|
CODEC_H264 = "h264"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Stream struct {
|
||||||
|
c *Config
|
||||||
|
m *Manager
|
||||||
|
quality string
|
||||||
|
order int
|
||||||
|
height int
|
||||||
|
width int
|
||||||
|
bitrate int
|
||||||
|
|
||||||
|
goal int
|
||||||
|
|
||||||
|
mutex sync.Mutex
|
||||||
|
chunks map[int]*Chunk
|
||||||
|
seenChunks map[int]bool // only for stdout reader
|
||||||
|
|
||||||
|
coder *exec.Cmd
|
||||||
|
|
||||||
|
inactive int
|
||||||
|
stop chan bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Stream) Run() {
|
||||||
|
// run every 5s
|
||||||
|
t := time.NewTicker(5 * time.Second)
|
||||||
|
defer t.Stop()
|
||||||
|
|
||||||
|
s.stop = make(chan bool)
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-t.C:
|
||||||
|
s.mutex.Lock()
|
||||||
|
// Prune chunks
|
||||||
|
for id := range s.chunks {
|
||||||
|
if id < s.goal-s.c.GoalBufferMax {
|
||||||
|
s.pruneChunk(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s.inactive++
|
||||||
|
|
||||||
|
// Nothing done for 2 minutes
|
||||||
|
if s.inactive >= s.c.StreamIdleTime/5 && s.coder != nil {
|
||||||
|
t.Stop()
|
||||||
|
s.clear()
|
||||||
|
}
|
||||||
|
s.mutex.Unlock()
|
||||||
|
|
||||||
|
case <-s.stop:
|
||||||
|
t.Stop()
|
||||||
|
s.mutex.Lock()
|
||||||
|
s.clear()
|
||||||
|
s.mutex.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Stream) clear() {
|
||||||
|
log.Printf("%s-%s: stopping stream", s.m.id, s.quality)
|
||||||
|
|
||||||
|
for _, chunk := range s.chunks {
|
||||||
|
// Delete files
|
||||||
|
s.pruneChunk(chunk.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.chunks = make(map[int]*Chunk)
|
||||||
|
s.seenChunks = make(map[int]bool)
|
||||||
|
s.goal = 0
|
||||||
|
|
||||||
|
if s.coder != nil {
|
||||||
|
s.coder.Process.Kill()
|
||||||
|
s.coder.Wait()
|
||||||
|
s.coder = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Stream) Stop() {
|
||||||
|
select {
|
||||||
|
case s.stop <- true:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Stream) ServeList(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
WriteM3U8ContentType(w)
|
||||||
|
w.Write([]byte("#EXTM3U\n"))
|
||||||
|
w.Write([]byte("#EXT-X-VERSION:4\n"))
|
||||||
|
w.Write([]byte("#EXT-X-MEDIA-SEQUENCE:0\n"))
|
||||||
|
w.Write([]byte("#EXT-X-PLAYLIST-TYPE:VOD\n"))
|
||||||
|
w.Write([]byte(fmt.Sprintf("#EXT-X-TARGETDURATION:%d\n", s.c.ChunkSize)))
|
||||||
|
|
||||||
|
query := GetQueryString(r)
|
||||||
|
|
||||||
|
duration := s.m.probe.Duration.Seconds()
|
||||||
|
i := 0
|
||||||
|
for duration > 0 {
|
||||||
|
size := float64(s.c.ChunkSize)
|
||||||
|
if duration < size {
|
||||||
|
size = duration
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Write([]byte(fmt.Sprintf("#EXTINF:%.3f, nodesc\n", size)))
|
||||||
|
w.Write([]byte(fmt.Sprintf("%s-%06d.ts%s\n", s.quality, i, query)))
|
||||||
|
|
||||||
|
duration -= float64(s.c.ChunkSize)
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Write([]byte("#EXT-X-ENDLIST\n"))
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Stream) ServeChunk(w http.ResponseWriter, id int) error {
|
||||||
|
s.mutex.Lock()
|
||||||
|
defer s.mutex.Unlock()
|
||||||
|
|
||||||
|
s.inactive = 0
|
||||||
|
s.checkGoal(id)
|
||||||
|
|
||||||
|
// Already have this chunk
|
||||||
|
if chunk, ok := s.chunks[id]; ok {
|
||||||
|
// Chunk is finished, just return it
|
||||||
|
if chunk.done {
|
||||||
|
s.returnChunk(w, chunk)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Still waiting on transcoder
|
||||||
|
s.waitForChunk(w, chunk)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Will have this soon enough
|
||||||
|
foundBehind := false
|
||||||
|
for i := id - 1; i > id-s.c.LookBehind && i >= 0; i-- {
|
||||||
|
if _, ok := s.chunks[i]; ok {
|
||||||
|
foundBehind = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if foundBehind {
|
||||||
|
// Make sure the chunk exists
|
||||||
|
chunk := s.createChunk(id)
|
||||||
|
|
||||||
|
// Wait for it
|
||||||
|
s.waitForChunk(w, chunk)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Let's start over
|
||||||
|
s.restartAtChunk(w, id)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Stream) ServeFullVideo(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
args := s.transcodeArgs(0, false)
|
||||||
|
|
||||||
|
if s.m.probe.CodecName == CODEC_H264 && s.quality == QUALITY_MAX {
|
||||||
|
// try to just send the original file
|
||||||
|
http.ServeFile(w, r, s.m.path)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Output mov
|
||||||
|
args = append(args, []string{
|
||||||
|
"-movflags", "frag_keyframe+empty_moov+faststart",
|
||||||
|
"-f", "mp4", "pipe:1",
|
||||||
|
}...)
|
||||||
|
|
||||||
|
coder := exec.Command(s.c.FFmpeg, args...)
|
||||||
|
log.Printf("%s-%s: %s", s.m.id, s.quality, strings.Join(coder.Args[:], " "))
|
||||||
|
|
||||||
|
cmdStdOut, err := coder.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("FATAL: ffmpeg command stdout failed with %s\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmdStdErr, err := coder.StderrPipe()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("FATAL: ffmpeg command stdout failed with %s\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = coder.Start()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("FATAL: ffmpeg command failed with %s\n", err)
|
||||||
|
}
|
||||||
|
go s.monitorStderr(cmdStdErr)
|
||||||
|
|
||||||
|
// Write to response
|
||||||
|
defer cmdStdOut.Close()
|
||||||
|
stdoutReader := bufio.NewReader(cmdStdOut)
|
||||||
|
|
||||||
|
// Write mov headers
|
||||||
|
w.Header().Set("Content-Type", "video/mp4")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
flusher, ok := w.(http.Flusher)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "Server does not support Flusher!",
|
||||||
|
http.StatusInternalServerError)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write data, flusing every 1MB
|
||||||
|
buf := make([]byte, 1024*1024)
|
||||||
|
for {
|
||||||
|
n, err := stdoutReader.Read(buf)
|
||||||
|
if err != nil {
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
log.Printf("FATAL: ffmpeg command failed with %s\n", err)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = w.Write(buf[:n])
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("%s-%s: client closed connection", s.m.id, s.quality)
|
||||||
|
log.Println(err)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Terminate ffmpeg process
|
||||||
|
coder.Process.Kill()
|
||||||
|
coder.Wait()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Stream) createChunk(id int) *Chunk {
|
||||||
|
if c, ok := s.chunks[id]; ok {
|
||||||
|
return c
|
||||||
|
} else {
|
||||||
|
s.chunks[id] = NewChunk(id)
|
||||||
|
return s.chunks[id]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Stream) pruneChunk(id int) {
|
||||||
|
delete(s.chunks, id)
|
||||||
|
|
||||||
|
// Remove file
|
||||||
|
filename := s.getTsPath(id)
|
||||||
|
os.Remove(filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Stream) returnChunk(w http.ResponseWriter, chunk *Chunk) {
|
||||||
|
// This function is called with lock, but we don't need it
|
||||||
|
s.mutex.Unlock()
|
||||||
|
defer s.mutex.Lock()
|
||||||
|
|
||||||
|
// Read file and write to response
|
||||||
|
filename := s.getTsPath(chunk.id)
|
||||||
|
f, err := os.Open(filename)
|
||||||
|
if err != nil {
|
||||||
|
log.Println(err)
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
w.Header().Set("Content-Type", "video/MP2T")
|
||||||
|
io.Copy(w, f)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Stream) waitForChunk(w http.ResponseWriter, chunk *Chunk) {
|
||||||
|
if chunk.done {
|
||||||
|
s.returnChunk(w, chunk)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add our channel
|
||||||
|
notif := make(chan bool)
|
||||||
|
chunk.notifs = append(chunk.notifs, notif)
|
||||||
|
t := time.NewTimer(10 * time.Second)
|
||||||
|
coder := s.coder
|
||||||
|
|
||||||
|
s.mutex.Unlock()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-notif:
|
||||||
|
t.Stop()
|
||||||
|
case <-t.C:
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mutex.Lock()
|
||||||
|
|
||||||
|
// remove channel
|
||||||
|
for i, c := range chunk.notifs {
|
||||||
|
if c == notif {
|
||||||
|
chunk.notifs = append(chunk.notifs[:i], chunk.notifs[i+1:]...)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// check for success
|
||||||
|
if chunk.done {
|
||||||
|
s.returnChunk(w, chunk)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if coder was changed
|
||||||
|
if coder != s.coder {
|
||||||
|
w.WriteHeader(http.StatusConflict)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return timeout error
|
||||||
|
w.WriteHeader(http.StatusRequestTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Stream) restartAtChunk(w http.ResponseWriter, id int) {
|
||||||
|
// Stop current transcoder
|
||||||
|
s.clear()
|
||||||
|
|
||||||
|
chunk := s.createChunk(id) // create first chunk
|
||||||
|
|
||||||
|
// Start the transcoder
|
||||||
|
s.goal = id + s.c.GoalBufferMax
|
||||||
|
s.transcode(id)
|
||||||
|
|
||||||
|
s.waitForChunk(w, chunk) // this is also a request
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get arguments to ffmpeg
|
||||||
|
func (s *Stream) transcodeArgs(startAt float64, isHls bool) []string {
|
||||||
|
args := []string{
|
||||||
|
"-loglevel", "warning",
|
||||||
|
}
|
||||||
|
|
||||||
|
if startAt > 0 {
|
||||||
|
args = append(args, []string{
|
||||||
|
"-ss", fmt.Sprintf("%.6f", startAt),
|
||||||
|
}...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// encoder selection
|
||||||
|
CV := ENCODER_X264
|
||||||
|
|
||||||
|
// Check whether hwaccel should be used
|
||||||
|
if s.c.VAAPI {
|
||||||
|
CV = ENCODER_VAAPI
|
||||||
|
extra := "-hwaccel vaapi -hwaccel_device /dev/dri/renderD128 -hwaccel_output_format vaapi"
|
||||||
|
args = append(args, strings.Split(extra, " ")...)
|
||||||
|
} else if s.c.NVENC {
|
||||||
|
CV = ENCODER_NVENC
|
||||||
|
extra := "-hwaccel cuda"
|
||||||
|
args = append(args, strings.Split(extra, " ")...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable autorotation (see transpose comments below)
|
||||||
|
if s.c.UseTranspose {
|
||||||
|
args = append(args, []string{"-noautorotate"}...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Input specs
|
||||||
|
args = append(args, []string{
|
||||||
|
"-i", s.m.path, // Input file
|
||||||
|
"-copyts", // So the "-to" refers to the original TS
|
||||||
|
"-fflags", "+genpts",
|
||||||
|
}...)
|
||||||
|
|
||||||
|
// Filters
|
||||||
|
format := "format=nv12"
|
||||||
|
scaler := "scale"
|
||||||
|
scalerArgs := make([]string, 0)
|
||||||
|
scalerArgs = append(scalerArgs, "force_original_aspect_ratio=decrease")
|
||||||
|
|
||||||
|
if CV == ENCODER_VAAPI {
|
||||||
|
format = "format=nv12|vaapi,hwupload"
|
||||||
|
scaler = "scale_vaapi"
|
||||||
|
scalerArgs = append(scalerArgs, "format=nv12")
|
||||||
|
} else if CV == ENCODER_NVENC {
|
||||||
|
format = "format=nv12|cuda,hwupload"
|
||||||
|
scaler = fmt.Sprintf("scale_%s", s.c.NVENCScale)
|
||||||
|
|
||||||
|
// workaround to force scale_cuda to examine all input frames
|
||||||
|
if s.c.NVENCScale == "cuda" {
|
||||||
|
scalerArgs = append(scalerArgs, "passthrough=0")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scale height and width if not max quality
|
||||||
|
if s.quality != QUALITY_MAX {
|
||||||
|
maxDim := s.height
|
||||||
|
if s.width > s.height {
|
||||||
|
maxDim = s.width
|
||||||
|
}
|
||||||
|
|
||||||
|
scalerArgs = append(scalerArgs, fmt.Sprintf("w=%d", maxDim))
|
||||||
|
scalerArgs = append(scalerArgs, fmt.Sprintf("h=%d", maxDim))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply filter
|
||||||
|
if CV != ENCODER_COPY {
|
||||||
|
filter := fmt.Sprintf("%s,%s=%s", format, scaler, strings.Join(scalerArgs, ":"))
|
||||||
|
|
||||||
|
// Rotation is a mess: https://trac.ffmpeg.org/ticket/8329
|
||||||
|
// 1/ -noautorotate copies the sidecar metadata to the output
|
||||||
|
// 2/ autorotation doesn't seem to work with some types of HW (at least not with VAAPI)
|
||||||
|
// 3/ autorotation doesn't work with HLS streams
|
||||||
|
// 4/ VAAPI cannot transport on AMD GPUs
|
||||||
|
// So: give the user to disable autorotation for HLS and use a manual transpose
|
||||||
|
if isHls && s.c.UseTranspose {
|
||||||
|
transposer := "transpose"
|
||||||
|
if CV == ENCODER_VAAPI {
|
||||||
|
transposer = "transpose_vaapi"
|
||||||
|
} else if CV == ENCODER_NVENC {
|
||||||
|
transposer = fmt.Sprintf("transpose_%s", s.c.NVENCScale)
|
||||||
|
}
|
||||||
|
|
||||||
|
if transposer != "transpose_cuda" { // does not exist
|
||||||
|
if s.m.probe.Rotation == -90 {
|
||||||
|
filter = fmt.Sprintf("%s,%s=1", filter, transposer)
|
||||||
|
} else if s.m.probe.Rotation == 90 {
|
||||||
|
filter = fmt.Sprintf("%s,%s=2", filter, transposer)
|
||||||
|
} else if s.m.probe.Rotation == 180 || s.m.probe.Rotation == -180 {
|
||||||
|
filter = fmt.Sprintf("%s,%s=1,%s=1", filter, transposer, transposer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
args = append(args, []string{"-vf", filter}...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Output specs for video
|
||||||
|
args = append(args, []string{
|
||||||
|
"-map", "0:v:0",
|
||||||
|
"-c:v", CV,
|
||||||
|
}...)
|
||||||
|
|
||||||
|
// Device specific output args
|
||||||
|
if CV == ENCODER_VAAPI {
|
||||||
|
args = append(args, []string{"-global_quality", fmt.Sprintf("%d", s.c.QF)}...)
|
||||||
|
|
||||||
|
if s.c.VAAPILowPower {
|
||||||
|
args = append(args, []string{"-low_power", "1"}...)
|
||||||
|
}
|
||||||
|
} else if CV == ENCODER_NVENC {
|
||||||
|
args = append(args, []string{
|
||||||
|
"-preset", "p6",
|
||||||
|
"-tune", "ll",
|
||||||
|
"-rc", "vbr",
|
||||||
|
"-rc-lookahead", "30",
|
||||||
|
"-cq", fmt.Sprintf("%d", s.c.QF),
|
||||||
|
}...)
|
||||||
|
|
||||||
|
if s.c.NVENCTemporalAQ {
|
||||||
|
args = append(args, []string{"-temporal-aq", "1"}...)
|
||||||
|
}
|
||||||
|
} else if CV == ENCODER_X264 {
|
||||||
|
args = append(args, []string{
|
||||||
|
"-preset", "faster",
|
||||||
|
"-crf", fmt.Sprintf("%d", s.c.QF),
|
||||||
|
}...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audio output specs
|
||||||
|
args = append(args, []string{
|
||||||
|
"-map", "0:a:0?",
|
||||||
|
"-c:a", "aac",
|
||||||
|
"-ac", "1",
|
||||||
|
}...)
|
||||||
|
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Stream) transcode(startId int) {
|
||||||
|
if startId > 0 {
|
||||||
|
// Start one frame before
|
||||||
|
// This ensures that the keyframes are aligned
|
||||||
|
startId--
|
||||||
|
}
|
||||||
|
startAt := float64(startId * s.c.ChunkSize)
|
||||||
|
|
||||||
|
args := s.transcodeArgs(startAt, true)
|
||||||
|
|
||||||
|
// Segmenting specs
|
||||||
|
args = append(args, []string{
|
||||||
|
"-start_number", fmt.Sprintf("%d", startId),
|
||||||
|
"-avoid_negative_ts", "disabled",
|
||||||
|
"-f", "hls",
|
||||||
|
"-hls_flags", "split_by_time",
|
||||||
|
"-hls_time", fmt.Sprintf("%d", s.c.ChunkSize),
|
||||||
|
"-hls_segment_type", "mpegts",
|
||||||
|
"-hls_segment_filename", s.getTsPath(-1),
|
||||||
|
}...)
|
||||||
|
|
||||||
|
// Keyframe specs
|
||||||
|
if s.c.UseGopSize && s.m.probe.FrameRate > 0 {
|
||||||
|
// Fix GOP size
|
||||||
|
args = append(args, []string{
|
||||||
|
"-g", fmt.Sprintf("%d", s.c.ChunkSize*s.m.probe.FrameRate),
|
||||||
|
"-keyint_min", fmt.Sprintf("%d", s.c.ChunkSize*s.m.probe.FrameRate),
|
||||||
|
}...)
|
||||||
|
} else {
|
||||||
|
// Force keyframes every chunk
|
||||||
|
args = append(args, []string{
|
||||||
|
"-force_key_frames", fmt.Sprintf("expr:gte(t,n_forced*%d)", s.c.ChunkSize),
|
||||||
|
}...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Output to stdout
|
||||||
|
args = append(args, "-")
|
||||||
|
|
||||||
|
// Start the process
|
||||||
|
s.coder = exec.Command(s.c.FFmpeg, args...)
|
||||||
|
|
||||||
|
// Log command, quoting the args as needed
|
||||||
|
quotedArgs := make([]string, len(s.coder.Args))
|
||||||
|
invalidChars := strings.Join([]string{" ", "=", ":", "\"", "\\", "\n", "\t"}, "")
|
||||||
|
for i, arg := range s.coder.Args {
|
||||||
|
if strings.ContainsAny(arg, invalidChars) {
|
||||||
|
quotedArgs[i] = fmt.Sprintf("\"%s\"", arg)
|
||||||
|
} else {
|
||||||
|
quotedArgs[i] = arg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.Printf("%s-%s: %s", s.m.id, s.quality, strings.Join(quotedArgs[:], " "))
|
||||||
|
|
||||||
|
cmdStdOut, err := s.coder.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("FATAL: ffmpeg command stdout failed with %s\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmdStdErr, err := s.coder.StderrPipe()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("FATAL: ffmpeg command stdout failed with %s\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = s.coder.Start()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("FATAL: ffmpeg command failed with %s\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
go s.monitorTranscodeOutput(cmdStdOut, startAt)
|
||||||
|
go s.monitorStderr(cmdStdErr)
|
||||||
|
go s.monitorExit()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Stream) checkGoal(id int) {
|
||||||
|
goal := id + s.c.GoalBufferMin
|
||||||
|
if goal > s.goal {
|
||||||
|
s.goal = id + s.c.GoalBufferMax
|
||||||
|
|
||||||
|
// resume encoding
|
||||||
|
if s.coder != nil {
|
||||||
|
log.Printf("%s-%s: resuming transcoding", s.m.id, s.quality)
|
||||||
|
s.coder.Process.Signal(syscall.SIGCONT)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Stream) getTsPath(id int) string {
|
||||||
|
if id == -1 {
|
||||||
|
return fmt.Sprintf("%s/%s-%%06d.ts", s.m.tempDir, s.quality)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s/%s-%06d.ts", s.m.tempDir, s.quality, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Separate goroutine
|
||||||
|
func (s *Stream) monitorTranscodeOutput(cmdStdOut io.ReadCloser, startAt float64) {
|
||||||
|
s.mutex.Lock()
|
||||||
|
coder := s.coder
|
||||||
|
s.mutex.Unlock()
|
||||||
|
|
||||||
|
defer cmdStdOut.Close()
|
||||||
|
stdoutReader := bufio.NewReader(cmdStdOut)
|
||||||
|
|
||||||
|
for {
|
||||||
|
if s.coder != coder {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
line, err := stdoutReader.ReadBytes('\n')
|
||||||
|
if err == io.EOF {
|
||||||
|
if len(line) == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} else if err != nil {
|
||||||
|
log.Println(err)
|
||||||
|
break
|
||||||
|
} else {
|
||||||
|
line = line[:(len(line) - 1)]
|
||||||
|
}
|
||||||
|
|
||||||
|
l := string(line)
|
||||||
|
|
||||||
|
if strings.Contains(l, ".ts") {
|
||||||
|
// 1080p-000003.ts
|
||||||
|
idx := strings.Split(strings.Split(l, "-")[1], ".")[0]
|
||||||
|
id, err := strconv.Atoi(idx)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("Error parsing chunk id")
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.seenChunks[id] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s.seenChunks[id] = true
|
||||||
|
|
||||||
|
// Debug
|
||||||
|
log.Printf("%s-%s: recv %s", s.m.id, s.quality, l)
|
||||||
|
|
||||||
|
func() {
|
||||||
|
s.mutex.Lock()
|
||||||
|
defer s.mutex.Unlock()
|
||||||
|
|
||||||
|
// The coder has changed; do nothing
|
||||||
|
if s.coder != coder {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify everyone
|
||||||
|
chunk := s.createChunk(id)
|
||||||
|
if chunk.done {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
chunk.done = true
|
||||||
|
for _, n := range chunk.notifs {
|
||||||
|
n <- true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check goal satisfied
|
||||||
|
if id >= s.goal {
|
||||||
|
log.Printf("%s-%s: goal satisfied: %d", s.m.id, s.quality, s.goal)
|
||||||
|
s.coder.Process.Signal(syscall.SIGSTOP)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Stream) monitorStderr(cmdStdErr io.ReadCloser) {
|
||||||
|
stderrReader := bufio.NewReader(cmdStdErr)
|
||||||
|
|
||||||
|
for {
|
||||||
|
line, err := stderrReader.ReadBytes('\n')
|
||||||
|
if err == io.EOF {
|
||||||
|
if len(line) == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} else if err != nil {
|
||||||
|
log.Println(err)
|
||||||
|
break
|
||||||
|
} else {
|
||||||
|
line = line[:(len(line) - 1)]
|
||||||
|
}
|
||||||
|
log.Println("ffmpeg-error:", string(line))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Stream) monitorExit() {
|
||||||
|
// Join the process
|
||||||
|
coder := s.coder
|
||||||
|
err := coder.Wait()
|
||||||
|
|
||||||
|
// Try to get exit status
|
||||||
|
if exitError, ok := err.(*exec.ExitError); ok {
|
||||||
|
exitcode := exitError.ExitCode()
|
||||||
|
log.Printf("%s-%s: ffmpeg exited with status: %d", s.m.id, s.quality, exitcode)
|
||||||
|
|
||||||
|
s.mutex.Lock()
|
||||||
|
defer s.mutex.Unlock()
|
||||||
|
|
||||||
|
// If error code is >0, there was an error in transcoding
|
||||||
|
if exitcode > 0 && s.coder == coder {
|
||||||
|
// Notify all outstanding chunks
|
||||||
|
for _, chunk := range s.chunks {
|
||||||
|
for _, n := range chunk.notifs {
|
||||||
|
n <- true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,49 @@
|
||||||
|
package transcoder
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io/ioutil"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (h *Handler) createTempFile(w http.ResponseWriter, r *http.Request, parts []string) (string, error) {
|
||||||
|
streamid := parts[0]
|
||||||
|
body, err := ioutil.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("Error reading body", err)
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create temporary file
|
||||||
|
file, err := ioutil.TempFile(h.c.TempDir, streamid+"-govod-temp-")
|
||||||
|
if err != nil {
|
||||||
|
log.Println("Error creating temp file", err)
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
// Write data to file
|
||||||
|
if _, err := file.Write(body); err != nil {
|
||||||
|
log.Println("Error writing to temp file", err)
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return full path to file in JSON
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"path": file.Name()})
|
||||||
|
|
||||||
|
// Return path to file
|
||||||
|
return file.Name(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func freeIfTemp(path string) {
|
||||||
|
if strings.Contains(path, "-govod-temp-") {
|
||||||
|
os.Remove(path)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,15 @@
|
||||||
|
package transcoder
|
||||||
|
|
||||||
|
import "net/http"
|
||||||
|
|
||||||
|
func GetQueryString(r *http.Request) string {
|
||||||
|
query := r.URL.Query().Encode()
|
||||||
|
if query != "" {
|
||||||
|
query = "?" + query
|
||||||
|
}
|
||||||
|
return query
|
||||||
|
}
|
||||||
|
|
||||||
|
func WriteM3U8ContentType(w http.ResponseWriter) {
|
||||||
|
w.Header().Set("Content-Type", "application/x-mpegURL")
|
||||||
|
}
|
Loading…
Reference in New Issue