diff --git a/database/migrations.go b/database/migrations.go index 20fe906d..edf926dc 100644 --- a/database/migrations.go +++ b/database/migrations.go @@ -660,4 +660,12 @@ var migrations = []func(tx *sql.Tx) error{ _, err = tx.Exec(sql) return err }, + func(tx *sql.Tx) (err error) { + sql := ` + ALTER TABLE feeds ADD COLUMN no_media_player boolean default 'f'; + ALTER TABLE enclosures ADD COLUMN media_progression int default 0; + ` + _, err = tx.Exec(sql) + return err + }, } diff --git a/locale/translations/de_DE.json b/locale/translations/de_DE.json index ac2b9c13..b71c2b70 100644 --- a/locale/translations/de_DE.json +++ b/locale/translations/de_DE.json @@ -284,6 +284,7 @@ "form.feed.label.allow_self_signed_certificates": "Erlaube selbstsignierte oder ungültige Zertifikate", "form.feed.label.fetch_via_proxy": "Über Proxy abrufen", "form.feed.label.disabled": "Dieses Abonnement nicht aktualisieren", + "form.feed.label.no_media_player": "No media player (audio/video)", "form.feed.label.hide_globally": "Einträge in der globalen Ungelesen-Liste ausblenden", "form.category.label.title": "Titel", "form.category.hide_globally": "Einträge in der globalen Ungelesen-Liste ausblenden", diff --git a/locale/translations/el_EL.json b/locale/translations/el_EL.json index 41e13e19..42b88c32 100644 --- a/locale/translations/el_EL.json +++ b/locale/translations/el_EL.json @@ -284,6 +284,7 @@ "form.feed.label.allow_self_signed_certificates": "Να επιτρέπονται αυτο-υπογεγραμμένα ή μη έγκυρα πιστοποιητικά", "form.feed.label.fetch_via_proxy": "Λήψη μέσω διακομιστή μεσολάβησης", "form.feed.label.disabled": "Μη ανανέωση αυτής της ροής", + "form.feed.label.no_media_player": "No media player (audio/video)", "form.feed.label.hide_globally": "Απόκρυψη καταχωρήσεων σε γενική λίστα μη αναγνωσμένων", "form.category.label.title": "Τίτλος", "form.category.hide_globally": "Απόκρυψη καταχωρήσεων σε γενική λίστα μη αναγνωσμένων", diff --git a/locale/translations/en_US.json b/locale/translations/en_US.json index c79a39ae..7beacf9a 100644 --- a/locale/translations/en_US.json +++ b/locale/translations/en_US.json @@ -284,6 +284,7 @@ "form.feed.label.allow_self_signed_certificates": "Allow self-signed or invalid certificates", "form.feed.label.fetch_via_proxy": "Fetch via proxy", "form.feed.label.disabled": "Do not refresh this feed", + "form.feed.label.no_media_player": "No media player (audio/video)", "form.feed.label.hide_globally": "Hide entries in global unread list", "form.category.label.title": "Title", "form.category.hide_globally": "Hide entries in global unread list", diff --git a/locale/translations/es_ES.json b/locale/translations/es_ES.json index 6f01d235..d4e32ea9 100644 --- a/locale/translations/es_ES.json +++ b/locale/translations/es_ES.json @@ -284,6 +284,7 @@ "form.feed.label.allow_self_signed_certificates": "Permitir certificados autofirmados o no válidos", "form.feed.label.fetch_via_proxy": "Buscar a través de proxy", "form.feed.label.disabled": "No actualice este feed", + "form.feed.label.no_media_player": "No media player (audio/video)", "form.feed.label.hide_globally": "Ocultar artículos en la lista global de no leídos", "form.category.label.title": "Título", "form.category.hide_globally": "Ocultar artículos en la lista global de no leídos", diff --git a/locale/translations/fi_FI.json b/locale/translations/fi_FI.json index 190d3715..a41f37bd 100644 --- a/locale/translations/fi_FI.json +++ b/locale/translations/fi_FI.json @@ -284,6 +284,7 @@ "form.feed.label.allow_self_signed_certificates": "Salli itseallekirjoitetut tai virheelliset varmenteet", "form.feed.label.fetch_via_proxy": "Nouda välityspalvelimen kautta", "form.feed.label.disabled": "Älä päivitä tätä syötettä", + "form.feed.label.no_media_player": "No media player (audio/video)", "form.feed.label.hide_globally": "Piilota artikkelit lukemattomien listassa", "form.category.label.title": "Otsikko", "form.category.hide_globally": "Piilota artikkelit lukemattomien listassa", diff --git a/locale/translations/fr_FR.json b/locale/translations/fr_FR.json index 604a7ff9..19d41ca8 100644 --- a/locale/translations/fr_FR.json +++ b/locale/translations/fr_FR.json @@ -284,6 +284,7 @@ "form.feed.label.allow_self_signed_certificates": "Autoriser les certificats auto-signés ou non valides", "form.feed.label.fetch_via_proxy": "Récupérer via proxy", "form.feed.label.disabled": "Ne pas actualiser ce flux", + "form.feed.label.no_media_player": "Pas de lecteur multimedia (audio/vidéo)", "form.feed.label.hide_globally": "Masquer les entrées dans la liste globale non lue", "form.category.label.title": "Titre", "form.category.hide_globally": "Masquer les entrées dans la liste globale non lue", diff --git a/locale/translations/hi_IN.json b/locale/translations/hi_IN.json index fa997f0d..209f404f 100644 --- a/locale/translations/hi_IN.json +++ b/locale/translations/hi_IN.json @@ -284,6 +284,7 @@ "form.feed.label.allow_self_signed_certificates": "स्व-हस्ताक्षरित या अमान्य प्रमाणपत्रों की अनुमति दें", "form.feed.label.fetch_via_proxy": "प्रॉक्सी के माध्यम से प्राप्त करें", "form.feed.label.disabled": "इस फ़ीड को रीफ़्रेश न करें", + "form.feed.label.no_media_player": "No media player (audio/video)", "form.feed.label.hide_globally": "वैश्विक अपठित सूची में प्रविष्टियां छिपाएं", "form.category.label.title": "शीर्षक", "form.category.hide_globally": "वैश्विक अपठित सूची में प्रविष्टियां छिपाएं", diff --git a/locale/translations/id_ID.json b/locale/translations/id_ID.json index 7880fcf5..17ed9d26 100644 --- a/locale/translations/id_ID.json +++ b/locale/translations/id_ID.json @@ -281,6 +281,7 @@ "form.feed.label.allow_self_signed_certificates": "Perbolehkan sertifikat web tidak valid atau sertifikasi sendiri", "form.feed.label.fetch_via_proxy": "Ambil via Proksi", "form.feed.label.disabled": "Jangan perbarui umpan ini", + "form.feed.label.no_media_player": "No media player (audio/video)", "form.feed.label.hide_globally": "Sembunyikan entri di daftar belum dibaca global", "form.category.label.title": "Judul", "form.category.hide_globally": "Sembunyikan entri di daftar belum dibaca global", diff --git a/locale/translations/it_IT.json b/locale/translations/it_IT.json index 246faa86..15481c7b 100644 --- a/locale/translations/it_IT.json +++ b/locale/translations/it_IT.json @@ -284,6 +284,7 @@ "form.feed.label.allow_self_signed_certificates": "Consenti certificati autofirmati o non validi", "form.feed.label.fetch_via_proxy": "Recuperare tramite proxy", "form.feed.label.disabled": "Non aggiornare questo feed", + "form.feed.label.no_media_player": "No media player (audio/video)", "form.feed.label.hide_globally": "Nascondere le voci nella lista globale dei non letti", "form.category.label.title": "Titolo", "form.category.hide_globally": "Nascondere le voci nella lista globale dei non letti", diff --git a/locale/translations/ja_JP.json b/locale/translations/ja_JP.json index 6baa6a1e..bdbdad98 100644 --- a/locale/translations/ja_JP.json +++ b/locale/translations/ja_JP.json @@ -284,6 +284,7 @@ "form.feed.label.allow_self_signed_certificates": "自己署名証明書または無効な証明書を許可する", "form.feed.label.fetch_via_proxy": "プロキシ経由で取得", "form.feed.label.disabled": "このフィードを更新しない", + "form.feed.label.no_media_player": "No media player (audio/video)", "form.feed.label.hide_globally": "未読一覧に記事を表示しない", "form.category.label.title": "タイトル", "form.category.hide_globally": "未読一覧に記事を表示しない", diff --git a/locale/translations/nl_NL.json b/locale/translations/nl_NL.json index 11f83ab8..ddc96191 100644 --- a/locale/translations/nl_NL.json +++ b/locale/translations/nl_NL.json @@ -284,6 +284,7 @@ "form.feed.label.allow_self_signed_certificates": "Sta zelfondertekende of ongeldige certificaten toe", "form.feed.label.fetch_via_proxy": "Ophalen via proxy", "form.feed.label.disabled": "Vernieuw deze feed niet", + "form.feed.label.no_media_player": "No media player (audio/video)", "form.feed.label.hide_globally": "Verberg items in de globale ongelezen lijst", "form.category.label.title": "Naam", "form.category.hide_globally": "Verberg items in de globale ongelezen lijst", diff --git a/locale/translations/pl_PL.json b/locale/translations/pl_PL.json index f162c550..82507496 100644 --- a/locale/translations/pl_PL.json +++ b/locale/translations/pl_PL.json @@ -286,6 +286,7 @@ "form.feed.label.allow_self_signed_certificates": "Zezwalaj na certyfikaty z podpisem własnym lub nieprawidłowe certyfikaty", "form.feed.label.fetch_via_proxy": "Pobierz przez proxy", "form.feed.label.disabled": "Nie odświeżaj tego kanału", + "form.feed.label.no_media_player": "No media player (audio/video)", "form.feed.label.hide_globally": "Ukryj wpisy na globalnej liście nieprzeczytanych", "form.category.label.title": "Tytuł", "form.category.hide_globally": "Ukryj wpisy na globalnej liście nieprzeczytanych", diff --git a/locale/translations/pt_BR.json b/locale/translations/pt_BR.json index e53bd100..12b0e7f0 100644 --- a/locale/translations/pt_BR.json +++ b/locale/translations/pt_BR.json @@ -283,6 +283,7 @@ "form.feed.label.ignore_http_cache": "Ignorar cache HTTP", "form.feed.label.allow_self_signed_certificates": "Permitir certificados autoassinados ou inválidos", "form.feed.label.disabled": "Não atualizar esta fonte", + "form.feed.label.no_media_player": "No media player (audio/video)", "form.feed.label.fetch_via_proxy": "Buscar via proxy", "form.feed.label.hide_globally": "Ocultar entradas na lista global não lida", "form.category.label.title": "Título", diff --git a/locale/translations/ru_RU.json b/locale/translations/ru_RU.json index af09d58d..13b89a18 100644 --- a/locale/translations/ru_RU.json +++ b/locale/translations/ru_RU.json @@ -286,6 +286,7 @@ "form.feed.label.allow_self_signed_certificates": "Разрешить самоподписанные или недействительные сертификаты", "form.feed.label.fetch_via_proxy": "Получить через прокси", "form.feed.label.disabled": "Не обновлять этот канал", + "form.feed.label.no_media_player": "No media player (audio/video)", "form.feed.label.hide_globally": "Скрыть записи в глобальном списке непрочитанных", "form.category.label.title": "Название", "form.category.hide_globally": "Скрыть записи в глобальном списке непрочитанных", diff --git a/locale/translations/tr_TR.json b/locale/translations/tr_TR.json index 3c679c83..71929071 100644 --- a/locale/translations/tr_TR.json +++ b/locale/translations/tr_TR.json @@ -284,6 +284,7 @@ "form.feed.label.allow_self_signed_certificates": "Kendinden imzalı veya geçersiz sertifikalara izin ver", "form.feed.label.fetch_via_proxy": "Proxy ile çek", "form.feed.label.disabled": "Bu beslemeyi yenileme", + "form.feed.label.no_media_player": "No media player (audio/video)", "form.feed.label.hide_globally": "Genel okunmamış listesindeki girişleri gizle", "form.category.label.title": "Başlık", "form.category.hide_globally": "Genel okunmamış listesindeki girişleri gizle", diff --git a/locale/translations/uk_UA.json b/locale/translations/uk_UA.json index 38e80ef4..599cb6e7 100644 --- a/locale/translations/uk_UA.json +++ b/locale/translations/uk_UA.json @@ -283,6 +283,7 @@ "form.feed.label.allow_self_signed_certificates": "Дозволити сертифікати з власним підписом або недійсні", "form.feed.label.fetch_via_proxy": "Використати проксі-сервер", "form.feed.label.disabled": "Не оновлювати цю стрічку", + "form.feed.label.no_media_player": "No media player (audio/video)", "form.feed.label.hide_globally": "Приховати записи в глобальному списку непрочитаного", "form.category.label.title": "Назва", "form.category.hide_globally": "Приховати записи в глобальному списку непрочитаного", diff --git a/locale/translations/zh_CN.json b/locale/translations/zh_CN.json index 98a20d15..f52a9d85 100644 --- a/locale/translations/zh_CN.json +++ b/locale/translations/zh_CN.json @@ -282,6 +282,7 @@ "form.feed.label.allow_self_signed_certificates": "允许自签名证书或无效证书", "form.feed.label.fetch_via_proxy": "通过代理获取", "form.feed.label.disabled": "请勿刷新此源", + "form.feed.label.no_media_player": "No media player (audio/video)", "form.feed.label.hide_globally": "隐藏全局未读列表中的文章", "form.category.label.title": "标题", "form.category.hide_globally": "隐藏全局未读列表中的文章", diff --git a/locale/translations/zh_TW.json b/locale/translations/zh_TW.json index 7ed280a8..45422226 100644 --- a/locale/translations/zh_TW.json +++ b/locale/translations/zh_TW.json @@ -284,6 +284,7 @@ "form.feed.label.allow_self_signed_certificates": "允許自簽章憑證或無效憑證", "form.feed.label.fetch_via_proxy": "透過代理獲取", "form.feed.label.disabled": "請勿重新整理此Feed", + "form.feed.label.no_media_player": "No media player (audio/video)", "form.feed.label.hide_globally": "隱藏全域性未讀列表中的文章", "form.category.label.title": "標題", "form.category.hide_globally": "隱藏全域性未讀列表中的文章", diff --git a/model/enclosure.go b/model/enclosure.go index 52388794..b0887ef4 100644 --- a/model/enclosure.go +++ b/model/enclosure.go @@ -3,15 +3,32 @@ // license that can be found in the LICENSE file. package model // import "miniflux.app/model" +import "strings" // Enclosure represents an attachment. type Enclosure struct { - ID int64 `json:"id"` - UserID int64 `json:"user_id"` - EntryID int64 `json:"entry_id"` - URL string `json:"url"` - MimeType string `json:"mime_type"` - Size int64 `json:"size"` + ID int64 `json:"id"` + UserID int64 `json:"user_id"` + EntryID int64 `json:"entry_id"` + URL string `json:"url"` + MimeType string `json:"mime_type"` + Size int64 `json:"size"` + MediaProgression int64 `json:"media_progression"` +} + +// Html5MimeType will modify the actual MimeType to allow direct playback from HTML5 player for some kind of MimeType +func (e Enclosure) Html5MimeType() string { + if strings.HasPrefix(e.MimeType, "video") { + switch e.MimeType { + // Solution from this stackoverflow discussion: + // https://stackoverflow.com/questions/15277147/m4v-mimetype-video-mp4-or-video-m4v/66945470#66945470 + // tested at the time of this commit (06/2023) on latest Firefox & Vivaldi on this feed + // https://www.florenceporcel.com/podcast/lfhdu.xml + case "video/m4v": + return "video/x-m4v" + } + } + return e.MimeType } // EnclosureList represents a list of attachments. diff --git a/model/enclosure_test.go b/model/enclosure_test.go new file mode 100644 index 00000000..e1a3b6b4 --- /dev/null +++ b/model/enclosure_test.go @@ -0,0 +1,30 @@ +package model + +import ( + "testing" +) + +func TestEnclosure_Html5MimeTypeGivesOriginalMimeType(t *testing.T) { + enclosure := Enclosure{MimeType: "thing/thisMimeTypeIsNotExpectedToBeReplaced"} + if enclosure.Html5MimeType() != enclosure.MimeType { + t.Fatalf( + "HTML5 MimeType must provide original MimeType if not explicitly Replaced. Got %s ,expected '%s' ", + enclosure.Html5MimeType(), + enclosure.MimeType, + ) + } +} + +func TestEnclosure_Html5MimeTypeReplaceStandardM4vByAppleSpecificMimeType(t *testing.T) { + enclosure := Enclosure{MimeType: "video/m4v"} + if enclosure.Html5MimeType() != "video/x-m4v" { + // Solution from this stackoverflow discussion: + // https://stackoverflow.com/questions/15277147/m4v-mimetype-video-mp4-or-video-m4v/66945470#66945470 + // tested at the time of this commit (06/2023) on latest Firefox & Vivaldi on this feed + // https://www.florenceporcel.com/podcast/lfhdu.xml + t.Fatalf( + "HTML5 MimeType must be replaced by 'video/x-m4v' when originally video/m4v to ensure playbacks in brownser. Got '%s'", + enclosure.Html5MimeType(), + ) + } +} diff --git a/model/feed.go b/model/feed.go index 7068404f..de93f2b6 100644 --- a/model/feed.go +++ b/model/feed.go @@ -46,6 +46,7 @@ type Feed struct { Username string `json:"username"` Password string `json:"password"` Disabled bool `json:"disabled"` + NoMediaPlayer bool `json:"no_media_player"` IgnoreHTTPCache bool `json:"ignore_http_cache"` AllowSelfSignedCertificates bool `json:"allow_self_signed_certificates"` FetchViaProxy bool `json:"fetch_via_proxy"` @@ -134,6 +135,7 @@ type FeedCreationRequest struct { Password string `json:"password"` Crawler bool `json:"crawler"` Disabled bool `json:"disabled"` + NoMediaPlayer bool `json:"no_media_player"` IgnoreHTTPCache bool `json:"ignore_http_cache"` AllowSelfSignedCertificates bool `json:"allow_self_signed_certificates"` FetchViaProxy bool `json:"fetch_via_proxy"` @@ -162,6 +164,7 @@ type FeedModificationRequest struct { Password *string `json:"password"` CategoryID *int64 `json:"category_id"` Disabled *bool `json:"disabled"` + NoMediaPlayer *bool `json:"no_media_player"` IgnoreHTTPCache *bool `json:"ignore_http_cache"` AllowSelfSignedCertificates *bool `json:"allow_self_signed_certificates"` FetchViaProxy *bool `json:"fetch_via_proxy"` @@ -230,6 +233,10 @@ func (f *FeedModificationRequest) Patch(feed *Feed) { feed.Disabled = *f.Disabled } + if f.NoMediaPlayer != nil { + feed.NoMediaPlayer = *f.NoMediaPlayer + } + if f.IgnoreHTTPCache != nil { feed.IgnoreHTTPCache = *f.IgnoreHTTPCache } diff --git a/storage/enclosure.go b/storage/enclosure.go index aa9f64ef..452804dc 100644 --- a/storage/enclosure.go +++ b/storage/enclosure.go @@ -13,6 +13,20 @@ import ( // GetEnclosures returns all attachments for the given entry. func (s *Storage) GetEnclosures(entryID int64) (model.EnclosureList, error) { + tx, err := s.db.Begin() + if err != nil { + return nil, fmt.Errorf(`store: unable to start transaction: %v`, err) + } + // As the transaction is only created to use the txGetEnclosures function, we can commit it and close it. + // to avoid leaving an open transaction as I don't have any idea if it will be closed automatically, + // I manually close it. I chose `commit` over `rollback` because I assumed it cost less on SGBD, but I'm no Database + // administrator so any better solution is welcome. + defer tx.Commit() + return s.txGetEnclosures(tx, entryID) +} + +// GetEnclosures returns all attachments for the given entry within a Database transaction +func (s *Storage) txGetEnclosures(tx *sql.Tx, entryID int64) (model.EnclosureList, error) { query := ` SELECT id, @@ -20,7 +34,8 @@ func (s *Storage) GetEnclosures(entryID int64) (model.EnclosureList, error) { entry_id, url, size, - mime_type + mime_type, + media_progression FROM enclosures WHERE @@ -28,7 +43,7 @@ func (s *Storage) GetEnclosures(entryID int64) (model.EnclosureList, error) { ORDER BY id ASC ` - rows, err := s.db.Query(query, entryID) + rows, err := tx.Query(query, entryID) if err != nil { return nil, fmt.Errorf(`store: unable to fetch enclosures: %v`, err) } @@ -44,6 +59,7 @@ func (s *Storage) GetEnclosures(entryID int64) (model.EnclosureList, error) { &enclosure.URL, &enclosure.Size, &enclosure.MimeType, + &enclosure.MediaProgression, ) if err != nil { @@ -56,6 +72,43 @@ func (s *Storage) GetEnclosures(entryID int64) (model.EnclosureList, error) { return enclosures, nil } +func (s *Storage) GetEnclosure(enclosureID int64) (*model.Enclosure, error) { + query := ` + SELECT + id, + user_id, + entry_id, + url, + size, + mime_type, + media_progression + FROM + enclosures + WHERE + id = $1 + ORDER BY id ASC + ` + + row := s.db.QueryRow(query, enclosureID) + + var enclosure model.Enclosure + err := row.Scan( + &enclosure.ID, + &enclosure.UserID, + &enclosure.EntryID, + &enclosure.URL, + &enclosure.Size, + &enclosure.MimeType, + &enclosure.MediaProgression, + ) + + if err != nil { + return nil, fmt.Errorf(`store: unable to fetch enclosure row: %v`, err) + } + + return &enclosure, nil +} + func (s *Storage) createEnclosure(tx *sql.Tx, enclosure *model.Enclosure) error { if enclosure.URL == "" { return nil @@ -63,9 +116,9 @@ func (s *Storage) createEnclosure(tx *sql.Tx, enclosure *model.Enclosure) error query := ` INSERT INTO enclosures - (url, size, mime_type, entry_id, user_id) + (url, size, mime_type, entry_id, user_id, media_progression) VALUES - ($1, $2, $3, $4, $5) + ($1, $2, $3, $4, $5, $6) RETURNING id ` @@ -76,6 +129,7 @@ func (s *Storage) createEnclosure(tx *sql.Tx, enclosure *model.Enclosure) error enclosure.MimeType, enclosure.EntryID, enclosure.UserID, + enclosure.MediaProgression, ).Scan(&enclosure.ID) if err != nil { @@ -86,12 +140,48 @@ func (s *Storage) createEnclosure(tx *sql.Tx, enclosure *model.Enclosure) error } func (s *Storage) updateEnclosures(tx *sql.Tx, userID, entryID int64, enclosures model.EnclosureList) error { - // We delete all attachments in the transaction to keep only the ones visible in the feeds. - if _, err := tx.Exec(`DELETE FROM enclosures WHERE user_id=$1 AND entry_id=$2`, userID, entryID); err != nil { - return err + originalEnclosures, err := s.txGetEnclosures(tx, entryID) + if err != nil { + return fmt.Errorf(`store: unable fetch enclosures for entry #%d : %v`, entryID, err) } + // this map will allow to identify enclosure already in the database based on their URL. + originalEnclosuresByURL := map[string]*model.Enclosure{} + for _, enclosure := range originalEnclosures { + originalEnclosuresByURL[enclosure.URL] = enclosure + } + + // in order to keep enclosure ID consistent I need to identify already existing one to keep them as is, and only + // add/delete enclosure that need to be + enclosuresToAdd := map[string]*model.Enclosure{} + enclosuresToDelete := map[string]*model.Enclosure{} + enclosuresToKeep := map[string]*model.Enclosure{} + for _, enclosure := range enclosures { + originalEnclosure, alreadyExist := originalEnclosuresByURL[enclosure.URL] + if alreadyExist { + enclosuresToKeep[originalEnclosure.URL] = originalEnclosure // we keep the original already in the database + } else { + enclosuresToAdd[enclosure.URL] = enclosure // we insert the new one + } + } + + // we know what to keep, and add. We need to find what's in the database that need to be deleted + for _, enclosure := range originalEnclosures { + _, existToAdd := enclosuresToAdd[enclosure.URL] + _, existToKeep := enclosuresToKeep[enclosure.URL] + if !existToKeep && !existToAdd { // if it does not exist to keep or add this mean it has been deleted. + enclosuresToDelete[enclosure.URL] = enclosure + } + } + + for _, enclosure := range enclosuresToDelete { + if _, err := tx.Exec(`DELETE FROM enclosures WHERE user_id=$1 AND entry_id=$2 and id=$3`, userID, entryID, enclosure.ID); err != nil { + return err + } + } + + for _, enclosure := range enclosuresToAdd { if err := s.createEnclosure(tx, enclosure); err != nil { return err } @@ -99,3 +189,31 @@ func (s *Storage) updateEnclosures(tx *sql.Tx, userID, entryID int64, enclosures return nil } +func (s *Storage) UpdateEnclosure(enclosure *model.Enclosure) error { + query := ` + UPDATE + enclosures + SET + url=$1, + size=$2, + mime_type=$3, + entry_id=$4, + user_id=$5, + media_progression=$6 + WHERE + id=$7 + ` + _, err := s.db.Exec(query, + enclosure.URL, + enclosure.Size, + enclosure.MimeType, + enclosure.EntryID, + enclosure.UserID, + enclosure.MediaProgression, + enclosure.ID, + ) + if err != nil { + return fmt.Errorf(`store: unable to update enclosure #%d : %v`, enclosure.ID, err) + } + return nil +} diff --git a/storage/entry_query_builder.go b/storage/entry_query_builder.go index dae7bc1f..d47ca3af 100644 --- a/storage/entry_query_builder.go +++ b/storage/entry_query_builder.go @@ -272,6 +272,7 @@ func (e *EntryQueryBuilder) GetEntries() (model.Entries, error) { f.crawler, f.user_agent, f.cookie, + f.no_media_player, fi.icon_id, u.timezone FROM @@ -336,6 +337,7 @@ func (e *EntryQueryBuilder) GetEntries() (model.Entries, error) { &entry.Feed.Crawler, &entry.Feed.UserAgent, &entry.Feed.Cookie, + &entry.Feed.NoMediaPlayer, &iconID, &tz, ) diff --git a/storage/feed.go b/storage/feed.go index 03d0ba1c..efa1b516 100644 --- a/storage/feed.go +++ b/storage/feed.go @@ -243,10 +243,11 @@ func (s *Storage) CreateFeed(feed *model.Feed) error { allow_self_signed_certificates, fetch_via_proxy, hide_globally, - url_rewrite_rules + url_rewrite_rules, + no_media_player ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22) + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23) RETURNING id ` @@ -274,6 +275,7 @@ func (s *Storage) CreateFeed(feed *model.Feed) error { feed.FetchViaProxy, feed.HideGlobally, feed.UrlRewriteRules, + feed.NoMediaPlayer, ).Scan(&feed.ID) if err != nil { return fmt.Errorf(`store: unable to create feed %q: %v`, feed.FeedURL, err) @@ -333,9 +335,10 @@ func (s *Storage) UpdateFeed(feed *model.Feed) (err error) { allow_self_signed_certificates=$22, fetch_via_proxy=$23, hide_globally=$24, - url_rewrite_rules=$25 + url_rewrite_rules=$25, + no_media_player=$26 WHERE - id=$26 AND user_id=$27 + id=$27 AND user_id=$28 ` _, err = s.db.Exec(query, feed.FeedURL, @@ -363,6 +366,7 @@ func (s *Storage) UpdateFeed(feed *model.Feed) (err error) { feed.FetchViaProxy, feed.HideGlobally, feed.UrlRewriteRules, + feed.NoMediaPlayer, feed.ID, feed.UserID, ) diff --git a/storage/feed_query_builder.go b/storage/feed_query_builder.go index 4f3194db..1c310eb7 100644 --- a/storage/feed_query_builder.go +++ b/storage/feed_query_builder.go @@ -167,6 +167,7 @@ func (f *FeedQueryBuilder) GetFeeds() (model.Feeds, error) { f.allow_self_signed_certificates, f.fetch_via_proxy, f.disabled, + f.no_media_player, f.hide_globally, f.category_id, c.title as category_title, @@ -230,6 +231,7 @@ func (f *FeedQueryBuilder) GetFeeds() (model.Feeds, error) { &feed.AllowSelfSignedCertificates, &feed.FetchViaProxy, &feed.Disabled, + &feed.NoMediaPlayer, &feed.HideGlobally, &feed.Category.ID, &feed.Category.Title, diff --git a/template/templates/views/edit_feed.html b/template/templates/views/edit_feed.html index eb3df395..af4a5b2a 100644 --- a/template/templates/views/edit_feed.html +++ b/template/templates/views/edit_feed.html @@ -130,6 +130,8 @@ {{ end }} + + {{ if not .form.CategoryHidden }} {{ end }} diff --git a/template/templates/views/entry.html b/template/templates/views/entry.html index a0fec9b7..73273988 100644 --- a/template/templates/views/entry.html +++ b/template/templates/views/entry.html @@ -144,6 +144,39 @@ {{ end }} {{ end }}
+ {{ if (and .entry.Enclosures (not .entry.Feed.NoMediaPlayer)) }} + {{ range .entry.Enclosures }} + {{ if ne .URL "" }} + {{ if hasPrefix .MimeType "audio/" }} +
+ +
+ {{ else if hasPrefix .MimeType "video/" }} +
+ +
+ {{ end }} + {{ end }} + {{ end }} + {{end}} {{ if .user }} {{ noescape (proxyFilter .entry.Content) }} {{ else }} @@ -158,21 +191,27 @@
{{ if hasPrefix .MimeType "audio/" }}
-
{{ else if hasPrefix .MimeType "video/" }}
-
diff --git a/ui/entry_enclosure_save_position.go b/ui/entry_enclosure_save_position.go new file mode 100644 index 00000000..729fb163 --- /dev/null +++ b/ui/entry_enclosure_save_position.go @@ -0,0 +1,48 @@ +// Copyright 2018 Frédéric Guillot. All rights reserved. +// Use of this source code is governed by the Apache 2.0 +// license that can be found in the LICENSE file. + +package ui // import "miniflux.app/ui" + +import ( + json2 "encoding/json" + "io" + "net/http" + + "miniflux.app/http/request" + "miniflux.app/http/response/json" +) + +type enclosurePositionSaveRequest struct { + Progression int64 `json:"progression"` +} + +func (h *handler) saveEnclosureProgression(w http.ResponseWriter, r *http.Request) { + enclosureID := request.RouteInt64Param(r, "enclosureID") + enclosure, err := h.store.GetEnclosure(enclosureID) + if err != nil { + json.ServerError(w, r, err) + return + } + var postData enclosurePositionSaveRequest + body, err := io.ReadAll(r.Body) + if err != nil { + json.ServerError(w, r, err) + return + } + + json2.Unmarshal(body, &postData) + if err != nil { + json.ServerError(w, r, err) + return + } + enclosure.MediaProgression = postData.Progression + + err = h.store.UpdateEnclosure(enclosure) + if err != nil { + json.ServerError(w, r, err) + return + } + + json.Created(w, r, map[string]string{"message": "saved"}) +} diff --git a/ui/feed_edit.go b/ui/feed_edit.go index ed9e5378..14cf6b9f 100644 --- a/ui/feed_edit.go +++ b/ui/feed_edit.go @@ -59,6 +59,7 @@ func (h *handler) showEditFeedPage(w http.ResponseWriter, r *http.Request) { AllowSelfSignedCertificates: feed.AllowSelfSignedCertificates, FetchViaProxy: feed.FetchViaProxy, Disabled: feed.Disabled, + NoMediaPlayer: feed.NoMediaPlayer, HideGlobally: feed.HideGlobally, CategoryHidden: feed.Category.HideGlobally, } diff --git a/ui/form/feed.go b/ui/form/feed.go index 53181a7f..f93937aa 100644 --- a/ui/form/feed.go +++ b/ui/form/feed.go @@ -31,6 +31,7 @@ type FeedForm struct { AllowSelfSignedCertificates bool FetchViaProxy bool Disabled bool + NoMediaPlayer bool HideGlobally bool CategoryHidden bool // Category has "hide_globally" } @@ -57,6 +58,7 @@ func (f FeedForm) Merge(feed *model.Feed) *model.Feed { feed.AllowSelfSignedCertificates = f.AllowSelfSignedCertificates feed.FetchViaProxy = f.FetchViaProxy feed.Disabled = f.Disabled + feed.NoMediaPlayer = f.NoMediaPlayer feed.HideGlobally = f.HideGlobally return feed } @@ -86,6 +88,7 @@ func NewFeedForm(r *http.Request) *FeedForm { AllowSelfSignedCertificates: r.FormValue("allow_self_signed_certificates") == "1", FetchViaProxy: r.FormValue("fetch_via_proxy") == "1", Disabled: r.FormValue("disabled") == "1", + NoMediaPlayer: r.FormValue("no_media_player") == "1", HideGlobally: r.FormValue("hide_globally") == "1", } } diff --git a/ui/static/css/common.css b/ui/static/css/common.css index 821d10c9..f61720cf 100644 --- a/ui/static/css/common.css +++ b/ui/static/css/common.css @@ -1081,3 +1081,7 @@ details.entry-enclosures { .disabled { opacity: 20%; } + +audio,video { + width: 100%; +} diff --git a/ui/static/js/app.js b/ui/static/js/app.js index b8082fc8..308a22b7 100644 --- a/ui/static/js/app.js +++ b/ui/static/js/app.js @@ -618,3 +618,23 @@ function showToast(label, iconElement) { function goToAddSubscription() { window.location.href = document.body.dataset.addSubscriptionUrl; } + +/** + * save player position to allow to resume playback later + * @param {Element} playerElement + */ +function handlePlayerProgressionSave(playerElement) { + const currentPositionInSeconds = Math.floor(playerElement.currentTime); // we do not need a precise value + const lastKnownPositionInSeconds = parseInt(playerElement.dataset.lastPosition, 10); + const recordInterval = 10; + + // we limit the number of update to only one by interval. Otherwise, we would have multiple update per seconds + if (currentPositionInSeconds >= (lastKnownPositionInSeconds + recordInterval) || + currentPositionInSeconds <= (lastKnownPositionInSeconds - recordInterval) + ) { + playerElement.dataset.lastPosition = currentPositionInSeconds.toString(); + let request = new RequestBuilder(playerElement.dataset.saveUrl); + request.withBody({progression: currentPositionInSeconds}); + request.execute(); + } +} diff --git a/ui/static/js/bootstrap.js b/ui/static/js/bootstrap.js index b4c46017..5ff97734 100644 --- a/ui/static/js/bootstrap.js +++ b/ui/static/js/bootstrap.js @@ -112,4 +112,12 @@ document.addEventListener("DOMContentLoaded", function () { } } }); + + // enclosure media player position save & resume + const elements = document.querySelectorAll("audio[data-last-position],video[data-last-position]"); + elements.forEach((element) => { + // we set the current time of media players + if (element.dataset.lastPosition){ element.currentTime = element.dataset.lastPosition; } + element.ontimeupdate = () => handlePlayerProgressionSave(element); + }); }); diff --git a/ui/ui.go b/ui/ui.go index 15f5da78..cf7b53c0 100644 --- a/ui/ui.go +++ b/ui/ui.go @@ -95,6 +95,7 @@ func Serve(router *mux.Router, store *storage.Storage, pool *worker.Pool) { // Entry pages. uiRouter.HandleFunc("/entry/status", handler.updateEntriesStatus).Name("updateEntriesStatus").Methods(http.MethodPost) uiRouter.HandleFunc("/entry/save/{entryID}", handler.saveEntry).Name("saveEntry").Methods(http.MethodPost) + uiRouter.HandleFunc("/entry/enclosure/{enclosureID}/save-progression", handler.saveEnclosureProgression).Name("saveEnclosureProgression").Methods(http.MethodPost) uiRouter.HandleFunc("/entry/download/{entryID}", handler.fetchContent).Name("fetchContent").Methods(http.MethodPost) uiRouter.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", handler.mediaProxy).Name("proxy").Methods(http.MethodGet) uiRouter.HandleFunc("/entry/bookmark/{entryID}", handler.toggleBookmark).Name("toggleBookmark").Methods(http.MethodPost)