diff --git a/client/model.go b/client/model.go index c28168d0157..04f8c250015 100644 --- a/client/model.go +++ b/client/model.go @@ -174,6 +174,7 @@ type Feed struct { HideGlobally bool `json:"hide_globally"` DisableHTTP2 bool `json:"disable_http2"` ProxyURL string `json:"proxy_url"` + RefreshIntervalMinutes *int `json:"refresh_interval_minutes"` } // FeedCreationRequest represents the request to create a feed. @@ -200,6 +201,7 @@ type FeedCreationRequest struct { HideGlobally bool `json:"hide_globally"` DisableHTTP2 bool `json:"disable_http2"` ProxyURL string `json:"proxy_url"` + RefreshIntervalMinutes *int `json:"refresh_interval_minutes,omitempty"` } // FeedModificationRequest represents the request to update a feed. @@ -228,6 +230,7 @@ type FeedModificationRequest struct { HideGlobally *bool `json:"hide_globally"` DisableHTTP2 *bool `json:"disable_http2"` ProxyURL *string `json:"proxy_url"` + RefreshIntervalMinutes *int `json:"refresh_interval_minutes,omitempty"` } // FeedIcon represents the feed icon. diff --git a/internal/api/api_integration_test.go b/internal/api/api_integration_test.go index 01c261aed38..2fa76d23581 100644 --- a/internal/api/api_integration_test.go +++ b/internal/api/api_integration_test.go @@ -1625,6 +1625,70 @@ func TestUpdateFeedEndpoint(t *testing.T) { } } +func TestUpdateFeedRefreshInterval(t *testing.T) { + testConfig := newIntegrationTestConfig() + if !testConfig.isConfigured() { + t.Skip(skipIntegrationTestsMessage) + } + + adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword) + + regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false) + if err != nil { + t.Fatal(err) + } + defer adminClient.DeleteUser(regularTestUser.ID) + + regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword) + + feedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{ + FeedURL: testConfig.testFeedURL, + }) + if err != nil { + t.Fatal(err) + } + + feed, err := regularUserClient.Feed(feedID) + if err != nil { + t.Fatal(err) + } + if feed.RefreshIntervalMinutes != nil { + t.Fatalf(`A new feed must inherit the global polling frequency, got refresh_interval_minutes=%d`, *feed.RefreshIntervalMinutes) + } + + updated, err := regularUserClient.UpdateFeed(feedID, &miniflux.FeedModificationRequest{ + RefreshIntervalMinutes: new(120), + }) + if err != nil { + t.Fatal(err) + } + if updated.RefreshIntervalMinutes == nil || *updated.RefreshIntervalMinutes != 120 { + got := "" + if updated.RefreshIntervalMinutes != nil { + got = fmt.Sprintf("%d", *updated.RefreshIntervalMinutes) + } + t.Fatalf(`Expected refresh_interval_minutes=120 after update, got %s`, got) + } + + // Setting it back to zero must clear the override. + cleared, err := regularUserClient.UpdateFeed(feedID, &miniflux.FeedModificationRequest{ + RefreshIntervalMinutes: new(0), + }) + if err != nil { + t.Fatal(err) + } + if cleared.RefreshIntervalMinutes != nil { + t.Fatalf(`Expected refresh_interval_minutes to be cleared, got %d`, *cleared.RefreshIntervalMinutes) + } + + // Below the minimum should be rejected. + if _, err := regularUserClient.UpdateFeed(feedID, &miniflux.FeedModificationRequest{ + RefreshIntervalMinutes: new(1), + }); err == nil { + t.Fatal(`Expected an error when refresh_interval_minutes is below the minimum`) + } +} + func TestCannotHaveDuplicateFeedWhenUpdatingFeed(t *testing.T) { testConfig := newIntegrationTestConfig() if !testConfig.isConfigured() { diff --git a/internal/database/migrations.go b/internal/database/migrations.go index 38cef552eeb..9c16485edc8 100644 --- a/internal/database/migrations.go +++ b/internal/database/migrations.go @@ -1496,4 +1496,12 @@ var migrations = [...]func(tx *sql.Tx) error{ `) return err }, + func(tx *sql.Tx) (err error) { + _, err = tx.Exec(` + ALTER TABLE feeds ADD COLUMN refresh_interval_minutes integer; + ALTER TABLE feeds ADD CONSTRAINT feeds_refresh_interval_minutes_check + CHECK (refresh_interval_minutes IS NULL OR refresh_interval_minutes >= 1); + `) + return err + }, } diff --git a/internal/locale/translations/ar_SA.json b/internal/locale/translations/ar_SA.json index 0c8f4d19dec..dfe76008e1f 100644 --- a/internal/locale/translations/ar_SA.json +++ b/internal/locale/translations/ar_SA.json @@ -111,6 +111,7 @@ "error.feed_format_not_detected": "تعذر اكتشاف تنسيق المصدر: %v.", "error.feed_invalid_blocklist_rule": "قاعدة قائمة الحظر غير صالحة.", "error.feed_invalid_keeplist_rule": "قاعدة قائمة الاحتفاظ غير صالحة.", + "error.feed_invalid_refresh_interval": "The refresh interval must be between %d and %d minutes.", "error.feed_mandatory_fields": "الرابط والفئة إلزاميان.", "error.feed_not_found": "هذا المصدر غير موجود أو لا ينتمي لهذا المستخدم.", "error.feed_title_not_empty": "عنوان المصدر لا يمكن أن يكون فارغاً.", @@ -210,6 +211,8 @@ "form.feed.label.ntfy_priority": "أولوية Ntfy", "form.feed.label.ntfy_topic": "موضوع Ntfy (اختياري)", "form.feed.label.proxy_url": "رابط الوكيل (Proxy)", + "form.feed.label.refresh_interval_minutes": "Refresh interval (minutes)", + "form.feed.help.refresh_interval_minutes": "Override the global polling frequency for this feed. Leave at 0 to use the application default.", "form.feed.label.pushover_activate": "إرسال المقالات إلى Pushover", "form.feed.label.pushover_default_priority": "الأولوية الافتراضية", "form.feed.label.pushover_high_priority": "أولوية عالية", diff --git a/internal/locale/translations/de_DE.json b/internal/locale/translations/de_DE.json index bcbd59e2f7a..f59a6012e23 100644 --- a/internal/locale/translations/de_DE.json +++ b/internal/locale/translations/de_DE.json @@ -98,6 +98,7 @@ "error.feed_format_not_detected": "Das Format des Abonnements kann nicht erkannt werden: %v.", "error.feed_invalid_blocklist_rule": "Die Blockierregel ist ungültig.", "error.feed_invalid_keeplist_rule": "Die Erlaubnisregel ist ungültig.", + "error.feed_invalid_refresh_interval": "The refresh interval must be between %d and %d minutes.", "error.feed_mandatory_fields": "Die URL und die Kategorie sind obligatorisch.", "error.feed_not_found": "Dieses Abonnement existiert nicht oder gehört nicht zu diesem Benutzer.", "error.feed_title_not_empty": "Der Feed-Titel darf nicht leer sein.", @@ -198,6 +199,8 @@ "form.feed.label.ntfy_priority": "Ntfy-Priorität", "form.feed.label.ntfy_topic": "Ntfy-Thema (optional)", "form.feed.label.proxy_url": "Proxy-URL", + "form.feed.label.refresh_interval_minutes": "Refresh interval (minutes)", + "form.feed.help.refresh_interval_minutes": "Override the global polling frequency for this feed. Leave at 0 to use the application default.", "form.feed.label.pushover_activate": "Artikel an pushover.net senden", "form.feed.label.pushover_default_priority": "Pushover-Standardpriorität", "form.feed.label.pushover_high_priority": "Hohe Pushoverpriorität", diff --git a/internal/locale/translations/el_EL.json b/internal/locale/translations/el_EL.json index 792f57f69b4..634b8cd49ab 100644 --- a/internal/locale/translations/el_EL.json +++ b/internal/locale/translations/el_EL.json @@ -98,6 +98,7 @@ "error.feed_format_not_detected": "Δεν είναι δυνατή η ανίχνευση της μορφής ροής: %v.", "error.feed_invalid_blocklist_rule": "Ο κανόνας λίστας μπλοκ δεν είναι έγκυρος.", "error.feed_invalid_keeplist_rule": "Ο κανόνας keep list δεν είναι έγκυρος.", + "error.feed_invalid_refresh_interval": "The refresh interval must be between %d and %d minutes.", "error.feed_mandatory_fields": "Η διεύθυνση URL και η κατηγορία είναι υποχρεωτικά.", "error.feed_not_found": "Αυτή η ροή δεν υπάρχει ή δεν ανήκει σε αυτόν τον χρήστη.", "error.feed_title_not_empty": "Ο τίτλος ροής δεν μπορεί να είναι κενός.", @@ -198,6 +199,8 @@ "form.feed.label.ntfy_priority": "Προτεραιότητα Ntfy", "form.feed.label.ntfy_topic": "Θέμα Ntfy (προαιρετικό)", "form.feed.label.proxy_url": "Διεύθυνση URL διακομιστή μεσολάβησης", + "form.feed.label.refresh_interval_minutes": "Refresh interval (minutes)", + "form.feed.help.refresh_interval_minutes": "Override the global polling frequency for this feed. Leave at 0 to use the application default.", "form.feed.label.pushover_activate": "Προώθηση καταχωρήσεων στο pushover.net", "form.feed.label.pushover_default_priority": "Προεπιλεγμένη προτεραιότητα Pushover", "form.feed.label.pushover_high_priority": "Υψηλή προτεραιότητα Pushover", diff --git a/internal/locale/translations/en_US.json b/internal/locale/translations/en_US.json index c9198d3718b..1d35b86b691 100644 --- a/internal/locale/translations/en_US.json +++ b/internal/locale/translations/en_US.json @@ -99,6 +99,7 @@ "error.feed_format_not_detected": "Unable to detect feed format: %v.", "error.feed_invalid_blocklist_rule": "The block list rule is invalid.", "error.feed_invalid_keeplist_rule": "The keep list rule is invalid.", + "error.feed_invalid_refresh_interval": "The refresh interval must be between %d and %d minutes.", "error.feed_mandatory_fields": "The URL and the category are mandatory.", "error.feed_not_found": "This feed does not exist or does not belong to this user.", "error.feed_title_not_empty": "The feed title cannot be empty.", @@ -198,6 +199,8 @@ "form.feed.label.ntfy_priority": "Ntfy priority", "form.feed.label.ntfy_topic": "Ntfy topic (optional)", "form.feed.label.proxy_url": "Proxy URL", + "form.feed.label.refresh_interval_minutes": "Refresh interval (minutes)", + "form.feed.help.refresh_interval_minutes": "Override the global polling frequency for this feed. Leave at 0 to use the application default.", "form.feed.label.pushover_activate": "Push entries to Pushover", "form.feed.label.pushover_default_priority": "Default priority", "form.feed.label.pushover_high_priority": "High priority", diff --git a/internal/locale/translations/es_ES.json b/internal/locale/translations/es_ES.json index 80691d3af30..5944d1454cf 100644 --- a/internal/locale/translations/es_ES.json +++ b/internal/locale/translations/es_ES.json @@ -98,6 +98,7 @@ "error.feed_format_not_detected": "No se puede detectar el formato del feed: %v.", "error.feed_invalid_blocklist_rule": "La regla de la lista de bloqueo no es válida.", "error.feed_invalid_keeplist_rule": "La regla de mantener la lista no es válida.", + "error.feed_invalid_refresh_interval": "The refresh interval must be between %d and %d minutes.", "error.feed_mandatory_fields": "Los campos de URL y categoría son obligatorios.", "error.feed_not_found": "Este feed no existe o no pertenece a este usuario.", "error.feed_title_not_empty": "El título del feed no puede estar vacío.", @@ -198,6 +199,8 @@ "form.feed.label.ntfy_priority": "Prioridad Ntfy", "form.feed.label.ntfy_topic": "Tema Ntfy (opcional)", "form.feed.label.proxy_url": "URL del Proxy", + "form.feed.label.refresh_interval_minutes": "Refresh interval (minutes)", + "form.feed.help.refresh_interval_minutes": "Override the global polling frequency for this feed. Leave at 0 to use the application default.", "form.feed.label.pushover_activate": "Enviar artículos a pushover.net", "form.feed.label.pushover_default_priority": "Prioridad predeterminada de Pushover", "form.feed.label.pushover_high_priority": "Prioridad alta de Pushover", diff --git a/internal/locale/translations/fi_FI.json b/internal/locale/translations/fi_FI.json index 4784663f80e..fddbb838213 100644 --- a/internal/locale/translations/fi_FI.json +++ b/internal/locale/translations/fi_FI.json @@ -98,6 +98,7 @@ "error.feed_format_not_detected": "Syötteen muotoa ei voitu tunnistaa: %v.", "error.feed_invalid_blocklist_rule": "Estolistan sääntö on virheellinen.", "error.feed_invalid_keeplist_rule": "Säilytettävien listan sääntö on virheellinen.", + "error.feed_invalid_refresh_interval": "The refresh interval must be between %d and %d minutes.", "error.feed_mandatory_fields": "URL-osoite ja kategoria ovat pakollisia.", "error.feed_not_found": "Tämä syöte ei ole olemassa tai se ei kuulu tälle käyttäjälle.", "error.feed_title_not_empty": "Syötteen otsikko ei voi olla tyhjä.", @@ -198,6 +199,8 @@ "form.feed.label.ntfy_priority": "Ntfy-prioriteetti", "form.feed.label.ntfy_topic": "Ntfy-aihe (valinnainen)", "form.feed.label.proxy_url": "Välityspalvelimen URL", + "form.feed.label.refresh_interval_minutes": "Refresh interval (minutes)", + "form.feed.help.refresh_interval_minutes": "Override the global polling frequency for this feed. Leave at 0 to use the application default.", "form.feed.label.pushover_activate": "Lähetä merkinnät pushover.net-palveluun", "form.feed.label.pushover_default_priority": "Pushover-oletusprioriteetti", "form.feed.label.pushover_high_priority": "Pushover-korkea prioriteetti", diff --git a/internal/locale/translations/fr_FR.json b/internal/locale/translations/fr_FR.json index 7723d021c1a..55a5d244046 100644 --- a/internal/locale/translations/fr_FR.json +++ b/internal/locale/translations/fr_FR.json @@ -98,6 +98,7 @@ "error.feed_format_not_detected": "Impossible de détecter le format du flux : %v.", "error.feed_invalid_blocklist_rule": "La règle de blocage n'est pas valide.", "error.feed_invalid_keeplist_rule": "La règle d'autorisation n'est pas valide.", + "error.feed_invalid_refresh_interval": "The refresh interval must be between %d and %d minutes.", "error.feed_mandatory_fields": "L'URL et la catégorie sont obligatoire.", "error.feed_not_found": "Impossible de trouver ce flux.", "error.feed_title_not_empty": "Le titre du flux ne peut pas être vide.", @@ -198,6 +199,8 @@ "form.feed.label.ntfy_priority": "Priorité de notification", "form.feed.label.ntfy_topic": "Sujet Ntfy (facultatif)", "form.feed.label.proxy_url": "URL du proxy", + "form.feed.label.refresh_interval_minutes": "Refresh interval (minutes)", + "form.feed.help.refresh_interval_minutes": "Override the global polling frequency for this feed. Leave at 0 to use the application default.", "form.feed.label.pushover_activate": "Activer les notifications vers Pushover", "form.feed.label.pushover_default_priority": "Priorité par défaut", "form.feed.label.pushover_high_priority": "Priorité élevée", diff --git a/internal/locale/translations/gl_ES.json b/internal/locale/translations/gl_ES.json index e16b07fb4f2..fcee7fef084 100644 --- a/internal/locale/translations/gl_ES.json +++ b/internal/locale/translations/gl_ES.json @@ -99,6 +99,7 @@ "error.feed_format_not_detected": "Non se puido detectar o formato da canle: %v.", "error.feed_invalid_blocklist_rule": "A regra da lista de bloqueo non é válida.", "error.feed_invalid_keeplist_rule": "A regra da lista a manter non é válida.", + "error.feed_invalid_refresh_interval": "The refresh interval must be between %d and %d minutes.", "error.feed_mandatory_fields": "É obrigatorio engadir un URL para a categoría.", "error.feed_not_found": "A canle non existe ou non pertence a esta usuaria.", "error.feed_title_not_empty": "O título da canle non pode quedar baleiro.", @@ -198,6 +199,8 @@ "form.feed.label.ntfy_priority": "Prioridade en Ntfy", "form.feed.label.ntfy_topic": "Tema en Ntfy (optativo)", "form.feed.label.proxy_url": "URL do mandatario", + "form.feed.label.refresh_interval_minutes": "Refresh interval (minutes)", + "form.feed.help.refresh_interval_minutes": "Override the global polling frequency for this feed. Leave at 0 to use the application default.", "form.feed.label.pushover_activate": "Enviar novidades a Pushover", "form.feed.label.pushover_default_priority": "Prioridade predeterminada", "form.feed.label.pushover_high_priority": "Alta prioridade", diff --git a/internal/locale/translations/hi_IN.json b/internal/locale/translations/hi_IN.json index 89613e8b8d6..e7657452dec 100644 --- a/internal/locale/translations/hi_IN.json +++ b/internal/locale/translations/hi_IN.json @@ -98,6 +98,7 @@ "error.feed_format_not_detected": "फ़ीड प्रारूप का पता नहीं लगा सकते: %v।", "error.feed_invalid_blocklist_rule": "ब्लॉक सूची नियम अमान्य है।", "error.feed_invalid_keeplist_rule": "सूची रखें नियम अमान्य है।", + "error.feed_invalid_refresh_interval": "The refresh interval must be between %d and %d minutes.", "error.feed_mandatory_fields": "URL और श्रेणी अनिवार्य हैं।", "error.feed_not_found": "यह फ़ीड मौजूद नहीं है या इस उपयोगकर्ता से संबंधित नहीं है।", "error.feed_title_not_empty": "फ़ीड शीर्षक खाली नहीं हो सकता.", @@ -198,6 +199,8 @@ "form.feed.label.ntfy_priority": "Ntfy प्राथमिकता", "form.feed.label.ntfy_topic": "Ntfy विषय (वैकल्पिक)", "form.feed.label.proxy_url": "प्रॉक्सी URL", + "form.feed.label.refresh_interval_minutes": "Refresh interval (minutes)", + "form.feed.help.refresh_interval_minutes": "Override the global polling frequency for this feed. Leave at 0 to use the application default.", "form.feed.label.pushover_activate": "प्रविष्टियाँ pushover.net पर भेजें", "form.feed.label.pushover_default_priority": "Pushover डिफ़ॉल्ट प्राथमिकता", "form.feed.label.pushover_high_priority": "Pushover उच्च प्राथमिकता", diff --git a/internal/locale/translations/id_ID.json b/internal/locale/translations/id_ID.json index b6e104224ff..08271cbaef1 100644 --- a/internal/locale/translations/id_ID.json +++ b/internal/locale/translations/id_ID.json @@ -95,6 +95,7 @@ "error.feed_format_not_detected": "Tidak dapat mendeteksi format umpan: %v.", "error.feed_invalid_blocklist_rule": "Aturan blokir tidak valid.", "error.feed_invalid_keeplist_rule": "Aturan simpan tidak valid.", + "error.feed_invalid_refresh_interval": "The refresh interval must be between %d and %d minutes.", "error.feed_mandatory_fields": "Harus ada URL dan kategorinya.", "error.feed_not_found": "Umpan ini tidak ada atau tidak dipunyai oleh pengguna ini", "error.feed_title_not_empty": "Judul umpan tidak boleh kosong.", @@ -195,6 +196,8 @@ "form.feed.label.ntfy_priority": "Prioritas Ntfy", "form.feed.label.ntfy_topic": "Topik Ntfy (opsional)", "form.feed.label.proxy_url": "URL Proksi", + "form.feed.label.refresh_interval_minutes": "Refresh interval (minutes)", + "form.feed.help.refresh_interval_minutes": "Override the global polling frequency for this feed. Leave at 0 to use the application default.", "form.feed.label.pushover_activate": "Kirim artikel ke pushover.net", "form.feed.label.pushover_default_priority": "Prioritas baku Pushover", "form.feed.label.pushover_high_priority": "Prioritas tinggi Pushover", diff --git a/internal/locale/translations/it_IT.json b/internal/locale/translations/it_IT.json index 6dcc0f7ff56..0fdd4b53109 100644 --- a/internal/locale/translations/it_IT.json +++ b/internal/locale/translations/it_IT.json @@ -98,6 +98,7 @@ "error.feed_format_not_detected": "Impossibile rilevare il formato del feed: %v.", "error.feed_invalid_blocklist_rule": "La regola dell'elenco di blocco non è valida.", "error.feed_invalid_keeplist_rule": "La regola dell'elenco di conservazione non è valida.", + "error.feed_invalid_refresh_interval": "The refresh interval must be between %d and %d minutes.", "error.feed_mandatory_fields": "L'URL e la categoria sono obbligatori.", "error.feed_not_found": "Questo feed non esiste o non appartiene a questo utente.", "error.feed_title_not_empty": "Il titolo del feed non può essere vuoto.", @@ -198,6 +199,8 @@ "form.feed.label.ntfy_priority": "Priorità ntfy", "form.feed.label.ntfy_topic": "Topic ntfy (opzionale)", "form.feed.label.proxy_url": "URL del proxy", + "form.feed.label.refresh_interval_minutes": "Refresh interval (minutes)", + "form.feed.help.refresh_interval_minutes": "Override the global polling frequency for this feed. Leave at 0 to use the application default.", "form.feed.label.pushover_activate": "Invia le voci a pushover.net", "form.feed.label.pushover_default_priority": "Priorità predefinita Pushover", "form.feed.label.pushover_high_priority": "Priorità alta Pushover", diff --git a/internal/locale/translations/ja_JP.json b/internal/locale/translations/ja_JP.json index f2d8f523a4a..82412d8b855 100644 --- a/internal/locale/translations/ja_JP.json +++ b/internal/locale/translations/ja_JP.json @@ -95,6 +95,7 @@ "error.feed_format_not_detected": "フィードの形式を検出できません: %v.", "error.feed_invalid_blocklist_rule": "ブロックリストルールが無効です。", "error.feed_invalid_keeplist_rule": "リストの保持ルールが無効です。", + "error.feed_invalid_refresh_interval": "The refresh interval must be between %d and %d minutes.", "error.feed_mandatory_fields": "URL と カテゴリが必要です。", "error.feed_not_found": "このフィードは存在しないか、このユーザーに属していません。", "error.feed_title_not_empty": "フィードのタイトルを空にすることはできません。", @@ -195,6 +196,8 @@ "form.feed.label.ntfy_priority": "ntfy 優先度", "form.feed.label.ntfy_topic": "ntfy トピック(任意)", "form.feed.label.proxy_url": "プロキシ URL", + "form.feed.label.refresh_interval_minutes": "Refresh interval (minutes)", + "form.feed.help.refresh_interval_minutes": "Override the global polling frequency for this feed. Leave at 0 to use the application default.", "form.feed.label.pushover_activate": "エントリを pushover.net に送信", "form.feed.label.pushover_default_priority": "Pushover 既定の優先度", "form.feed.label.pushover_high_priority": "Pushover 高優先度", diff --git a/internal/locale/translations/nan_Latn_pehoeji.json b/internal/locale/translations/nan_Latn_pehoeji.json index c307b67e8d8..b3071f6ceb4 100644 --- a/internal/locale/translations/nan_Latn_pehoeji.json +++ b/internal/locale/translations/nan_Latn_pehoeji.json @@ -95,6 +95,7 @@ "error.feed_format_not_detected": "Bōe līn chit ê siau-sit lâi-goân ê keh-sek: %v.", "error.feed_invalid_blocklist_rule": "Hong-só kui-chek bô-hāu.", "error.feed_invalid_keeplist_rule": "Pó-liû kui-chek bô-hāu.", + "error.feed_invalid_refresh_interval": "The refresh interval must be between %d and %d minutes.", "error.feed_mandatory_fields": "Tio̍h-ài su-lip bāng-chí kah lūi-pia̍t.", "error.feed_not_found": "Chhē bô chit ê siau-sit lâi-goân ah-sī bô sio̍k-tī lí", "error.feed_title_not_empty": "Beh tēng ê siau-sit lâi-goân ê piau-tôe bōe-sái sī khang--ê.", @@ -195,6 +196,8 @@ "form.feed.label.ntfy_priority": "Ntfy iu-sian sūn-sū", "form.feed.label.ntfy_topic": "Ntfy topic (soán thiⁿ)", "form.feed.label.proxy_url": "Proxy ê URL", + "form.feed.label.refresh_interval_minutes": "Refresh interval (minutes)", + "form.feed.help.refresh_interval_minutes": "Override the global polling frequency for this feed. Leave at 0 to use the application default.", "form.feed.label.pushover_activate": "Pó-chûn siau-sit kàu pushover.net", "form.feed.label.pushover_default_priority": "Pushover ū-siat iu-sian sūn-sū", "form.feed.label.pushover_high_priority": "Pushover koân iu-sian sūn-sū", diff --git a/internal/locale/translations/nl_NL.json b/internal/locale/translations/nl_NL.json index baa201904d9..c69a4ae46c9 100644 --- a/internal/locale/translations/nl_NL.json +++ b/internal/locale/translations/nl_NL.json @@ -98,6 +98,7 @@ "error.feed_format_not_detected": "Feed-formaat kan niet worden gedetecteerd: %v.", "error.feed_invalid_blocklist_rule": "De blokkeerregel is ongeldig.", "error.feed_invalid_keeplist_rule": "De bewaarregel is ongeldig.", + "error.feed_invalid_refresh_interval": "The refresh interval must be between %d and %d minutes.", "error.feed_mandatory_fields": "De velden URL en categorie zijn verplicht.", "error.feed_not_found": "Deze feed bestaat niet of is niet van deze gebruiker.", "error.feed_title_not_empty": "De feed titel mag niet leeg zijn.", @@ -198,6 +199,8 @@ "form.feed.label.ntfy_priority": "Ntfy prioriteit", "form.feed.label.ntfy_topic": "Ntfy onderwerp (optioneel)", "form.feed.label.proxy_url": "Proxy-URL", + "form.feed.label.refresh_interval_minutes": "Refresh interval (minutes)", + "form.feed.help.refresh_interval_minutes": "Override the global polling frequency for this feed. Leave at 0 to use the application default.", "form.feed.label.pushover_activate": "Stuur artikelen naar pushover.net", "form.feed.label.pushover_default_priority": "Pushover standaard prioriteit", "form.feed.label.pushover_high_priority": "Pushover hoge prioriteit", diff --git a/internal/locale/translations/pl_PL.json b/internal/locale/translations/pl_PL.json index fc997a717c9..b4c7798ed86 100644 --- a/internal/locale/translations/pl_PL.json +++ b/internal/locale/translations/pl_PL.json @@ -101,6 +101,7 @@ "error.feed_format_not_detected": "Nie można wykryć formatu kanału: %v.", "error.feed_invalid_blocklist_rule": "Reguła listy zablokowanych jest nieprawidłowa.", "error.feed_invalid_keeplist_rule": "Reguła listy zachowywania jest nieprawidłowa.", + "error.feed_invalid_refresh_interval": "The refresh interval must be between %d and %d minutes.", "error.feed_mandatory_fields": "Adres URL i kategoria są obowiązkowe.", "error.feed_not_found": "Ten kanał nie istnieje lub nie należy do tego użytkownika.", "error.feed_title_not_empty": "Tytuł kanału nie może być pusty.", @@ -201,6 +202,8 @@ "form.feed.label.ntfy_priority": "Priorytet ntfy", "form.feed.label.ntfy_topic": "Temat ntfy (opcjonalny)", "form.feed.label.proxy_url": "Adres URL serwera proxy", + "form.feed.label.refresh_interval_minutes": "Refresh interval (minutes)", + "form.feed.help.refresh_interval_minutes": "Override the global polling frequency for this feed. Leave at 0 to use the application default.", "form.feed.label.pushover_activate": "Prześlij wpisy do pushover.net", "form.feed.label.pushover_default_priority": "Domyślny priorytet Pushover", "form.feed.label.pushover_high_priority": "Wysoki priorytet Pushover", diff --git a/internal/locale/translations/pt_BR.json b/internal/locale/translations/pt_BR.json index 448df76638a..30355e7ada9 100644 --- a/internal/locale/translations/pt_BR.json +++ b/internal/locale/translations/pt_BR.json @@ -98,6 +98,7 @@ "error.feed_format_not_detected": "Não foi possível detectar o formato da fonte: %v.", "error.feed_invalid_blocklist_rule": "A regra da lista de bloqueio é inválida.", "error.feed_invalid_keeplist_rule": "A regra de manutenção da lista é inválida.", + "error.feed_invalid_refresh_interval": "The refresh interval must be between %d and %d minutes.", "error.feed_mandatory_fields": "O campo de URL e categoria são obrigatórios.", "error.feed_not_found": "Esta fonte não existe ou não pertence a este usuário.", "error.feed_title_not_empty": "O título do feed não pode estar vazio.", @@ -198,6 +199,8 @@ "form.feed.label.ntfy_priority": "Prioridade do ntfy", "form.feed.label.ntfy_topic": "Tópico do ntfy (opcional)", "form.feed.label.proxy_url": "Proxy URL", + "form.feed.label.refresh_interval_minutes": "Refresh interval (minutes)", + "form.feed.help.refresh_interval_minutes": "Override the global polling frequency for this feed. Leave at 0 to use the application default.", "form.feed.label.pushover_activate": "Enviar itens para o pushover.net", "form.feed.label.pushover_default_priority": "Prioridade padrão do Pushover", "form.feed.label.pushover_high_priority": "Alta prioridade do Pushover", diff --git a/internal/locale/translations/ro_RO.json b/internal/locale/translations/ro_RO.json index bfdeb6e0dbb..79b447c0f35 100644 --- a/internal/locale/translations/ro_RO.json +++ b/internal/locale/translations/ro_RO.json @@ -101,6 +101,7 @@ "error.feed_format_not_detected": "Nu pot detecta formatul fluxului: %v.", "error.feed_invalid_blocklist_rule": "Blocul listei de reguli este invalid.", "error.feed_invalid_keeplist_rule": "Lista de reguli keep este invalidă.", + "error.feed_invalid_refresh_interval": "The refresh interval must be between %d and %d minutes.", "error.feed_mandatory_fields": "Adresa URL și categoria sunt obligatorii.", "error.feed_not_found": "Acest flux nu există sau un aparține acestui utilizator.", "error.feed_title_not_empty": "Titlul fluxului nu poate fi gol.", @@ -201,6 +202,8 @@ "form.feed.label.ntfy_priority": "Prioritate Ntfy", "form.feed.label.ntfy_topic": "Subiect Ntfy (opțional)", "form.feed.label.proxy_url": "URL Proxy", + "form.feed.label.refresh_interval_minutes": "Refresh interval (minutes)", + "form.feed.help.refresh_interval_minutes": "Override the global polling frequency for this feed. Leave at 0 to use the application default.", "form.feed.label.pushover_activate": "Activează Pushover", "form.feed.label.pushover_default_priority": "Prioritate implicită Pushover", "form.feed.label.pushover_high_priority": "Prioritate ridicată Pushover", diff --git a/internal/locale/translations/ru_RU.json b/internal/locale/translations/ru_RU.json index 285e6ccb9a8..b07cecc2cce 100644 --- a/internal/locale/translations/ru_RU.json +++ b/internal/locale/translations/ru_RU.json @@ -101,6 +101,7 @@ "error.feed_format_not_detected": "Не удалось определить формат подписки: %v.", "error.feed_invalid_blocklist_rule": "Правило черного списка некорректно.", "error.feed_invalid_keeplist_rule": "Правило белого списка некорректно.", + "error.feed_invalid_refresh_interval": "The refresh interval must be between %d and %d minutes.", "error.feed_mandatory_fields": "Ссылка и категория обязательны.", "error.feed_not_found": "Эта подписка не существует или не принадлежит этому пользователю.", "error.feed_title_not_empty": "Заголовок подписки не может быть пустым.", @@ -201,6 +202,8 @@ "form.feed.label.ntfy_priority": "Приоритет ntfy", "form.feed.label.ntfy_topic": "Топик ntfy (опционально)", "form.feed.label.proxy_url": "URL прокси", + "form.feed.label.refresh_interval_minutes": "Refresh interval (minutes)", + "form.feed.help.refresh_interval_minutes": "Override the global polling frequency for this feed. Leave at 0 to use the application default.", "form.feed.label.pushover_activate": "Отправлять статьи в pushover.net", "form.feed.label.pushover_default_priority": "По умолчанию", "form.feed.label.pushover_high_priority": "Высокий", diff --git a/internal/locale/translations/tr_TR.json b/internal/locale/translations/tr_TR.json index 9c07dec6ae2..89a9365689a 100644 --- a/internal/locale/translations/tr_TR.json +++ b/internal/locale/translations/tr_TR.json @@ -98,6 +98,7 @@ "error.feed_format_not_detected": "Besleme formatı algılanamadı: %v.", "error.feed_invalid_blocklist_rule": "Engelleme listesi kuralı geçersiz.", "error.feed_invalid_keeplist_rule": "Saklama listesi kuralı geçersiz.", + "error.feed_invalid_refresh_interval": "The refresh interval must be between %d and %d minutes.", "error.feed_mandatory_fields": "URL ve kategori zorunlu.", "error.feed_not_found": "Bu makele mevcut değil ya da bu kullanıcıya ait değil.", "error.feed_title_not_empty": "Besleme başlığı boş olamaz.", @@ -198,6 +199,8 @@ "form.feed.label.ntfy_priority": "Ntfy öncelik", "form.feed.label.ntfy_topic": "Ntfy konusu (isteğe bağlı)", "form.feed.label.proxy_url": "Proxy URL", + "form.feed.label.refresh_interval_minutes": "Refresh interval (minutes)", + "form.feed.help.refresh_interval_minutes": "Override the global polling frequency for this feed. Leave at 0 to use the application default.", "form.feed.label.pushover_activate": "Makaleleri pushover.net'e gönder", "form.feed.label.pushover_default_priority": "Pushover varsayılan öncelik", "form.feed.label.pushover_high_priority": "Pushover yüksek öncelik", diff --git a/internal/locale/translations/uk_UA.json b/internal/locale/translations/uk_UA.json index 47af81260d2..42fe0037f5c 100644 --- a/internal/locale/translations/uk_UA.json +++ b/internal/locale/translations/uk_UA.json @@ -101,6 +101,7 @@ "error.feed_format_not_detected": "Не вдалося визначити формат стрічки: %v.", "error.feed_invalid_blocklist_rule": "Правило списку блокувань недійсне.", "error.feed_invalid_keeplist_rule": "Правило списку дозволень недійсне.", + "error.feed_invalid_refresh_interval": "The refresh interval must be between %d and %d minutes.", "error.feed_mandatory_fields": "URL та категорія є обов’язковими.", "error.feed_not_found": "Ця стрічка не існує або не належить цьому користувачу.", "error.feed_title_not_empty": "Назва стрічки не може бути порожньою.", @@ -201,6 +202,8 @@ "form.feed.label.ntfy_priority": "Пріоритет ntfy", "form.feed.label.ntfy_topic": "Тема ntfy (необов’язково)", "form.feed.label.proxy_url": "URL-адреса проксі", + "form.feed.label.refresh_interval_minutes": "Refresh interval (minutes)", + "form.feed.help.refresh_interval_minutes": "Override the global polling frequency for this feed. Leave at 0 to use the application default.", "form.feed.label.pushover_activate": "Надсилати записи у pushover.net", "form.feed.label.pushover_default_priority": "Стандартний пріоритет Pushover", "form.feed.label.pushover_high_priority": "Високий пріоритет Pushover", diff --git a/internal/locale/translations/zh_CN.json b/internal/locale/translations/zh_CN.json index 23a71d2b543..153d01a7829 100644 --- a/internal/locale/translations/zh_CN.json +++ b/internal/locale/translations/zh_CN.json @@ -95,6 +95,7 @@ "error.feed_format_not_detected": "无法解析订阅源格式:%v。", "error.feed_invalid_blocklist_rule": "阻止列表规则无效。", "error.feed_invalid_keeplist_rule": "保留列表规则无效。", + "error.feed_invalid_refresh_interval": "The refresh interval must be between %d and %d minutes.", "error.feed_mandatory_fields": "必须填写 URL 和分类。", "error.feed_not_found": "此订阅源不存在或不属于此用户。", "error.feed_title_not_empty": "订阅源的标题不能为空。", @@ -195,6 +196,8 @@ "form.feed.label.ntfy_priority": "Ntfy 优先级", "form.feed.label.ntfy_topic": "Ntfy 主题(可选)", "form.feed.label.proxy_url": "代理 URL", + "form.feed.label.refresh_interval_minutes": "Refresh interval (minutes)", + "form.feed.help.refresh_interval_minutes": "Override the global polling frequency for this feed. Leave at 0 to use the application default.", "form.feed.label.pushover_activate": "推送条目到 Pushover", "form.feed.label.pushover_default_priority": "Pushover 默认优先级", "form.feed.label.pushover_high_priority": "Pushover 高优先级", diff --git a/internal/locale/translations/zh_TW.json b/internal/locale/translations/zh_TW.json index 18060c9bf7a..a137f6ee1dd 100644 --- a/internal/locale/translations/zh_TW.json +++ b/internal/locale/translations/zh_TW.json @@ -95,6 +95,7 @@ "error.feed_format_not_detected": "無法辨識 Feed 格式:%v。", "error.feed_invalid_blocklist_rule": "阻擋規則無效。", "error.feed_invalid_keeplist_rule": "保留規則無效。", + "error.feed_invalid_refresh_interval": "The refresh interval must be between %d and %d minutes.", "error.feed_mandatory_fields": "必須填寫網址和分類", "error.feed_not_found": "無法找到此 Feed 或不屬於您。", "error.feed_title_not_empty": "訂閱的標題不能為空。", @@ -195,6 +196,8 @@ "form.feed.label.ntfy_priority": "Ntfy 優先順序", "form.feed.label.ntfy_topic": "Ntfy topic (選填)", "form.feed.label.proxy_url": "代理 URL", + "form.feed.label.refresh_interval_minutes": "Refresh interval (minutes)", + "form.feed.help.refresh_interval_minutes": "Override the global polling frequency for this feed. Leave at 0 to use the application default.", "form.feed.label.pushover_activate": "推送文章到 Pushover", "form.feed.label.pushover_default_priority": "Pushover 預設優先順序", "form.feed.label.pushover_high_priority": "Pushover 高優先順序", diff --git a/internal/model/feed.go b/internal/model/feed.go index aa391eff4b3..0441a5fa1d9 100644 --- a/internal/model/feed.go +++ b/internal/model/feed.go @@ -62,6 +62,7 @@ type Feed struct { NtfyTopic string `json:"ntfy_topic"` PushoverPriority int `json:"pushover_priority"` ProxyURL string `json:"proxy_url"` + RefreshIntervalMinutes *int `json:"refresh_interval_minutes"` // Non-persisted attributes Category *Category `json:"category,omitempty"` @@ -120,6 +121,16 @@ func (f *Feed) CheckedNow() { // ScheduleNextCheck set "next_check_at" of a feed based on the scheduler selected from the configuration. func (f *Feed) ScheduleNextCheck(weeklyCount int, refreshDelay time.Duration) time.Duration { + // A per-feed override takes precedence over the global scheduler. The + // HTTP refresh delay (RSS TTL, Retry-After, Cache-Control, Expires) is + // still honoured to avoid hitting servers more often than they request. + if f.RefreshIntervalMinutes != nil && *f.RefreshIntervalMinutes > 0 { + interval := time.Duration(*f.RefreshIntervalMinutes) * time.Minute + interval = max(interval, refreshDelay) + f.NextCheckAt = time.Now().Add(interval) + return interval + } + // Default to the global config Polling Frequency. interval := config.Opts.SchedulerRoundRobinMinInterval() @@ -173,6 +184,7 @@ type FeedCreationRequest struct { KeepFilterEntryRules string `json:"keep_filter_entry_rules"` UrlRewriteRules string `json:"urlrewrite_rules"` ProxyURL string `json:"proxy_url"` + RefreshIntervalMinutes *int `json:"refresh_interval_minutes"` } type FeedCreationRequestFromSubscriptionDiscovery struct { @@ -211,6 +223,7 @@ type FeedModificationRequest struct { HideGlobally *bool `json:"hide_globally"` DisableHTTP2 *bool `json:"disable_http2"` ProxyURL *string `json:"proxy_url"` + RefreshIntervalMinutes *int `json:"refresh_interval_minutes"` } // Patch updates a feed with modified values. @@ -318,6 +331,17 @@ func (f *FeedModificationRequest) Patch(feed *Feed) { if f.ProxyURL != nil { feed.ProxyURL = *f.ProxyURL } + + if f.RefreshIntervalMinutes != nil { + // A non-positive value clears the override and lets the global + // scheduler take over again. + if *f.RefreshIntervalMinutes > 0 { + value := *f.RefreshIntervalMinutes + feed.RefreshIntervalMinutes = &value + } else { + feed.RefreshIntervalMinutes = nil + } + } } // Feeds is a list of feed diff --git a/internal/model/feed_test.go b/internal/model/feed_test.go index b5094870e6e..7075ae17968 100644 --- a/internal/model/feed_test.go +++ b/internal/model/feed_test.go @@ -376,3 +376,156 @@ func TestFeedScheduleNextCheckEntryFrequencyLargeNewTTL(t *testing.T) { t.Error(`The next_check_at should be after timeBefore + entry frequency min interval`) } } + +func TestFeedScheduleNextCheckRefreshIntervalOverride(t *testing.T) { + os.Clearenv() + + var err error + parser := config.NewConfigParser() + config.Opts, err = parser.ParseEnvironmentVariables() + if err != nil { + t.Fatalf(`Parsing failure: %v`, err) + } + + override := 90 + feed := &Feed{RefreshIntervalMinutes: &override} + + timeBefore := time.Now() + interval := feed.ScheduleNextCheck(0, noRefreshDelay) + + expected := time.Duration(override) * time.Minute + if interval != expected { + t.Errorf(`Expected interval %s, got %s`, expected, interval) + } + + checkTargetInterval(t, feed, expected, timeBefore, "TestFeedScheduleNextCheckRefreshIntervalOverride") +} + +func TestFeedScheduleNextCheckRefreshIntervalOverrideRespectsRefreshDelay(t *testing.T) { + os.Clearenv() + + var err error + parser := config.NewConfigParser() + config.Opts, err = parser.ParseEnvironmentVariables() + if err != nil { + t.Fatalf(`Parsing failure: %v`, err) + } + + // Override is 10 minutes, but the server returns Retry-After 30 minutes: + // the larger value must win to avoid hammering the publisher. + override := 10 + feed := &Feed{RefreshIntervalMinutes: &override} + refreshDelay := 30 * time.Minute + + timeBefore := time.Now() + interval := feed.ScheduleNextCheck(0, refreshDelay) + + if interval != refreshDelay { + t.Errorf(`Expected interval %s, got %s`, refreshDelay, interval) + } + + checkTargetInterval(t, feed, refreshDelay, timeBefore, "TestFeedScheduleNextCheckRefreshIntervalOverrideRespectsRefreshDelay") +} + +func TestFeedScheduleNextCheckRefreshIntervalOverrideIgnoresGlobalCap(t *testing.T) { + os.Clearenv() + + // Round-robin global cap is 1 day by default. Confirm an override above + // that cap (e.g. 5 days) is honoured rather than clamped, since users + // who set a per-feed value should get exactly what they asked for. + var err error + parser := config.NewConfigParser() + config.Opts, err = parser.ParseEnvironmentVariables() + if err != nil { + t.Fatalf(`Parsing failure: %v`, err) + } + + override := 5 * 24 * 60 + feed := &Feed{RefreshIntervalMinutes: &override} + + interval := feed.ScheduleNextCheck(0, noRefreshDelay) + expected := time.Duration(override) * time.Minute + if interval != expected { + t.Errorf(`Expected interval %s, got %s`, expected, interval) + } +} + +func TestFeedScheduleNextCheckRefreshIntervalNilFallsBackToGlobal(t *testing.T) { + os.Clearenv() + + var err error + parser := config.NewConfigParser() + config.Opts, err = parser.ParseEnvironmentVariables() + if err != nil { + t.Fatalf(`Parsing failure: %v`, err) + } + + feed := &Feed{} + timeBefore := time.Now() + interval := feed.ScheduleNextCheck(0, noRefreshDelay) + + if interval != config.Opts.SchedulerRoundRobinMinInterval() { + t.Errorf(`Expected global round-robin interval %s, got %s`, config.Opts.SchedulerRoundRobinMinInterval(), interval) + } + + checkTargetInterval(t, feed, interval, timeBefore, "TestFeedScheduleNextCheckRefreshIntervalNilFallsBackToGlobal") +} + +func TestFeedScheduleNextCheckRefreshIntervalZeroFallsBackToGlobal(t *testing.T) { + os.Clearenv() + + var err error + parser := config.NewConfigParser() + config.Opts, err = parser.ParseEnvironmentVariables() + if err != nil { + t.Fatalf(`Parsing failure: %v`, err) + } + + zero := 0 + feed := &Feed{RefreshIntervalMinutes: &zero} + interval := feed.ScheduleNextCheck(0, noRefreshDelay) + + if interval != config.Opts.SchedulerRoundRobinMinInterval() { + t.Errorf(`Expected global round-robin interval %s when override is zero, got %s`, config.Opts.SchedulerRoundRobinMinInterval(), interval) + } +} + +func intPtr(value int) *int { return &value } + +func TestFeedModificationRequestPatchSetsRefreshInterval(t *testing.T) { + feed := &Feed{Category: &Category{ID: 1}} + req := &FeedModificationRequest{RefreshIntervalMinutes: intPtr(60)} + req.Patch(feed) + + if feed.RefreshIntervalMinutes == nil { + t.Fatal(`RefreshIntervalMinutes should be set on the feed`) + } + if *feed.RefreshIntervalMinutes != 60 { + t.Errorf(`Expected RefreshIntervalMinutes=60, got %d`, *feed.RefreshIntervalMinutes) + } +} + +func TestFeedModificationRequestPatchClearsRefreshInterval(t *testing.T) { + existing := 90 + feed := &Feed{Category: &Category{ID: 1}, RefreshIntervalMinutes: &existing} + req := &FeedModificationRequest{RefreshIntervalMinutes: intPtr(0)} + req.Patch(feed) + + if feed.RefreshIntervalMinutes != nil { + t.Errorf(`Expected RefreshIntervalMinutes to be cleared, got %d`, *feed.RefreshIntervalMinutes) + } +} + +func TestFeedModificationRequestPatchLeavesRefreshIntervalAloneWhenNil(t *testing.T) { + existing := 45 + feed := &Feed{Category: &Category{ID: 1}, RefreshIntervalMinutes: &existing} + req := &FeedModificationRequest{} + req.Patch(feed) + + if feed.RefreshIntervalMinutes == nil { + t.Fatal(`RefreshIntervalMinutes should not be cleared when omitted from the request`) + } + if *feed.RefreshIntervalMinutes != 45 { + t.Errorf(`Expected RefreshIntervalMinutes=45, got %d`, *feed.RefreshIntervalMinutes) + } +} diff --git a/internal/storage/feed.go b/internal/storage/feed.go index 4cd5c6dfbdb..acbea0ae490 100644 --- a/internal/storage/feed.go +++ b/internal/storage/feed.go @@ -263,10 +263,11 @@ func (s *Storage) CreateFeed(feed *model.Feed) error { disable_http2, description, proxy_url, - ignore_entry_updates + ignore_entry_updates, + refresh_interval_minutes ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31) + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32) RETURNING id ` @@ -303,6 +304,7 @@ func (s *Storage) CreateFeed(feed *model.Feed) error { feed.Description, feed.ProxyURL, feed.IgnoreEntryUpdates, + feed.RefreshIntervalMinutes, ).Scan(&feed.ID) if err != nil { return fmt.Errorf(`store: unable to create feed %q: %v`, feed.FeedURL, err) @@ -386,9 +388,10 @@ func (s *Storage) UpdateFeed(feed *model.Feed) (err error) { pushover_enabled=$36, pushover_priority=$37, proxy_url=$38, - ignore_entry_updates=$39 + ignore_entry_updates=$39, + refresh_interval_minutes=$40 WHERE - id=$40 AND user_id=$41 + id=$41 AND user_id=$42 ` _, err = s.db.Exec(query, feed.FeedURL, @@ -430,6 +433,7 @@ func (s *Storage) UpdateFeed(feed *model.Feed) (err error) { feed.PushoverPriority, feed.ProxyURL, feed.IgnoreEntryUpdates, + feed.RefreshIntervalMinutes, feed.ID, feed.UserID, ) diff --git a/internal/storage/feed_query_builder.go b/internal/storage/feed_query_builder.go index 9100d9aad03..cea27c40b30 100644 --- a/internal/storage/feed_query_builder.go +++ b/internal/storage/feed_query_builder.go @@ -177,7 +177,8 @@ func (f *feedQueryBuilder) GetFeeds() (model.Feeds, error) { f.pushover_enabled, f.pushover_priority, f.proxy_url, - f.ignore_entry_updates + f.ignore_entry_updates, + f.refresh_interval_minutes FROM feeds f LEFT JOIN @@ -210,6 +211,7 @@ func (f *feedQueryBuilder) GetFeeds() (model.Feeds, error) { var feed model.Feed var iconID sql.NullInt64 var externalIconID sql.NullString + var refreshIntervalMinutes sql.NullInt64 var tz string feed.Category = &model.Category{} @@ -260,12 +262,18 @@ func (f *feedQueryBuilder) GetFeeds() (model.Feeds, error) { &feed.PushoverPriority, &feed.ProxyURL, &feed.IgnoreEntryUpdates, + &refreshIntervalMinutes, ) if err != nil { return nil, fmt.Errorf(`store: unable to fetch feeds row: %w`, err) } + if refreshIntervalMinutes.Valid { + value := int(refreshIntervalMinutes.Int64) + feed.RefreshIntervalMinutes = &value + } + if iconID.Valid && externalIconID.Valid { feed.Icon = &model.FeedIcon{FeedID: feed.ID, IconID: iconID.Int64, ExternalIconID: externalIconID.String} } else { diff --git a/internal/template/templates/views/edit_feed.html b/internal/template/templates/views/edit_feed.html index 01354ce0c5f..8b84e12f535 100644 --- a/internal/template/templates/views/edit_feed.html +++ b/internal/template/templates/views/edit_feed.html @@ -73,6 +73,10 @@

