616c6b1c62
Release Docker multi arch / docker (push) Has been cancelled
Test Install Script / Test Script Syntax (push) Has been cancelled
Test Install Script / Test on almalinux-10 (default) (push) Has been cancelled
Test Install Script / Test on almalinux-10 (root) (push) Has been cancelled
Test Install Script / Test on almalinux-8 (default) (push) Has been cancelled
Test Install Script / Test on almalinux-8 (root) (push) Has been cancelled
Test Install Script / Test on almalinux-9 (default) (push) Has been cancelled
Test Install Script / Test on almalinux-9 (root) (push) Has been cancelled
Test Install Script / Test on amazonlinux-2 (default) (push) Has been cancelled
Test Install Script / Test on amazonlinux-2 (root) (push) Has been cancelled
Test Install Script / Test on debian-11 (default) (push) Has been cancelled
Test Install Script / Test on debian-11 (root) (push) Has been cancelled
Test Install Script / Test on debian-12 (default) (push) Has been cancelled
Test Install Script / Test on debian-12 (root) (push) Has been cancelled
Test Install Script / Test on debian-13 (default) (push) Has been cancelled
Test Install Script / Test on debian-13 (root) (push) Has been cancelled
Test Install Script / Test on fedora-latest (default) (push) Has been cancelled
Test Install Script / Test on fedora-latest (root) (push) Has been cancelled
Test Install Script / Test on rocky-10 (default) (push) Has been cancelled
Test Install Script / Test on rocky-10 (root) (push) Has been cancelled
Test Install Script / Test on rocky-8 (default) (push) Has been cancelled
Test Install Script / Test on rocky-8 (root) (push) Has been cancelled
Test Install Script / Test on rocky-9 (default) (push) Has been cancelled
Test Install Script / Test on rocky-9 (root) (push) Has been cancelled
Test Install Script / Test on ubuntu-22.04 (default) (push) Has been cancelled
Test Install Script / Test on ubuntu-22.04 (root) (push) Has been cancelled
Test Install Script / Test on ubuntu-24.04 (default) (push) Has been cancelled
Test Install Script / Test on ubuntu-24.04 (root) (push) Has been cancelled
436 lines
10 KiB
Go
436 lines
10 KiB
Go
package tgbot
|
|
|
|
import (
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/dustin/go-humanize"
|
|
tele "gopkg.in/telebot.v4"
|
|
"server/log"
|
|
"server/torr"
|
|
cacheSt "server/torr/storage/state"
|
|
)
|
|
|
|
var (
|
|
snakeStopChans = make(map[int]chan struct{})
|
|
snakeStopChansMu sync.Mutex
|
|
snakeWindowStart = make(map[string]int)
|
|
snakeWindowStartMu sync.Mutex
|
|
)
|
|
|
|
const (
|
|
snakeBlockFilled = "🟩"
|
|
snakeBlockEmpty = "⬜"
|
|
snakeBlockReader = "🔵"
|
|
snakeBlockInRange = "🟦"
|
|
snakeTitleMaxLen = 55
|
|
snakeHashDisplayLen = 8
|
|
)
|
|
|
|
func cmdSnake(c tele.Context) error {
|
|
args := c.Args()
|
|
hash := ""
|
|
cols, rows := 20, 3
|
|
|
|
if len(args) > 0 {
|
|
hash = resolveHash(c, args[0])
|
|
}
|
|
if len(args) > 1 {
|
|
if n, err := strconv.Atoi(args[1]); err == nil && n > 0 && n <= 50 {
|
|
cols = n
|
|
}
|
|
}
|
|
if len(args) > 2 {
|
|
if n, err := strconv.Atoi(args[2]); err == nil && n > 0 && n <= 15 {
|
|
rows = n
|
|
}
|
|
}
|
|
|
|
if hash == "" {
|
|
return c.Send(tr(c.Sender().ID, "snake_usage"))
|
|
}
|
|
|
|
t := torr.GetTorrent(hash)
|
|
if t == nil {
|
|
return c.Send(tr(c.Sender().ID, "torrent_not_found") + ":\n<code>" + hash + "</code>")
|
|
}
|
|
|
|
st := t.CacheState()
|
|
if st == nil {
|
|
return c.Send(fmt.Sprintf(tr(c.Sender().ID, "cache_unavailable"), hash))
|
|
}
|
|
|
|
uid := c.Sender().ID
|
|
txt := formatSnake(uid, st, hash, cols, rows)
|
|
kbd := snakeKeyboard(uid, hash, cols, rows, true)
|
|
msg, err := c.Bot().Send(c.Sender(), txt, kbd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
log.TLogln("tg snake sent", logUserID(uid), logSafeStr(st.Torrent.Title, 40), hash)
|
|
go snakeRefreshLoop(c.Bot(), msg, hash, uid, cols, rows)
|
|
return nil
|
|
}
|
|
|
|
func formatSnake(uid int64, st *cacheSt.CacheState, hash string, cols, rows int) string {
|
|
totalBlocks := cols * rows
|
|
if totalBlocks <= 0 {
|
|
return tr(uid, "snake_no_data")
|
|
}
|
|
if st.PiecesCount <= 0 {
|
|
title := ""
|
|
if st.Torrent != nil {
|
|
title = escapeHtml(st.Torrent.Title)
|
|
}
|
|
txt := "📊 <b>" + title + "</b>\n"
|
|
txt += fmt.Sprintf("%s: %s / %s\n", tr(uid, "snake_cache"),
|
|
humanize.IBytes(uint64(st.Filled)), humanize.IBytes(uint64(st.Capacity)))
|
|
dispHash := st.Hash
|
|
if len(dispHash) > snakeHashDisplayLen {
|
|
dispHash = dispHash[:snakeHashDisplayLen]
|
|
}
|
|
txt += tr(uid, "snake_no_data") + " <code>" + dispHash + "</code>"
|
|
return txt
|
|
}
|
|
|
|
pieceFilled := make(map[int]bool)
|
|
for id, p := range st.Pieces {
|
|
if id >= 0 && id < st.PiecesCount && p.Size > 0 {
|
|
pieceFilled[id] = true
|
|
}
|
|
}
|
|
|
|
readerPositions := make(map[int]bool)
|
|
readerRanges := make(map[int]bool)
|
|
for _, r := range st.Readers {
|
|
readerPositions[r.Reader] = true
|
|
for p := r.Start; p < r.End && p < st.PiecesCount; p++ {
|
|
readerRanges[p] = true
|
|
}
|
|
}
|
|
|
|
cacheWindowPieces := int64(totalBlocks) * 2
|
|
if st.PiecesLength > 0 {
|
|
cacheWindowPieces = st.Capacity / st.PiecesLength
|
|
}
|
|
if cacheWindowPieces < int64(totalBlocks) {
|
|
cacheWindowPieces = int64(totalBlocks)
|
|
}
|
|
|
|
startPiece, endPiece := 0, st.PiecesCount
|
|
if len(st.Readers) > 0 {
|
|
minReader, maxReader := st.PiecesCount, 0
|
|
for _, r := range st.Readers {
|
|
if r.Reader < minReader {
|
|
minReader = r.Reader
|
|
}
|
|
if r.Reader > maxReader {
|
|
maxReader = r.Reader
|
|
}
|
|
}
|
|
windowSize := int(cacheWindowPieces)
|
|
snakeWindowStartMu.Lock()
|
|
lastStart := snakeWindowStart[hash]
|
|
scrollThreshold := windowSize * 3 / 4
|
|
if lastStart == 0 || minReader < lastStart {
|
|
lastStart = minReader
|
|
} else if minReader >= lastStart+scrollThreshold {
|
|
lastStart = minReader - windowSize/5
|
|
}
|
|
if lastStart < 0 {
|
|
lastStart = 0
|
|
}
|
|
snakeWindowStart[hash] = lastStart
|
|
snakeWindowStartMu.Unlock()
|
|
startPiece = lastStart
|
|
endPiece = startPiece + windowSize
|
|
if endPiece > st.PiecesCount {
|
|
endPiece = st.PiecesCount
|
|
startPiece = endPiece - windowSize
|
|
if startPiece < 0 {
|
|
startPiece = 0
|
|
}
|
|
}
|
|
} else if len(pieceFilled) > 0 {
|
|
minP, maxP := st.PiecesCount, 0
|
|
for id := range pieceFilled {
|
|
if id < minP {
|
|
minP = id
|
|
}
|
|
if id > maxP {
|
|
maxP = id
|
|
}
|
|
}
|
|
window := maxP - minP + 1
|
|
if window > int(cacheWindowPieces) {
|
|
window = int(cacheWindowPieces)
|
|
}
|
|
startPiece = minP
|
|
endPiece = minP + window
|
|
if endPiece > st.PiecesCount {
|
|
endPiece = st.PiecesCount
|
|
}
|
|
}
|
|
|
|
windowSize := endPiece - startPiece
|
|
if windowSize <= 0 {
|
|
windowSize = 1
|
|
}
|
|
|
|
blocks := make([]string, totalBlocks)
|
|
piecesPerBlock := (windowSize + totalBlocks - 1) / totalBlocks
|
|
if piecesPerBlock < 1 {
|
|
piecesPerBlock = 1
|
|
}
|
|
|
|
for i := 0; i < totalBlocks; i++ {
|
|
start := startPiece + i*piecesPerBlock
|
|
end := start + piecesPerBlock
|
|
if end > endPiece {
|
|
end = endPiece
|
|
}
|
|
if start >= end {
|
|
blocks[i] = snakeBlockEmpty
|
|
continue
|
|
}
|
|
|
|
blockFilled := false
|
|
blockHasReader := false
|
|
blockInRange := false
|
|
for p := start; p < end; p++ {
|
|
if pieceFilled[p] {
|
|
blockFilled = true
|
|
}
|
|
if readerPositions[p] {
|
|
blockHasReader = true
|
|
}
|
|
if readerRanges[p] {
|
|
blockInRange = true
|
|
}
|
|
}
|
|
|
|
switch {
|
|
case blockHasReader:
|
|
blocks[i] = snakeBlockReader
|
|
case blockFilled:
|
|
blocks[i] = snakeBlockFilled
|
|
case blockInRange:
|
|
blocks[i] = snakeBlockInRange
|
|
default:
|
|
blocks[i] = snakeBlockEmpty
|
|
}
|
|
}
|
|
|
|
var sb strings.Builder
|
|
title := ""
|
|
if st.Torrent != nil {
|
|
title = st.Torrent.Title
|
|
}
|
|
if len([]rune(title)) > snakeTitleMaxLen {
|
|
title = string([]rune(title)[:snakeTitleMaxLen]) + "…"
|
|
}
|
|
title = escapeHtml(title)
|
|
sb.WriteString("📊 <b>")
|
|
sb.WriteString(title)
|
|
sb.WriteString("</b>\n")
|
|
fmt.Fprintf(&sb, "%s: %s / %s",
|
|
tr(uid, "snake_cache"),
|
|
humanize.IBytes(uint64(st.Filled)),
|
|
humanize.IBytes(uint64(st.Capacity)))
|
|
if len(st.Readers) > 1 {
|
|
fmt.Fprintf(&sb, " · %d %s", len(st.Readers), tr(uid, "status_streams"))
|
|
}
|
|
if endPiece-startPiece < st.PiecesCount {
|
|
fmt.Fprintf(&sb, " · %s %d-%d", tr(uid, "snake_pieces"), startPiece+1, endPiece)
|
|
}
|
|
sb.WriteString("\n")
|
|
|
|
for r := 0; r < rows; r++ {
|
|
for c := 0; c < cols; c++ {
|
|
var idx int
|
|
if r%2 == 0 {
|
|
idx = r*cols + c
|
|
} else {
|
|
idx = r*cols + (cols - 1 - c)
|
|
}
|
|
if idx < len(blocks) {
|
|
sb.WriteString(blocks[idx])
|
|
}
|
|
}
|
|
sb.WriteString("\n")
|
|
}
|
|
dispHash := st.Hash
|
|
if len(dispHash) > snakeHashDisplayLen {
|
|
dispHash = dispHash[:snakeHashDisplayLen]
|
|
}
|
|
sb.WriteString(tr(uid, "snake_legend"))
|
|
sb.WriteString(" <code>")
|
|
sb.WriteString(dispHash)
|
|
sb.WriteString("</code>")
|
|
return sb.String()
|
|
}
|
|
|
|
func snakeData(hash string, cols, rows int) string {
|
|
return fmt.Sprintf("%s|%d|%d", hash, cols, rows)
|
|
}
|
|
|
|
func parseSnakeData(data string) (hash string, cols, rows int) {
|
|
cols, rows = 20, 3
|
|
parts := strings.Split(data, "|")
|
|
if len(parts) > 0 {
|
|
hash = parts[0]
|
|
}
|
|
if len(parts) > 1 {
|
|
if n, err := strconv.Atoi(parts[1]); err == nil && n > 0 {
|
|
cols = n
|
|
}
|
|
}
|
|
if len(parts) > 2 {
|
|
if n, err := strconv.Atoi(parts[2]); err == nil && n > 0 {
|
|
rows = n
|
|
}
|
|
}
|
|
return hash, cols, rows
|
|
}
|
|
|
|
func snakeKeyboard(uid int64, hash string, cols, rows int, active bool) *tele.ReplyMarkup {
|
|
data := snakeData(hash, cols, rows)
|
|
if active {
|
|
return &tele.ReplyMarkup{InlineKeyboard: [][]tele.InlineButton{
|
|
{
|
|
{Text: "🔄", Unique: "fsnakerefresh", Data: data},
|
|
{Text: tr(uid, "status_stop_btn"), Unique: "fsnakestop", Data: data},
|
|
},
|
|
}}
|
|
}
|
|
return &tele.ReplyMarkup{InlineKeyboard: [][]tele.InlineButton{
|
|
{{Text: tr(uid, "status_refresh_btn"), Unique: "fsnakerefresh", Data: data}},
|
|
}}
|
|
}
|
|
|
|
func snakeRefreshLoop(api tele.API, msg *tele.Message, hash string, uid int64, cols, rows int) {
|
|
const interval = 2 * time.Second
|
|
const duration = 2 * time.Minute
|
|
stopCh := make(chan struct{})
|
|
snakeStopChansMu.Lock()
|
|
snakeStopChans[msg.ID] = stopCh
|
|
snakeStopChansMu.Unlock()
|
|
defer func() {
|
|
snakeStopChansMu.Lock()
|
|
delete(snakeStopChans, msg.ID)
|
|
snakeStopChansMu.Unlock()
|
|
}()
|
|
|
|
ticker := time.NewTicker(interval)
|
|
defer ticker.Stop()
|
|
deadline := time.Now().Add(duration)
|
|
for {
|
|
select {
|
|
case <-stopCh:
|
|
return
|
|
case <-ticker.C:
|
|
if time.Now().After(deadline) {
|
|
t := torr.GetTorrent(hash)
|
|
if t != nil {
|
|
if st := t.CacheState(); st != nil {
|
|
txt := formatSnake(uid, st, hash, cols, rows) + "\n" + tr(uid, "status_auto_ended")
|
|
_, _ = api.Edit(msg, txt, snakeKeyboard(uid, hash, cols, rows, false), tele.ModeHTML)
|
|
}
|
|
}
|
|
return
|
|
}
|
|
t := torr.GetTorrent(hash)
|
|
if t == nil {
|
|
return
|
|
}
|
|
st := t.CacheState()
|
|
if st == nil {
|
|
return
|
|
}
|
|
txt := formatSnake(uid, st, hash, cols, rows)
|
|
if _, err := api.Edit(msg, txt, snakeKeyboard(uid, hash, cols, rows, true), tele.ModeHTML); err != nil {
|
|
errStr := err.Error()
|
|
if strings.Contains(errStr, "message is not modified") {
|
|
continue
|
|
}
|
|
if strings.Contains(errStr, "message to edit not found") {
|
|
return
|
|
}
|
|
log.TLogln("tg snake refresh err", err)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func stopSnakeRefresh(msgID int) {
|
|
snakeStopChansMu.Lock()
|
|
ch := snakeStopChans[msgID]
|
|
delete(snakeStopChans, msgID)
|
|
snakeStopChansMu.Unlock()
|
|
if ch != nil {
|
|
close(ch)
|
|
}
|
|
}
|
|
|
|
func callbackSnakeRefresh(c tele.Context, data string) error {
|
|
hash, cols, rows := parseSnakeData(data)
|
|
if hash == "" {
|
|
return c.Respond(&tele.CallbackResponse{Text: tr(c.Sender().ID, "callback_unknown")})
|
|
}
|
|
t := torr.GetTorrent(hash)
|
|
if t == nil {
|
|
return c.Respond(&tele.CallbackResponse{Text: tr(c.Sender().ID, "torrent_not_found")})
|
|
}
|
|
st := t.CacheState()
|
|
if st == nil {
|
|
return c.Respond(&tele.CallbackResponse{Text: fmt.Sprintf(tr(c.Sender().ID, "cache_unavailable"), hash)})
|
|
}
|
|
if c.Callback().Message != nil {
|
|
stopSnakeRefresh(c.Callback().Message.ID)
|
|
_ = c.Bot().Delete(c.Callback().Message)
|
|
}
|
|
_ = c.Respond(&tele.CallbackResponse{})
|
|
uid := c.Sender().ID
|
|
txt := formatSnake(uid, st, hash, cols, rows)
|
|
kbd := snakeKeyboard(uid, hash, cols, rows, true)
|
|
msg, err := c.Bot().Send(c.Sender(), txt, kbd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
go snakeRefreshLoop(c.Bot(), msg, hash, uid, cols, rows)
|
|
return nil
|
|
}
|
|
|
|
func callbackSnakeStop(c tele.Context, data string) error {
|
|
uid := c.Sender().ID
|
|
hash, cols, rows := parseSnakeData(data)
|
|
if hash != "" {
|
|
if t := torr.GetTorrent(hash); t != nil {
|
|
log.TLogln("tg snake stop", logUserID(uid), logSafeStr(t.Title, 40), hash)
|
|
}
|
|
}
|
|
if c.Callback().Message != nil {
|
|
stopSnakeRefresh(c.Callback().Message.ID)
|
|
if hash != "" {
|
|
msg := c.Callback().Message
|
|
t := torr.GetTorrent(hash)
|
|
txt := ""
|
|
if t != nil {
|
|
if st := t.CacheState(); st != nil {
|
|
txt = formatSnake(uid, st, hash, cols, rows)
|
|
}
|
|
}
|
|
if txt == "" {
|
|
txt = "<code>" + hash + "</code>"
|
|
}
|
|
txt += "\n" + tr(uid, "status_stopped")
|
|
_, _ = c.Bot().Edit(msg, txt, snakeKeyboard(uid, hash, cols, rows, false), tele.ModeHTML)
|
|
}
|
|
}
|
|
return c.Respond(&tele.CallbackResponse{Text: "🛑"})
|
|
}
|