diff --git a/docs/configuration.md b/docs/configuration.md index ea8c76e..014aad9 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1827,14 +1827,14 @@ Preview: | allow-insecure | bool | no | false | | url | string | yes | | | username | string | when service is `adguard` | | -| password | string | when service is `adguard` | | +| password | string | when service is `adguard` or `pihole-v6` | | | token | string | when service is `pihole` | | | hide-graph | bool | no | false | | hide-top-domains | bool | no | false | | hour-format | string | no | 12h | ##### `service` -Either `adguard`, `pihole`, or `technitium`. +Either `adguard`, `technitium`, or `pihole` (major version 5 and below) or `pihole-v6` (major version 6 and above). ##### `allow-insecure` Whether to allow invalid/self-signed certificates when making the request to the service. @@ -1846,10 +1846,14 @@ The base URL of the service. Only required when using AdGuard Home. The username used to log into the admin dashboard. ##### `password` -Only required when using AdGuard Home. The password used to log into the admin dashboard. +Required when using AdGuard Home, where the password is the one used to log into the admin dashboard. + +Also required when using Pi-hole major version 6 and above, where the password is the one used to log into the admin dashboard or the application password, which can be found in `Settings -> Web Interface / API -> Configure app password`. ##### `token` -Only required when using Pi-hole or Technitium. For Pi-hole, the API token which can be found in `Settings -> API -> Show API token`; for Technitium, an API token can be generated at `Administration -> Sessions -> Create Token`. +Required when using Pi-hole major version 5 or earlier. The API token which can be found in `Settings -> API -> Show API token`. + +Also required when using Technitium, an API token can be generated at `Administration -> Sessions -> Create Token`. ##### `hide-graph` Whether to hide the graph showing the number of queries over time. diff --git a/docs/glance.yml b/docs/glance.yml index 92b4de1..35dc7cb 100644 --- a/docs/glance.yml +++ b/docs/glance.yml @@ -66,9 +66,6 @@ pages: # hide-location: true - type: markets - # The link to go to when clicking on the symbol in the UI, - # {SYMBOL} will be substituded with the symbol for each market - symbol-link-template: https://www.tradingview.com/symbols/{SYMBOL}/news markets: - symbol: SPY name: S&P 500 diff --git a/internal/glance/static/main.css b/internal/glance/static/main.css index c9c3c17..7b7b592 100644 --- a/internal/glance/static/main.css +++ b/internal/glance/static/main.css @@ -552,6 +552,10 @@ kbd:active { z-index: 1; } +.summary::-webkit-details-marker { + display: none; +} + .details[open] .summary { margin-bottom: .8rem; } @@ -1128,7 +1132,7 @@ details[open] .summary::after { .calendar-date { padding: 0.4rem 0; - color: var(--color-text-paragraph); + color: var(--color-text-base); position: relative; border-radius: var(--border-radius); background: none; @@ -1269,6 +1273,7 @@ details[open] .summary::after { .dns-stats-graph-bar > .blocked { background-color: var(--color-negative); + flex-basis: calc(var(--percent) - 1px); } .dns-stats-graph-column:nth-child(even) .dns-stats-graph-time { diff --git a/internal/glance/templates/dns-stats.html b/internal/glance/templates/dns-stats.html index feb90f9..bb4222c 100644 --- a/internal/glance/templates/dns-stats.html +++ b/internal/glance/templates/dns-stats.html @@ -59,8 +59,8 @@ {{ if ne $column.Queries $column.Blocked }}
{{ end }} - {{ if or (gt $column.Blocked 0) (and (lt $column.PercentTotal 15) (lt $column.PercentBlocked 10)) }} -
+ {{ if gt $column.PercentBlocked 0 }} +
{{ end }} {{ end }} diff --git a/internal/glance/templates/docker-containers.html b/internal/glance/templates/docker-containers.html index 66c79fd..aeb2f0f 100644 --- a/internal/glance/templates/docker-containers.html +++ b/internal/glance/templates/docker-containers.html @@ -1,10 +1,10 @@ {{ template "widget-base.html" . }} {{- define "widget-content" }} -
+ {{- end }} {{- define "state-icon" }} {{- if eq . "ok" }} - + {{- else if eq . "warn" }} - + {{- else if eq . "paused" }} - + {{- else }} - + {{- end }} diff --git a/internal/glance/templates/videos-vertical-list.html b/internal/glance/templates/videos-vertical-list.html index b7ea6b2..a735a74 100644 --- a/internal/glance/templates/videos-vertical-list.html +++ b/internal/glance/templates/videos-vertical-list.html @@ -6,7 +6,7 @@
  • - {{ .Title }} + {{ .Title }}
    • diff --git a/internal/glance/utils.go b/internal/glance/utils.go index 8455bfe..72d5a28 100644 --- a/internal/glance/utils.go +++ b/internal/glance/utils.go @@ -186,3 +186,7 @@ func ternary[T any](condition bool, a, b T) T { return b } + +// Having compile time errors about unused variables is cool and all, but I don't want to +// have to constantly comment out my code while I'm working on it and testing things out +func ItsUsedTrustMeBro(...any) {} diff --git a/internal/glance/widget-custom-api.go b/internal/glance/widget-custom-api.go index 32f47b4..a73dfc4 100644 --- a/internal/glance/widget-custom-api.go +++ b/internal/glance/widget-custom-api.go @@ -266,12 +266,12 @@ func (r *decoratedGJSONResult) String(key string) string { return r.Get(key).String() } -func (r *decoratedGJSONResult) Int(key string) int64 { +func (r *decoratedGJSONResult) Int(key string) int { if key == "" { - return r.Result.Int() + return int(r.Result.Int()) } - return r.Get(key).Int() + return int(r.Get(key).Int()) } func (r *decoratedGJSONResult) Float(key string) float64 { @@ -292,11 +292,11 @@ func (r *decoratedGJSONResult) Bool(key string) bool { var customAPITemplateFuncs = func() template.FuncMap { funcs := template.FuncMap{ - "toFloat": func(a int64) float64 { + "toFloat": func(a int) float64 { return float64(a) }, - "toInt": func(a float64) int64 { - return int64(a) + "toInt": func(a float64) int { + return int(a) }, "add": func(a, b float64) float64 { return a + b diff --git a/internal/glance/widget-dns-stats.go b/internal/glance/widget-dns-stats.go index 68b6ac2..be99294 100644 --- a/internal/glance/widget-dns-stats.go +++ b/internal/glance/widget-dns-stats.go @@ -1,14 +1,18 @@ package glance import ( + "bytes" "context" "encoding/json" "errors" + "fmt" "html/template" + "io" "log/slog" "net/http" "sort" "strings" + "sync" "time" ) @@ -23,8 +27,9 @@ const ( type dnsStatsWidget struct { widgetBase `yaml:",inline"` - TimeLabels [8]string `yaml:"-"` - Stats *dnsStats `yaml:"-"` + TimeLabels [8]string `yaml:"-"` + Stats *dnsStats `yaml:"-"` + piholeSessionID string `yaml:"-"` HourFormat string `yaml:"hour-format"` HideGraph bool `yaml:"hide-graph"` @@ -37,11 +42,18 @@ type dnsStatsWidget struct { Password string `yaml:"password"` } +const ( + dnsServiceAdguard = "adguard" + dnsServicePihole = "pihole" + dnsServiceTechnitium = "technitium" + dnsServicePiholeV6 = "pihole-v6" +) + func makeDNSWidgetTimeLabels(format string) [8]string { now := time.Now() - var labels [8]string + var labels [dnsStatsBars]string - for h := 24; h > 0; h -= 3 { + for h := dnsStatsHoursSpan; h > 0; h -= dnsStatsHoursPerBar { labels[7-(h/3-1)] = strings.ToLower(now.Add(-time.Duration(h) * time.Hour).Format(format)) } @@ -55,11 +67,12 @@ func (widget *dnsStatsWidget) initialize() error { withCacheDuration(10 * time.Minute) switch widget.Service { - case "adguard": - case "pihole": - case "technitium": + case dnsServiceAdguard: + case dnsServicePiholeV6: + case dnsServicePihole: + case dnsServiceTechnitium: default: - return errors.New("service must be either 'adguard', 'pihole', or 'technitium'") + return fmt.Errorf("service must be one of: %s, %s, %s, %s", dnsServiceAdguard, dnsServicePihole, dnsServicePiholeV6, dnsServiceTechnitium) } return nil @@ -70,12 +83,25 @@ func (widget *dnsStatsWidget) update(ctx context.Context) { var err error switch widget.Service { - case "adguard": + case dnsServiceAdguard: stats, err = fetchAdguardStats(widget.URL, widget.AllowInsecure, widget.Username, widget.Password, widget.HideGraph) - case "pihole": - stats, err = fetchPiholeStats(widget.URL, widget.AllowInsecure, widget.Token, widget.HideGraph) - case "technitium": + case dnsServicePihole: + stats, err = fetchPihole5Stats(widget.URL, widget.AllowInsecure, widget.Token, widget.HideGraph) + case dnsServiceTechnitium: stats, err = fetchTechnitiumStats(widget.URL, widget.AllowInsecure, widget.Token, widget.HideGraph) + case dnsServicePiholeV6: + var newSessionID string + stats, newSessionID, err = fetchPiholeStats( + widget.URL, + widget.AllowInsecure, + widget.Password, + widget.piholeSessionID, + !widget.HideGraph, + !widget.HideTopDomains, + ) + if err == nil { + widget.piholeSessionID = newSessionID + } } if !widget.canContinueUpdateAfterHandlingErr(err) { @@ -97,11 +123,11 @@ func (widget *dnsStatsWidget) Render() template.HTML { type dnsStats struct { TotalQueries int - BlockedQueries int + BlockedQueries int // we don't actually use this anywhere in templates, maybe remove it later? BlockedPercent int ResponseTime int DomainsBlocked int - Series [8]dnsStatsSeries + Series [dnsStatsBars]dnsStatsSeries TopBlockedDomains []dnsStatsBlockedDomain } @@ -136,13 +162,7 @@ func fetchAdguardStats(instanceURL string, allowInsecure bool, username, passwor request.SetBasicAuth(username, password) - var client requestDoer - if !allowInsecure { - client = defaultHTTPClient - } else { - client = defaultInsecureHTTPClient - } - + var client = ternary(allowInsecure, defaultInsecureHTTPClient, defaultHTTPClient) responseJson, err := decodeJsonFromRequest[adguardStatsResponse](client, request) if err != nil { return nil, err @@ -163,7 +183,7 @@ func fetchAdguardStats(instanceURL string, allowInsecure bool, username, passwor stats.BlockedPercent = int(float64(responseJson.BlockedQueries) / float64(responseJson.TotalQueries) * 100) - for i := 0; i < topBlockedDomainsCount; i++ { + for i := range topBlockedDomainsCount { domain := responseJson.TopBlockedDomains[i] var firstDomain string @@ -206,11 +226,11 @@ func fetchAdguardStats(instanceURL string, allowInsecure bool, username, passwor maxQueriesInSeries := 0 - for i := 0; i < dnsStatsBars; i++ { + for i := range dnsStatsBars { queries := 0 blocked := 0 - for j := 0; j < dnsStatsHoursPerBar; j++ { + for j := range dnsStatsHoursPerBar { queries += queriesSeries[i*dnsStatsHoursPerBar+j] blocked += blockedSeries[i*dnsStatsHoursPerBar+j] } @@ -229,35 +249,36 @@ func fetchAdguardStats(instanceURL string, allowInsecure bool, username, passwor } } - for i := 0; i < dnsStatsBars; i++ { + for i := range dnsStatsBars { stats.Series[i].PercentTotal = int(float64(stats.Series[i].Queries) / float64(maxQueriesInSeries) * 100) } return stats, nil } -type piholeStatsResponse struct { - TotalQueries int `json:"dns_queries_today"` - QueriesSeries piholeQueriesSeries `json:"domains_over_time"` - BlockedQueries int `json:"ads_blocked_today"` - BlockedSeries map[int64]int `json:"ads_over_time"` - BlockedPercentage float64 `json:"ads_percentage_today"` - TopBlockedDomains piholeTopBlockedDomains `json:"top_ads"` - DomainsBlocked int `json:"domains_being_blocked"` +// Legacy Pi-hole stats response (before v6) +type pihole5StatsResponse struct { + TotalQueries int `json:"dns_queries_today"` + QueriesSeries pihole5QueriesSeries `json:"domains_over_time"` + BlockedQueries int `json:"ads_blocked_today"` + BlockedSeries map[int64]int `json:"ads_over_time"` + BlockedPercentage float64 `json:"ads_percentage_today"` + TopBlockedDomains pihole5TopBlockedDomains `json:"top_ads"` + DomainsBlocked int `json:"domains_being_blocked"` } // If the user has query logging disabled it's possible for domains_over_time to be returned as an // empty array rather than a map which will prevent unmashalling the rest of the data so we use // custom unmarshal behavior to fallback to an empty map. // See https://github.com/glanceapp/glance/issues/289 -type piholeQueriesSeries map[int64]int +type pihole5QueriesSeries map[int64]int -func (p *piholeQueriesSeries) UnmarshalJSON(data []byte) error { +func (p *pihole5QueriesSeries) UnmarshalJSON(data []byte) error { temp := make(map[int64]int) err := json.Unmarshal(data, &temp) if err != nil { - *p = make(piholeQueriesSeries) + *p = make(pihole5QueriesSeries) } else { *p = temp } @@ -267,16 +288,16 @@ func (p *piholeQueriesSeries) UnmarshalJSON(data []byte) error { // If user has some level of privacy enabled on Pihole, `json:"top_ads"` is an empty array // Use custom unmarshal behavior to avoid not getting the rest of the valid data when unmarshalling -type piholeTopBlockedDomains map[string]int +type pihole5TopBlockedDomains map[string]int -func (p *piholeTopBlockedDomains) UnmarshalJSON(data []byte) error { +func (p *pihole5TopBlockedDomains) UnmarshalJSON(data []byte) error { // NOTE: do not change to piholeTopBlockedDomains type here or it will cause a stack overflow // because of the UnmarshalJSON method getting called recursively temp := make(map[string]int) err := json.Unmarshal(data, &temp) if err != nil { - *p = make(piholeTopBlockedDomains) + *p = make(pihole5TopBlockedDomains) } else { *p = temp } @@ -284,7 +305,7 @@ func (p *piholeTopBlockedDomains) UnmarshalJSON(data []byte) error { return nil } -func fetchPiholeStats(instanceURL string, allowInsecure bool, token string, noGraph bool) (*dnsStats, error) { +func fetchPihole5Stats(instanceURL string, allowInsecure bool, token string, noGraph bool) (*dnsStats, error) { if token == "" { return nil, errors.New("missing API token") } @@ -297,14 +318,8 @@ func fetchPiholeStats(instanceURL string, allowInsecure bool, token string, noGr return nil, err } - var client requestDoer - if !allowInsecure { - client = defaultHTTPClient - } else { - client = defaultInsecureHTTPClient - } - - responseJson, err := decodeJsonFromRequest[piholeStatsResponse](client, request) + var client = ternary(allowInsecure, defaultInsecureHTTPClient, defaultHTTPClient) + responseJson, err := decodeJsonFromRequest[pihole5StatsResponse](client, request) if err != nil { return nil, err } @@ -348,7 +363,6 @@ func fetchPiholeStats(instanceURL string, allowInsecure bool, token string, noGr } var lowestTimestamp int64 = 0 - for timestamp := range responseJson.QueriesSeries { if lowestTimestamp == 0 || timestamp < lowestTimestamp { lowestTimestamp = timestamp @@ -357,11 +371,11 @@ func fetchPiholeStats(instanceURL string, allowInsecure bool, token string, noGr maxQueriesInSeries := 0 - for i := 0; i < 8; i++ { + for i := range dnsStatsBars { queries := 0 blocked := 0 - for j := 0; j < 18; j++ { + for j := range 18 { index := lowestTimestamp + int64(i*10800+j*600) queries += responseJson.QueriesSeries[index] @@ -382,13 +396,287 @@ func fetchPiholeStats(instanceURL string, allowInsecure bool, token string, noGr } } - for i := 0; i < 8; i++ { + for i := range dnsStatsBars { stats.Series[i].PercentTotal = int(float64(stats.Series[i].Queries) / float64(maxQueriesInSeries) * 100) } return stats, nil } +func fetchPiholeStats( + instanceURL string, + allowInsecure bool, + password string, + sessionID string, + includeGraph bool, + includeTopDomains bool, +) (*dnsStats, string, error) { + instanceURL = strings.TrimRight(instanceURL, "/") + var client = ternary(allowInsecure, defaultInsecureHTTPClient, defaultHTTPClient) + + fetchNewSessionID := func() error { + newSessionID, err := fetchPiholeSessionID(instanceURL, client, password) + if err != nil { + return err + } + sessionID = newSessionID + return nil + } + + if sessionID == "" { + if err := fetchNewSessionID(); err != nil { + slog.Error("Failed to fetch Pihole v6 session ID", "error", err) + return nil, "", fmt.Errorf("fetching session ID: %v", err) + } + } else { + isValid, err := checkPiholeSessionIDIsValid(instanceURL, client, sessionID) + if err != nil { + slog.Error("Failed to check Pihole v6 session ID validity", "error", err) + return nil, "", fmt.Errorf("checking session ID: %v", err) + } + + if !isValid { + if err := fetchNewSessionID(); err != nil { + slog.Error("Failed to renew Pihole v6 session ID", "error", err) + return nil, "", fmt.Errorf("renewing session ID: %v", err) + } + } + } + + var wg sync.WaitGroup + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + type statsResponseJson struct { + Queries struct { + Total int `json:"total"` + Blocked int `json:"blocked"` + PercentBlocked float64 `json:"percent_blocked"` + } `json:"queries"` + Gravity struct { + DomainsBlocked int `json:"domains_being_blocked"` + } `json:"gravity"` + } + + statsRequest, _ := http.NewRequestWithContext(ctx, "GET", instanceURL+"/api/stats/summary", nil) + statsRequest.Header.Set("x-ftl-sid", sessionID) + + var statsResponse statsResponseJson + var statsErr error + + wg.Add(1) + go func() { + defer wg.Done() + statsResponse, statsErr = decodeJsonFromRequest[statsResponseJson](client, statsRequest) + if statsErr != nil { + cancel() + } + }() + + type seriesResponseJson struct { + History []struct { + Timestamp int64 `json:"timestamp"` + Total int `json:"total"` + Blocked int `json:"blocked"` + } `json:"history"` + } + + var seriesResponse seriesResponseJson + var seriesErr error + + if includeGraph { + seriesRequest, _ := http.NewRequestWithContext(ctx, "GET", instanceURL+"/api/history", nil) + seriesRequest.Header.Set("x-ftl-sid", sessionID) + + wg.Add(1) + go func() { + defer wg.Done() + seriesResponse, seriesErr = decodeJsonFromRequest[seriesResponseJson](client, seriesRequest) + }() + } + + type topDomainsResponseJson struct { + Domains []struct { + Domain string `json:"domain"` + Count int `json:"count"` + } `json:"domains"` + TotalQueries int `json:"total_queries"` + BlockedQueries int `json:"blocked_queries"` + Took float64 `json:"took"` + } + + var topDomainsResponse topDomainsResponseJson + var topDomainsErr error + + if includeTopDomains { + topDomainsRequest, _ := http.NewRequestWithContext(ctx, "GET", instanceURL+"/api/stats/top_domains?blocked=true", nil) + topDomainsRequest.Header.Set("x-ftl-sid", sessionID) + + wg.Add(1) + go func() { + defer wg.Done() + topDomainsResponse, topDomainsErr = decodeJsonFromRequest[topDomainsResponseJson](client, topDomainsRequest) + }() + } + + wg.Wait() + partialContent := false + + if statsErr != nil { + return nil, "", fmt.Errorf("fetching stats: %v", statsErr) + } + + if includeGraph && seriesErr != nil { + slog.Error("Failed to fetch Pihole v6 graph data", "error", seriesErr) + partialContent = true + } + + if includeTopDomains && topDomainsErr != nil { + slog.Error("Failed to fetch Pihole v6 top domains", "error", topDomainsErr) + partialContent = true + } + + stats := &dnsStats{ + TotalQueries: statsResponse.Queries.Total, + BlockedQueries: statsResponse.Queries.Blocked, + BlockedPercent: int(statsResponse.Queries.PercentBlocked), + DomainsBlocked: statsResponse.Gravity.DomainsBlocked, + } + + if includeGraph && seriesErr == nil { + if len(seriesResponse.History) != 145 { + slog.Error( + "Pihole v6 graph data has unexpected length", + "length", len(seriesResponse.History), + "expected", 145, + ) + partialContent = true + } else { + // The API from v5 used to return 144 data points, but v6 returns 145. + // We only show data from the last 24 hours hours, Pihole returns data + // points in a 10 minute interval, 24*(60/10) = 144. Why is there an extra + // data point? I don't know, but we'll just ignore the first one since it's + // the oldest data point. + history := seriesResponse.History[1:] + + const interval = 10 + const dataPointsPerBar = dnsStatsHoursPerBar * (60 / interval) + + maxQueriesInSeries := 0 + + for i := range dnsStatsBars { + queries := 0 + blocked := 0 + for j := range dataPointsPerBar { + index := i*dataPointsPerBar + j + queries += history[index].Total + blocked += history[index].Blocked + } + if queries > maxQueriesInSeries { + maxQueriesInSeries = queries + } + stats.Series[i] = dnsStatsSeries{ + Queries: queries, + Blocked: blocked, + } + if queries > 0 { + stats.Series[i].PercentBlocked = int(float64(blocked) / float64(queries) * 100) + } + } + + for i := range dnsStatsBars { + stats.Series[i].PercentTotal = int(float64(stats.Series[i].Queries) / float64(maxQueriesInSeries) * 100) + } + } + } + + if includeTopDomains && topDomainsErr == nil && len(topDomainsResponse.Domains) > 0 { + domains := make([]dnsStatsBlockedDomain, 0, len(topDomainsResponse.Domains)) + for i := range topDomainsResponse.Domains { + d := &topDomainsResponse.Domains[i] + domains = append(domains, dnsStatsBlockedDomain{ + Domain: d.Domain, + PercentBlocked: int(float64(d.Count) / float64(statsResponse.Queries.Blocked) * 100), + }) + } + + sort.Slice(domains, func(a, b int) bool { + return domains[a].PercentBlocked > domains[b].PercentBlocked + }) + stats.TopBlockedDomains = domains[:min(len(domains), 5)] + } + + return stats, sessionID, ternary(partialContent, errPartialContent, nil) +} + +func fetchPiholeSessionID(instanceURL string, client *http.Client, password string) (string, error) { + requestBody := []byte(`{"password":"` + password + `"}`) + + request, err := http.NewRequest("POST", instanceURL+"/api/auth", bytes.NewBuffer(requestBody)) + if err != nil { + return "", fmt.Errorf("creating authentication request: %v", err) + } + request.Header.Set("Content-Type", "application/json") + + response, err := client.Do(request) + if err != nil { + return "", fmt.Errorf("sending authentication request: %v", err) + } + defer response.Body.Close() + + body, err := io.ReadAll(response.Body) + if err != nil { + return "", fmt.Errorf("reading authentication response: %v", err) + } + + var jsonResponse struct { + Session struct { + SID string `json:"sid"` + Message string `json:"message"` + } `json:"session"` + } + + if err := json.Unmarshal(body, &jsonResponse); err != nil { + return "", fmt.Errorf("parsing authentication response: %v", err) + } + + if response.StatusCode != http.StatusOK { + return "", fmt.Errorf( + "authentication request returned status %s with message '%s'", + response.Status, jsonResponse.Session.Message, + ) + } + + if jsonResponse.Session.SID == "" { + return "", fmt.Errorf( + "authentication response returned empty session ID, status code %d, message '%s'", + response.StatusCode, jsonResponse.Session.Message, + ) + } + + return jsonResponse.Session.SID, nil +} + +func checkPiholeSessionIDIsValid(instanceURL string, client *http.Client, sessionID string) (bool, error) { + request, err := http.NewRequest("GET", instanceURL+"/api/auth", nil) + if err != nil { + return false, fmt.Errorf("creating session ID check request: %v", err) + } + request.Header.Set("x-ftl-sid", sessionID) + + response, err := client.Do(request) + if err != nil { + return false, err + } + defer response.Body.Close() + + if response.StatusCode != http.StatusOK && response.StatusCode != http.StatusUnauthorized { + return false, fmt.Errorf("session ID check request returned status %s", response.Status) + } + + return response.StatusCode == http.StatusOK, nil +} + type technitiumStatsResponse struct { Response struct { Stats struct { diff --git a/internal/glance/widget-videos.go b/internal/glance/widget-videos.go index fdc654c..ff79864 100644 --- a/internal/glance/widget-videos.go +++ b/internal/glance/widget-videos.go @@ -56,7 +56,7 @@ func (widget *videosWidget) initialize() error { widget.Channels = append(widget.Channels, make([]string, len(widget.Playlists))...) for i := range widget.Playlists { - widget.Channels[initialLen+i] = "playlist:" + widget.Playlists[i] + widget.Channels[initialLen+i] = videosWidgetPlaylistPrefix + widget.Playlists[i] } }