feat(player): per-mirror viewer count

This commit is contained in:
2022-01-30 23:03:01 +08:00
parent 83125749d9
commit 2ef9f0ecef
4 changed files with 220 additions and 24 deletions
+94 -11
View File
@@ -1,6 +1,7 @@
package main
import (
"encoding/json"
"flag"
"fmt"
"net/http"
@@ -8,30 +9,69 @@ import (
"time"
)
func getIp(r *http.Request) string {
// Get the remote address
var ip = r.RemoteAddr
// If behind a proxy, get the real IP
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
ip = fwd
}
return ip
}
type Viewer struct {
LastActive int64
ActiveMirror int
}
type ViewCountRequest struct {
ActiveMirror int `json:"active_mirror"`
}
type ViewCountResponse struct {
TotalCount int `json:"total_count"`
ByMirror map[int]int `json:"by_mirror"`
}
func main() {
var addr = flag.String("addr", ":8080", "http service address")
flag.Parse()
// Map of active viewers and their last active time
var viewers = make(map[string]int64)
var viewers = make(map[string]Viewer)
var mirrorActivity = make(map[int]int)
// Handle view counting
http.HandleFunc("/viewcount", func(w http.ResponseWriter, r *http.Request) {
// Get the current time
var now = time.Now().Unix()
// Get the remote address
var ip = r.RemoteAddr
var ip = getIp(r)
// If behind a proxy, get the real IP
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
ip = fwd
// Decode body JSON as a ViewerMetric
var metric ViewCountRequest
if err := json.NewDecoder(r.Body).Decode(&metric); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Update the last active time
viewers[ip] = now
viewers[ip] = Viewer{
LastActive: now,
ActiveMirror: metric.ActiveMirror,
}
// Respond with the number of viewers
fmt.Fprintf(w, "%d", len(viewers))
// Respond
var response = ViewCountResponse{
TotalCount: len(viewers),
ByMirror: mirrorActivity,
}
if err := json.NewEncoder(w).Encode(response); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
})
// Periodically remove inactive viewers
@@ -43,8 +83,8 @@ func main() {
var now = time.Now().Unix()
// Remove any viewers that have not been active in the last 10 seconds
for ip, lastActive := range viewers {
if now-lastActive > 10 {
for ip, viewer := range viewers {
if now-viewer.LastActive > 10 {
delete(viewers, ip)
}
}
@@ -77,6 +117,49 @@ func main() {
}
}()
// Periodically aggregate viewer count per mirror
go func() {
// Prepare file to log to
var f, ferr = os.OpenFile("viewers_per_mirror.log", os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666)
if ferr != nil {
fmt.Println("Error opening file:", ferr)
fmt.Println("mirror statistics will not be logged")
return
}
// Loop forever
for {
var now = time.Now().Format(time.RFC3339)
var counts = make(map[int]int)
// Loop through all viewers
for _, viewer := range viewers {
// Increment the mirror count
counts[viewer.ActiveMirror]++
}
// Check if the counts have changed
var changed = false
for mirror, count := range counts {
if count != mirrorActivity[mirror] {
changed = true
break
}
}
// If the counts have changed, log them
if changed {
for mirror, count := range counts {
fmt.Fprintf(f, "%s\t%d\t%d\n", now, mirror, count)
}
}
// Update the mirror activity
mirrorActivity = counts
time.Sleep(time.Second)
}
}()
// Handle static files
http.Handle("/", http.FileServer(http.Dir("./static")))
+23
View File
@@ -48,6 +48,29 @@
<span id="viewer-count-value">0</span> viewers
</div>
</header>
<div id="mirror-popup" style="transition: all 0.2s">
<div class="popup-backdrop"></div>
<div
class="popup-box"
style="
display: flex;
flex-direction: row;
align-items: center;
gap: 1rem;
"
>
<div>
<span class="loader"></span>
</div>
<div>
<h1 style="font-weight: bold; margin-bottom: 0.2rem">Please wait</h1>
<p style="color: #bbb">
Selecting a mirror... <span id="mirror-load-progress"></span>
</p>
</div>
</div>
</div>
<video id="video" controls></video>
</body>
</html>
+42
View File
@@ -53,6 +53,28 @@ a:active {
text-decoration: underline;
}
.popup-backdrop {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(10px);
z-index: 99;
}
.popup-box {
background-color: #333;
padding: 1.5em 2em;
border-radius: 0.5em;
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 100;
}
#viewer-count {
flex: 1;
text-align: right;
@@ -65,3 +87,23 @@ a:active {
background-color: black;
overflow: hidden;
}
.loader {
width: 48px;
height: 48px;
border: 5px solid #fff;
border-bottom-color: #ff3d00;
border-radius: 50%;
display: inline-block;
box-sizing: border-box;
animation: rotation 1s linear infinite;
}
@keyframes rotation {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
+61 -13
View File
@@ -44,6 +44,7 @@
const updateMirrorList = () => {
const mirrorSelect = document.getElementById('mirror-select');
const current = mirrorSelect.value;
// Clear the current list
while (mirrorSelect.firstChild) {
@@ -56,13 +57,19 @@
option.value = upstream.url;
option.text =
`[${upstream.region}] ${upstream.url}` +
(typeof upstream.latency === 'number'
? ` (${Math.floor(upstream.latency)}ms)`
: upstream.latency === null
? ' (unavailable)'
: '');
'(' +
[
typeof upstream.latency === 'number'
? Math.floor(upstream.latency) + 'ms'
: 'error',
(upstream.active_viewers || '0') + ' viewers',
].join(', ') +
')';
mirrorSelect.add(option);
}
// Restore the previous selection
if (current) mirrorSelect.value = current;
};
const reloadVideo = () => {
@@ -85,17 +92,46 @@
mirrorLink.innerText = '[m3u8]';
};
const viewCountLoop = async () => {
const doViewCount = async () => {
const viewCount = document.getElementById('viewer-count-value');
const url = '/viewcount';
const active_mirror = window.restream_config.upstreams.findIndex(
(upstream) =>
upstream.url === document.getElementById('mirror-select').value
);
if (active_mirror < 0) {
await sleep(1000);
return;
}
const response = await fetchWithTimeout('/viewcount', {
method: 'POST',
timeout: 1000,
mode: 'cors',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ active_mirror }),
});
const { total_count, by_mirror } = await response.json();
viewCount.innerText = total_count;
for (let i = 0; i < window.restream_config.upstreams.length; i++) {
window.restream_config.upstreams[i].active_viewers =
i in by_mirror ? by_mirror[i] : 0;
}
updateMirrorList();
};
const viewCountLoop = async () => {
while (true) {
const response = await fetchWithTimeout(url, {
timeout: 1000,
mode: 'cors',
});
const count = Number(await response.text());
viewCount.innerText = count;
try {
await doViewCount();
} catch (e) {
console.error(e);
}
await sleep(5000);
}
};
@@ -103,6 +139,8 @@
const init = async () => {
const video = document.getElementById('video');
const mirrorSelect = document.getElementById('mirror-select');
const mirrorPopup = document.getElementById('mirror-popup');
const mirrorLoadProgress = document.getElementById('mirror-load-progress');
// Init viewer counter
viewCountLoop();
@@ -118,11 +156,14 @@
const { upstreams } = window.restream_config;
let n = 0;
for (const upstream of upstreams) {
n += 1;
const playlistURL = upstream.url + window.restream_config.playlist;
const latency = await measureLatency(playlistURL, 3);
upstream.latency = latency;
updateMirrorList();
mirrorLoadProgress.innerText = '(' + n + '/' + upstreams.length + ')';
}
// Choose the fastest upstream server
@@ -131,6 +172,13 @@
.sort((a, b) => a.latency - b.latency)[0];
mirrorSelect.value = upstream.url;
reloadVideo();
// Destroy the popup
mirrorPopup.style.opacity = 0;
mirrorPopup.style.pointerEvents = 'none';
setTimeout(() => {
mirrorPopup.remove();
}, 1000);
};
if (document.readyState === 'loading')