Repository: miniflux/v2

Issue:
Optimize for low memory use?
I'm using Miniflux v2.2.1 with ~30 feeds on an old Raspberry Pi. Typically I have some 150 MB of RAM available.

It used to run fine, but recently the OS has to kill the program regularly:

    [1383230.652600] Out of memory: Killed process 16052 (miniflux.app) total-vm:554492kB, anon-rss:137368kB, file-rss:4kB, shmem-rss:0kB, UID:985 pgtables:168kB oom_score_adj:0

I suspect that these crashes may be related to having set some feeds to "fetch original content" - which I only recently learned about. I'm not sure though.

As a first mitigation attempt I set `BATCH_SIZE=1`, but that didn't prevent the OOM's.


Hints from issue comments:
@jvoisin: Wow, this is odd. My instance is only using 40MB of ram, and I have __a lot__ of feeds :/@asilentdreamer: For me miniflux in docker consistently uses 30-50 mb ram, while postgres can go over 150 when refreshing lots of feeds at once. I have little under 400 feeds.@rdelaage: Maybe it would be interesting to find where this huge memory consumption come from. A way to to that would be to use the go profiler (https://go.dev/blog/pprof)@rdelaage: @GSI Did you achieve to find what is consuming a lot of memory in your setup? If not, you can run pprof like that (it will require to build a custom version of Miniflux)
```diff
diff --git a/internal/http/server/httpd.go b/internal/http/server/httpd.go
index c7428a32..3ed8ee1e 100644
--- a/internal/http/server/httpd.go
+++ b/internal/http/server/httpd.go
@@ -9,6 +9,7 @@ import (
        "log/slog"
        "net"
        "net/http"
+       _ "net/http/pprof"
        "os"
        "strconv"
        "strings"
@@ -207,6 +208,8 @@ func setupHandler(store *storage.Storage, pool *worker.Pool) *mux.Router {
                w.Write([]byte(version.Version))
        }).Name("version")
 
+       router.PathPrefix("/debug/pprof/").Handler(http.DefaultServeMux)
+
        if config.Opts.HasMetricsCollector() {
                router.Handle("/metrics", promhttp.Handler()).Name("metrics")
                router.Use(func(next http.Handler) http.Handler {
```

And you can draw the memory usage graph like that `go tool pprof -http :7879 http://localhost:7878/debug/pprof/heap` (replace with the relevant addresses, and open the web ui in a web browser)@GSI: Thank you, that's an interesting tool. I just enabled it and will have to wait until the next OOM occurs.

 The last one was 8 days ago, even though I have miniflux configured to update a single feed every 30 minutes (`export BATCH_SIZE=1` and `export POLLING_FREQUENCY=30`).
@GSI: In the meantime I had two OOM's, but I it seems pprof doesn't keep a history?@rdelaage: the profiler doesn't keep anything. You need to poll it using a command like that `go tool pprof -http :7879 http://localhost:7878/debug/pprof/heap`. Of course before the OOM, so for example if it get OOM killed each 1 week, you can poll after 6 days when it still running@jvoisin: I have been profiling miniflux for a week now, and the used memory never went above a couple dozens of MB.

I sent a couple of (now merged) pull-requests to bring this number down, albeit I don't think it'll help in case of huge spikes, but since I can't reproduce your issue, it's the best I could do.@GSI: Thank you. I just wanted to create a cron task that fetches and stores something relevant to disk every minute for later inspection, but couldn't yet find a one-shot variant that would only load the pb.gz without then going on the launch the http service.

Can you give me a hint?

Maybe something like pprof's `top` or is there anything better suited?@jvoisin: You can use [this package](https://github.com/johannes94/go-heapdump-threshold)@jvoisin: @GSI do you see less memory consumption with (the freshly released) [2.2.4](https://github.com/miniflux/v2/releases/tag/2.2.4)?@GSI: > @GSI do you see less memory consumption with (the freshly released) [2.2.4](https://github.com/miniflux/v2/releases/tag/2.2.4)?

I just switched directly to the current 2.2.6 (from 2.2.3) and will report back if there's anything noteworthy.@jvoisin: Miniflux has been optimized quite a lot both CPU and memory-wise, let's close this issue for now. Feel free to re-open it should significant resource consumption be encountered.@GSI: 
As a result of closely monitoring memory usage on the Pi, I moved PostgreSQL to a separate machine.

Exclusively used for miniflux, postgres once reached a peak of 240 MB (normally idling in the range 105-125 MB).

The recorded peak for miniflux itself was 195 MB (normally idling around 25 MB).

Interestingly, it was always miniflux that got killed due to OOM, never (afaict) postgres.

Note that this data is again from the system's v2.2.3 as I had run v2.2.6 only manually and forgot to use it for the recent tests.

Maybe the data is of interest to you anyway.


Solution patch:
diff --git a/internal/config/options.go b/internal/config/options.go
index 303c8ff3..015a725e 100644
--- a/internal/config/options.go
+++ b/internal/config/options.go
@@ -60,6 +60,7 @@ const (
 	defaultFetchNebulaWatchTime               = false
 	defaultFetchOdyseeWatchTime               = false
 	defaultFetchYouTubeWatchTime              = false
+	defaultYouTubeApiKey                      = ""
 	defaultYouTubeEmbedUrlOverride            = "https://www.youtube-nocookie.com/embed/"
 	defaultCreateAdmin                        = false
 	defaultAdminUsername                      = ""
@@ -149,6 +150,7 @@ type Options struct {
 	fetchOdyseeWatchTime               bool
 	fetchYouTubeWatchTime              bool
 	filterEntryMaxAgeDays              int
+	youTubeApiKey                      string
 	youTubeEmbedUrlOverride            string
 	oauth2UserCreationAllowed          bool
 	oauth2ClientID                     string
@@ -228,6 +230,7 @@ func NewOptions() *Options {
 		fetchNebulaWatchTime:               defaultFetchNebulaWatchTime,
 		fetchOdyseeWatchTime:               defaultFetchOdyseeWatchTime,
 		fetchYouTubeWatchTime:              defaultFetchYouTubeWatchTime,
+		youTubeApiKey:                      defaultYouTubeApiKey,
 		youTubeEmbedUrlOverride:            defaultYouTubeEmbedUrlOverride,
 		oauth2UserCreationAllowed:          defaultOAuth2UserCreation,
 		oauth2ClientID:                     defaultOAuth2ClientID,
@@ -503,6 +506,11 @@ func (o *Options) FetchYouTubeWatchTime() bool {
 	return o.fetchYouTubeWatchTime
 }
 
+// YouTubeApiKey returns the YouTube API key if defined.
+func (o *Options) YouTubeApiKey() string {
+	return o.youTubeApiKey
+}
+
 // YouTubeEmbedUrlOverride returns YouTube URL which will be used for embeds
 func (o *Options) YouTubeEmbedUrlOverride() string {
 	return o.youTubeEmbedUrlOverride
@@ -733,6 +741,7 @@ func (o *Options) SortedOptions(redactSecret bool) []*Option {
 		"SERVER_TIMING_HEADER":                   o.serverTimingHeader,
 		"WATCHDOG":                               o.watchdog,
 		"WORKER_POOL_SIZE":                       o.workerPoolSize,
+		"YOUTUBE_API_KEY":                        redactSecretValue(o.youTubeApiKey, redactSecret),
 		"YOUTUBE_EMBED_URL_OVERRIDE":             o.youTubeEmbedUrlOverride,
 		"WEBAUTHN":                               o.webAuthn,
 	}
diff --git a/internal/config/parser.go b/internal/config/parser.go
index b443bae0..4d893a35 100644
--- a/internal/config/parser.go
+++ b/internal/config/parser.go
@@ -271,6 +271,8 @@ func (p *Parser) parseLines(lines []string) (err error) {
 			p.opts.fetchOdyseeWatchTime = parseBool(value, defaultFetchOdyseeWatchTime)
 		case "FETCH_YOUTUBE_WATCH_TIME":
 			p.opts.fetchYouTubeWatchTime = parseBool(value, defaultFetchYouTubeWatchTime)
+		case "YOUTUBE_API_KEY":
+			p.opts.youTubeApiKey = parseString(value, defaultYouTubeApiKey)
 		case "YOUTUBE_EMBED_URL_OVERRIDE":
 			p.opts.youTubeEmbedUrlOverride = parseString(value, defaultYouTubeEmbedUrlOverride)
 		case "WATCHDOG":
diff --git a/internal/integration/apprise/apprise.go b/internal/integration/apprise/apprise.go
index 5c9fa9d5..25ef5369 100644
--- a/internal/integration/apprise/apprise.go
+++ b/internal/integration/apprise/apprise.go
@@ -7,6 +7,7 @@ import (
 	"bytes"
 	"encoding/json"
 	"fmt"
+	"log/slog"
 	"net/http"
 	"time"
 
@@ -26,42 +27,53 @@ func NewClient(serviceURL, baseURL string) *Client {
 	return &Client{serviceURL, baseURL}
 }
 
-func (c *Client) SendNotification(entry *model.Entry) error {
+func (c *Client) SendNotification(feed *model.Feed, entries model.Entries) error {
 	if c.baseURL == "" || c.servicesURL == "" {
 		return fmt.Errorf("apprise: missing base URL or services URL")
 	}
 
-	message := "[" + entry.Title + "]" + "(" + entry.URL + ")" + "\n\n"
-	apiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, "/notify")
-	if err != nil {
-		return fmt.Errorf(`apprise: invalid API endpoint: %v`, err)
-	}
+	for _, entry := range entries {
+		message := "[" + entry.Title + "]" + "(" + entry.URL + ")" + "\n\n"
+		apiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, "/notify")
+		if err != nil {
+			return fmt.Errorf(`apprise: invalid API endpoint: %v`, err)
+		}
 
-	requestBody, err := json.Marshal(map[string]any{
-		"urls": c.servicesURL,
-		"body": message,
-	})
-	if err != nil {
-		return fmt.Errorf("apprise: unable to encode request body: %v", err)
-	}
+		requestBody, err := json.Marshal(map[string]any{
+			"urls":  c.servicesURL,
+			"body":  message,
+			"title": feed.Title,
+		})
+		if err != nil {
+			return fmt.Errorf("apprise: unable to encode request body: %v", err)
+		}
 
-	request, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))
-	if err != nil {
-		return fmt.Errorf("apprise: unable to create request: %v", err)
-	}
+		request, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))
+		if err != nil {
+			return fmt.Errorf("apprise: unable to create request: %v", err)
+		}
 
-	request.Header.Set("Content-Type", "application/json")
-	request.Header.Set("User-Agent", "Miniflux/"+version.Version)
+		request.Header.Set("Content-Type", "application/json")
+		request.Header.Set("User-Agent", "Miniflux/"+version.Version)
 
-	httpClient := &http.Client{Timeout: defaultClientTimeout}
-	response, err := httpClient.Do(request)
-	if err != nil {
-		return fmt.Errorf("apprise: unable to send request: %v", err)
-	}
-	defer response.Body.Close()
+		slog.Debug("Sending Apprise notification",
+			slog.String("apprise_url", c.baseURL),
+			slog.String("services_url", c.servicesURL),
+			slog.String("title", feed.Title),
+			slog.String("body", message),
+			slog.String("entry_url", entry.URL),
+		)
+
+		httpClient := &http.Client{Timeout: defaultClientTimeout}
+		response, err := httpClient.Do(request)
+		if err != nil {
+			return fmt.Errorf("apprise: unable to send request: %v", err)
+		}
+		defer response.Body.Close()
 
-	if response.StatusCode >= 400 {
-		return fmt.Errorf("apprise: unable to send a notification: url=%s status=%d", apiEndpoint, response.StatusCode)
+		if response.StatusCode >= 400 {
+			return fmt.Errorf("apprise: unable to send a notification: url=%s status=%d", apiEndpoint, response.StatusCode)
+		}
 	}
 
 	return nil
diff --git a/internal/integration/integration.go b/internal/integration/integration.go
index 850811c9..5910baaa 100644
--- a/internal/integration/integration.go
+++ b/internal/integration/integration.go
@@ -513,8 +513,30 @@ func PushEntries(feed *model.Feed, entries model.Entries, userIntegrations *mode
 		}
 	}
 
+	if userIntegrations.AppriseEnabled {
+		slog.Debug("Sending new entries to Apprise",
+			slog.Int64("user_id", userIntegrations.UserID),
+			slog.Int("nb_entries", len(entries)),
+			slog.Int64("feed_id", feed.ID),
+		)
+
+		appriseServiceURLs := userIntegrations.AppriseServicesURL
+		if feed.AppriseServiceURLs != "" {
+			appriseServiceURLs = feed.AppriseServiceURLs
+		}
+
+		client := apprise.NewClient(
+			appriseServiceURLs,
+			userIntegrations.AppriseURL,
+		)
+
+		if err := client.SendNotification(feed, entries); err != nil {
+			slog.Warn("Unable to send new entries to Apprise", slog.Any("error", err))
+		}
+	}
+
 	// Integrations that only support sending individual entries
-	if userIntegrations.TelegramBotEnabled || userIntegrations.AppriseEnabled {
+	if userIntegrations.TelegramBotEnabled {
 		for _, entry := range entries {
 			if userIntegrations.TelegramBotEnabled {
 				slog.Debug("Sending a new entry to Telegram",
@@ -541,35 +563,6 @@ func PushEntries(feed *model.Feed, entries model.Entries, userIntegrations *mode
 					)
 				}
 			}
-
-			if userIntegrations.AppriseEnabled {
-				slog.Debug("Sending a new entry to Apprise",
-					slog.Int64("user_id", userIntegrations.UserID),
-					slog.Int64("entry_id", entry.ID),
-					slog.String("entry_url", entry.URL),
-					slog.String("apprise_url", userIntegrations.AppriseURL),
-				)
-
-				appriseServiceURLs := userIntegrations.AppriseServicesURL
-				if feed.AppriseServiceURLs != "" {
-					appriseServiceURLs = feed.AppriseServiceURLs
-				}
-
-				client := apprise.NewClient(
-					appriseServiceURLs,
-					userIntegrations.AppriseURL,
-				)
-
-				if err := client.SendNotification(entry); err != nil {
-					slog.Error("Unable to send entry to Apprise",
-						slog.Int64("user_id", userIntegrations.UserID),
-						slog.Int64("entry_id", entry.ID),
-						slog.String("entry_url", entry.URL),
-						slog.String("apprise_url", userIntegrations.AppriseURL),
-						slog.Any("error", err),
-					)
-				}
-			}
 		}
 	}
 }
diff --git a/internal/reader/handler/handler.go b/internal/reader/handler/handler.go
index 2e992b67..185c57fa 100644
--- a/internal/reader/handler/handler.go
+++ b/internal/reader/handler/handler.go
@@ -335,6 +335,7 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
 		originalFeed.EtagHeader = responseHandler.ETag()
 		originalFeed.LastModifiedHeader = responseHandler.LastModified()
 
+		originalFeed.IconURL = updatedFeed.IconURL
 		iconChecker := icon.NewIconChecker(store, originalFeed)
 		if forceRefresh {
 			iconChecker.UpdateOrCreateFeedIcon()
diff --git a/internal/reader/icon/checker.go b/internal/reader/icon/checker.go
index 4ec63635..a639844b 100644
--- a/internal/reader/icon/checker.go
+++ b/internal/reader/icon/checker.go
@@ -34,32 +34,32 @@ func (c *IconChecker) fetchAndStoreIcon() {
 	requestBuilder.IgnoreTLSErrors(c.feed.AllowSelfSignedCertificates)
 	requestBuilder.DisableHTTP2(c.feed.DisableHTTP2)
 
-	iconFinder := NewIconFinder(requestBuilder, c.feed.FeedURL, c.feed.IconURL)
+	iconFinder := NewIconFinder(requestBuilder, c.feed.SiteURL, c.feed.IconURL)
 	if icon, err := iconFinder.FindIcon(); err != nil {
 		slog.Debug("Unable to find feed icon",
 			slog.Int64("feed_id", c.feed.ID),
-			slog.String("website_url", c.feed.FeedURL),
+			slog.String("website_url", c.feed.SiteURL),
 			slog.String("feed_icon_url", c.feed.IconURL),
 			slog.Any("error", err),
 		)
 	} else if icon == nil {
 		slog.Debug("No icon found",
 			slog.Int64("feed_id", c.feed.ID),
-			slog.String("website_url", c.feed.FeedURL),
+			slog.String("website_url", c.feed.SiteURL),
 			slog.String("feed_icon_url", c.feed.IconURL),
 		)
 	} else {
 		if err := c.store.StoreFeedIcon(c.feed.ID, icon); err != nil {
 			slog.Error("Unable to store feed icon",
 				slog.Int64("feed_id", c.feed.ID),
-				slog.String("website_url", c.feed.FeedURL),
+				slog.String("website_url", c.feed.SiteURL),
 				slog.String("feed_icon_url", c.feed.IconURL),
 				slog.Any("error", err),
 			)
 		} else {
 			slog.Debug("Feed icon stored",
 				slog.Int64("feed_id", c.feed.ID),
-				slog.String("website_url", c.feed.FeedURL),
+				slog.String("website_url", c.feed.SiteURL),
 				slog.String("feed_icon_url", c.feed.IconURL),
 				slog.Int64("icon_id", icon.ID),
 				slog.String("icon_hash", icon.Hash),
diff --git a/internal/reader/processor/processor.go b/internal/reader/processor/processor.go
index 12ab4461..ceae674c 100644
--- a/internal/reader/processor/processor.go
+++ b/internal/reader/processor/processor.go
@@ -25,7 +25,7 @@ import (
 	"github.com/tdewolff/minify/v2/html"
 )
 
-var customReplaceRuleRegex = regexp.MustCompile(`rewrite\("(.*)"\|"(.*)"\)`)
+var customReplaceRuleRegex = regexp.MustCompile(`rewrite\("([^"]+)"\|"([^"]+)"\)`)
 
 // ProcessFeedEntries downloads original web page for entries and apply filters.
 func ProcessFeedEntries(store *storage.Storage, feed *model.Feed, user *model.User, forceRefresh bool) {
diff --git a/internal/reader/processor/youtube.go b/internal/reader/processor/youtube.go
index 52de18c4..2d41e11f 100644
--- a/internal/reader/processor/youtube.go
+++ b/internal/reader/processor/youtube.go
@@ -4,9 +4,11 @@
 package processor
 
 import (
+	"encoding/json"
 	"errors"
 	"fmt"
 	"log/slog"
+	"net/url"
 	"regexp"
 	"strconv"
 	"time"
@@ -33,6 +35,14 @@ func shouldFetchYouTubeWatchTime(entry *model.Entry) bool {
 }
 
 func fetchYouTubeWatchTime(websiteURL string) (int, error) {
+	if config.Opts.YouTubeApiKey() == "" {
+		return fetchYouTubeWatchTimeFromWebsite(websiteURL)
+	} else {
+		return fetchYouTubeWatchTimeFromApi(websiteURL)
+	}
+}
+
+func fetchYouTubeWatchTimeFromWebsite(websiteURL string) (int, error) {
 	requestBuilder := fetcher.NewRequestBuilder()
 	requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
 	requestBuilder.WithProxy(config.Opts.HTTPClientProxy())
@@ -63,6 +73,61 @@ func fetchYouTubeWatchTime(websiteURL string) (int, error) {
 	return int(dur.Minutes()), nil
 }
 
+func fetchYouTubeWatchTimeFromApi(websiteURL string) (int, error) {
+	requestBuilder := fetcher.NewRequestBuilder()
+	requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
+	requestBuilder.WithProxy(config.Opts.HTTPClientProxy())
+
+	parsedWebsiteURL, err := url.Parse(websiteURL)
+	if err != nil {
+		return 0, fmt.Errorf("unable to parse URL: %v", err)
+	}
+
+	apiQuery := url.Values{}
+	apiQuery.Set("id", parsedWebsiteURL.Query().Get("v"))
+	apiQuery.Set("key", config.Opts.YouTubeApiKey())
+	apiQuery.Set("part", "contentDetails")
+
+	apiURL := url.URL{
+		Scheme:   "https",
+		Host:     "www.googleapis.com",
+		Path:     "youtube/v3/videos",
+		RawQuery: apiQuery.Encode(),
+	}
+
+	responseHandler := fetcher.NewResponseHandler(requestBuilder.ExecuteRequest(apiURL.String()))
+	defer responseHandler.Close()
+
+	if localizedError := responseHandler.LocalizedError(); localizedError != nil {
+		slog.Warn("Unable to fetch contentDetails from YouTube API", slog.String("website_url", websiteURL), slog.Any("error", localizedError.Error()))
+		return 0, localizedError.Error()
+	}
+
+	var videos struct {
+		Items []struct {
+			ContentDetails struct {
+				Duration string `json:"duration"`
+			} `json:"contentDetails"`
+		} `json:"items"`
+	}
+
+	if err := json.NewDecoder(responseHandler.Body(config.Opts.HTTPClientMaxBodySize())).Decode(&videos); err != nil {
+		return 0, fmt.Errorf("unable to decode JSON: %v", err)
+	}
+
+	if n := len(videos.Items); n != 1 {
+		return 0, fmt.Errorf("invalid items length: %d", n)
+	}
+
+	durs := videos.Items[0].ContentDetails.Duration
+	dur, err := parseISO8601(durs)
+	if err != nil {
+		return 0, fmt.Errorf("unable to parse duration %s: %v", durs, err)
+	}
+
+	return int(dur.Minutes()), nil
+}
+
 func parseISO8601(from string) (time.Duration, error) {
 	var match []string
 	var d time.Duration
diff --git a/internal/reader/sanitizer/sanitizer.go b/internal/reader/sanitizer/sanitizer.go
index 05c6e7b8..4608c335 100644
--- a/internal/reader/sanitizer/sanitizer.go
+++ b/internal/reader/sanitizer/sanitizer.go
@@ -98,6 +98,7 @@ func Sanitize(baseURL, input string) string {
 		}
 
 		token := tokenizer.Token()
+		tagName := token.DataAtom.String()
 		switch token.Type {
 		case html.TextToken:
 			if len(blockedStack) > 0 {
@@ -110,9 +111,8 @@ func Sanitize(baseURL, input string) string {
 				continue
 			}
 
-			buffer.WriteString(html.EscapeString(token.Data))
+			buffer.WriteString(token.String())
 		case html.StartTagToken:
-			tagName := token.DataAtom.String()
 			parentTag = tagName
 
 			if isPixelTracker(tagName, token.Attr) {
@@ -121,38 +121,42 @@ func Sanitize(baseURL, input string) string {
 
 			if isBlockedTag(tagName) || slices.ContainsFunc(token.Attr, func(attr html.Attribute) bool { return attr.Key == "hidden" }) {
 				blockedStack = append(blockedStack, tagName)
-			} else if len(blockedStack) == 0 && isValidTag(tagName) {
-				attrNames, htmlAttributes := sanitizeAttributes(baseURL, tagName, token.Attr)
+				continue
+			}
 
+			if len(blockedStack) == 0 && isValidTag(tagName) {
+				attrNames, htmlAttributes := sanitizeAttributes(baseURL, tagName, token.Attr)
 				if hasRequiredAttributes(tagName, attrNames) {
 					if len(attrNames) > 0 {
 						buffer.WriteString("<" + tagName + " " + htmlAttributes + ">")
 					} else {
-						buffer.WriteString("<" + tagName + ">")
+						buffer.WriteString(token.String())
 					}
 
 					tagStack = append(tagStack, tagName)
 				}
 			}
 		case html.EndTagToken:
-			tagName := token.DataAtom.String()
-			if len(blockedStack) > 0 && blockedStack[len(blockedStack)-1] == tagName {
-				blockedStack = blockedStack[:len(blockedStack)-1]
-			} else if len(blockedStack) == 0 && isValidTag(tagName) && slices.Contains(tagStack, tagName) {
-				buffer.WriteString("</" + tagName + ">")
+			if len(blockedStack) == 0 {
+				if isValidTag(tagName) && slices.Contains(tagStack, tagName) {
+					buffer.WriteString(token.String())
+				}
+			} else {
+				if blockedStack[len(blockedStack)-1] == tagName {
+					blockedStack = blockedStack[:len(blockedStack)-1]
+				}
 			}
 		case html.SelfClosingTagToken:
-			tagName := token.DataAtom.String()
 			if isPixelTracker(tagName, token.Attr) {
 				continue
 			}
-			if isValidTag(tagName) && len(blockedStack) == 0 {
+			if len(blockedStack) == 0 && isValidTag(tagName) {
 				attrNames, htmlAttributes := sanitizeAttributes(baseURL, tagName, token.Attr)
 				if hasRequiredAttributes(tagName, attrNames) {
 					if len(attrNames) > 0 {
 						buffer.WriteString("<" + tagName + " " + htmlAttributes + "/>")
 					} else {
-						buffer.WriteString("<" + tagName + "/>")
+						buffer.WriteString(token.String())
 					}
 				}
 			}
@@ -183,11 +187,7 @@ func sanitizeAttributes(baseURL, tagName string, attributes []html.Attribute) ([
 		}
 
 		if tagName == "img" && (attribute.Key == "width" || attribute.Key == "height") {
-			if !isPositiveInteger(value) {
-				continue
-			}
-
-			if isImageLargerThanLayout {
+			if isImageLargerThanLayout || !isPositiveInteger(value) {
 				continue
 			}
 		}
@@ -201,7 +201,7 @@ func sanitizeAttributes(baseURL, tagName string, attributes []html.Attribute) ([
 				value = rewriteIframeURL(attribute.Val)
 			case tagName == "img" && attribute.Key == "src" && isValidDataAttribute(attribute.Val):
 				value = attribute.Val
-			case isAnchor("a", attribute):
+			case tagName == "a" && attribute.Key == "href" && strings.HasPrefix(attribute.Val, "#"):
 				value = attribute.Val
 				isAnchorLink = true
 			default:
@@ -251,10 +251,8 @@ func getExtraAttributes(tagName string) ([]string, []string) {
 }
 
 func isValidTag(tagName string) bool {
-	if _, ok := tagAllowList[tagName]; ok {
-		return true
-	}
-	return false
+	_, ok := tagAllowList[tagName]
+	return ok
 }
 
 func isValidAttribute(tagName, attributeName string) bool {
@@ -294,24 +292,16 @@ func isPixelTracker(tagName string, attributes []html.Attribute) bool {
 }
 
 func hasRequiredAttributes(tagName string, attributes []string) bool {
-	elements := map[string][]string{
-		"a":      {"href"},
-		"iframe": {"src"},
-		"img":    {"src"},
-		"source": {"src", "srcset"},
-	}
-
-	if attrs, ok := elements[tagName]; ok {
-		for _, attribute := range attributes {
-			if slices.Contains(attrs, attribute) {
-				return true
-			}
-		}
-
-		return false
+	switch tagName {
+	case "a":
+		return slices.Contains(attributes, "href")
+	case "iframe", "img":
+		return slices.Contains(attributes, "src")
+	case "source":
+		return slices.Contains(attributes, "src") || slices.Contains(attributes, "srcset")
+	default:
+		return true
 	}
-
-	return true
 }
 
 // See https://www.iana.org/assignments/uri-schemes/uri-schemes.xhtml
@@ -369,7 +359,6 @@ func isBlockedResource(src string) bool {
 		"feedsportal.com",
 		"api.flattr.com",
 		"stats.wordpress.com",
-		"plus.google.com/share",
 		"twitter.com/share",
 		"feeds.feedburner.com",
 	}
@@ -401,7 +390,7 @@ func isValidIframeSource(baseURL, src string) bool {
 	}
 
 	// allow iframe from custom invidious instance
-	if config.Opts != nil && config.Opts.InvidiousInstance() == domain {
+	if config.Opts.InvidiousInstance() == domain {
 		return true
 	}
 
@@ -431,8 +420,7 @@ func sanitizeSrcsetAttr(baseURL, value string) string {
 	imageCandidates := ParseSrcSetAttribute(value)
 
 	for _, imageCandidate := range imageCandidates {
-		absoluteURL, err := urllib.AbsoluteURL(baseURL, imageCandidate.ImageURL)
-		if err == nil {
+		if absoluteURL, err := urllib.AbsoluteURL(baseURL, imageCandidate.ImageURL); err == nil {
 			imageCandidate.ImageURL = absoluteURL
 		}
 	}
@@ -457,10 +445,6 @@ func isValidDataAttribute(value string) bool {
 	})
 }
 
-func isAnchor(tagName string, attribute html.Attribute) bool {
-	return tagName == "a" && attribute.Key == "href" && strings.HasPrefix(attribute.Val, "#")
-}
-
 func isPositiveInteger(value string) bool {
 	if number, err := strconv.Atoi(value); err == nil {
 		return number > 0
@@ -468,16 +452,12 @@ func isPositiveInteger(value string) bool {
 	return false
 }
 
-func getAttributeValue(name string, attributes []html.Attribute) string {
+func getIntegerAttributeValue(name string, attributes []html.Attribute) int {
 	for _, attribute := range attributes {
 		if attribute.Key == name {
-			return attribute.Val
+			number, _ := strconv.Atoi(attribute.Val)
+			return number
 		}
 	}
-	return ""
-}
-
-func getIntegerAttributeValue(name string, attributes []html.Attribute) int {
-	number, _ := strconv.Atoi(getAttributeValue(name, attributes))
-	return number
+	return 0
 }
diff --git a/internal/reader/sanitizer/srcset.go b/internal/reader/sanitizer/srcset.go
index 0db17b43..1ba2c8e8 100644
--- a/internal/reader/sanitizer/srcset.go
+++ b/internal/reader/sanitizer/srcset.go
@@ -17,12 +17,12 @@ type ImageCandidate struct {
 type ImageCandidates []*ImageCandidate
 
 func (c ImageCandidates) String() string {
-	var htmlCandidates []string
+	htmlCandidates := make([]string, 0, len(c))
 
 	for _, imageCandidate := range c {
 		var htmlCandidate string
 		if imageCandidate.Descriptor != "" {
-			htmlCandidate = fmt.Sprintf(`%s %s`, imageCandidate.ImageURL, imageCandidate.Descriptor)
+			htmlCandidate = imageCandidate.ImageURL + " " + imageCandidate.Descriptor
 		} else {
 			htmlCandidate = imageCandidate.ImageURL
 		}
@@ -36,9 +36,7 @@ func (c ImageCandidates) String() string {
 // ParseSrcSetAttribute returns the list of image candidates from the set.
 // https://html.spec.whatwg.org/#parse-a-srcset-attribute
 func ParseSrcSetAttribute(attributeValue string) (imageCandidates ImageCandidates) {
-	unparsedCandidates := strings.Split(attributeValue, ", ")
-
-	for _, unparsedCandidate := range unparsedCandidates {
+	for _, unparsedCandidate := range strings.Split(attributeValue, ", ") {
 		if candidate, err := parseImageCandidate(unparsedCandidate); err == nil {
 			imageCandidates = append(imageCandidates, candidate)
 		}
@@ -48,22 +46,20 @@ func ParseSrcSetAttribute(attributeValue string) (imageCandidates ImageCandidate
 }
 
 func parseImageCandidate(input string) (*ImageCandidate, error) {
-	input = strings.TrimSpace(input)
 	parts := strings.Split(strings.TrimSpace(input), " ")
 	nbParts := len(parts)
 
-	if nbParts > 2 || nbParts == 0 {
-		return nil, fmt.Errorf(`srcset: invalid number of descriptors`)
-	}
-
-	if nbParts == 2 {
+	switch {
+	case nbParts == 1:
+		return &ImageCandidate{ImageURL: parts[0]}, nil
+	case nbParts == 2:
 		if !isValidWidthOrDensityDescriptor(parts[1]) {
 			return nil, fmt.Errorf(`srcset: invalid descriptor`)
 		}
 		return &ImageCandidate{ImageURL: parts[0], Descriptor: parts[1]}, nil
+	default:
+		return nil, fmt.Errorf(`srcset: invalid number of descriptors`)
 	}
-
-	return &ImageCandidate{ImageURL: parts[0]}, nil
 }
 
 func isValidWidthOrDensityDescriptor(value string) bool {
diff --git a/internal/reader/sanitizer/strip_tags.go b/internal/reader/sanitizer/strip_tags.go
index a9f898d8..2da7f5b2 100644
--- a/internal/reader/sanitizer/strip_tags.go
+++ b/internal/reader/sanitizer/strip_tags.go
@@ -4,8 +4,8 @@
 package sanitizer // import "miniflux.app/v2/internal/reader/sanitizer"
 
 import (
-	"bytes"
 	"io"
+	"strings"
 
 	"golang.org/x/net/html"
 )
@@ -13,8 +13,8 @@ import (
 // StripTags removes all HTML/XML tags from the input string.
 // This function must *only* be used for cosmetic purposes, not to prevent code injections like XSS.
 func StripTags(input string) string {
-	tokenizer := html.NewTokenizer(bytes.NewBufferString(input))
-	var buffer bytes.Buffer
+	tokenizer := html.NewTokenizer(strings.NewReader(input))
+	var buffer strings.Builder
 
 	for {
 		if tokenizer.Next() == html.ErrorToken {
diff --git a/miniflux.1 b/miniflux.1
index 7c785d3c..528b43fd 100644
--- a/miniflux.1
+++ b/miniflux.1
@@ -555,6 +555,11 @@ Number of background workers\&.
 .br
 Default is 16 workers\&.
 .TP
+.B YOUTUBE_API_KEY
+YouTube API key for use with FETCH_YOUTUBE_WATCH_TIME. If nonempty, the duration will be fetched from the YouTube API. Otherwise, the duration will be fetched from the YouTube website\&.
+.br
+Default is empty\&.
+.TP
 .B YOUTUBE_EMBED_URL_OVERRIDE
 YouTube URL which will be used for embeds\&.
 .br



Test patch:
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
index 309d0464..bfe40450 100644
--- a/internal/config/config_test.go
+++ b/internal/config/config_test.go
@@ -2116,6 +2116,24 @@ func TestFetchYouTubeWatchTime(t *testing.T) {
 	}
 }
 
+func TestYouTubeApiKey(t *testing.T) {
+	os.Clearenv()
+	os.Setenv("YOUTUBE_API_KEY", "AAAAAAAAAAAAAaaaaaaaaaaaaa0000000000000")
+
+	parser := NewParser()
+	opts, err := parser.ParseEnvironmentVariables()
+	if err != nil {
+		t.Fatalf(`Parsing failure: %v`, err)
+	}
+
+	expected := "AAAAAAAAAAAAAaaaaaaaaaaaaa0000000000000"
+	result := opts.YouTubeApiKey()
+
+	if result != expected {
+		t.Fatalf(`Unexpected YOUTUBE_API_KEY value, got %v instead of %v`, result, expected)
+	}
+}
+
 func TestYouTubeEmbedUrlOverride(t *testing.T) {
 	os.Clearenv()
 	os.Setenv("YOUTUBE_EMBED_URL_OVERRIDE", "https://invidious.custom/embed/")