mirror of
https://github.com/sartoopjj/thefeed.git
synced 2026-05-19 05:34:35 +03:00
feat: implement saved resolvers feature with UI prompt and API integration
This commit is contained in:
@@ -120,6 +120,21 @@ func (f *Fetcher) SetRateLimit(qps float64) {
|
||||
f.rateQPS = qps
|
||||
}
|
||||
|
||||
// ScanConcurrency returns how many resolvers the scanner should probe in
|
||||
// parallel, derived from the configured rate limit.
|
||||
// Rule: concurrency = max(1, floor(rateQPS)).
|
||||
// If rateQPS is 0 (unlimited), falls back to the default of 10.
|
||||
func (f *Fetcher) ScanConcurrency() int {
|
||||
if f.rateQPS <= 0 {
|
||||
return 10
|
||||
}
|
||||
n := int(f.rateQPS)
|
||||
if n < 1 {
|
||||
n = 1
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// SetTimeout sets the per-query DNS timeout.
|
||||
func (f *Fetcher) SetTimeout(d time.Duration) {
|
||||
f.timeout = d
|
||||
@@ -146,6 +161,7 @@ func (f *Fetcher) SetActiveResolvers(resolvers []string) {
|
||||
defer f.mu.Unlock()
|
||||
f.activeResolvers = make([]string, len(resolvers))
|
||||
copy(f.activeResolvers, resolvers)
|
||||
f.log("active resolvers updated: %d/%d healthy", len(resolvers), len(f.allResolvers))
|
||||
}
|
||||
|
||||
// SetResolvers replaces the full resolver list and resets the active pool.
|
||||
@@ -344,6 +360,7 @@ func (f *Fetcher) scatterQuery(ctx context.Context, resolvers []string, qname st
|
||||
// Call once per fetcher configuration; creating a new fetcher replaces the old one.
|
||||
func (f *Fetcher) Start(ctx context.Context) {
|
||||
if f.rateQPS > 0 {
|
||||
f.log("fetcher started: %d configured resolvers, rate=%.1f q/s, scatter=%d", len(f.allResolvers), f.rateQPS, f.scatter)
|
||||
f.rateCh = make(chan struct{}, 1)
|
||||
go f.runRateLimiter(ctx)
|
||||
go f.runNoise(ctx)
|
||||
@@ -473,9 +490,6 @@ func (f *Fetcher) FetchBlock(ctx context.Context, channel, block uint16) ([]byte
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode query: %w", err)
|
||||
}
|
||||
if f.debug {
|
||||
f.log("[debug] query ch=%d blk=%d attempt=%d qname=%s", channel, block, attempt+1, qname)
|
||||
}
|
||||
|
||||
scatter := f.scatter
|
||||
if scatter < 1 {
|
||||
@@ -485,6 +499,10 @@ func (f *Fetcher) FetchBlock(ctx context.Context, channel, block uint16) ([]byte
|
||||
if len(picked) == 0 {
|
||||
return nil, fmt.Errorf("no active resolvers")
|
||||
}
|
||||
if f.debug {
|
||||
f.log("[debug] query ch=%d blk=%d attempt=%d qname=%s resolvers=[%s]",
|
||||
channel, block, attempt+1, qname, strings.Join(picked, ","))
|
||||
}
|
||||
|
||||
data, err := f.scatterQuery(ctx, picked, qname)
|
||||
if err == nil {
|
||||
|
||||
+79
-29
@@ -3,6 +3,7 @@ package client
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
@@ -20,6 +21,7 @@ type ResolverChecker struct {
|
||||
fetcher *Fetcher
|
||||
timeout time.Duration
|
||||
logFunc LogFunc
|
||||
onScanDone func([]string) // called after each completed scan with healthy resolvers
|
||||
started atomic.Bool // guards against double-start
|
||||
scanMu sync.Mutex // protects scanCancel
|
||||
scanRunMu sync.Mutex // only one CheckNow at a time (via TryLock)
|
||||
@@ -43,6 +45,12 @@ func (rc *ResolverChecker) SetLogFunc(fn LogFunc) {
|
||||
rc.logFunc = fn
|
||||
}
|
||||
|
||||
// SetOnScanDone registers a callback invoked after each completed CheckNow pass
|
||||
// with the list of healthy resolver addresses. Not called when the scan is cancelled.
|
||||
func (rc *ResolverChecker) SetOnScanDone(fn func([]string)) {
|
||||
rc.onScanDone = fn
|
||||
}
|
||||
|
||||
// Start begins the periodic health-check loop in the background.
|
||||
// An initial check runs immediately; subsequent checks happen every 10 minutes.
|
||||
// ctx controls the lifetime — cancel it to stop the checker.
|
||||
@@ -82,35 +90,51 @@ func (rc *ResolverChecker) StartAndNotify(ctx context.Context, onFirstDone func(
|
||||
onFirstDone()
|
||||
}
|
||||
|
||||
// Periodic re-check every 30 minutes.
|
||||
ticker := time.NewTicker(30 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
rc.CheckNow(ctx)
|
||||
// If the periodic check leaves us with no resolvers,
|
||||
// fall back into the retry-every-minute loop.
|
||||
if ctx.Err() == nil && len(rc.fetcher.Resolvers()) == 0 {
|
||||
rc.log("All resolvers lost — scanning every minute until one recovers...")
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(1 * time.Minute):
|
||||
}
|
||||
rc.CheckNow(ctx)
|
||||
if ctx.Err() != nil || len(rc.fetcher.Resolvers()) > 0 {
|
||||
break
|
||||
}
|
||||
rc.log("Still no healthy resolvers — retrying in 1 minute...")
|
||||
rc.runPeriodicLoop(ctx)
|
||||
}()
|
||||
}
|
||||
|
||||
// StartPeriodic starts only the periodic 30-minute health-check loop without
|
||||
// running an initial scan. Use when resolvers are already available (e.g.
|
||||
// loaded from a saved last-scan file on startup).
|
||||
// Safe to call only once per checker instance; subsequent calls are no-ops.
|
||||
func (rc *ResolverChecker) StartPeriodic(ctx context.Context) {
|
||||
if !rc.started.CompareAndSwap(false, true) {
|
||||
return
|
||||
}
|
||||
go rc.runPeriodicLoop(ctx)
|
||||
}
|
||||
|
||||
// runPeriodicLoop is the shared 30-minute ticker loop used by both
|
||||
// StartAndNotify and StartPeriodic.
|
||||
func (rc *ResolverChecker) runPeriodicLoop(ctx context.Context) {
|
||||
ticker := time.NewTicker(30 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
rc.CheckNow(ctx)
|
||||
// If the periodic check leaves us with no resolvers,
|
||||
// fall back into the retry-every-minute loop.
|
||||
if ctx.Err() == nil && len(rc.fetcher.Resolvers()) == 0 {
|
||||
rc.log("All resolvers lost — scanning every minute until one recovers...")
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(1 * time.Minute):
|
||||
}
|
||||
rc.CheckNow(ctx)
|
||||
if ctx.Err() != nil || len(rc.fetcher.Resolvers()) > 0 {
|
||||
break
|
||||
}
|
||||
rc.log("Still no healthy resolvers — retrying in 1 minute...")
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
// CancelCurrentScan cancels any in-progress CheckNow call, causing it to
|
||||
@@ -155,20 +179,43 @@ func (rc *ResolverChecker) CheckNow(ctx context.Context) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// Shuffle so each scan probes resolvers in a fresh random order, preventing
|
||||
// the same resolvers from always being probed first (more even load distribution).
|
||||
rand.Shuffle(len(resolvers), func(i, j int) { resolvers[i], resolvers[j] = resolvers[j], resolvers[i] })
|
||||
|
||||
total := len(resolvers)
|
||||
concurrency := rc.fetcher.ScanConcurrency()
|
||||
rc.log("RESOLVER_SCAN start %d", total)
|
||||
rc.log("scanner started: probing %d resolvers (concurrency=%d, batch-pause every 50)", total, concurrency)
|
||||
|
||||
var healthy []string
|
||||
var mu sync.Mutex
|
||||
var done int
|
||||
wg := &sync.WaitGroup{}
|
||||
sem := make(chan struct{}, 10) // probe up to 10 resolvers concurrently
|
||||
sem := make(chan struct{}, concurrency)
|
||||
|
||||
launched := 0
|
||||
for _, r := range resolvers {
|
||||
// Stop launching new probes if context was cancelled.
|
||||
if scanCtx.Err() != nil {
|
||||
break
|
||||
}
|
||||
// Rate-limit pause: every 50 launched probes, sleep 3-10 s so we don't
|
||||
// flood resolver rate limits before moving to the next batch.
|
||||
if launched > 0 && launched%50 == 0 {
|
||||
pause := 3*time.Second + time.Duration(rand.Intn(8))*time.Second
|
||||
timer := time.NewTimer(pause)
|
||||
select {
|
||||
case <-scanCtx.Done():
|
||||
timer.Stop()
|
||||
break
|
||||
case <-timer.C:
|
||||
}
|
||||
if scanCtx.Err() != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
launched++
|
||||
wg.Add(1)
|
||||
go func(r string) {
|
||||
defer wg.Done()
|
||||
@@ -198,10 +245,13 @@ func (rc *ResolverChecker) CheckNow(ctx context.Context) bool {
|
||||
if len(healthy) == 0 {
|
||||
rc.log("Resolver check done: 0/%d healthy", len(resolvers))
|
||||
rc.log("RESOLVER_SCAN done 0/%d", total)
|
||||
return true
|
||||
} else {
|
||||
rc.log("Resolver check done: %d/%d healthy", len(healthy), len(resolvers))
|
||||
rc.log("RESOLVER_SCAN done %d/%d", len(healthy), total)
|
||||
}
|
||||
if rc.onScanDone != nil {
|
||||
rc.onScanDone(healthy)
|
||||
}
|
||||
rc.log("Resolver check done: %d/%d healthy", len(healthy), len(resolvers))
|
||||
rc.log("RESOLVER_SCAN done %d/%d", len(healthy), total)
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ input,textarea,select{font-family:inherit}
|
||||
/* ===== LAYOUT ===== */
|
||||
.app{display:flex;height:100vh;direction:ltr}
|
||||
.sidebar{width:280px;min-width:280px;background:var(--sidebar-bg);display:flex;flex-direction:column;border-right:1px solid var(--border);overflow:hidden}
|
||||
.chat-area{flex:1;display:flex;flex-direction:column;background:var(--bg);overflow:hidden}
|
||||
.chat-area{flex:1;display:flex;flex-direction:column;background:var(--bg);overflow:hidden;position:relative}
|
||||
|
||||
/* ===== SIDEBAR HEADER ===== */
|
||||
.sidebar-header{padding:10px 12px;display:flex;flex-direction:column;gap:8px;border-bottom:1px solid var(--border)}
|
||||
@@ -104,6 +104,13 @@ input,textarea,select{font-family:inherit}
|
||||
.refresh-has-new{animation:badge-pulse 2s ease-in-out infinite;color:var(--accent)!important}
|
||||
.msg-copy-btn{background:none;border:none;color:var(--text-dim);font-size:14px;cursor:pointer;padding:0 3px;line-height:1;flex-shrink:0;opacity:.45;transition:opacity .15s}.msg-copy-btn:hover{opacity:1}
|
||||
|
||||
/* ===== SCROLL-TO-BOTTOM ===== */
|
||||
.scroll-down-btn{position:absolute;bottom:70px;right:16px;width:36px;height:36px;border:none;border-radius:50%;background:var(--surface2);color:var(--text);font-size:18px;display:none;align-items:center;justify-content:center;box-shadow:0 2px 10px rgba(0,0,0,.45);z-index:10;cursor:pointer;border:1px solid var(--border)}
|
||||
.scroll-down-btn.visible{display:flex}
|
||||
.scroll-down-btn:hover{background:var(--hover)}
|
||||
.scroll-down-badge{position:absolute;top:-4px;right:-4px;background:var(--accent);color:#fff;font-size:9px;font-weight:700;min-width:16px;height:16px;border-radius:8px;display:none;align-items:center;justify-content:center;padding:0 3px}
|
||||
.scroll-down-badge.visible{display:flex}
|
||||
|
||||
/* ===== LOG ===== */
|
||||
.log-toggle{display:flex;align-items:center;justify-content:space-between;padding:3px 14px;background:var(--bg2);cursor:pointer;user-select:none;font-size:10px;color:var(--text-dim);letter-spacing:.5px;border-top:1px solid var(--border)}
|
||||
.log-toggle:hover{color:var(--text)}
|
||||
@@ -260,6 +267,7 @@ html[dir=ltr] .active-badge{margin-left:0;margin-right:6px}
|
||||
<input class="send-input" id="sendInput" data-i18n-ph="write_message" placeholder="Write a message..." maxlength="4000">
|
||||
<button class="send-btn" onclick="sendMessage()">➤</button>
|
||||
</div>
|
||||
<button class="scroll-down-btn" id="scrollDownBtn" onclick="scrollToBottom()" title="Jump to latest">↓<span class="scroll-down-badge" id="scrollDownBadge"></span></button>
|
||||
<div class="progress-panel" id="progressPanel"></div>
|
||||
<div class="log-toggle" onclick="toggleLog()"><span>LOG</span><span id="logToggleIcon">▶</span></div>
|
||||
<div class="log-panel hidden" id="logPanel"></div>
|
||||
@@ -269,6 +277,19 @@ html[dir=ltr] .active-badge{margin-left:0;margin-right:6px}
|
||||
<!-- TOAST -->
|
||||
<div id="toast"></div>
|
||||
|
||||
<!-- ===== SAVED RESOLVERS POPUP ===== -->
|
||||
<div class="modal-overlay" id="savedResolversModal">
|
||||
<div class="modal" style="max-width:380px">
|
||||
<h2 data-i18n="saved_resolvers_title">Quick Start</h2>
|
||||
<p id="savedResolversMsg" style="font-size:13px;color:var(--text-dim);margin-bottom:16px;line-height:1.6"></p>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-flat" onclick="savedResolversSkip()" data-i18n="saved_resolvers_skip">Skip</button>
|
||||
<button class="btn btn-outline" onclick="savedResolversRescan()" data-i18n="saved_resolvers_rescan">Scan Again</button>
|
||||
<button class="btn btn-primary" onclick="savedResolversUseNow()" data-i18n="saved_resolvers_use">Use Now</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== SETTINGS MODAL ===== -->
|
||||
<div class="modal-overlay" id="settingsModal">
|
||||
<div class="modal">
|
||||
@@ -401,6 +422,14 @@ var I18N = {
|
||||
add_manual:'✎ ساخت دستی',rescan:'بررسی مجدد',
|
||||
new_messages:'پیام جدید',missed_messages:'{n} پیام از دست رفته یا حذف شده',
|
||||
clear_cache:'پاک کردن کش',cache_cleared:'کش پاک شد!',
|
||||
saved_resolvers_title:'شروع سریع',
|
||||
saved_resolvers_msg:'آخرین اسکن ({t}) نتیجه داد: {n} سرور DNS سالم پیدا شد. همینها را استفاده کنیم یا دوباره اسکن کنیم؟',
|
||||
saved_resolvers_use:'استفاده کن (بدون اسکن)',
|
||||
saved_resolvers_rescan:'اسکن مجدد',
|
||||
saved_resolvers_skip:'بعداً',
|
||||
saved_resolvers_applied:'سرورهای DNS ذخیرهشده اعمال شدند',
|
||||
minutes_ago:'دقیقه پیش',
|
||||
hours_ago:'ساعت پیش',
|
||||
},
|
||||
en: {
|
||||
search:'Search...',settings:'Settings',profiles:'Profiles',
|
||||
@@ -435,6 +464,14 @@ var I18N = {
|
||||
add_manual:'✎ Create Manually',rescan:'Rescan',
|
||||
new_messages:'New messages',missed_messages:'{n} messages missed or deleted',
|
||||
clear_cache:'Clear Cache',cache_cleared:'Cache cleared!',
|
||||
saved_resolvers_title:'Quick Start',
|
||||
saved_resolvers_msg:'Last scan ({t}) found {n} healthy DNS servers. Use them now (no scan needed), or scan again to re-verify.',
|
||||
saved_resolvers_use:'Use Now (skip scan)',
|
||||
saved_resolvers_rescan:'Scan Again',
|
||||
saved_resolvers_skip:'Later',
|
||||
saved_resolvers_applied:'Saved DNS servers applied!',
|
||||
minutes_ago:'min ago',
|
||||
hours_ago:'hr ago',
|
||||
}
|
||||
};
|
||||
var lang = localStorage.getItem('thefeed_lang') || 'fa';
|
||||
@@ -476,6 +513,7 @@ async function init(){
|
||||
var r=await fetch('/api/status');var st=await r.json();
|
||||
await loadProfiles();
|
||||
if(!st.configured){openProfiles();return}
|
||||
checkAndShowSavedResolversPrompt(st);
|
||||
telegramLoggedIn=!!st.telegramLoggedIn;
|
||||
serverNextFetch=st.nextFetch||0;
|
||||
updateNextFetchDisplay();
|
||||
@@ -514,6 +552,35 @@ async function clearCache(){
|
||||
try{var r=await fetch('/api/cache/clear',{method:'POST'});var j=await r.json();if(j.ok){alert(t('cache_cleared'))}}catch(e){}
|
||||
}
|
||||
|
||||
// ===== SAVED RESOLVERS PROMPT =====
|
||||
function checkAndShowSavedResolversPrompt(status){
|
||||
if(sessionStorage.getItem('thefeed_scan_prompt_shown'))return;
|
||||
if(!status.lastScan||!status.lastScan.count)return;
|
||||
var ls=status.lastScan;
|
||||
var ageSec=Math.floor(Date.now()/1000)-ls.scannedAt;
|
||||
var ageStr;
|
||||
if(ageSec<3600)ageStr=Math.max(1,Math.round(ageSec/60))+' '+t('minutes_ago');
|
||||
else ageStr=Math.round(ageSec/3600)+' '+t('hours_ago');
|
||||
var msg=t('saved_resolvers_msg').replace('{n}',ls.count).replace('{t}',ageStr);
|
||||
document.getElementById('savedResolversMsg').textContent=msg;
|
||||
document.getElementById('savedResolversModal').classList.add('active');
|
||||
}
|
||||
function savedResolversSkip(){
|
||||
// "Later" — just close, server already applied saved resolvers and refresh is underway
|
||||
document.getElementById('savedResolversModal').classList.remove('active');
|
||||
sessionStorage.setItem('thefeed_scan_prompt_shown','1');
|
||||
}
|
||||
function savedResolversUseNow(){
|
||||
// Server already applied saved resolvers at startup; just close the popup
|
||||
savedResolversSkip();
|
||||
showToast(t('saved_resolvers_applied'));
|
||||
}
|
||||
async function savedResolversRescan(){
|
||||
savedResolversSkip();
|
||||
try{await fetch('/api/rescan',{method:'POST'})}catch(e){}
|
||||
showToast(t('rescan_started'));
|
||||
}
|
||||
|
||||
// ===== SSE =====
|
||||
function connectSSE(){
|
||||
if(eventSource)eventSource.close();
|
||||
@@ -902,6 +969,7 @@ async function selectChannel(num){
|
||||
document.getElementById('chatName').textContent=name;
|
||||
renderChannels();updateSendPanel();
|
||||
document.getElementById('messages').innerHTML='<div class="empty-state"><p>'+t('loading')+'</p></div>';
|
||||
document.getElementById('scrollDownBtn').classList.remove('visible');
|
||||
// Show immediate feedback progress bar
|
||||
showChannelFetchProgress(num,name);
|
||||
await loadMessages(num);
|
||||
@@ -1017,7 +1085,7 @@ function renderMessages(msgs){
|
||||
html+='<div class="msg'+(isPersian(text)?' rtl-msg':'')+'" dir="auto">'+mediaHtml+textHtml+'<div class="msg-meta"><button class="msg-copy-btn" onclick="copyMsg('+i+')" title="'+t('copy')+'">📋</button><span>#'+id+'</span><span>'+timeStr+'</span></div></div>';
|
||||
}
|
||||
el.innerHTML=html;
|
||||
if(isFirstRender||wasAtBottom)el.scrollTop=el.scrollHeight;
|
||||
if(isFirstRender||wasAtBottom){el.scrollTop=el.scrollHeight;document.getElementById('scrollDownBtn').classList.remove('visible');}
|
||||
}
|
||||
|
||||
// ===== LOG =====
|
||||
@@ -1270,6 +1338,25 @@ function copyMsg(idx){
|
||||
navigator.clipboard.writeText(text).then(function(){showToast(t('msg_copied'))}).catch(function(){});
|
||||
}
|
||||
|
||||
// ===== SCROLL TO BOTTOM =====
|
||||
(function(){
|
||||
var messagesEl=null;
|
||||
function initScrollBtn(){
|
||||
messagesEl=document.getElementById('messages');
|
||||
if(!messagesEl)return;
|
||||
messagesEl.addEventListener('scroll',function(){
|
||||
var atBottom=messagesEl.scrollHeight-messagesEl.scrollTop-messagesEl.clientHeight<150;
|
||||
document.getElementById('scrollDownBtn').classList.toggle('visible',!atBottom);
|
||||
});
|
||||
}
|
||||
// Init after DOM is ready
|
||||
document.addEventListener('DOMContentLoaded',initScrollBtn,{once:true});
|
||||
})();
|
||||
function scrollToBottom(){
|
||||
var el=document.getElementById('messages');
|
||||
if(el)el.scrollTop=el.scrollHeight;
|
||||
}
|
||||
|
||||
// ===== TOAST =====
|
||||
function showToast(msg){
|
||||
var el=document.getElementById('toast');el.textContent=msg;el.classList.add('show');
|
||||
|
||||
+100
-2
@@ -10,6 +10,7 @@ import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log"
|
||||
mrand "math/rand/v2"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -57,6 +58,12 @@ type ProfileList struct {
|
||||
Debug bool `json:"debug,omitempty"`
|
||||
}
|
||||
|
||||
// lastScanData is the on-disk structure for last_scan.json.
|
||||
type lastScanData struct {
|
||||
Resolvers []string `json:"resolvers"`
|
||||
ScannedAt int64 `json:"scannedAt"`
|
||||
}
|
||||
|
||||
// Server is the web UI server for thefeed client.
|
||||
type Server struct {
|
||||
dataDir string
|
||||
@@ -165,6 +172,7 @@ func (s *Server) Run() error {
|
||||
mux.HandleFunc("/api/profiles/switch", s.handleProfileSwitch)
|
||||
mux.HandleFunc("/api/settings", s.handleSettings)
|
||||
mux.HandleFunc("/api/cache/clear", s.handleClearCache)
|
||||
mux.HandleFunc("/api/resolvers/apply-saved", s.handleApplySavedResolvers)
|
||||
mux.HandleFunc("/", s.handleIndex)
|
||||
|
||||
addr := fmt.Sprintf("127.0.0.1:%d", s.port)
|
||||
@@ -172,7 +180,16 @@ func (s *Server) Run() error {
|
||||
fmt.Printf("\n Open in browser: http://%s\n\n", addr)
|
||||
|
||||
if s.fetcher != nil {
|
||||
s.startCheckerThenRefresh()
|
||||
if ls := s.loadLastScan(); ls != nil {
|
||||
// Fast path: apply saved healthy resolvers immediately and skip the
|
||||
// initial full scan. Only the periodic 30-min checker starts.
|
||||
// This gives the UI near-instant channel data on app open.
|
||||
s.fetcher.SetActiveResolvers(ls.Resolvers)
|
||||
s.checker.StartPeriodic(s.fetcherCtx)
|
||||
go s.refreshMetadataOnly()
|
||||
} else {
|
||||
s.startCheckerThenRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
var handler http.Handler = mux
|
||||
@@ -227,6 +244,14 @@ func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
status["channels"] = s.channels
|
||||
status["telegramLoggedIn"] = s.telegramLoggedIn
|
||||
status["nextFetch"] = s.nextFetch
|
||||
// Include last resolver scan if recent (<24 h) so the frontend can offer a quick-start.
|
||||
if ls := s.loadLastScan(); ls != nil {
|
||||
status["lastScan"] = map[string]any{
|
||||
"resolvers": ls.Resolvers,
|
||||
"scannedAt": ls.ScannedAt,
|
||||
"count": len(ls.Resolvers),
|
||||
}
|
||||
}
|
||||
}
|
||||
writeJSON(w, status)
|
||||
}
|
||||
@@ -344,7 +369,24 @@ func (s *Server) handleRescan(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
// Cancel any in-progress metadata refresh so it doesn't race with the
|
||||
// scan — we want fresh resolver data before we hit DNS again.
|
||||
s.refreshMu.Lock()
|
||||
if s.refreshCancel != nil {
|
||||
s.refreshCancel()
|
||||
s.refreshCancel = nil
|
||||
}
|
||||
s.refreshMu.Unlock()
|
||||
|
||||
if checker.CheckNow(baseCtx) {
|
||||
// Cool-down: give resolvers time to recover from the scan's DNS
|
||||
// queries before we immediately hit them again with a fetch.
|
||||
sleep := 3*time.Second + time.Duration(mrand.IntN(13))*time.Second // 3–15 s
|
||||
select {
|
||||
case <-baseCtx.Done():
|
||||
return
|
||||
case <-time.After(sleep):
|
||||
}
|
||||
s.refreshMetadataOnly()
|
||||
}
|
||||
}()
|
||||
@@ -610,6 +652,11 @@ func (s *Server) initFetcher() error {
|
||||
checker.SetLogFunc(func(msg string) {
|
||||
s.addLog(msg)
|
||||
})
|
||||
checker.SetOnScanDone(func(healthy []string) {
|
||||
if len(healthy) > 0 {
|
||||
s.saveLastScan(healthy)
|
||||
}
|
||||
})
|
||||
s.checker = checker
|
||||
|
||||
s.fetcher = fetcher
|
||||
@@ -727,7 +774,7 @@ func (s *Server) refreshMetadataOnly() {
|
||||
s.refreshMu.Unlock()
|
||||
}()
|
||||
|
||||
s.addLog("Fetching metadata...")
|
||||
s.addLog(fmt.Sprintf("Fetching metadata... (%d active resolvers)", len(fetcher.Resolvers())))
|
||||
|
||||
// If the server's next Telegram fetch is imminent (within 5 s), wait for it first.
|
||||
if dl := s.nextFetchDeadline(); !dl.IsZero() && time.Until(dl) < 5*time.Second {
|
||||
@@ -961,6 +1008,57 @@ func (s *Server) loadConfig() (*Config, error) {
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
// saveLastScan persists the healthy resolver list from the most recent scan.
|
||||
func (s *Server) saveLastScan(resolvers []string) {
|
||||
d := lastScanData{Resolvers: resolvers, ScannedAt: time.Now().Unix()}
|
||||
b, err := json.MarshalIndent(d, "", " ")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = os.WriteFile(filepath.Join(s.dataDir, "last_scan.json"), b, 0600)
|
||||
}
|
||||
|
||||
// loadLastScan reads the most recent resolver scan result.
|
||||
// Returns nil when the file doesn't exist or is older than 24 hours.
|
||||
func (s *Server) loadLastScan() *lastScanData {
|
||||
b, err := os.ReadFile(filepath.Join(s.dataDir, "last_scan.json"))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var d lastScanData
|
||||
if err := json.Unmarshal(b, &d); err != nil {
|
||||
return nil
|
||||
}
|
||||
if len(d.Resolvers) == 0 || time.Since(time.Unix(d.ScannedAt, 0)) > 24*time.Hour {
|
||||
return nil
|
||||
}
|
||||
return &d
|
||||
}
|
||||
|
||||
// handleApplySavedResolvers immediately activates the resolvers from the last
|
||||
// scan file, letting the UI skip the current scan and start fetching channels.
|
||||
func (s *Server) handleApplySavedResolvers(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", 405)
|
||||
return
|
||||
}
|
||||
ls := s.loadLastScan()
|
||||
if ls == nil {
|
||||
http.Error(w, "no saved scan", 400)
|
||||
return
|
||||
}
|
||||
s.mu.RLock()
|
||||
fetcher := s.fetcher
|
||||
s.mu.RUnlock()
|
||||
if fetcher == nil {
|
||||
http.Error(w, "not configured", 400)
|
||||
return
|
||||
}
|
||||
fetcher.SetActiveResolvers(ls.Resolvers)
|
||||
go s.refreshMetadataOnly()
|
||||
writeJSON(w, map[string]any{"ok": true, "count": len(ls.Resolvers)})
|
||||
}
|
||||
|
||||
func (s *Server) saveConfig(cfg *Config) error {
|
||||
path := filepath.Join(s.dataDir, "config.json")
|
||||
data, err := json.MarshalIndent(cfg, "", " ")
|
||||
|
||||
Reference in New Issue
Block a user