Du kan inte välja fler än 25 ämnen Ämnen måste starta med en bokstav eller siffra, kan innehålla bindestreck ('-') och vara max 35 tecken långa.

505 lines
14KB

  1. package main
  2. import (
  3. "bufio"
  4. "encoding/csv"
  5. "encoding/json"
  6. "flag"
  7. "fmt"
  8. "io"
  9. "net/http"
  10. "net/url"
  11. "os"
  12. "strings"
  13. "sync"
  14. "time"
  15. "radio-stream-extractor/internal/extractor"
  16. )
  17. type scanResult struct {
  18. URL string `json:"url"`
  19. Streams []string `json:"streams"`
  20. Playlists []string `json:"playlists,omitempty"`
  21. Probes []probeResult `json:"probes,omitempty"`
  22. Error string `json:"error,omitempty"`
  23. FetchedAt time.Time `json:"fetchedAt"`
  24. FromPlaylist bool `json:"fromPlaylist"`
  25. }
  26. type probeResult struct {
  27. URL string `json:"url"`
  28. Status string `json:"status"`
  29. ContentType string `json:"contentType,omitempty"`
  30. }
  31. type config struct {
  32. Format string
  33. Probe bool
  34. Headers headerList
  35. Proxy string
  36. HistoryPath string
  37. Watch time.Duration
  38. Concurrency int
  39. }
  40. type headerList []string
  41. func (h *headerList) String() string { return strings.Join(*h, ", ") }
  42. func (h *headerList) Set(v string) error {
  43. *h = append(*h, v)
  44. return nil
  45. }
  46. func main() {
  47. port := flag.String("port", ":8080", "listen address for the web server (default :8080)")
  48. web := flag.Bool("web", false, "force web-server mode even when URLs are provided")
  49. cfg := config{}
  50. flag.StringVar(&cfg.Format, "format", "text", "output format: text|json|csv|pls")
  51. flag.BoolVar(&cfg.Probe, "probe", true, "probe discovered stream URLs with HTTP HEAD")
  52. flag.Var(&cfg.Headers, "header", "custom HTTP header (repeatable), e.g. -header 'Referer: https://example.com'")
  53. flag.StringVar(&cfg.Proxy, "proxy", "", "HTTP proxy URL (optional)")
  54. flag.StringVar(&cfg.HistoryPath, "history", "history.jsonl", "path to JSONL history log (empty to disable)")
  55. flag.DurationVar(&cfg.Watch, "watch", 0, "repeat scan in CLI mode at interval (e.g. 30s, 2m)")
  56. flag.IntVar(&cfg.Concurrency, "concurrency", 4, "number of concurrent fetch workers")
  57. flag.Usage = func() {
  58. fmt.Fprintf(flag.CommandLine.Output(), "Usage: %s [flags] <url> [url...]\n", os.Args[0])
  59. flag.PrintDefaults()
  60. }
  61. flag.Parse()
  62. urls := flag.Args()
  63. client := newHTTPClient(cfg.Proxy)
  64. history := newHistoryWriter(cfg.HistoryPath)
  65. if *web || len(urls) == 0 {
  66. if err := runWebMode(*port, client, &cfg, history); err != nil {
  67. fmt.Fprintf(os.Stderr, "web mode failed: %v\n", err)
  68. os.Exit(1)
  69. }
  70. return
  71. }
  72. runCLIMode(urls, client, &cfg, history)
  73. }
  74. func runCLIMode(urls []string, client *http.Client, cfg *config, history *historyWriter) {
  75. for {
  76. results := scanURLs(urls, client, cfg)
  77. outputResults(results, cfg.Format, os.Stdout)
  78. history.Write(results)
  79. if cfg.Watch == 0 {
  80. return
  81. }
  82. time.Sleep(cfg.Watch)
  83. }
  84. }
  85. func runWebMode(addr string, client *http.Client, cfg *config, history *historyWriter) error {
  86. mux := http.NewServeMux()
  87. mux.HandleFunc("/", indexHandler)
  88. mux.HandleFunc("/scan", makeScanHandler(client, cfg, history))
  89. mux.HandleFunc("/watch", watchHandler)
  90. fmt.Printf("radiostreamscan listening on %s (GET /scan?url=... or POST url=...)\n", addr)
  91. return http.ListenAndServe(addr, mux)
  92. }
  93. func indexHandler(w http.ResponseWriter, r *http.Request) {
  94. fmt.Fprintf(w, `<!doctype html>
  95. <html>
  96. <head><meta charset="utf-8"><title>radiostreamscan</title></head>
  97. <body>
  98. <h1>radiostreamscan</h1>
  99. <form method="get" action="/watch">
  100. <label>Stream-URLs (eine pro Zeile)</label><br/>
  101. <textarea name="url" rows="6" cols="80" required></textarea><br/>
  102. <label>Format
  103. <select name="format">
  104. <option value="json">json</option>
  105. <option value="text">text</option>
  106. <option value="csv">csv</option>
  107. <option value="pls">pls</option>
  108. </select>
  109. </label>
  110. <label>Auto-Refresh (Sekunden)
  111. <input type="number" name="interval" value="0" min="0" />
  112. </label>
  113. <label><input type="checkbox" name="probe" value="1" checked> Probing</label>
  114. <button type="submit">Scan</button>
  115. </form>
  116. <p>Mehrere URLs: /scan?url=a&url=b&url=c</p>
  117. </body>
  118. </html>`)
  119. }
  120. func watchHandler(w http.ResponseWriter, r *http.Request) {
  121. urls := normalizeURLInputs(r.URL.Query()["url"])
  122. interval := r.URL.Query().Get("interval")
  123. probe := r.URL.Query().Get("probe")
  124. fmt.Fprintf(w, `<!doctype html>
  125. <html>
  126. <head><meta charset="utf-8"><title>radiostreamscan results</title>
  127. <style>
  128. body { font-family: Arial, sans-serif; }
  129. .url-block { margin: 10px 0; padding: 10px; border: 1px solid #ccc; }
  130. .error { color: #b00020; }
  131. button { margin: 8px 0; }
  132. </style>
  133. </head>
  134. <body>
  135. <h1>radiostreamscan results</h1>
  136. <button id="copy">Alle Streams kopieren</button>
  137. <div id="output">Loading...</div>
  138. <textarea id="clipboard" style="position:absolute; left:-9999px; top:-9999px;"></textarea>
  139. <script>
  140. const urls = %q.split("\n").filter(Boolean);
  141. const interval = %q;
  142. const probe = %q;
  143. async function fetchData() {
  144. const params = new URLSearchParams();
  145. urls.forEach(u => params.append("url", u));
  146. params.set("format", "json");
  147. if (probe) params.set("probe", "1");
  148. const res = await fetch("/scan?" + params.toString());
  149. const data = await res.json();
  150. const container = document.getElementById("output");
  151. container.innerHTML = "";
  152. const allStreams = [];
  153. data.forEach(item => {
  154. const block = document.createElement("div");
  155. block.className = "url-block";
  156. const title = document.createElement("h3");
  157. title.textContent = item.url;
  158. block.appendChild(title);
  159. if (item.error) {
  160. const err = document.createElement("div");
  161. err.className = "error";
  162. err.textContent = item.error;
  163. block.appendChild(err);
  164. container.appendChild(block);
  165. return;
  166. }
  167. const list = document.createElement("ul");
  168. (item.streams || []).forEach(s => {
  169. const li = document.createElement("li");
  170. li.textContent = s;
  171. list.appendChild(li);
  172. allStreams.push(s);
  173. });
  174. block.appendChild(list);
  175. container.appendChild(block);
  176. });
  177. document.getElementById("clipboard").value = allStreams.join("\n");
  178. }
  179. document.getElementById("copy").addEventListener("click", () => {
  180. const text = document.getElementById("clipboard").value;
  181. if (navigator.clipboard && navigator.clipboard.writeText) {
  182. navigator.clipboard.writeText(text);
  183. } else {
  184. const el = document.getElementById("clipboard");
  185. el.select();
  186. document.execCommand("copy");
  187. }
  188. });
  189. fetchData();
  190. if (interval && Number(interval) > 0) {
  191. setInterval(fetchData, Number(interval) * 1000);
  192. }
  193. </script>
  194. </body>
  195. </html>`, strings.Join(urls, "\n"), interval, probe)
  196. }
  197. func makeScanHandler(client *http.Client, cfg *config, history *historyWriter) http.HandlerFunc {
  198. return func(w http.ResponseWriter, r *http.Request) {
  199. var urls []string
  200. switch r.Method {
  201. case http.MethodGet:
  202. urls = r.URL.Query()["url"]
  203. case http.MethodPost:
  204. if err := r.ParseForm(); err != nil {
  205. http.Error(w, err.Error(), http.StatusBadRequest)
  206. return
  207. }
  208. urls = r.Form["url"]
  209. default:
  210. http.Error(w, "only GET and POST supported", http.StatusMethodNotAllowed)
  211. return
  212. }
  213. urls = normalizeURLInputs(urls)
  214. if len(urls) == 0 {
  215. http.Error(w, "provide at least one url parameter", http.StatusBadRequest)
  216. return
  217. }
  218. localCfg := *cfg
  219. if r.URL.Query().Get("probe") == "1" {
  220. localCfg.Probe = true
  221. } else if r.URL.Query().Get("probe") == "0" {
  222. localCfg.Probe = false
  223. }
  224. if f := r.URL.Query().Get("format"); f != "" {
  225. localCfg.Format = f
  226. }
  227. results := scanURLs(urls, client, &localCfg)
  228. history.Write(results)
  229. outputResults(results, localCfg.Format, w)
  230. }
  231. }
  232. func normalizeURLInputs(inputs []string) []string {
  233. var urls []string
  234. for _, item := range inputs {
  235. for _, line := range strings.Split(item, "\n") {
  236. line = strings.TrimSpace(line)
  237. if line == "" {
  238. continue
  239. }
  240. urls = append(urls, line)
  241. }
  242. }
  243. return urls
  244. }
  245. func scanURLs(urls []string, client *http.Client, cfg *config) []scanResult {
  246. results := make([]scanResult, len(urls))
  247. type job struct {
  248. index int
  249. url string
  250. }
  251. jobs := make(chan job)
  252. var wg sync.WaitGroup
  253. workers := cfg.Concurrency
  254. if workers < 1 {
  255. workers = 1
  256. }
  257. for i := 0; i < workers; i++ {
  258. wg.Add(1)
  259. go func() {
  260. defer wg.Done()
  261. for j := range jobs {
  262. res := scanOneURL(client, cfg, j.url)
  263. results[j.index] = res
  264. }
  265. }()
  266. }
  267. for i, u := range urls {
  268. jobs <- job{index: i, url: u}
  269. }
  270. close(jobs)
  271. wg.Wait()
  272. return results
  273. }
  274. func scanOneURL(client *http.Client, cfg *config, raw string) scanResult {
  275. res := scanResult{URL: raw, FetchedAt: time.Now()}
  276. html, contentType, err := fetchContent(client, cfg, raw)
  277. if err != nil {
  278. res.Error = err.Error()
  279. return res
  280. }
  281. streams := extractor.ExtractStreams(html)
  282. playlists := extractor.ExtractPlaylistLinks(html)
  283. res.Playlists = playlists
  284. for _, pl := range playlists {
  285. plContent, plType, err := fetchContent(client, cfg, pl)
  286. if err != nil {
  287. continue
  288. }
  289. parsed := extractor.ParsePlaylist(plContent, plType)
  290. if len(parsed) > 0 {
  291. streams = append(streams, parsed...)
  292. res.FromPlaylist = true
  293. }
  294. }
  295. res.Streams = uniqueStrings(streams)
  296. if cfg.Probe {
  297. res.Probes = probeStreams(client, cfg, res.Streams)
  298. }
  299. _ = contentType
  300. return res
  301. }
  302. func fetchContent(client *http.Client, cfg *config, raw string) (string, string, error) {
  303. req, err := http.NewRequest(http.MethodGet, raw, nil)
  304. if err != nil {
  305. return "", "", err
  306. }
  307. req.Header.Set("User-Agent", "radiostreamscan/0.2")
  308. for _, h := range cfg.Headers {
  309. parts := strings.SplitN(h, ":", 2)
  310. if len(parts) == 2 {
  311. req.Header.Set(strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]))
  312. }
  313. }
  314. resp, err := client.Do(req)
  315. if err != nil {
  316. return "", "", err
  317. }
  318. defer resp.Body.Close()
  319. if resp.StatusCode != http.StatusOK {
  320. return "", "", fmt.Errorf("unexpected status %s", resp.Status)
  321. }
  322. body, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
  323. if err != nil {
  324. return "", "", err
  325. }
  326. return string(body), resp.Header.Get("Content-Type"), nil
  327. }
  328. func probeStreams(client *http.Client, cfg *config, streams []string) []probeResult {
  329. var results []probeResult
  330. for _, s := range streams {
  331. req, err := http.NewRequest(http.MethodHead, s, nil)
  332. if err != nil {
  333. continue
  334. }
  335. for _, h := range cfg.Headers {
  336. parts := strings.SplitN(h, ":", 2)
  337. if len(parts) == 2 {
  338. req.Header.Set(strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]))
  339. }
  340. }
  341. resp, err := client.Do(req)
  342. if err != nil {
  343. results = append(results, probeResult{URL: s, Status: err.Error()})
  344. continue
  345. }
  346. resp.Body.Close()
  347. results = append(results, probeResult{URL: s, Status: resp.Status, ContentType: resp.Header.Get("Content-Type")})
  348. }
  349. return results
  350. }
  351. func outputResults(results []scanResult, format string, w io.Writer) {
  352. if rw, ok := w.(http.ResponseWriter); ok {
  353. if strings.ToLower(format) == "json" {
  354. rw.Header().Set("Content-Type", "application/json")
  355. } else if strings.ToLower(format) == "csv" {
  356. rw.Header().Set("Content-Type", "text/csv")
  357. }
  358. }
  359. switch strings.ToLower(format) {
  360. case "json":
  361. json.NewEncoder(w).Encode(results)
  362. case "csv":
  363. cw := csv.NewWriter(w)
  364. cw.Write([]string{"input_url", "stream_url"})
  365. for _, res := range results {
  366. for _, s := range res.Streams {
  367. cw.Write([]string{res.URL, s})
  368. }
  369. }
  370. cw.Flush()
  371. case "pls":
  372. fmt.Fprintln(w, "[playlist]")
  373. i := 1
  374. for _, res := range results {
  375. for _, s := range res.Streams {
  376. fmt.Fprintf(w, "File%d=%s\n", i, s)
  377. i++
  378. }
  379. }
  380. fmt.Fprintf(w, "NumberOfEntries=%d\nVersion=2\n", i-1)
  381. default:
  382. for _, res := range results {
  383. fmt.Fprintf(w, "URL: %s\n", res.URL)
  384. if res.Error != "" {
  385. fmt.Fprintf(w, " error: %s\n", res.Error)
  386. continue
  387. }
  388. if len(res.Streams) == 0 {
  389. fmt.Fprintln(w, " (no candidate streams found)")
  390. continue
  391. }
  392. for _, s := range res.Streams {
  393. fmt.Fprintf(w, " - %s\n", s)
  394. }
  395. }
  396. }
  397. }
  398. func newHTTPClient(proxyURL string) *http.Client {
  399. transport := &http.Transport{}
  400. if proxyURL != "" {
  401. if parsed, err := url.Parse(proxyURL); err == nil {
  402. transport.Proxy = http.ProxyURL(parsed)
  403. }
  404. }
  405. return &http.Client{Timeout: 15 * time.Second, Transport: transport}
  406. }
  407. func uniqueStrings(values []string) []string {
  408. set := make(map[string]struct{})
  409. for _, v := range values {
  410. set[v] = struct{}{}
  411. }
  412. out := make([]string, 0, len(set))
  413. for v := range set {
  414. out = append(out, v)
  415. }
  416. return out
  417. }
  418. type historyWriter struct {
  419. path string
  420. mu sync.Mutex
  421. }
  422. func newHistoryWriter(path string) *historyWriter {
  423. return &historyWriter{path: path}
  424. }
  425. func (h *historyWriter) Write(results []scanResult) {
  426. if h == nil || h.path == "" {
  427. return
  428. }
  429. h.mu.Lock()
  430. defer h.mu.Unlock()
  431. f, err := os.OpenFile(h.path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
  432. if err != nil {
  433. return
  434. }
  435. defer f.Close()
  436. writer := bufio.NewWriter(f)
  437. for _, res := range results {
  438. data, err := json.Marshal(res)
  439. if err != nil {
  440. continue
  441. }
  442. writer.Write(data)
  443. writer.WriteString("\n")
  444. }
  445. writer.Flush()
  446. }