package m3u import ( "bufio" "crypto/sha256" "fmt" "os" "strings" "tuner/models" ) // ParseFile reads and parses an M3U/M3U8 playlist file into a slice of Channels. func ParseFile(path string) ([]models.Channel, error) { f, err := os.Open(path) if err != nil { return nil, fmt.Errorf("open playlist: %w", err) } defer f.Close() var channels []models.Channel scanner := bufio.NewScanner(f) var extinf string for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if strings.HasPrefix(line, "#EXTINF:") { extinf = line continue } // Skip comments and blank lines if line == "" || strings.HasPrefix(line, "#") { continue } // If we have a pending EXTINF, this line is the URL if extinf != "" { ch := parseExtinf(extinf, line) channels = append(channels, ch) extinf = "" } } if err := scanner.Err(); err != nil { return nil, fmt.Errorf("read playlist: %w", err) } return channels, nil } // parseExtinf extracts channel metadata from an #EXTINF line and pairs it with a stream URL. func parseExtinf(extinf string, streamURL string) models.Channel { ch := models.Channel{ StreamURL: streamURL, } // Extract attributes from the EXTINF line ch.Name = extractDisplayName(extinf) ch.Group = extractAttribute(extinf, "group-title") ch.LogoURL = extractAttribute(extinf, "tvg-logo") tvgName := extractAttribute(extinf, "tvg-name") if tvgName != "" && ch.Name == "" { ch.Name = tvgName } // Generate stable ID from name + URL ch.ID = generateID(ch.Name, ch.StreamURL) return ch } // extractAttribute extracts a named attribute value from an EXTINF line. // Example: `#EXTINF:-1 tvg-name="CNN" group-title="News",CNN HD` func extractAttribute(line string, attr string) string { key := attr + `="` idx := strings.Index(line, key) if idx == -1 { return "" } start := idx + len(key) end := strings.Index(line[start:], `"`) if end == -1 { return "" } return line[start : start+end] } // extractDisplayName extracts the display name (the part after the last comma in the EXTINF line). func extractDisplayName(line string) string { // The display name is after the last comma idx := strings.LastIndex(line, ",") if idx == -1 { return "" } return strings.TrimSpace(line[idx+1:]) } // generateID creates a stable ID for a channel based on its name and stream URL. func generateID(name, url string) string { h := sha256.Sum256([]byte(name + "|" + url)) return fmt.Sprintf("%x", h[:8]) } // FilterChannels returns channels matching the given search term and/or group. func FilterChannels(channels []models.Channel, search string, group string) []models.Channel { if search == "" && group == "" { return channels } searchLower := strings.ToLower(search) var result []models.Channel for _, ch := range channels { if group != "" && !strings.EqualFold(ch.Group, group) { continue } if search != "" && !strings.Contains(strings.ToLower(ch.Name), searchLower) { continue } result = append(result, ch) } return result } // GetGroups returns a deduplicated, sorted list of group names from the channel list. func GetGroups(channels []models.Channel) []string { seen := make(map[string]bool) var groups []string for _, ch := range channels { if ch.Group != "" && !seen[ch.Group] { seen[ch.Group] = true groups = append(groups, ch.Group) } } return groups }