feat(player): per-mirror viewer count
This commit is contained in:
+94
-11
@@ -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")))
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user