{{ t "page.edit_feed.last_parsing_error" }}

+ + +

{{ t "form.feed.help.refresh_interval_minutes" }}

+
diff --git a/internal/ui/feed_edit.go b/internal/ui/feed_edit.go index 90bb2d4553a..c2d02b0eacf 100644 --- a/internal/ui/feed_edit.go +++ b/internal/ui/feed_edit.go @@ -38,6 +38,11 @@ func (h *handler) showEditFeedPage(w http.ResponseWriter, r *http.Request) { return } + refreshIntervalMinutes := 0 + if feed.RefreshIntervalMinutes != nil { + refreshIntervalMinutes = *feed.RefreshIntervalMinutes + } + feedForm := form.FeedForm{ SiteURL: feed.SiteURL, FeedURL: feed.FeedURL, @@ -73,6 +78,7 @@ func (h *handler) showEditFeedPage(w http.ResponseWriter, r *http.Request) { PushoverEnabled: feed.PushoverEnabled, PushoverPriority: feed.PushoverPriority, ProxyURL: feed.ProxyURL, + RefreshIntervalMinutes: refreshIntervalMinutes, } view := view.New(h.tpl, r) diff --git a/internal/ui/feed_update.go b/internal/ui/feed_update.go index b3f0aec532b..21dd0949b11 100644 --- a/internal/ui/feed_update.go +++ b/internal/ui/feed_update.go @@ -53,15 +53,16 @@ func (h *handler) updateFeed(w http.ResponseWriter, r *http.Request) { view.Set("defaultUserAgent", config.Opts.HTTPClientUserAgent()) feedModificationRequest := &model.FeedModificationRequest{ - FeedURL: model.OptionalString(feedForm.FeedURL), - SiteURL: model.OptionalString(feedForm.SiteURL), - Title: model.OptionalString(feedForm.Title), - Description: model.OptionalString(feedForm.Description), - CategoryID: model.OptionalNumber(feedForm.CategoryID), - BlocklistRules: model.OptionalString(feedForm.BlocklistRules), - KeeplistRules: model.OptionalString(feedForm.KeeplistRules), - UrlRewriteRules: model.OptionalString(feedForm.UrlRewriteRules), - ProxyURL: model.OptionalString(feedForm.ProxyURL), + FeedURL: model.OptionalString(feedForm.FeedURL), + SiteURL: model.OptionalString(feedForm.SiteURL), + Title: model.OptionalString(feedForm.Title), + Description: model.OptionalString(feedForm.Description), + CategoryID: model.OptionalNumber(feedForm.CategoryID), + BlocklistRules: model.OptionalString(feedForm.BlocklistRules), + KeeplistRules: model.OptionalString(feedForm.KeeplistRules), + UrlRewriteRules: model.OptionalString(feedForm.UrlRewriteRules), + ProxyURL: model.OptionalString(feedForm.ProxyURL), + RefreshIntervalMinutes: model.OptionalNumber(feedForm.RefreshIntervalMinutes), } if validationErr := validator.ValidateFeedModification(h.store, loggedUser.ID, feed.ID, feedModificationRequest); validationErr != nil { diff --git a/internal/ui/form/feed.go b/internal/ui/form/feed.go index dd45ac4d65a..f6e652bbf30 100644 --- a/internal/ui/form/feed.go +++ b/internal/ui/form/feed.go @@ -46,6 +46,7 @@ type FeedForm struct { PushoverEnabled bool PushoverPriority int ProxyURL string + RefreshIntervalMinutes int } // Merge updates the fields of the given feed. @@ -85,6 +86,12 @@ func (f FeedForm) Merge(feed *model.Feed) *model.Feed { feed.PushoverEnabled = f.PushoverEnabled feed.PushoverPriority = f.PushoverPriority feed.ProxyURL = f.ProxyURL + if f.RefreshIntervalMinutes > 0 { + value := f.RefreshIntervalMinutes + feed.RefreshIntervalMinutes = &value + } else { + feed.RefreshIntervalMinutes = nil + } return feed } @@ -105,6 +112,11 @@ func NewFeedForm(r *http.Request) *FeedForm { pushoverPriority = 0 } + refreshIntervalMinutes, err := strconv.Atoi(r.FormValue("refresh_interval_minutes")) + if err != nil { + refreshIntervalMinutes = 0 + } + return &FeedForm{ FeedURL: r.FormValue("feed_url"), SiteURL: r.FormValue("site_url"), @@ -139,5 +151,6 @@ func NewFeedForm(r *http.Request) *FeedForm { PushoverEnabled: r.FormValue("pushover_enabled") == "1", PushoverPriority: pushoverPriority, ProxyURL: r.FormValue("proxy_url"), + RefreshIntervalMinutes: refreshIntervalMinutes, } } diff --git a/internal/validator/feed.go b/internal/validator/feed.go index a0dd8df7849..6afac60be2f 100644 --- a/internal/validator/feed.go +++ b/internal/validator/feed.go @@ -10,6 +10,21 @@ import ( "miniflux.app/v2/internal/urllib" ) +// MinFeedRefreshIntervalMinutes is the smallest per-feed refresh interval +// that can be configured. The cap prevents users from accidentally hammering +// publishers (and getting rate limited) when the global polling frequency is +// higher than this value. +const MinFeedRefreshIntervalMinutes = 5 + +// MaxFeedRefreshIntervalMinutes is one week. Larger values are accepted by +// the database column but are unlikely to be intentional, and the round-robin +// scheduler also caps the global interval at one week. +const MaxFeedRefreshIntervalMinutes = 7 * 24 * 60 + +func isValidRefreshInterval(value int) bool { + return value >= MinFeedRefreshIntervalMinutes && value <= MaxFeedRefreshIntervalMinutes +} + // ValidateFeedCreation validates feed creation. func ValidateFeedCreation(store *storage.Storage, userID int64, request *model.FeedCreationRequest) *locale.LocalizedError { if request.FeedURL == "" || request.CategoryID <= 0 { @@ -40,6 +55,10 @@ func ValidateFeedCreation(store *storage.Storage, userID int64, request *model.F return locale.NewLocalizedError("error.invalid_feed_proxy_url") } + if request.RefreshIntervalMinutes != nil && *request.RefreshIntervalMinutes != 0 && !isValidRefreshInterval(*request.RefreshIntervalMinutes) { + return locale.NewLocalizedError("error.feed_invalid_refresh_interval", MinFeedRefreshIntervalMinutes, MaxFeedRefreshIntervalMinutes) + } + return nil } @@ -103,5 +122,9 @@ func ValidateFeedModification(store *storage.Storage, userID, feedID int64, requ } } + if request.RefreshIntervalMinutes != nil && *request.RefreshIntervalMinutes > 0 && !isValidRefreshInterval(*request.RefreshIntervalMinutes) { + return locale.NewLocalizedError("error.feed_invalid_refresh_interval", MinFeedRefreshIntervalMinutes, MaxFeedRefreshIntervalMinutes) + } + return nil } diff --git a/internal/validator/feed_test.go b/internal/validator/feed_test.go new file mode 100644 index 00000000000..e91887f941d --- /dev/null +++ b/internal/validator/feed_test.go @@ -0,0 +1,29 @@ +// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package validator // import "miniflux.app/v2/internal/validator" + +import "testing" + +func TestIsValidRefreshInterval(t *testing.T) { + cases := []struct { + name string + value int + want bool + }{ + {"below minimum", MinFeedRefreshIntervalMinutes - 1, false}, + {"at minimum", MinFeedRefreshIntervalMinutes, true}, + {"normal", 60, true}, + {"at maximum", MaxFeedRefreshIntervalMinutes, true}, + {"above maximum", MaxFeedRefreshIntervalMinutes + 1, false}, + {"zero", 0, false}, + {"negative", -1, false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := isValidRefreshInterval(c.value); got != c.want { + t.Errorf(`isValidRefreshInterval(%d) = %v, want %v`, c.value, got, c.want) + } + }) + } +}