Compare commits

...

61 Commits
v2.1.2 ... main

Author SHA1 Message Date
dependabot[bot] 839c4ad044 build(deps): bump github.com/yuin/goldmark from 1.7.1 to 1.7.2
Bumps [github.com/yuin/goldmark](https://github.com/yuin/goldmark) from 1.7.1 to 1.7.2.
- [Release notes](https://github.com/yuin/goldmark/releases)
- [Commits](https://github.com/yuin/goldmark/compare/v1.7.1...v1.7.2)

---
updated-dependencies:
- dependency-name: github.com/yuin/goldmark
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-14 16:43:20 -07:00
Ztec 9f3a8e7f1b Request builder: Allow the use of insecure TLS ciphers when `Allow self-signed or invalid certificates` is used
Some server on the wild are badly configured. Either by mistake or lack
of maintenance. Safe and unsafe Ciphers change overtime based on new
discoveries.

This proposition will include considered unsafe ciphers when `Allow self-signed or invalid certificates` is used.
It could be put into a separate option but, I felt this could fit in.

fix #2671
2024-06-13 20:23:37 -07:00
Ztec e54825bf02 Improve YouTube page feed detection
In order to be more resilient to YouTube URLs variation and
to address this feature_request: https://github.com/miniflux/v2/issues/2628
I've reworked a bit the way the YouTube feed extraction is done.

I've kept all the `FindSubscriptionsFromYouTube*` in order
to keep all the existing unit tests as-is ensuring little to no
regressions. By doing so, I had to call twice `youtubeURLIDExtractor`.
Small performance penalty for peace of mind in my opinion.

`youtubeURLIDExtractor` is made in a way only one kind
of page can be detected at a time. This mean I can
solve the "video in a playlist" feature_request
by prioritizing the playlist ID over the Video ID

Also, by using `url.Parse()` to get ids, it's safer
to url mangle and variation. The most common variation
being the `t=42` parameters that start the playback
at a given position. Previously, this kind of url
would not be detected as "YouTube URL".

I deliberately ignored the url parsing error
to keep previous behavior (skip the YouTube analysis and follow with the other analysis)

I also tried to keep debug logs the same as before as much as I could.

I manually tested all the YouTube cases (video,channel,playlist)
and they all work as expected except for the video. But this one
does not work either on main. The `meta` html tag that was searched for
does not seem to exist anymore.

fix: #2628
2024-06-13 20:18:47 -07:00
Ztec 07f6d397d4 Fix Playback speed indicator precision
The original idea was to have two digit precision at all time
in order to ensure the length of the string is always the same.
This prevents the UI button to move when pressed.
I completely missed the first press as the precision was not right
upon first click.
2024-06-13 20:13:07 -07:00
Ztec f33e76eb8c Fix Playback speed indicator on shared entries
On shared entries, there is no speed configured as this
is bound to the user. Shared entries are displayed without user config.

I've changed the default view to reflect the
actual default playback speed in this case. 1x.
2024-06-13 20:11:33 -07:00
dependabot[bot] 84e97826d8 build(deps): bump github.com/tdewolff/minify/v2 from 2.20.33 to 2.20.34
Bumps [github.com/tdewolff/minify/v2](https://github.com/tdewolff/minify) from 2.20.33 to 2.20.34.
- [Release notes](https://github.com/tdewolff/minify/releases)
- [Commits](https://github.com/tdewolff/minify/compare/v2.20.33...v2.20.34)

---
updated-dependencies:
- dependency-name: github.com/tdewolff/minify/v2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-11 20:24:20 -07:00
x 839fc3843a Add pitchfork.com scraping rule 2024-06-10 21:08:59 -07:00
x 0bab8fac8e Update theverge.com rewrite rule: fix duplicate image
See: https://github.com/miniflux/v2/issues/1979
2024-06-10 21:08:59 -07:00
dependabot[bot] 0cf1a40276 build(deps): bump github.com/tdewolff/minify/v2 from 2.20.32 to 2.20.33
Bumps [github.com/tdewolff/minify/v2](https://github.com/tdewolff/minify) from 2.20.32 to 2.20.33.
- [Release notes](https://github.com/tdewolff/minify/releases)
- [Commits](https://github.com/tdewolff/minify/compare/v2.20.32...v2.20.33)

---
updated-dependencies:
- dependency-name: github.com/tdewolff/minify/v2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-09 20:38:34 -07:00
dependabot[bot] 91479bc0ee build(deps): bump golang.org/x/net from 0.25.0 to 0.26.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.25.0 to 0.26.0.
- [Commits](https://github.com/golang/net/compare/v0.25.0...v0.26.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-04 20:02:27 -07:00
dependabot[bot] 251821289c build(deps): bump golang.org/x/oauth2 from 0.20.0 to 0.21.0
Bumps [golang.org/x/oauth2](https://github.com/golang/oauth2) from 0.20.0 to 0.21.0.
- [Commits](https://github.com/golang/oauth2/compare/v0.20.0...v0.21.0)

---
updated-dependencies:
- dependency-name: golang.org/x/oauth2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-04 19:40:09 -07:00
dependabot[bot] cac0bc682f build(deps): bump golang.org/x/crypto from 0.23.0 to 0.24.0
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.23.0 to 0.24.0.
- [Commits](https://github.com/golang/crypto/compare/v0.23.0...v0.24.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-04 19:39:49 -07:00
dependabot[bot] a733c14c61 build(deps): bump golang.org/x/term from 0.20.0 to 0.21.0
Bumps [golang.org/x/term](https://github.com/golang/term) from 0.20.0 to 0.21.0.
- [Commits](https://github.com/golang/term/compare/v0.20.0...v0.21.0)

---
updated-dependencies:
- dependency-name: golang.org/x/term
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-04 19:15:01 -07:00
Ankit Pandey b68b05c64c reader/processor: error out for improper rewrite regexp
It's possible to specify a rewrite regex that validates but doesn't compile such
as:

    rewrite("(((unmatched-capture-group"|"rewrite)))")

In case we encounter one, exit early instead of letting the server panic.
2024-06-01 10:37:02 -07:00
Frédéric Guillot 5ce3f24838 googelreader: set CrawlTimeMsec at the correct precision
Fixes #2669

Fixes #2670
2024-05-29 21:54:02 -07:00
dependabot[bot] 48ddc02ba8 build(deps): bump github.com/tdewolff/minify/v2 from 2.20.30 to 2.20.32
Bumps [github.com/tdewolff/minify/v2](https://github.com/tdewolff/minify) from 2.20.30 to 2.20.32.
- [Release notes](https://github.com/tdewolff/minify/releases)
- [Commits](https://github.com/tdewolff/minify/compare/v2.20.30...v2.20.32)

---
updated-dependencies:
- dependency-name: github.com/tdewolff/minify/v2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-27 15:46:53 -07:00
dependabot[bot] fe9f1bba16 build(deps): bump library/alpine in /packaging/docker/alpine
Bumps library/alpine from 3.19 to 3.20.

---
updated-dependencies:
- dependency-name: library/alpine
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-27 15:44:57 -07:00
Krish Mamtora 740fa4a5d2 Add missing properties when reloading page after error 2024-05-27 15:37:53 -07:00
dependabot[bot] 8a38f54ef5 build(deps): bump github.com/tdewolff/minify/v2 from 2.20.25 to 2.20.30
Bumps [github.com/tdewolff/minify/v2](https://github.com/tdewolff/minify) from 2.20.25 to 2.20.30.
- [Release notes](https://github.com/tdewolff/minify/releases)
- [Commits](https://github.com/tdewolff/minify/compare/v2.20.25...v2.20.30)

---
updated-dependencies:
- dependency-name: github.com/tdewolff/minify/v2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-22 19:16:11 -07:00
Zhizhen He ae432bc9c6
reader/readingtime: fix incorrect package name 2024-05-21 18:12:24 -07:00
dependabot[bot] 96f7e8bae0 ---
updated-dependencies:
- dependency-name: github.com/tdewolff/minify/v2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-21 17:48:00 -07:00
rootknight 1f35ed1675
ui: add `viewport-fit=cover` 2024-05-19 10:39:34 -07:00
dependabot[bot] d6deac1810 build(deps): bump github.com/tdewolff/minify/v2 from 2.20.21 to 2.20.24
Bumps [github.com/tdewolff/minify/v2](https://github.com/tdewolff/minify) from 2.20.21 to 2.20.24.
- [Release notes](https://github.com/tdewolff/minify/releases)
- [Commits](https://github.com/tdewolff/minify/compare/v2.20.21...v2.20.24)

---
updated-dependencies:
- dependency-name: github.com/tdewolff/minify/v2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-18 08:39:43 -07:00
Frédéric Guillot b692768730 packaging: fix failed to solve: arm64v8/golang:1.22-bookworm 2024-05-17 21:07:40 -07:00
dependabot[bot] 2178580a75 build(deps): bump golangci/golangci-lint-action from 5 to 6
Bumps [golangci/golangci-lint-action](https://github.com/golangci/golangci-lint-action) from 5 to 6.
- [Release notes](https://github.com/golangci/golangci-lint-action/releases)
- [Commits](https://github.com/golangci/golangci-lint-action/compare/v5...v6)

---
updated-dependencies:
- dependency-name: golangci/golangci-lint-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-13 17:59:04 -07:00
dependabot[bot] b52f61cc77 build(deps): bump github.com/tdewolff/minify/v2 from 2.20.20 to 2.20.21
Bumps [github.com/tdewolff/minify/v2](https://github.com/tdewolff/minify) from 2.20.20 to 2.20.21.
- [Release notes](https://github.com/tdewolff/minify/releases)
- [Commits](https://github.com/tdewolff/minify/compare/v2.20.20...v2.20.21)

---
updated-dependencies:
- dependency-name: github.com/tdewolff/minify/v2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-13 17:58:41 -07:00
dependabot[bot] 3388f8e376 Bump github.com/prometheus/client_golang from 1.19.0 to 1.19.1
Bumps [github.com/prometheus/client_golang](https://github.com/prometheus/client_golang) from 1.19.0 to 1.19.1.
- [Release notes](https://github.com/prometheus/client_golang/releases)
- [Changelog](https://github.com/prometheus/client_golang/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prometheus/client_golang/compare/v1.19.0...v1.19.1)

---
updated-dependencies:
- dependency-name: github.com/prometheus/client_golang
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-09 21:37:53 -07:00
dependabot[bot] 83ceb20c1c Bump golang.org/x/net from 0.24.0 to 0.25.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.24.0 to 0.25.0.
- [Commits](https://github.com/golang/net/compare/v0.24.0...v0.25.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-06 16:45:02 -07:00
dependabot[bot] c06850ca34 Bump golang.org/x/oauth2 from 0.19.0 to 0.20.0
Bumps [golang.org/x/oauth2](https://github.com/golang/oauth2) from 0.19.0 to 0.20.0.
- [Commits](https://github.com/golang/oauth2/compare/v0.19.0...v0.20.0)

---
updated-dependencies:
- dependency-name: golang.org/x/oauth2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-06 16:22:00 -07:00
dependabot[bot] d856c02fbb Bump golang.org/x/crypto from 0.22.0 to 0.23.0
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.22.0 to 0.23.0.
- [Commits](https://github.com/golang/crypto/compare/v0.22.0...v0.23.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-06 15:42:12 -07:00
Jan-Lukas Else a33b1adf13 Add description field to feed settings
This adds a new "description" field to the feed settings. This allows to
save custom description regarding a feed. It is also exported and
imported as "description" in OPML.
2024-05-06 15:40:36 -07:00
fin444 a631bd527d options: add FETCH_NEBULA_WATCH_TIME 2024-05-02 16:30:01 -07:00
Alpha Chen ca62b0b36b integration/raindrop: initial draft implementation 2024-05-02 16:23:00 -07:00
Kioubit 7d6a4243c1 Make cookie duration dependent on configuration
This ensures that session cookies are not expiring before the session is cleaned up from the database as per CLEANUP_REMOVE_SESSIONS_DAYS.
As of now the usefulness of this configuration option is diminished as extending it has no effect on the actual browser session due to the cookie expiry.
Fixes: #2214
2024-05-01 19:34:13 -07:00
dependabot[bot] d056aa1f73 Bump github.com/PuerkitoBio/goquery from 1.9.1 to 1.9.2
Bumps [github.com/PuerkitoBio/goquery](https://github.com/PuerkitoBio/goquery) from 1.9.1 to 1.9.2.
- [Release notes](https://github.com/PuerkitoBio/goquery/releases)
- [Commits](https://github.com/PuerkitoBio/goquery/compare/v1.9.1...v1.9.2)

---
updated-dependencies:
- dependency-name: github.com/PuerkitoBio/goquery
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-30 17:23:35 -07:00
dependabot[bot] 018e24404e Bump golangci/golangci-lint-action from 4 to 5
Bumps [golangci/golangci-lint-action](https://github.com/golangci/golangci-lint-action) from 4 to 5.
- [Release notes](https://github.com/golangci/golangci-lint-action/releases)
- [Commits](https://github.com/golangci/golangci-lint-action/compare/v4...v5)

---
updated-dependencies:
- dependency-name: golangci/golangci-lint-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-29 16:51:20 -07:00
Frédéric Guillot 4d3ee0d15d ci: fix docker workflow to add distroless suffix on latest tag 2024-04-27 15:26:16 -07:00
Frédéric Guillot 797450986b Update ChangeLog 2024-04-27 15:06:28 -07:00
Ztec 93bc9ce24d add seek and speed controls to media player
When listening to podcast, it is usual to want to speed up the playback.
https://github.com/miniflux/v2/pull/2521 was addressing the need globally, this PR
allow to address it for just the current open enclosure media. (no save) Some Browser
already include this control directly, but firefox does not (directly anyway).

Also, it is often useful to be able to skip chunk of a podcast, to skip commercials
for example, or get back a bit because we couldn't hear the last part. I added rudimentary
seek controls with the usual +/-10 and 30 seconds chuck size. This is pretty handy when podcast
are very long and using the seek bar is way too tricky to just skip 30s.

As always, I'm French and could only provide English and French translation for the few
text I added in the locale/translations files. Any help is welcome.

Tested mostly on Firefox (121.0) and quickly on Vivaldi(6.5.3206.53), chrome based.

Fixes: #1845 #1846
2024-04-26 13:44:26 -07:00
dependabot[bot] 9233568da3 Bump github.com/tdewolff/minify/v2 from 2.20.19 to 2.20.20
Bumps [github.com/tdewolff/minify/v2](https://github.com/tdewolff/minify) from 2.20.19 to 2.20.20.
- [Release notes](https://github.com/tdewolff/minify/releases)
- [Commits](https://github.com/tdewolff/minify/compare/v2.20.19...v2.20.20)

---
updated-dependencies:
- dependency-name: github.com/tdewolff/minify/v2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-24 19:09:18 -07:00
Frédéric Guillot fb075b60b5 reader/processor: minifier is breaking HTML entry content 2024-04-23 20:31:52 -07:00
Frédéric Guillot 2c4c845cd2 http/response: add brotli compression support 2024-04-19 12:16:49 -07:00
bo0tzz 2caabbe939 fix: Use `FORCE_REFRESH_INTERVAL` config for category refresh 2024-04-19 11:58:13 -07:00
Frédéric Guillot 771f9d2b5f reader/fetcher: add brotli content encoding support 2024-04-19 10:50:46 -07:00
Romain de Laage 647c66e70a ui: add tag entries page 2024-04-14 20:08:38 -07:00
jvoisin b205b5aad0 reader/processor: minimize the feed's entries html
Compress the html of feed entries before storing it. This should reduce the
size of the database a bit, but more importantly, reduce the amount of data
sent to clients

minify being [stupidly fast](https://github.com/tdewolff/minify/?tab=readme-ov-file#performance), the performance impact should be in the noise level.
2024-04-10 19:48:48 -07:00
goodfirm 4ab0d9422d chore: fix function name in comment
Signed-off-by: goodfirm <fanyishang@yeah.net>
2024-04-10 19:36:30 -07:00
Frédéric Guillot 38b80d96ea storage: change GetReadTime() function to use entries_feed_id_hash_key index 2024-04-09 20:37:30 -07:00
Michael Kuhn 35edd8ea92 Fix clicking unread counter
When clicking the unread counter, the following exception occurs:
```
Uncaught TypeError: Cannot read properties of null (reading 'getAttribute')
```

This is due to `onClickMainMenuListItem` not working correctly for the
unread counter `span`s, which return `null` when using `querySelector`.
2024-04-09 20:36:42 -07:00
Alexandros Kosiaris f0cb041885 Add back removed other repo owners in GH docker actions
In cf96ab45c1, support was added for using Docker related Github
actions in repositories of other owners. This was pretty helpful as it
allowed running modified forks off of main in a nightly fashion before
patches were pushed upstream. This was 6e870cdccc, add it
back
2024-04-06 11:31:29 -07:00
Frédéric Guillot fdd1b3f18e database: entry URLs can exceeds btree index size limit 2024-04-04 20:22:23 -07:00
Frédéric Guillot 6e870cdccc ci: use docker/metadata-action instead of deprecated shell-scripts 2024-04-04 18:04:32 -07:00
Michael Kuhn 194f517be8 Improve Dockerfiles
- Specify Docker registry explicitly (e.g., Podman does not use
  `docker.io` by default)
- Use `make miniflux` instead of duplicating `go build` arguments (this
  leverages Go's PIE build mode)
- Enable cgo to fix ARM containers (we need to make sure to use the same
  OS version for both container stages to avoid libc issues)
2024-04-04 17:36:28 -07:00
dependabot[bot] 11fd1c935e Bump golang.org/x/net from 0.23.0 to 0.24.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.23.0 to 0.24.0.
- [Commits](https://github.com/golang/net/compare/v0.23.0...v0.24.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-04 16:37:41 -07:00
dependabot[bot] 47e1111908 Bump golang.org/x/term from 0.18.0 to 0.19.0
Bumps [golang.org/x/term](https://github.com/golang/term) from 0.18.0 to 0.19.0.
- [Commits](https://github.com/golang/term/compare/v0.18.0...v0.19.0)

---
updated-dependencies:
- dependency-name: golang.org/x/term
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-04 16:08:16 -07:00
dependabot[bot] c5b812eb7b Bump golang.org/x/oauth2 from 0.18.0 to 0.19.0
Bumps [golang.org/x/oauth2](https://github.com/golang/oauth2) from 0.18.0 to 0.19.0.
- [Commits](https://github.com/golang/oauth2/compare/v0.18.0...v0.19.0)

---
updated-dependencies:
- dependency-name: golang.org/x/oauth2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-04 15:46:40 -07:00
dependabot[bot] 53be550e8a Bump github.com/yuin/goldmark from 1.7.0 to 1.7.1
Bumps [github.com/yuin/goldmark](https://github.com/yuin/goldmark) from 1.7.0 to 1.7.1.
- [Release notes](https://github.com/yuin/goldmark/releases)
- [Commits](https://github.com/yuin/goldmark/compare/v1.7.0...v1.7.1)

---
updated-dependencies:
- dependency-name: github.com/yuin/goldmark
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-03 17:30:47 -07:00
dependabot[bot] d0d693a6ef Bump golang.org/x/net from 0.22.0 to 0.23.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.22.0 to 0.23.0.
- [Commits](https://github.com/golang/net/compare/v0.22.0...v0.23.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-03 17:30:26 -07:00
Evan Elias Young 1b8c45d162 finder: Find feed from YouTube playlist
The feed from a YouTube playlist page is derived in practically the same way as a feed from a YouTube channel page.
2024-04-01 21:16:32 -07:00
jvoisin 19ce519836 reader/rewrite: add a rule for oglaf.com
By default, Oglaf show some disclaimer/warning about its content, and this
doesn't play well with rss readers, so let's rewrite it to show the actual
comic instead of a placeholder.
2024-04-01 21:05:01 -07:00
Thomas J Faughnan Jr 3e0d5de7a3 api tests: use intSize-agnostic random integers
rand.Intn(math.MaxInt64) causes tests to fail on 32-bit architectures.
Use the simpler rand.Int() instead, which still provides plenty of room
for generating pseudo-random test usernames.
2024-04-01 21:02:48 -07:00
86 changed files with 1501 additions and 281 deletions

View File

@ -8,35 +8,8 @@ on:
pull_request: pull_request:
branches: [ main ] branches: [ main ]
jobs: jobs:
test-docker-images: docker-images:
if: github.event.pull_request name: Docker Images
name: Test Images
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Build Alpine image
uses: docker/build-push-action@v5
with:
context: .
file: ./packaging/docker/alpine/Dockerfile
push: false
tags: ${{ github.repository_owner }}/miniflux:alpine-dev
- name: Test Alpine Docker image
run: docker run --rm ${{ github.repository_owner }}/miniflux:alpine-dev miniflux -i
- name: Build Distroless image
uses: docker/build-push-action@v5
with:
context: .
file: ./packaging/docker/distroless/Dockerfile
push: false
tags: ${{ github.repository_owner }}/miniflux:distroless-dev
- name: Test Distroless Docker image
run: docker run --rm ${{ github.repository_owner }}/miniflux:distroless-dev miniflux -i
publish-docker-images:
if: ${{ ! github.event.pull_request }}
name: Publish Images
permissions: permissions:
packages: write packages: write
runs-on: ubuntu-latest runs-on: ubuntu-latest
@ -46,33 +19,33 @@ jobs:
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Generate Alpine Docker tag - name: Generate Alpine Docker tags
id: docker_alpine_tag id: docker_alpine_tags
run: | uses: docker/metadata-action@v5
DOCKER_IMAGE=${{ github.repository_owner }}/miniflux with:
DOCKER_VERSION=dev images: |
if [ "${{ github.event_name }}" = "schedule" ]; then docker.io/${{ github.repository_owner }}/miniflux
DOCKER_VERSION=nightly ghcr.io/${{ github.repository_owner }}/miniflux
TAGS="docker.io/${DOCKER_IMAGE}:${DOCKER_VERSION},ghcr.io/${DOCKER_IMAGE}:${DOCKER_VERSION},quay.io/${DOCKER_IMAGE}:${DOCKER_VERSION}" quay.io/${{ github.repository_owner }}/miniflux
elif [[ $GITHUB_REF == refs/tags/* ]]; then tags: |
DOCKER_VERSION=${GITHUB_REF#refs/tags/} type=ref,event=pr
TAGS="docker.io/${DOCKER_IMAGE}:${DOCKER_VERSION},ghcr.io/${DOCKER_IMAGE}:${DOCKER_VERSION},quay.io/${DOCKER_IMAGE}:${DOCKER_VERSION},docker.io/${DOCKER_IMAGE}:latest,ghcr.io/${DOCKER_IMAGE}:latest,quay.io/${DOCKER_IMAGE}:latest" type=schedule,pattern=nightly
fi type=semver,pattern={{raw}}
echo ::set-output name=tags::${TAGS}
- name: Generate Distroless Docker tag - name: Generate Distroless Docker tags
id: docker_distroless_tag id: docker_distroless_tags
run: | uses: docker/metadata-action@v5
DOCKER_IMAGE=${{ github.repository_owner }}/miniflux with:
DOCKER_VERSION=dev-distroless images: |
if [ "${{ github.event_name }}" = "schedule" ]; then docker.io/${{ github.repository_owner }}/miniflux
DOCKER_VERSION=nightly-distroless ghcr.io/${{ github.repository_owner }}/miniflux
TAGS="docker.io/${DOCKER_IMAGE}:${DOCKER_VERSION},ghcr.io/${DOCKER_IMAGE}:${DOCKER_VERSION},quay.io/${DOCKER_IMAGE}:${DOCKER_VERSION}" quay.io/${{ github.repository_owner }}/miniflux
elif [[ $GITHUB_REF == refs/tags/* ]]; then tags: |
DOCKER_VERSION=${GITHUB_REF#refs/tags/}-distroless type=ref,event=pr
TAGS="docker.io/${DOCKER_IMAGE}:${DOCKER_VERSION},ghcr.io/${DOCKER_IMAGE}:${DOCKER_VERSION},quay.io/${DOCKER_IMAGE}:${DOCKER_VERSION},docker.io/${DOCKER_IMAGE}:latest-distroless,ghcr.io/${DOCKER_IMAGE}:latest-distroless,quay.io/${DOCKER_IMAGE}:latest-distroless" type=schedule,pattern=nightly
fi type=semver,pattern={{raw}}
echo ::set-output name=tags::${TAGS} flavor: |
suffix=-distroless,onlatest=true
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v3
@ -81,12 +54,14 @@ jobs:
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: Login to DockerHub - name: Login to DockerHub
if: github.event_name != 'pull_request'
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
registry: ghcr.io registry: ghcr.io
@ -94,6 +69,7 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to Quay Container Registry - name: Login to Quay Container Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
registry: quay.io registry: quay.io
@ -106,8 +82,8 @@ jobs:
context: . context: .
file: ./packaging/docker/alpine/Dockerfile file: ./packaging/docker/alpine/Dockerfile
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64 platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64
push: true push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.docker_alpine_tag.outputs.tags }} tags: ${{ steps.docker_alpine_tags.outputs.tags }}
- name: Build and Push Distroless images - name: Build and Push Distroless images
uses: docker/build-push-action@v5 uses: docker/build-push-action@v5
@ -115,5 +91,5 @@ jobs:
context: . context: .
file: ./packaging/docker/distroless/Dockerfile file: ./packaging/docker/distroless/Dockerfile
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
push: true push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.docker_distroless_tag.outputs.tags }} tags: ${{ steps.docker_distroless_tags.outputs.tags }}

View File

@ -30,7 +30,7 @@ jobs:
with: with:
go-version: "1.22.x" go-version: "1.22.x"
- run: "go vet ./..." - run: "go vet ./..."
- uses: golangci/golangci-lint-action@v4 - uses: golangci/golangci-lint-action@v6
with: with:
args: --timeout 10m --skip-dirs tests --disable errcheck --enable sqlclosecheck --enable misspell --enable gofmt --enable goimports --enable whitespace --enable gocritic args: --timeout 10m --skip-dirs tests --disable errcheck --enable sqlclosecheck --enable misspell --enable gofmt --enable goimports --enable whitespace --enable gocritic
- uses: dominikh/staticcheck-action@v1.3.1 - uses: dominikh/staticcheck-action@v1.3.1

View File

@ -1,3 +1,28 @@
Version 2.1.3 (April 27, 2024)
------------------------------
* `api`: `rand.Intn(math.MaxInt64)` causes tests to fail on 32-bit architectures (use `rand.Int()` instead)
* `ci`: use `docker/metadata-action` instead of deprecated shell-scripts
* `database`: remove `entries_feed_url_idx` index because entry URLs can exceeds btree index size limit
* `finder`: find feeds from YouTube playlist
* `http/response`: add brotli compression support
* `integration/matrix`: fix function name in comment
* `packaging`: specify container registry explicitly (e.g., Podman does not use `docker.io` by default)
* `packaging`: use `make miniflux` instead of duplicating `go build` arguments (this leverages Go's PIE build mode)
* `reader/fetcher`: add brotli content encoding support
* `reader/processor`: minimize feed entries HTML content
* `reader/rewrite`: add a rule for `oglaf.com`
* `storage`: change `GetReadTime()` function to use `entries_feed_id_hash_key` index
* `ui`: add seek and speed controls to media player
* `ui`: add tag entries page
* `ui`: fix JavaScript error when clicking on unread counter
* `ui`: use `FORCE_REFRESH_INTERVAL` config for category refresh
* Bump `github.com/tdewolff/minify/v2` from `2.20.19` to `2.20.20`
* Bump `golang.org/x/net` from `0.22.0` to `0.24.0`
* Bump `golang.org/x/term` from `0.18.0` to `0.19.0`
* Bump `golang.org/x/oauth2` from `0.18.0` to `0.19.0`
* Bump `github.com/yuin/goldmark` from `1.7.0` to `1.7.1`
Version 2.1.2 (March 30, 2024) Version 2.1.2 (March 30, 2024)
------------------------------ ------------------------------

View File

@ -1,12 +1,12 @@
APP := miniflux APP := miniflux
DOCKER_IMAGE := miniflux/miniflux DOCKER_IMAGE := miniflux/miniflux
VERSION := $(shell git describe --tags --abbrev=0 2>/dev/null) VERSION := $(shell git describe --tags --abbrev=0 2>/dev/null)
COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null) COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null)
BUILD_DATE := `date +%FT%T%z` BUILD_DATE := `date +%FT%T%z`
LD_FLAGS := "-s -w -X 'miniflux.app/v2/internal/version.Version=$(VERSION)' -X 'miniflux.app/v2/internal/version.Commit=$(COMMIT)' -X 'miniflux.app/v2/internal/version.BuildDate=$(BUILD_DATE)'" LD_FLAGS := "-s -w -X 'miniflux.app/v2/internal/version.Version=$(VERSION)' -X 'miniflux.app/v2/internal/version.Commit=$(COMMIT)' -X 'miniflux.app/v2/internal/version.BuildDate=$(BUILD_DATE)'"
PKG_LIST := $(shell go list ./... | grep -v /vendor/) PKG_LIST := $(shell go list ./... | grep -v /vendor/)
DB_URL := postgres://postgres:postgres@localhost/miniflux_test?sslmode=disable DB_URL := postgres://postgres:postgres@localhost/miniflux_test?sslmode=disable
DEB_IMG_ARCH := amd64 DOCKER_PLATFORM := amd64
export PGPASSWORD := postgres export PGPASSWORD := postgres
@ -163,15 +163,15 @@ rpm: clean
rpmbuild -bb --define "_miniflux_version $(VERSION)" /root/rpmbuild/SPECS/miniflux.spec rpmbuild -bb --define "_miniflux_version $(VERSION)" /root/rpmbuild/SPECS/miniflux.spec
debian: debian:
@ docker build --load \ @ docker buildx build --load \
--build-arg BASE_IMAGE_ARCH=$(DEB_IMG_ARCH) \ --platform linux/$(DOCKER_PLATFORM) \
-t $(DEB_IMG_ARCH)/miniflux-deb-builder \ -t miniflux-deb-builder \
-f packaging/debian/Dockerfile \ -f packaging/debian/Dockerfile \
. .
@ docker run --rm \ @ docker run --rm --platform linux/$(DOCKER_PLATFORM) \
-v ${PWD}:/pkg $(DEB_IMG_ARCH)/miniflux-deb-builder -v ${PWD}:/pkg miniflux-deb-builder
debian-packages: clean debian-packages: clean
$(MAKE) debian DEB_IMG_ARCH=amd64 $(MAKE) debian DOCKER_PLATFORM=amd64
$(MAKE) debian DEB_IMG_ARCH=arm64v8 $(MAKE) debian DOCKER_PLATFORM=arm64
$(MAKE) debian DEB_IMG_ARCH=arm32v7 $(MAKE) debian DOCKER_PLATFORM=arm/v7

25
go.mod
View File

@ -3,20 +3,21 @@ module miniflux.app/v2
// +heroku goVersion go1.22 // +heroku goVersion go1.22
require ( require (
github.com/PuerkitoBio/goquery v1.9.1 github.com/PuerkitoBio/goquery v1.9.2
github.com/abadojack/whatlanggo v1.0.1 github.com/abadojack/whatlanggo v1.0.1
github.com/andybalholm/brotli v1.1.0
github.com/coreos/go-oidc/v3 v3.10.0 github.com/coreos/go-oidc/v3 v3.10.0
github.com/go-webauthn/webauthn v0.10.2 github.com/go-webauthn/webauthn v0.10.2
github.com/gorilla/mux v1.8.1 github.com/gorilla/mux v1.8.1
github.com/lib/pq v1.10.9 github.com/lib/pq v1.10.9
github.com/prometheus/client_golang v1.19.0 github.com/prometheus/client_golang v1.19.1
github.com/tdewolff/minify/v2 v2.20.19 github.com/tdewolff/minify/v2 v2.20.34
github.com/yuin/goldmark v1.7.0 github.com/yuin/goldmark v1.7.2
golang.org/x/crypto v0.21.0 golang.org/x/crypto v0.24.0
golang.org/x/net v0.22.0 golang.org/x/net v0.26.0
golang.org/x/oauth2 v0.18.0 golang.org/x/oauth2 v0.21.0
golang.org/x/term v0.18.0 golang.org/x/term v0.21.0
golang.org/x/text v0.14.0 golang.org/x/text v0.16.0
mvdan.cc/xurls/v2 v2.5.0 mvdan.cc/xurls/v2 v2.5.0
) )
@ -32,16 +33,14 @@ require (
github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/fxamacker/cbor/v2 v2.6.0 // indirect github.com/fxamacker/cbor/v2 v2.6.0 // indirect
github.com/go-jose/go-jose/v4 v4.0.1 // indirect github.com/go-jose/go-jose/v4 v4.0.1 // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/prometheus/client_model v0.5.0 // indirect github.com/prometheus/client_model v0.5.0 // indirect
github.com/prometheus/common v0.48.0 // indirect github.com/prometheus/common v0.48.0 // indirect
github.com/prometheus/procfs v0.12.0 // indirect github.com/prometheus/procfs v0.12.0 // indirect
github.com/tdewolff/parse/v2 v2.7.12 // indirect github.com/tdewolff/parse/v2 v2.7.15 // indirect
github.com/x448/float16 v0.8.4 // indirect github.com/x448/float16 v0.8.4 // indirect
golang.org/x/sys v0.18.0 // indirect golang.org/x/sys v0.21.0 // indirect
google.golang.org/appengine v1.6.8 // indirect
google.golang.org/protobuf v1.33.0 // indirect google.golang.org/protobuf v1.33.0 // indirect
) )

57
go.sum
View File

@ -1,7 +1,9 @@
github.com/PuerkitoBio/goquery v1.9.1 h1:mTL6XjbJTZdpfL+Gwl5U2h1l9yEkJjhmlTeV9VPW7UI= github.com/PuerkitoBio/goquery v1.9.2 h1:4/wZksC3KgkQw7SQgkKotmKljk0M6V8TUvA8Wb4yPeE=
github.com/PuerkitoBio/goquery v1.9.1/go.mod h1:cW1n6TmIMDoORQU5IU/P1T3tGFunOeXEpGP2WHRwkbY= github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk=
github.com/abadojack/whatlanggo v1.0.1 h1:19N6YogDnf71CTHm3Mp2qhYfkRdyvbgwWdd2EPxJRG4= github.com/abadojack/whatlanggo v1.0.1 h1:19N6YogDnf71CTHm3Mp2qhYfkRdyvbgwWdd2EPxJRG4=
github.com/abadojack/whatlanggo v1.0.1/go.mod h1:66WiQbSbJBIlOZMsvbKe5m6pzQovxCH9B/K8tQB2uoc= github.com/abadojack/whatlanggo v1.0.1/go.mod h1:66WiQbSbJBIlOZMsvbKe5m6pzQovxCH9B/K8tQB2uoc=
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss= github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU= github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
@ -22,11 +24,6 @@ github.com/go-webauthn/x v0.1.9 h1:v1oeLmoaa+gPOaZqUdDentu6Rl7HkSSsmOT6gxEQHhE=
github.com/go-webauthn/x v0.1.9/go.mod h1:pJNMlIMP1SU7cN8HNlKJpLEnFHCygLCvaLZ8a1xeoQA= github.com/go-webauthn/x v0.1.9/go.mod h1:pJNMlIMP1SU7cN8HNlKJpLEnFHCygLCvaLZ8a1xeoQA=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-tpm v0.9.0 h1:sQF6YqWMi+SCXpsmS3fd21oPy/vSddwZry4JnmltHVk= github.com/google/go-tpm v0.9.0 h1:sQF6YqWMi+SCXpsmS3fd21oPy/vSddwZry4JnmltHVk=
@ -41,8 +38,8 @@ github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyua
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.19.0 h1:ygXvpU1AoN1MhdzckN+PyD9QJOSD4x7kmXYlnfbA6JU= github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE=
github.com/prometheus/client_golang v1.19.0/go.mod h1:ZRM9uEAypZakd+q/x7+gmsvXdURP+DABIEIjnmDdp+k= github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho=
github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw= github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw=
github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI= github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI=
github.com/prometheus/common v0.48.0 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSzKKE= github.com/prometheus/common v0.48.0 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSzKKE=
@ -51,22 +48,22 @@ github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tdewolff/minify/v2 v2.20.19 h1:tX0SR0LUrIqGoLjXnkIzRSIbKJ7PaNnSENLD4CyH6Xo= github.com/tdewolff/minify/v2 v2.20.34 h1:XueI6sQtgS7du45fyBCNkNfPQ9SINaYavMFNOxp37SA=
github.com/tdewolff/minify/v2 v2.20.19/go.mod h1:ulkFoeAVWMLEyjuDz1ZIWOA31g5aWOawCFRp9R/MudM= github.com/tdewolff/minify/v2 v2.20.34/go.mod h1:L1VYef/jwKw6Wwyk5A+T0mBjjn3mMPgmjjA688RNsxU=
github.com/tdewolff/parse/v2 v2.7.12 h1:tgavkHc2ZDEQVKy1oWxwIyh5bP4F5fEh/JmBwPP/3LQ= github.com/tdewolff/parse/v2 v2.7.15 h1:hysDXtdGZIRF5UZXwpfn3ZWRbm+ru4l53/ajBRGpCTw=
github.com/tdewolff/parse/v2 v2.7.12/go.mod h1:3FbJWZp3XT9OWVN3Hmfp0p/a08v4h8J9W1aghka0soA= github.com/tdewolff/parse/v2 v2.7.15/go.mod h1:3FbJWZp3XT9OWVN3Hmfp0p/a08v4h8J9W1aghka0soA=
github.com/tdewolff/test v1.0.11-0.20231101010635-f1265d231d52/go.mod h1:6DAvZliBAAnD7rhVgwaM7DE5/d9NMOAJ09SqYqeK4QE= github.com/tdewolff/test v1.0.11-0.20231101010635-f1265d231d52/go.mod h1:6DAvZliBAAnD7rhVgwaM7DE5/d9NMOAJ09SqYqeK4QE=
github.com/tdewolff/test v1.0.11-0.20240106005702-7de5f7df4739 h1:IkjBCtQOOjIn03u/dMQK9g+Iw9ewps4mCl1nB8Sscbo= github.com/tdewolff/test v1.0.11-0.20240106005702-7de5f7df4739 h1:IkjBCtQOOjIn03u/dMQK9g+Iw9ewps4mCl1nB8Sscbo=
github.com/tdewolff/test v1.0.11-0.20240106005702-7de5f7df4739/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8= github.com/tdewolff/test v1.0.11-0.20240106005702-7de5f7df4739/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.7.0 h1:EfOIvIMZIzHdB/R/zVrikYLPPwJlfMcNczJFMs1m6sA= github.com/yuin/goldmark v1.7.2 h1:NjGd7lO7zrUn/A7eKwn5PEOt4ONYGqpxSEeZuduvgxc=
github.com/yuin/goldmark v1.7.0/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= github.com/yuin/goldmark v1.7.2/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI=
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@ -74,10 +71,10 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc= golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ=
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
golang.org/x/oauth2 v0.18.0 h1:09qnuIAgzdx1XplqJvW6CQqMCtGZykZWcXzPMPUusvI= golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs=
golang.org/x/oauth2 v0.18.0/go.mod h1:Wf7knwG0MPoWIMMBgFlEaSUDaKskp0dCfrlJRJXbBi8= golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@ -88,32 +85,26 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
golang.org/x/term v0.18.0 h1:FcHjZXDMxI8mM3nwhX9HlKop4C0YQvCVCdwYl2wOtE8= golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA=
golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM=
google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

View File

@ -8,7 +8,6 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"math"
"math/rand" "math/rand"
"os" "os"
"strings" "strings"
@ -58,7 +57,7 @@ func (c *integrationTestConfig) isConfigured() bool {
} }
func (c *integrationTestConfig) genRandomUsername() string { func (c *integrationTestConfig) genRandomUsername() string {
return fmt.Sprintf("%s_%10d", c.testRegularUsername, rand.Intn(math.MaxInt64)) return fmt.Sprintf("%s_%10d", c.testRegularUsername, rand.Int())
} }
func TestIncorrectEndpoint(t *testing.T) { func TestIncorrectEndpoint(t *testing.T) {

View File

@ -2021,6 +2021,24 @@ func TestAuthProxyUserCreationAdmin(t *testing.T) {
} }
} }
func TestFetchNebulaWatchTime(t *testing.T) {
os.Clearenv()
os.Setenv("FETCH_NEBULA_WATCH_TIME", "1")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := true
result := opts.FetchNebulaWatchTime()
if result != expected {
t.Fatalf(`Unexpected FETCH_NEBULA_WATCH_TIME value, got %v instead of %v`, result, expected)
}
}
func TestFetchOdyseeWatchTime(t *testing.T) { func TestFetchOdyseeWatchTime(t *testing.T) {
os.Clearenv() os.Clearenv()
os.Setenv("FETCH_ODYSEE_WATCH_TIME", "1") os.Setenv("FETCH_ODYSEE_WATCH_TIME", "1")

View File

@ -56,6 +56,7 @@ const (
defaultMediaResourceTypes = "image" defaultMediaResourceTypes = "image"
defaultMediaProxyURL = "" defaultMediaProxyURL = ""
defaultFilterEntryMaxAgeDays = 0 defaultFilterEntryMaxAgeDays = 0
defaultFetchNebulaWatchTime = false
defaultFetchOdyseeWatchTime = false defaultFetchOdyseeWatchTime = false
defaultFetchYouTubeWatchTime = false defaultFetchYouTubeWatchTime = false
defaultYouTubeEmbedUrlOverride = "https://www.youtube-nocookie.com/embed/" defaultYouTubeEmbedUrlOverride = "https://www.youtube-nocookie.com/embed/"
@ -140,6 +141,7 @@ type Options struct {
mediaProxyMode string mediaProxyMode string
mediaProxyResourceTypes []string mediaProxyResourceTypes []string
mediaProxyCustomURL string mediaProxyCustomURL string
fetchNebulaWatchTime bool
fetchOdyseeWatchTime bool fetchOdyseeWatchTime bool
fetchYouTubeWatchTime bool fetchYouTubeWatchTime bool
filterEntryMaxAgeDays int filterEntryMaxAgeDays int
@ -216,6 +218,7 @@ func NewOptions() *Options {
mediaProxyResourceTypes: []string{defaultMediaResourceTypes}, mediaProxyResourceTypes: []string{defaultMediaResourceTypes},
mediaProxyCustomURL: defaultMediaProxyURL, mediaProxyCustomURL: defaultMediaProxyURL,
filterEntryMaxAgeDays: defaultFilterEntryMaxAgeDays, filterEntryMaxAgeDays: defaultFilterEntryMaxAgeDays,
fetchNebulaWatchTime: defaultFetchNebulaWatchTime,
fetchOdyseeWatchTime: defaultFetchOdyseeWatchTime, fetchOdyseeWatchTime: defaultFetchOdyseeWatchTime,
fetchYouTubeWatchTime: defaultFetchYouTubeWatchTime, fetchYouTubeWatchTime: defaultFetchYouTubeWatchTime,
youTubeEmbedUrlOverride: defaultYouTubeEmbedUrlOverride, youTubeEmbedUrlOverride: defaultYouTubeEmbedUrlOverride,
@ -486,6 +489,12 @@ func (o *Options) YouTubeEmbedUrlOverride() string {
return o.youTubeEmbedUrlOverride return o.youTubeEmbedUrlOverride
} }
// FetchNebulaWatchTime returns true if the Nebula video duration
// should be fetched and used as a reading time.
func (o *Options) FetchNebulaWatchTime() bool {
return o.fetchNebulaWatchTime
}
// FetchOdyseeWatchTime returns true if the Odysee video duration // FetchOdyseeWatchTime returns true if the Odysee video duration
// should be fetched and used as a reading time. // should be fetched and used as a reading time.
func (o *Options) FetchOdyseeWatchTime() bool { func (o *Options) FetchOdyseeWatchTime() bool {
@ -647,6 +656,7 @@ func (o *Options) SortedOptions(redactSecret bool) []*Option {
"DISABLE_SCHEDULER_SERVICE": !o.schedulerService, "DISABLE_SCHEDULER_SERVICE": !o.schedulerService,
"FILTER_ENTRY_MAX_AGE_DAYS": o.filterEntryMaxAgeDays, "FILTER_ENTRY_MAX_AGE_DAYS": o.filterEntryMaxAgeDays,
"FETCH_YOUTUBE_WATCH_TIME": o.fetchYouTubeWatchTime, "FETCH_YOUTUBE_WATCH_TIME": o.fetchYouTubeWatchTime,
"FETCH_NEBULA_WATCH_TIME": o.fetchNebulaWatchTime,
"FETCH_ODYSEE_WATCH_TIME": o.fetchOdyseeWatchTime, "FETCH_ODYSEE_WATCH_TIME": o.fetchOdyseeWatchTime,
"HTTPS": o.HTTPS, "HTTPS": o.HTTPS,
"HTTP_CLIENT_MAX_BODY_SIZE": o.httpClientMaxBodySize, "HTTP_CLIENT_MAX_BODY_SIZE": o.httpClientMaxBodySize,

View File

@ -259,6 +259,8 @@ func (p *Parser) parseLines(lines []string) (err error) {
p.opts.metricsPassword = parseString(value, defaultMetricsPassword) p.opts.metricsPassword = parseString(value, defaultMetricsPassword)
case "METRICS_PASSWORD_FILE": case "METRICS_PASSWORD_FILE":
p.opts.metricsPassword = readSecretFile(value, defaultMetricsPassword) p.opts.metricsPassword = readSecretFile(value, defaultMetricsPassword)
case "FETCH_NEBULA_WATCH_TIME":
p.opts.fetchNebulaWatchTime = parseBool(value, defaultFetchNebulaWatchTime)
case "FETCH_ODYSEE_WATCH_TIME": case "FETCH_ODYSEE_WATCH_TIME":
p.opts.fetchOdyseeWatchTime = parseBool(value, defaultFetchOdyseeWatchTime) p.opts.fetchOdyseeWatchTime = parseBool(value, defaultFetchOdyseeWatchTime)
case "FETCH_YOUTUBE_WATCH_TIME": case "FETCH_YOUTUBE_WATCH_TIME":

View File

@ -882,4 +882,25 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql) _, err = tx.Exec(sql)
return err return err
}, },
func(tx *sql.Tx) (err error) {
// Entry URLs can exceeds btree maximum size
// Checking entry existence is now using entries_feed_id_status_hash_idx index
_, err = tx.Exec(`DROP INDEX entries_feed_url_idx`)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN raindrop_enabled bool default 'f';
ALTER TABLE integrations ADD COLUMN raindrop_token text default '';
ALTER TABLE integrations ADD COLUMN raindrop_collection_id text default '';
ALTER TABLE integrations ADD COLUMN raindrop_tags text default '';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `ALTER TABLE feeds ADD COLUMN description text default ''`
_, err = tx.Exec(sql)
return err
},
} }

View File

@ -1021,10 +1021,10 @@ func (h *handler) streamItemContentsHandler(w http.ResponseWriter, r *http.Reque
ID: fmt.Sprintf(EntryIDLong, entry.ID), ID: fmt.Sprintf(EntryIDLong, entry.ID),
Title: entry.Title, Title: entry.Title,
Author: entry.Author, Author: entry.Author,
TimestampUsec: fmt.Sprintf("%d", entry.Date.UnixNano()/(int64(time.Microsecond)/int64(time.Nanosecond))), TimestampUsec: fmt.Sprintf("%d", entry.Date.UnixMicro()),
CrawlTimeMsec: fmt.Sprintf("%d", entry.Date.UnixNano()/(int64(time.Microsecond)/int64(time.Nanosecond))), CrawlTimeMsec: fmt.Sprintf("%d", entry.CreatedAt.UnixMilli()),
Published: entry.Date.Unix(), Published: entry.Date.Unix(),
Updated: entry.Date.Unix(), Updated: entry.ChangedAt.Unix(),
Categories: categories, Categories: categories,
Canonical: []contentHREF{ Canonical: []contentHREF{
{ {

View File

@ -6,15 +6,14 @@ package cookie // import "miniflux.app/v2/internal/http/cookie"
import ( import (
"net/http" "net/http"
"time" "time"
"miniflux.app/v2/internal/config"
) )
// Cookie names. // Cookie names.
const ( const (
CookieAppSessionID = "MinifluxAppSessionID" CookieAppSessionID = "MinifluxAppSessionID"
CookieUserSessionID = "MinifluxUserSessionID" CookieUserSessionID = "MinifluxUserSessionID"
// Cookie duration in days.
cookieDuration = 30
) )
// New creates a new cookie. // New creates a new cookie.
@ -25,7 +24,7 @@ func New(name, value string, isHTTPS bool, path string) *http.Cookie {
Path: basePath(path), Path: basePath(path),
Secure: isHTTPS, Secure: isHTTPS,
HttpOnly: true, HttpOnly: true,
Expires: time.Now().Add(cookieDuration * 24 * time.Hour), Expires: time.Now().Add(time.Duration(config.Opts.CleanupRemoveSessionsDays()) * 24 * time.Hour),
SameSite: http.SameSiteLaxMode, SameSite: http.SameSiteLaxMode,
} }
} }

View File

@ -12,6 +12,8 @@ import (
"net/http" "net/http"
"strings" "strings"
"time" "time"
"github.com/andybalholm/brotli"
) )
const compressionThreshold = 1024 const compressionThreshold = 1024
@ -110,8 +112,15 @@ func (b *Builder) writeHeaders() {
func (b *Builder) compress(data []byte) { func (b *Builder) compress(data []byte) {
if b.enableCompression && len(data) > compressionThreshold { if b.enableCompression && len(data) > compressionThreshold {
acceptEncoding := b.r.Header.Get("Accept-Encoding") acceptEncoding := b.r.Header.Get("Accept-Encoding")
switch { switch {
case strings.Contains(acceptEncoding, "br"):
b.headers["Content-Encoding"] = "br"
b.writeHeaders()
brotliWriter := brotli.NewWriterV2(b.w, brotli.DefaultCompression)
defer brotliWriter.Close()
brotliWriter.Write(data)
return
case strings.Contains(acceptEncoding, "gzip"): case strings.Contains(acceptEncoding, "gzip"):
b.headers["Content-Encoding"] = "gzip" b.headers["Content-Encoding"] = "gzip"
b.writeHeaders() b.writeHeaders()

View File

@ -228,7 +228,7 @@ func TestBuildResponseWithCachingAndEtag(t *testing.T) {
} }
} }
func TestBuildResponseWithGzipCompression(t *testing.T) { func TestBuildResponseWithBrotliCompression(t *testing.T) {
body := strings.Repeat("a", compressionThreshold+1) body := strings.Repeat("a", compressionThreshold+1)
r, err := http.NewRequest("GET", "/", nil) r, err := http.NewRequest("GET", "/", nil)
r.Header.Set("Accept-Encoding", "gzip, deflate, br") r.Header.Set("Accept-Encoding", "gzip, deflate, br")
@ -245,6 +245,30 @@ func TestBuildResponseWithGzipCompression(t *testing.T) {
handler.ServeHTTP(w, r) handler.ServeHTTP(w, r)
resp := w.Result() resp := w.Result()
expected := "br"
actual := resp.Header.Get("Content-Encoding")
if actual != expected {
t.Fatalf(`Unexpected header value, got %q instead of %q`, actual, expected)
}
}
func TestBuildResponseWithGzipCompression(t *testing.T) {
body := strings.Repeat("a", compressionThreshold+1)
r, err := http.NewRequest("GET", "/", nil)
r.Header.Set("Accept-Encoding", "gzip, deflate")
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
New(w, r).WithBody(body).Write()
})
handler.ServeHTTP(w, r)
resp := w.Result()
expected := "gzip" expected := "gzip"
actual := resp.Header.Get("Content-Encoding") actual := resp.Header.Get("Content-Encoding")
if actual != expected { if actual != expected {

View File

@ -19,6 +19,7 @@ import (
"miniflux.app/v2/internal/integration/omnivore" "miniflux.app/v2/internal/integration/omnivore"
"miniflux.app/v2/internal/integration/pinboard" "miniflux.app/v2/internal/integration/pinboard"
"miniflux.app/v2/internal/integration/pocket" "miniflux.app/v2/internal/integration/pocket"
"miniflux.app/v2/internal/integration/raindrop"
"miniflux.app/v2/internal/integration/readeck" "miniflux.app/v2/internal/integration/readeck"
"miniflux.app/v2/internal/integration/readwise" "miniflux.app/v2/internal/integration/readwise"
"miniflux.app/v2/internal/integration/shaarli" "miniflux.app/v2/internal/integration/shaarli"
@ -359,6 +360,7 @@ func SendEntry(entry *model.Entry, userIntegrations *model.Integration) {
) )
} }
} }
if userIntegrations.OmnivoreEnabled { if userIntegrations.OmnivoreEnabled {
slog.Debug("Sending entry to Omnivore", slog.Debug("Sending entry to Omnivore",
slog.Int64("user_id", userIntegrations.UserID), slog.Int64("user_id", userIntegrations.UserID),
@ -376,6 +378,24 @@ func SendEntry(entry *model.Entry, userIntegrations *model.Integration) {
) )
} }
} }
if userIntegrations.RaindropEnabled {
slog.Debug("Sending entry to Raindrop",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
)
client := raindrop.NewClient(userIntegrations.RaindropToken, userIntegrations.RaindropCollectionID, userIntegrations.RaindropTags)
if err := client.CreateRaindrop(entry.URL, entry.Title); err != nil {
slog.Error("Unable to send entry to Raindrop",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.Any("error", err),
)
}
}
} }
// PushEntries pushes a list of entries to activated third-party providers during feed refreshes. // PushEntries pushes a list of entries to activated third-party providers during feed refreshes.

View File

@ -10,7 +10,7 @@ import (
"miniflux.app/v2/internal/model" "miniflux.app/v2/internal/model"
) )
// PushEntry pushes entries to matrix chat using integration settings provided // PushEntries pushes entries to matrix chat using integration settings provided
func PushEntries(feed *model.Feed, entries model.Entries, matrixBaseURL, matrixUsername, matrixPassword, matrixRoomID string) error { func PushEntries(feed *model.Feed, entries model.Entries, matrixBaseURL, matrixUsername, matrixPassword, matrixRoomID string) error {
client := NewClient(matrixBaseURL) client := NewClient(matrixBaseURL)
discovery, err := client.DiscoverEndpoints() discovery, err := client.DiscoverEndpoints()

View File

@ -0,0 +1,78 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package raindrop // import "miniflux.app/v2/internal/integration/raindrop"
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"miniflux.app/v2/internal/version"
)
const defaultClientTimeout = 10 * time.Second
type Client struct {
token string
collectionID string
tags []string
}
func NewClient(token, collectionID, tags string) *Client {
return &Client{token: token, collectionID: collectionID, tags: strings.Split(tags, ",")}
}
// https://developer.raindrop.io/v1/raindrops/single#create-raindrop
func (c *Client) CreateRaindrop(entryURL, entryTitle string) error {
if c.token == "" {
return fmt.Errorf("raindrop: missing token")
}
var request *http.Request
requestBodyJson, err := json.Marshal(&raindrop{
Link: entryURL,
Title: entryTitle,
Collection: collection{Id: c.collectionID},
Tags: c.tags,
})
if err != nil {
return fmt.Errorf("raindrop: unable to encode request body: %v", err)
}
request, err = http.NewRequest(http.MethodPost, "https://api.raindrop.io/rest/v1/raindrop", bytes.NewReader(requestBodyJson))
if err != nil {
return fmt.Errorf("raindrop: unable to create request: %v", err)
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
request.Header.Set("Authorization", "Bearer "+c.token)
httpClient := &http.Client{Timeout: defaultClientTimeout}
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("raindrop: unable to send request: %v", err)
}
defer response.Body.Close()
if response.StatusCode >= 400 {
return fmt.Errorf("raindrop: unable to create bookmark: status=%d", response.StatusCode)
}
return nil
}
type raindrop struct {
Link string `json:"link"`
Title string `json:"title"`
Collection collection `json:"collection,omitempty"`
Tags []string `json:"tags"`
}
type collection struct {
Id string `json:"$id"`
}

View File

@ -258,6 +258,7 @@
"alert.no_bookmark": "Es existiert derzeit kein Lesezeichen.", "alert.no_bookmark": "Es existiert derzeit kein Lesezeichen.",
"alert.no_category": "Es ist keine Kategorie vorhanden.", "alert.no_category": "Es ist keine Kategorie vorhanden.",
"alert.no_category_entry": "Es befindet sich kein Artikel in dieser Kategorie.", "alert.no_category_entry": "Es befindet sich kein Artikel in dieser Kategorie.",
"alert.no_tag_entry": "Es gibt keine Artikel, die diesem Tag entsprechen.",
"alert.no_feed_entry": "Es existiert kein Artikel für dieses Abonnement.", "alert.no_feed_entry": "Es existiert kein Artikel für dieses Abonnement.",
"alert.no_feed": "Es sind keine Abonnements vorhanden.", "alert.no_feed": "Es sind keine Abonnements vorhanden.",
"alert.no_feed_in_category": "Für diese Kategorie gibt es kein Abonnement.", "alert.no_feed_in_category": "Für diese Kategorie gibt es kein Abonnement.",
@ -316,6 +317,7 @@
"form.feed.label.title": "Titel", "form.feed.label.title": "Titel",
"form.feed.label.site_url": "URL der Webseite", "form.feed.label.site_url": "URL der Webseite",
"form.feed.label.feed_url": "URL des Abonnements", "form.feed.label.feed_url": "URL des Abonnements",
"form.feed.label.description": "Beschreibung",
"form.feed.label.category": "Kategorie", "form.feed.label.category": "Kategorie",
"form.feed.label.crawler": "Originalinhalt herunterladen", "form.feed.label.crawler": "Originalinhalt herunterladen",
"form.feed.label.feed_username": "Benutzername des Abonnements", "form.feed.label.feed_username": "Benutzername des Abonnements",
@ -451,6 +453,10 @@
"form.integration.matrix_bot_password": "Passwort für Matrix-Benutzer", "form.integration.matrix_bot_password": "Passwort für Matrix-Benutzer",
"form.integration.matrix_bot_url": "URL des Matrix-Servers", "form.integration.matrix_bot_url": "URL des Matrix-Servers",
"form.integration.matrix_bot_chat_id": "ID des Matrix-Raums", "form.integration.matrix_bot_chat_id": "ID des Matrix-Raums",
"form.integration.raindrop_activate": "Save entries to Raindrop",
"form.integration.raindrop_token": "(Test) Token",
"form.integration.raindrop_collection_id": "Collection ID",
"form.integration.raindrop_tags": "Tags (comma-separated)",
"form.integration.readeck_activate": "Artikel in Readeck speichern", "form.integration.readeck_activate": "Artikel in Readeck speichern",
"form.integration.readeck_endpoint": "Readeck API-Endpunkt", "form.integration.readeck_endpoint": "Readeck API-Endpunkt",
"form.integration.readeck_api_key": "Readeck API-Schlüssel", "form.integration.readeck_api_key": "Readeck API-Schlüssel",
@ -528,5 +534,14 @@
"error.unable_to_detect_rssbridge": "Abonnement kann nicht durch RSS-Bridge erkannt werden: %v.", "error.unable_to_detect_rssbridge": "Abonnement kann nicht durch RSS-Bridge erkannt werden: %v.",
"error.feed_format_not_detected": "Das Format des Abonnements kann nicht erkannt werden: %v.", "error.feed_format_not_detected": "Das Format des Abonnements kann nicht erkannt werden: %v.",
"form.prefs.label.media_playback_rate": "Wiedergabegeschwindigkeit von Audio/Video", "form.prefs.label.media_playback_rate": "Wiedergabegeschwindigkeit von Audio/Video",
"error.settings_media_playback_rate_range": "Die Wiedergabegeschwindigkeit liegt außerhalb des Bereichs" "error.settings_media_playback_rate_range": "Die Wiedergabegeschwindigkeit liegt außerhalb des Bereichs",
"enclosure_media_controls.seek" : "Seek:",
"enclosure_media_controls.seek.title" : "Seek %s seconds",
"enclosure_media_controls.speed" : "Speed:",
"enclosure_media_controls.speed.faster" : "Faster",
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
"enclosure_media_controls.speed.slower" : "Slower",
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
"enclosure_media_controls.speed.reset" : "Reset",
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
} }

View File

@ -258,6 +258,7 @@
"alert.no_bookmark": "Δεν υπάρχει σελιδοδείκτης αυτή τη στιγμή.", "alert.no_bookmark": "Δεν υπάρχει σελιδοδείκτης αυτή τη στιγμή.",
"alert.no_category": "Δεν υπάρχει κατηγορία.", "alert.no_category": "Δεν υπάρχει κατηγορία.",
"alert.no_category_entry": "Δεν υπάρχουν άρθρα σε αυτήν την κατηγορία.", "alert.no_category_entry": "Δεν υπάρχουν άρθρα σε αυτήν την κατηγορία.",
"alert.no_tag_entry": "Δεν υπάρχουν αντικείμενα που να ταιριάζουν με αυτή την ετικέτα.",
"alert.no_feed_entry": "Δεν υπάρχουν άρθρα για αυτήν τη ροή.", "alert.no_feed_entry": "Δεν υπάρχουν άρθρα για αυτήν τη ροή.",
"alert.no_feed": "Δεν έχετε συνδρομές.", "alert.no_feed": "Δεν έχετε συνδρομές.",
"alert.no_feed_in_category": "Δεν υπάρχει συνδρομή για αυτήν την κατηγορία.", "alert.no_feed_in_category": "Δεν υπάρχει συνδρομή για αυτήν την κατηγορία.",
@ -318,6 +319,7 @@
"form.feed.label.title": "Τίτλος", "form.feed.label.title": "Τίτλος",
"form.feed.label.site_url": "Διεύθυνση URL ιστότοπου", "form.feed.label.site_url": "Διεύθυνση URL ιστότοπου",
"form.feed.label.feed_url": "Διεύθυνση URL ροής", "form.feed.label.feed_url": "Διεύθυνση URL ροής",
"form.feed.label.description": "Περιγραφή",
"form.feed.label.category": "Κατηγορία", "form.feed.label.category": "Κατηγορία",
"form.feed.label.crawler": "Λήψη αρχικού περιεχομένου", "form.feed.label.crawler": "Λήψη αρχικού περιεχομένου",
"form.feed.label.feed_username": "Όνομα Χρήστη ροής", "form.feed.label.feed_username": "Όνομα Χρήστη ροής",
@ -451,6 +453,10 @@
"form.integration.matrix_bot_password": "Κωδικός πρόσβασης για τον χρήστη Matrix", "form.integration.matrix_bot_password": "Κωδικός πρόσβασης για τον χρήστη Matrix",
"form.integration.matrix_bot_url": "URL διακομιστή Matrix", "form.integration.matrix_bot_url": "URL διακομιστή Matrix",
"form.integration.matrix_bot_chat_id": "Αναγνωριστικό της αίθουσας Matrix", "form.integration.matrix_bot_chat_id": "Αναγνωριστικό της αίθουσας Matrix",
"form.integration.raindrop_activate": "Save entries to Raindrop",
"form.integration.raindrop_token": "(Test) Token",
"form.integration.raindrop_collection_id": "Collection ID",
"form.integration.raindrop_tags": "Tags (comma-separated)",
"form.integration.readeck_activate": "Αποθήκευση άρθρων στο Readeck", "form.integration.readeck_activate": "Αποθήκευση άρθρων στο Readeck",
"form.integration.readeck_endpoint": "Τελικό σημείο Readeck API", "form.integration.readeck_endpoint": "Τελικό σημείο Readeck API",
"form.integration.readeck_api_key": "Κλειδί API Readeck", "form.integration.readeck_api_key": "Κλειδί API Readeck",
@ -528,5 +534,14 @@
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.", "error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
"error.feed_format_not_detected": "Unable to detect feed format: %v.", "error.feed_format_not_detected": "Unable to detect feed format: %v.",
"form.prefs.label.media_playback_rate": "Ταχύτητα αναπαραγωγής του ήχου/βίντεο", "form.prefs.label.media_playback_rate": "Ταχύτητα αναπαραγωγής του ήχου/βίντεο",
"error.settings_media_playback_rate_range": "Η ταχύτητα αναπαραγωγής είναι εκτός εύρους" "error.settings_media_playback_rate_range": "Η ταχύτητα αναπαραγωγής είναι εκτός εύρους",
"enclosure_media_controls.seek" : "Seek:",
"enclosure_media_controls.seek.title" : "Seek %s seconds",
"enclosure_media_controls.speed" : "Speed:",
"enclosure_media_controls.speed.faster" : "Faster",
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
"enclosure_media_controls.speed.slower" : "Slower",
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
"enclosure_media_controls.speed.reset" : "Reset",
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
} }

View File

@ -258,6 +258,7 @@
"alert.no_bookmark": "There are no starred entries.", "alert.no_bookmark": "There are no starred entries.",
"alert.no_category": "There is no category.", "alert.no_category": "There is no category.",
"alert.no_category_entry": "There are no entries in this category.", "alert.no_category_entry": "There are no entries in this category.",
"alert.no_tag_entry": "There are no entries matching this tag.",
"alert.no_feed_entry": "There are no entries for this feed.", "alert.no_feed_entry": "There are no entries for this feed.",
"alert.no_feed": "You dont have any feeds.", "alert.no_feed": "You dont have any feeds.",
"alert.no_feed_in_category": "There is no feed for this category.", "alert.no_feed_in_category": "There is no feed for this category.",
@ -316,6 +317,7 @@
"form.feed.label.title": "Title", "form.feed.label.title": "Title",
"form.feed.label.site_url": "Site URL", "form.feed.label.site_url": "Site URL",
"form.feed.label.feed_url": "Feed URL", "form.feed.label.feed_url": "Feed URL",
"form.feed.label.description": "Description",
"form.feed.label.category": "Category", "form.feed.label.category": "Category",
"form.feed.label.crawler": "Fetch original content", "form.feed.label.crawler": "Fetch original content",
"form.feed.label.feed_username": "Feed Username", "form.feed.label.feed_username": "Feed Username",
@ -451,6 +453,10 @@
"form.integration.matrix_bot_password": "Password for Matrix user", "form.integration.matrix_bot_password": "Password for Matrix user",
"form.integration.matrix_bot_url": "Matrix server URL", "form.integration.matrix_bot_url": "Matrix server URL",
"form.integration.matrix_bot_chat_id": "ID of Matrix Room", "form.integration.matrix_bot_chat_id": "ID of Matrix Room",
"form.integration.raindrop_activate": "Save entries to Raindrop",
"form.integration.raindrop_token": "(Test) Token",
"form.integration.raindrop_collection_id": "Collection ID",
"form.integration.raindrop_tags": "Tags (comma-separated)",
"form.integration.readeck_activate": "Save entries to readeck", "form.integration.readeck_activate": "Save entries to readeck",
"form.integration.readeck_endpoint": "Readeck API Endpoint", "form.integration.readeck_endpoint": "Readeck API Endpoint",
"form.integration.readeck_api_key": "Readeck API key", "form.integration.readeck_api_key": "Readeck API key",
@ -528,5 +534,14 @@
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.", "error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
"error.feed_format_not_detected": "Unable to detect feed format: %v.", "error.feed_format_not_detected": "Unable to detect feed format: %v.",
"form.prefs.label.media_playback_rate": "Playback speed of the audio/video", "form.prefs.label.media_playback_rate": "Playback speed of the audio/video",
"error.settings_media_playback_rate_range": "Playback speed is out of range" "error.settings_media_playback_rate_range": "Playback speed is out of range",
"enclosure_media_controls.seek" : "Seek:",
"enclosure_media_controls.seek.title" : "Seek %s seconds",
"enclosure_media_controls.speed" : "Speed:",
"enclosure_media_controls.speed.faster" : "Faster",
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
"enclosure_media_controls.speed.slower" : "Slower",
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
"enclosure_media_controls.speed.reset" : "Reset",
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
} }

View File

@ -258,6 +258,7 @@
"alert.no_bookmark": "No hay marcador en este momento.", "alert.no_bookmark": "No hay marcador en este momento.",
"alert.no_category": "No hay categoría.", "alert.no_category": "No hay categoría.",
"alert.no_category_entry": "No hay artículos en esta categoría.", "alert.no_category_entry": "No hay artículos en esta categoría.",
"alert.no_tag_entry": "No hay artículos con esta etiqueta.",
"alert.no_feed_entry": "No hay artículos para esta fuente.", "alert.no_feed_entry": "No hay artículos para esta fuente.",
"alert.no_feed": "No tienes fuentes.", "alert.no_feed": "No tienes fuentes.",
"alert.no_feed_in_category": "No hay fuentes para esta categoría.", "alert.no_feed_in_category": "No hay fuentes para esta categoría.",
@ -316,6 +317,7 @@
"form.feed.label.title": "Título", "form.feed.label.title": "Título",
"form.feed.label.site_url": "URL del sitio", "form.feed.label.site_url": "URL del sitio",
"form.feed.label.feed_url": "URL de la fuente", "form.feed.label.feed_url": "URL de la fuente",
"form.feed.label.description": "Descripción",
"form.feed.label.category": "Categoría", "form.feed.label.category": "Categoría",
"form.feed.label.crawler": "Obtener rastreador original", "form.feed.label.crawler": "Obtener rastreador original",
"form.feed.label.feed_username": "Nombre de usuario de la fuente", "form.feed.label.feed_username": "Nombre de usuario de la fuente",
@ -451,6 +453,10 @@
"form.integration.matrix_bot_password": "Contraseña para el usuario de Matrix", "form.integration.matrix_bot_password": "Contraseña para el usuario de Matrix",
"form.integration.matrix_bot_url": "URL del servidor de Matrix", "form.integration.matrix_bot_url": "URL del servidor de Matrix",
"form.integration.matrix_bot_chat_id": "ID de la sala de Matrix", "form.integration.matrix_bot_chat_id": "ID de la sala de Matrix",
"form.integration.raindrop_activate": "Save entries to Raindrop",
"form.integration.raindrop_token": "(Test) Token",
"form.integration.raindrop_collection_id": "Collection ID",
"form.integration.raindrop_tags": "Tags (comma-separated)",
"form.integration.readeck_activate": "Enviar artículos a Readeck", "form.integration.readeck_activate": "Enviar artículos a Readeck",
"form.integration.readeck_endpoint": "Acceso API de Readeck", "form.integration.readeck_endpoint": "Acceso API de Readeck",
"form.integration.readeck_api_key": "Clave de API de Readeck", "form.integration.readeck_api_key": "Clave de API de Readeck",
@ -528,5 +534,14 @@
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.", "error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
"error.feed_format_not_detected": "Unable to detect feed format: %v.", "error.feed_format_not_detected": "Unable to detect feed format: %v.",
"form.prefs.label.media_playback_rate": "Velocidad de reproducción del audio/vídeo", "form.prefs.label.media_playback_rate": "Velocidad de reproducción del audio/vídeo",
"error.settings_media_playback_rate_range": "La velocidad de reproducción está fuera de rango" "error.settings_media_playback_rate_range": "La velocidad de reproducción está fuera de rango",
"enclosure_media_controls.seek" : "Seek:",
"enclosure_media_controls.seek.title" : "Seek %s seconds",
"enclosure_media_controls.speed" : "Speed:",
"enclosure_media_controls.speed.faster" : "Faster",
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
"enclosure_media_controls.speed.slower" : "Slower",
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
"enclosure_media_controls.speed.reset" : "Reset",
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
} }

View File

@ -258,6 +258,7 @@
"alert.no_bookmark": "Tällä hetkellä ei ole kirjanmerkkiä.", "alert.no_bookmark": "Tällä hetkellä ei ole kirjanmerkkiä.",
"alert.no_category": "Ei ole kategoriaa.", "alert.no_category": "Ei ole kategoriaa.",
"alert.no_category_entry": "Tässä kategoriassa ei ole artikkeleita.", "alert.no_category_entry": "Tässä kategoriassa ei ole artikkeleita.",
"alert.no_tag_entry": "Tätä tunnistetta vastaavia merkintöjä ei ole.",
"alert.no_feed_entry": "Tässä syötteessä ei ole artikkeleita.", "alert.no_feed_entry": "Tässä syötteessä ei ole artikkeleita.",
"alert.no_feed": "Sinulla ei ole tilauksia.", "alert.no_feed": "Sinulla ei ole tilauksia.",
"alert.no_feed_in_category": "Tälle kategorialle ei ole tilausta.", "alert.no_feed_in_category": "Tälle kategorialle ei ole tilausta.",
@ -318,6 +319,7 @@
"form.feed.label.title": "Otsikko", "form.feed.label.title": "Otsikko",
"form.feed.label.site_url": "Sivuston URL-osoite", "form.feed.label.site_url": "Sivuston URL-osoite",
"form.feed.label.feed_url": "Syötteen URL-osoite", "form.feed.label.feed_url": "Syötteen URL-osoite",
"form.feed.label.description": "Kuvaus",
"form.feed.label.category": "Kategoria", "form.feed.label.category": "Kategoria",
"form.feed.label.crawler": "Nouda alkuperäinen sisältö", "form.feed.label.crawler": "Nouda alkuperäinen sisältö",
"form.feed.label.feed_username": "Syötteen käyttäjätunnus", "form.feed.label.feed_username": "Syötteen käyttäjätunnus",
@ -451,6 +453,10 @@
"form.integration.matrix_bot_password": "Matrix-käyttäjän salasana", "form.integration.matrix_bot_password": "Matrix-käyttäjän salasana",
"form.integration.matrix_bot_url": "Matrix-palvelimen URL-osoite", "form.integration.matrix_bot_url": "Matrix-palvelimen URL-osoite",
"form.integration.matrix_bot_chat_id": "Matrix-huoneen tunnus", "form.integration.matrix_bot_chat_id": "Matrix-huoneen tunnus",
"form.integration.raindrop_activate": "Save entries to Raindrop",
"form.integration.raindrop_token": "(Test) Token",
"form.integration.raindrop_collection_id": "Collection ID",
"form.integration.raindrop_tags": "Tags (comma-separated)",
"form.integration.readeck_activate": "Tallenna artikkelit Readeckiin", "form.integration.readeck_activate": "Tallenna artikkelit Readeckiin",
"form.integration.readeck_endpoint": "Readeck API-päätepiste", "form.integration.readeck_endpoint": "Readeck API-päätepiste",
"form.integration.readeck_api_key": "Readeck API-avain", "form.integration.readeck_api_key": "Readeck API-avain",
@ -528,5 +534,14 @@
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.", "error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
"error.feed_format_not_detected": "Unable to detect feed format: %v.", "error.feed_format_not_detected": "Unable to detect feed format: %v.",
"form.prefs.label.media_playback_rate": "Äänen/videon toistonopeus", "form.prefs.label.media_playback_rate": "Äänen/videon toistonopeus",
"error.settings_media_playback_rate_range": "Toistonopeus on alueen ulkopuolella" "error.settings_media_playback_rate_range": "Toistonopeus on alueen ulkopuolella",
"enclosure_media_controls.seek" : "Seek:",
"enclosure_media_controls.seek.title" : "Seek %s seconds",
"enclosure_media_controls.speed" : "Speed:",
"enclosure_media_controls.speed.faster" : "Faster",
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
"enclosure_media_controls.speed.slower" : "Slower",
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
"enclosure_media_controls.speed.reset" : "Reset",
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
} }

View File

@ -258,6 +258,7 @@
"alert.no_bookmark": "Il n'y a aucun favoris pour le moment.", "alert.no_bookmark": "Il n'y a aucun favoris pour le moment.",
"alert.no_category": "Il n'y a aucune catégorie.", "alert.no_category": "Il n'y a aucune catégorie.",
"alert.no_category_entry": "Il n'y a aucun article dans cette catégorie.", "alert.no_category_entry": "Il n'y a aucun article dans cette catégorie.",
"alert.no_tag_entry": "Il n'y a aucun article correspondant à ce tag.",
"alert.no_feed_entry": "Il n'y a aucun article pour cet abonnement.", "alert.no_feed_entry": "Il n'y a aucun article pour cet abonnement.",
"alert.no_feed": "Vous n'avez aucun abonnement.", "alert.no_feed": "Vous n'avez aucun abonnement.",
"alert.no_feed_in_category": "Il n'y a pas d'abonnement pour cette catégorie.", "alert.no_feed_in_category": "Il n'y a pas d'abonnement pour cette catégorie.",
@ -316,6 +317,7 @@
"form.feed.label.title": "Titre", "form.feed.label.title": "Titre",
"form.feed.label.site_url": "URL du site web", "form.feed.label.site_url": "URL du site web",
"form.feed.label.feed_url": "URL du flux", "form.feed.label.feed_url": "URL du flux",
"form.feed.label.description": "Description",
"form.feed.label.category": "Catégorie", "form.feed.label.category": "Catégorie",
"form.feed.label.crawler": "Récupérer le contenu original", "form.feed.label.crawler": "Récupérer le contenu original",
"form.feed.label.feed_username": "Nom d'utilisateur du flux", "form.feed.label.feed_username": "Nom d'utilisateur du flux",
@ -451,6 +453,10 @@
"form.integration.matrix_bot_password": "Mot de passe de l'utilisateur Matrix", "form.integration.matrix_bot_password": "Mot de passe de l'utilisateur Matrix",
"form.integration.matrix_bot_url": "URL du serveur Matrix", "form.integration.matrix_bot_url": "URL du serveur Matrix",
"form.integration.matrix_bot_chat_id": "Identifiant de la salle Matrix", "form.integration.matrix_bot_chat_id": "Identifiant de la salle Matrix",
"form.integration.raindrop_activate": "Save entries to Raindrop",
"form.integration.raindrop_token": "(Test) Token",
"form.integration.raindrop_collection_id": "Collection ID",
"form.integration.raindrop_tags": "Tags (comma-separated)",
"form.integration.readeck_activate": "Sauvegarder les articles vers Readeck", "form.integration.readeck_activate": "Sauvegarder les articles vers Readeck",
"form.integration.readeck_endpoint": "URL de l'API de Readeck", "form.integration.readeck_endpoint": "URL de l'API de Readeck",
"form.integration.readeck_api_key": "Clé d'API de Readeck", "form.integration.readeck_api_key": "Clé d'API de Readeck",
@ -528,5 +534,14 @@
"error.unable_to_detect_rssbridge": "Impossible de détecter un flux RSS en utilisant RSS-Bridge: %v.", "error.unable_to_detect_rssbridge": "Impossible de détecter un flux RSS en utilisant RSS-Bridge: %v.",
"error.feed_format_not_detected": "Impossible de détecter le format du flux : %v.", "error.feed_format_not_detected": "Impossible de détecter le format du flux : %v.",
"form.prefs.label.media_playback_rate": "Vitesse de lecture de l'audio/vidéo", "form.prefs.label.media_playback_rate": "Vitesse de lecture de l'audio/vidéo",
"error.settings_media_playback_rate_range": "La vitesse de lecture est hors limites" "error.settings_media_playback_rate_range": "La vitesse de lecture est hors limites",
"enclosure_media_controls.seek" : "Avancer/Reculer :",
"enclosure_media_controls.seek.title" : "Avancer/Reculer de %s seconds",
"enclosure_media_controls.speed" : "Vitesse :",
"enclosure_media_controls.speed.faster" : "Accélérer",
"enclosure_media_controls.speed.faster.title" : "Accélérer de %sx",
"enclosure_media_controls.speed.slower" : "Ralentir",
"enclosure_media_controls.speed.slower.title" : "Ralentir de %sx",
"enclosure_media_controls.speed.reset" : "Réinitialiser",
"enclosure_media_controls.speed.reset.title" : "Réinitialiser la vitesse de lecture à 1x"
} }

View File

@ -258,6 +258,7 @@
"alert.no_bookmark": "इस समय कोई बुकमार्क नहीं है", "alert.no_bookmark": "इस समय कोई बुकमार्क नहीं है",
"alert.no_category": "कोई श्रेणी नहीं है।", "alert.no_category": "कोई श्रेणी नहीं है।",
"alert.no_category_entry": "इस श्रेणी में कोई विषय-वस्तु नहीं है।", "alert.no_category_entry": "इस श्रेणी में कोई विषय-वस्तु नहीं है।",
"alert.no_tag_entry": "इस टैग से मेल खाती कोई प्रविष्टियाँ नहीं हैं।",
"alert.no_feed_entry": "इस फ़ीड के लिए कोई विषय-वस्तु नहीं है।", "alert.no_feed_entry": "इस फ़ीड के लिए कोई विषय-वस्तु नहीं है।",
"alert.no_feed": "आपके पास कोई सदस्यता नहीं है।", "alert.no_feed": "आपके पास कोई सदस्यता नहीं है।",
"alert.no_feed_in_category": "इस श्रेणी के लिए कोई सदस्यता नहीं है।", "alert.no_feed_in_category": "इस श्रेणी के लिए कोई सदस्यता नहीं है।",
@ -316,6 +317,7 @@
"form.feed.label.title": "शीर्षक", "form.feed.label.title": "शीर्षक",
"form.feed.label.site_url": "साइट यूआरएल", "form.feed.label.site_url": "साइट यूआरएल",
"form.feed.label.feed_url": "फ़ीड यूआरएल", "form.feed.label.feed_url": "फ़ीड यूआरएल",
"form.feed.label.description": "विवरण",
"form.feed.label.category": "श्रेणी", "form.feed.label.category": "श्रेणी",
"form.feed.label.crawler": "मूल सामग्री प्राप्त करें", "form.feed.label.crawler": "मूल सामग्री प्राप्त करें",
"form.feed.label.feed_username": "फ़ीड उपयोगकर्ता नाम", "form.feed.label.feed_username": "फ़ीड उपयोगकर्ता नाम",
@ -451,6 +453,10 @@
"form.integration.matrix_bot_password": "मैट्रिक्स उपयोगकर्ता के लिए पासवर्ड", "form.integration.matrix_bot_password": "मैट्रिक्स उपयोगकर्ता के लिए पासवर्ड",
"form.integration.matrix_bot_url": "मैट्रिक्स सर्वर URL", "form.integration.matrix_bot_url": "मैट्रिक्स सर्वर URL",
"form.integration.matrix_bot_chat_id": "मैट्रिक्स रूम की आईडी", "form.integration.matrix_bot_chat_id": "मैट्रिक्स रूम की आईडी",
"form.integration.raindrop_activate": "Save entries to Raindrop",
"form.integration.raindrop_token": "(Test) Token",
"form.integration.raindrop_collection_id": "Collection ID",
"form.integration.raindrop_tags": "Tags (comma-separated)",
"form.integration.readeck_activate": "Readeck में विषयवस्तु सहेजें", "form.integration.readeck_activate": "Readeck में विषयवस्तु सहेजें",
"form.integration.readeck_endpoint": "Readeck·एपीआई·समापन·बिंदु", "form.integration.readeck_endpoint": "Readeck·एपीआई·समापन·बिंदु",
"form.integration.readeck_api_key": "Readeck एपीआई कुंजी", "form.integration.readeck_api_key": "Readeck एपीआई कुंजी",
@ -528,5 +534,14 @@
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.", "error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
"error.feed_format_not_detected": "Unable to detect feed format: %v.", "error.feed_format_not_detected": "Unable to detect feed format: %v.",
"form.prefs.label.media_playback_rate": "ऑडियो/वीडियो की प्लेबैक गति", "form.prefs.label.media_playback_rate": "ऑडियो/वीडियो की प्लेबैक गति",
"error.settings_media_playback_rate_range": "प्लेबैक गति सीमा से बाहर है" "error.settings_media_playback_rate_range": "प्लेबैक गति सीमा से बाहर है",
"enclosure_media_controls.seek" : "Seek:",
"enclosure_media_controls.seek.title" : "Seek %s seconds",
"enclosure_media_controls.speed" : "Speed:",
"enclosure_media_controls.speed.faster" : "Faster",
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
"enclosure_media_controls.speed.slower" : "Slower",
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
"enclosure_media_controls.speed.reset" : "Reset",
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
} }

View File

@ -248,6 +248,7 @@
"alert.no_bookmark": "Tidak ada markah.", "alert.no_bookmark": "Tidak ada markah.",
"alert.no_category": "Tidak ada kategori.", "alert.no_category": "Tidak ada kategori.",
"alert.no_category_entry": "Tidak ada artikel di kategori ini.", "alert.no_category_entry": "Tidak ada artikel di kategori ini.",
"alert.no_tag_entry": "Tidak ada entri yang cocok dengan tag ini.",
"alert.no_feed_entry": "Tidak ada artikel di umpan ini.", "alert.no_feed_entry": "Tidak ada artikel di umpan ini.",
"alert.no_feed": "Anda tidak memiliki langganan.", "alert.no_feed": "Anda tidak memiliki langganan.",
"alert.no_feed_in_category": "Tidak ada langganan untuk kategori ini.", "alert.no_feed_in_category": "Tidak ada langganan untuk kategori ini.",
@ -306,6 +307,7 @@
"form.feed.label.title": "Judul", "form.feed.label.title": "Judul",
"form.feed.label.site_url": "URL Situs", "form.feed.label.site_url": "URL Situs",
"form.feed.label.feed_url": "URL Umpan", "form.feed.label.feed_url": "URL Umpan",
"form.feed.label.description": "Deskripsi",
"form.feed.label.category": "Kategori", "form.feed.label.category": "Kategori",
"form.feed.label.crawler": "Ambil konten asli", "form.feed.label.crawler": "Ambil konten asli",
"form.feed.label.feed_username": "Nama Pengguna Umpan", "form.feed.label.feed_username": "Nama Pengguna Umpan",
@ -441,6 +443,10 @@
"form.integration.matrix_bot_password": "Kata Sandi Matrix", "form.integration.matrix_bot_password": "Kata Sandi Matrix",
"form.integration.matrix_bot_url": "URL Peladen Matrix", "form.integration.matrix_bot_url": "URL Peladen Matrix",
"form.integration.matrix_bot_chat_id": "ID Ruang Matrix", "form.integration.matrix_bot_chat_id": "ID Ruang Matrix",
"form.integration.raindrop_activate": "Save entries to Raindrop",
"form.integration.raindrop_token": "(Test) Token",
"form.integration.raindrop_collection_id": "Collection ID",
"form.integration.raindrop_tags": "Tags (comma-separated)",
"form.integration.readeck_activate": "Simpan artikel ke Readeck", "form.integration.readeck_activate": "Simpan artikel ke Readeck",
"form.integration.readeck_endpoint": "Titik URL API Readeck", "form.integration.readeck_endpoint": "Titik URL API Readeck",
"form.integration.readeck_api_key": "Kunci API Readeck", "form.integration.readeck_api_key": "Kunci API Readeck",
@ -511,5 +517,14 @@
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.", "error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
"error.feed_format_not_detected": "Unable to detect feed format: %v.", "error.feed_format_not_detected": "Unable to detect feed format: %v.",
"form.prefs.label.media_playback_rate": "Kecepatan pemutaran audio/video", "form.prefs.label.media_playback_rate": "Kecepatan pemutaran audio/video",
"error.settings_media_playback_rate_range": "Kecepatan pemutaran di luar jangkauan" "error.settings_media_playback_rate_range": "Kecepatan pemutaran di luar jangkauan",
"enclosure_media_controls.seek" : "Seek:",
"enclosure_media_controls.seek.title" : "Seek %s seconds",
"enclosure_media_controls.speed" : "Speed:",
"enclosure_media_controls.speed.faster" : "Faster",
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
"enclosure_media_controls.speed.slower" : "Slower",
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
"enclosure_media_controls.speed.reset" : "Reset",
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
} }

View File

@ -258,6 +258,7 @@
"alert.no_bookmark": "Nessun preferito disponibile.", "alert.no_bookmark": "Nessun preferito disponibile.",
"alert.no_category": "Nessuna categoria disponibile.", "alert.no_category": "Nessuna categoria disponibile.",
"alert.no_category_entry": "Questa categoria non contiene alcun articolo.", "alert.no_category_entry": "Questa categoria non contiene alcun articolo.",
"alert.no_tag_entry": "Non ci sono voci corrispondenti a questo tag.",
"alert.no_feed_entry": "Questo feed non contiene alcun articolo.", "alert.no_feed_entry": "Questo feed non contiene alcun articolo.",
"alert.no_feed": "Nessun feed disponibile.", "alert.no_feed": "Nessun feed disponibile.",
"alert.no_feed_in_category": "Non esiste un abbonamento per questa categoria.", "alert.no_feed_in_category": "Non esiste un abbonamento per questa categoria.",
@ -316,6 +317,7 @@
"form.feed.label.title": "Titolo", "form.feed.label.title": "Titolo",
"form.feed.label.site_url": "URL del sito", "form.feed.label.site_url": "URL del sito",
"form.feed.label.feed_url": "URL del feed", "form.feed.label.feed_url": "URL del feed",
"form.feed.label.description": "Descrizione",
"form.feed.label.category": "Categoria", "form.feed.label.category": "Categoria",
"form.feed.label.crawler": "Scarica il contenuto integrale", "form.feed.label.crawler": "Scarica il contenuto integrale",
"form.feed.label.feed_username": "Nome utente del feed", "form.feed.label.feed_username": "Nome utente del feed",
@ -451,6 +453,10 @@
"form.integration.matrix_bot_password": "Password per l'utente Matrix", "form.integration.matrix_bot_password": "Password per l'utente Matrix",
"form.integration.matrix_bot_url": "URL del server Matrix", "form.integration.matrix_bot_url": "URL del server Matrix",
"form.integration.matrix_bot_chat_id": "ID della stanza Matrix", "form.integration.matrix_bot_chat_id": "ID della stanza Matrix",
"form.integration.raindrop_activate": "Save entries to Raindrop",
"form.integration.raindrop_token": "(Test) Token",
"form.integration.raindrop_collection_id": "Collection ID",
"form.integration.raindrop_tags": "Tags (comma-separated)",
"form.integration.readeck_activate": "Salva gli articoli su Readeck", "form.integration.readeck_activate": "Salva gli articoli su Readeck",
"form.integration.readeck_endpoint": "Endpoint dell'API di Readeck", "form.integration.readeck_endpoint": "Endpoint dell'API di Readeck",
"form.integration.readeck_api_key": "API key dell'account Readeck", "form.integration.readeck_api_key": "API key dell'account Readeck",
@ -528,5 +534,14 @@
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.", "error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
"error.feed_format_not_detected": "Unable to detect feed format: %v.", "error.feed_format_not_detected": "Unable to detect feed format: %v.",
"form.prefs.label.media_playback_rate": "Velocità di riproduzione dell'audio/video", "form.prefs.label.media_playback_rate": "Velocità di riproduzione dell'audio/video",
"error.settings_media_playback_rate_range": "La velocità di riproduzione non rientra nell'intervallo" "error.settings_media_playback_rate_range": "La velocità di riproduzione non rientra nell'intervallo",
"enclosure_media_controls.seek" : "Seek:",
"enclosure_media_controls.seek.title" : "Seek %s seconds",
"enclosure_media_controls.speed" : "Speed:",
"enclosure_media_controls.speed.faster" : "Faster",
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
"enclosure_media_controls.speed.slower" : "Slower",
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
"enclosure_media_controls.speed.reset" : "Reset",
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
} }

View File

@ -248,6 +248,7 @@
"alert.no_bookmark": "現在星付きはありません。", "alert.no_bookmark": "現在星付きはありません。",
"alert.no_category": "カテゴリが存在しません。", "alert.no_category": "カテゴリが存在しません。",
"alert.no_category_entry": "このカテゴリには記事がありません。", "alert.no_category_entry": "このカテゴリには記事がありません。",
"alert.no_tag_entry": "このタグに一致するエントリーはありません。",
"alert.no_feed_entry": "このフィードには記事がありません。", "alert.no_feed_entry": "このフィードには記事がありません。",
"alert.no_feed": "何も購読していません。", "alert.no_feed": "何も購読していません。",
"alert.no_feed_in_category": "このカテゴリには購読中のフィードがありません。", "alert.no_feed_in_category": "このカテゴリには購読中のフィードがありません。",
@ -306,6 +307,7 @@
"form.feed.label.title": "タイトル", "form.feed.label.title": "タイトル",
"form.feed.label.site_url": "サイト URL", "form.feed.label.site_url": "サイト URL",
"form.feed.label.feed_url": "フィード URL", "form.feed.label.feed_url": "フィード URL",
"form.feed.label.description": "説明",
"form.feed.label.category": "カテゴリ", "form.feed.label.category": "カテゴリ",
"form.feed.label.crawler": "オリジナルの内容を取得", "form.feed.label.crawler": "オリジナルの内容を取得",
"form.feed.label.feed_username": "フィードのユーザー名", "form.feed.label.feed_username": "フィードのユーザー名",
@ -441,6 +443,10 @@
"form.integration.matrix_bot_password": "Matrixユーザ用パスワード", "form.integration.matrix_bot_password": "Matrixユーザ用パスワード",
"form.integration.matrix_bot_url": "MatrixサーバーのURL", "form.integration.matrix_bot_url": "MatrixサーバーのURL",
"form.integration.matrix_bot_chat_id": "MatrixルームのID", "form.integration.matrix_bot_chat_id": "MatrixルームのID",
"form.integration.raindrop_activate": "Save entries to Raindrop",
"form.integration.raindrop_token": "(Test) Token",
"form.integration.raindrop_collection_id": "Collection ID",
"form.integration.raindrop_tags": "Tags (comma-separated)",
"form.integration.readeck_activate": "Readeck に記事を保存する", "form.integration.readeck_activate": "Readeck に記事を保存する",
"form.integration.readeck_endpoint": "Readeck の API Endpoint", "form.integration.readeck_endpoint": "Readeck の API Endpoint",
"form.integration.readeck_api_key": "Readeck の API key", "form.integration.readeck_api_key": "Readeck の API key",
@ -511,5 +517,14 @@
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.", "error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
"error.feed_format_not_detected": "Unable to detect feed format: %v.", "error.feed_format_not_detected": "Unable to detect feed format: %v.",
"form.prefs.label.media_playback_rate": "オーディオ/ビデオの再生速度", "form.prefs.label.media_playback_rate": "オーディオ/ビデオの再生速度",
"error.settings_media_playback_rate_range": "再生速度が範囲外" "error.settings_media_playback_rate_range": "再生速度が範囲外",
"enclosure_media_controls.seek" : "Seek:",
"enclosure_media_controls.seek.title" : "Seek %s seconds",
"enclosure_media_controls.speed" : "Speed:",
"enclosure_media_controls.speed.faster" : "Faster",
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
"enclosure_media_controls.speed.slower" : "Slower",
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
"enclosure_media_controls.speed.reset" : "Reset",
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
} }

View File

@ -258,6 +258,7 @@
"alert.no_bookmark": "Er zijn op dit moment geen favorieten.", "alert.no_bookmark": "Er zijn op dit moment geen favorieten.",
"alert.no_category": "Er zijn geen categorieën.", "alert.no_category": "Er zijn geen categorieën.",
"alert.no_category_entry": "Deze categorie bevat geen feeds.", "alert.no_category_entry": "Deze categorie bevat geen feeds.",
"alert.no_tag_entry": "Er zijn geen items die overeenkomen met deze tag.",
"alert.no_feed_entry": "Er zijn geen artikelen in deze feed.", "alert.no_feed_entry": "Er zijn geen artikelen in deze feed.",
"alert.no_feed": "Je hebt nog geen feeds geabboneerd staan.", "alert.no_feed": "Je hebt nog geen feeds geabboneerd staan.",
"alert.no_feed_in_category": "Er is geen abonnement voor deze categorie.", "alert.no_feed_in_category": "Er is geen abonnement voor deze categorie.",
@ -316,6 +317,7 @@
"form.feed.label.title": "Naam", "form.feed.label.title": "Naam",
"form.feed.label.site_url": "Website URL", "form.feed.label.site_url": "Website URL",
"form.feed.label.feed_url": "Feed URL", "form.feed.label.feed_url": "Feed URL",
"form.feed.label.description": "Beschrijving",
"form.feed.label.category": "Categorie", "form.feed.label.category": "Categorie",
"form.feed.label.crawler": "Download originele content", "form.feed.label.crawler": "Download originele content",
"form.feed.label.feed_username": "Feed-gebruikersnaam", "form.feed.label.feed_username": "Feed-gebruikersnaam",
@ -451,6 +453,10 @@
"form.integration.matrix_bot_password": "Wachtwoord voor Matrix-gebruiker", "form.integration.matrix_bot_password": "Wachtwoord voor Matrix-gebruiker",
"form.integration.matrix_bot_url": "URL van de Matrix-server", "form.integration.matrix_bot_url": "URL van de Matrix-server",
"form.integration.matrix_bot_chat_id": "ID van Matrix-kamer", "form.integration.matrix_bot_chat_id": "ID van Matrix-kamer",
"form.integration.raindrop_activate": "Save entries to Raindrop",
"form.integration.raindrop_token": "(Test) Token",
"form.integration.raindrop_collection_id": "Collection ID",
"form.integration.raindrop_tags": "Tags (comma-separated)",
"form.integration.readeck_activate": "Opslaan naar Readeck", "form.integration.readeck_activate": "Opslaan naar Readeck",
"form.integration.readeck_endpoint": "Readeck URL", "form.integration.readeck_endpoint": "Readeck URL",
"form.integration.readeck_api_key": "Readeck API-sleutel", "form.integration.readeck_api_key": "Readeck API-sleutel",
@ -528,5 +534,14 @@
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.", "error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
"error.feed_format_not_detected": "Unable to detect feed format: %v.", "error.feed_format_not_detected": "Unable to detect feed format: %v.",
"form.prefs.label.media_playback_rate": "Afspeelsnelheid van de audio/video", "form.prefs.label.media_playback_rate": "Afspeelsnelheid van de audio/video",
"error.settings_media_playback_rate_range": "Afspeelsnelheid is buiten bereik" "error.settings_media_playback_rate_range": "Afspeelsnelheid is buiten bereik",
"enclosure_media_controls.seek" : "Seek:",
"enclosure_media_controls.seek.title" : "Seek %s seconds",
"enclosure_media_controls.speed" : "Speed:",
"enclosure_media_controls.speed.faster" : "Faster",
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
"enclosure_media_controls.speed.slower" : "Slower",
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
"enclosure_media_controls.speed.reset" : "Reset",
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
} }

View File

@ -268,6 +268,7 @@
"alert.no_bookmark": "Obecnie nie ma żadnych zakładek.", "alert.no_bookmark": "Obecnie nie ma żadnych zakładek.",
"alert.no_category": "Nie ma żadnej kategorii!", "alert.no_category": "Nie ma żadnej kategorii!",
"alert.no_category_entry": "W tej kategorii nie ma żadnych artykułów", "alert.no_category_entry": "W tej kategorii nie ma żadnych artykułów",
"alert.no_tag_entry": "Nie ma wpisów pasujących do tego tagu.",
"alert.no_feed_entry": "Nie ma artykułu dla tego kanału.", "alert.no_feed_entry": "Nie ma artykułu dla tego kanału.",
"alert.no_feed": "Nie masz żadnej subskrypcji.", "alert.no_feed": "Nie masz żadnej subskrypcji.",
"alert.no_feed_in_category": "Nie ma subskrypcji dla tej kategorii.", "alert.no_feed_in_category": "Nie ma subskrypcji dla tej kategorii.",
@ -326,6 +327,7 @@
"form.feed.label.title": "Tytuł", "form.feed.label.title": "Tytuł",
"form.feed.label.site_url": "URL strony", "form.feed.label.site_url": "URL strony",
"form.feed.label.feed_url": "URL kanału", "form.feed.label.feed_url": "URL kanału",
"form.feed.label.description": "Opis",
"form.feed.label.category": "Kategoria", "form.feed.label.category": "Kategoria",
"form.feed.label.crawler": "Pobierz oryginalną treść", "form.feed.label.crawler": "Pobierz oryginalną treść",
"form.feed.label.feed_username": "Subskrypcję nazwa użytkownika", "form.feed.label.feed_username": "Subskrypcję nazwa użytkownika",
@ -461,6 +463,10 @@
"form.integration.matrix_bot_password": "Hasło dla użytkownika Matrix", "form.integration.matrix_bot_password": "Hasło dla użytkownika Matrix",
"form.integration.matrix_bot_url": "URL serwera Matrix", "form.integration.matrix_bot_url": "URL serwera Matrix",
"form.integration.matrix_bot_chat_id": "Identyfikator pokoju Matrix", "form.integration.matrix_bot_chat_id": "Identyfikator pokoju Matrix",
"form.integration.raindrop_activate": "Save entries to Raindrop",
"form.integration.raindrop_token": "(Test) Token",
"form.integration.raindrop_collection_id": "Collection ID",
"form.integration.raindrop_tags": "Tags (comma-separated)",
"form.integration.readeck_activate": "Zapisz artykuły do Readeck", "form.integration.readeck_activate": "Zapisz artykuły do Readeck",
"form.integration.readeck_endpoint": "Readeck URL", "form.integration.readeck_endpoint": "Readeck URL",
"form.integration.readeck_api_key": "Readeck API key", "form.integration.readeck_api_key": "Readeck API key",
@ -545,5 +551,14 @@
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.", "error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
"error.feed_format_not_detected": "Unable to detect feed format: %v.", "error.feed_format_not_detected": "Unable to detect feed format: %v.",
"form.prefs.label.media_playback_rate": "Prędkość odtwarzania audio/wideo", "form.prefs.label.media_playback_rate": "Prędkość odtwarzania audio/wideo",
"error.settings_media_playback_rate_range": "Prędkość odtwarzania jest poza zakresem" "error.settings_media_playback_rate_range": "Prędkość odtwarzania jest poza zakresem",
"enclosure_media_controls.seek" : "Seek:",
"enclosure_media_controls.seek.title" : "Seek %s seconds",
"enclosure_media_controls.speed" : "Speed:",
"enclosure_media_controls.speed.faster" : "Faster",
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
"enclosure_media_controls.speed.slower" : "Slower",
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
"enclosure_media_controls.speed.reset" : "Reset",
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
} }

View File

@ -258,6 +258,7 @@
"alert.no_bookmark": "Não há favorito neste momento.", "alert.no_bookmark": "Não há favorito neste momento.",
"alert.no_category": "Não há categoria.", "alert.no_category": "Não há categoria.",
"alert.no_category_entry": "Não há itens nesta categoria.", "alert.no_category_entry": "Não há itens nesta categoria.",
"alert.no_tag_entry": "Não há itens que correspondam a esta etiqueta.",
"alert.no_feed_entry": "Não há itens nessa fonte.", "alert.no_feed_entry": "Não há itens nessa fonte.",
"alert.no_feed": "Não há inscrições.", "alert.no_feed": "Não há inscrições.",
"alert.no_feed_in_category": "Não há inscrições nessa categoria.", "alert.no_feed_in_category": "Não há inscrições nessa categoria.",
@ -316,6 +317,7 @@
"form.feed.label.title": "Título", "form.feed.label.title": "Título",
"form.feed.label.site_url": "URL do site", "form.feed.label.site_url": "URL do site",
"form.feed.label.feed_url": "URL da fonte", "form.feed.label.feed_url": "URL da fonte",
"form.feed.label.description": "Descrição",
"form.feed.label.category": "Categoria", "form.feed.label.category": "Categoria",
"form.feed.label.crawler": "Obter conteúdo original", "form.feed.label.crawler": "Obter conteúdo original",
"form.feed.label.feed_username": "Nome de usuário da fonte", "form.feed.label.feed_username": "Nome de usuário da fonte",
@ -451,6 +453,10 @@
"form.integration.matrix_bot_password": "Palavra-passe para utilizador da Matrix", "form.integration.matrix_bot_password": "Palavra-passe para utilizador da Matrix",
"form.integration.matrix_bot_url": "URL do servidor Matrix", "form.integration.matrix_bot_url": "URL do servidor Matrix",
"form.integration.matrix_bot_chat_id": "Identificação da sala Matrix", "form.integration.matrix_bot_chat_id": "Identificação da sala Matrix",
"form.integration.raindrop_activate": "Save entries to Raindrop",
"form.integration.raindrop_token": "(Test) Token",
"form.integration.raindrop_collection_id": "Collection ID",
"form.integration.raindrop_tags": "Tags (comma-separated)",
"form.integration.readeck_activate": "Salvar itens no Readeck", "form.integration.readeck_activate": "Salvar itens no Readeck",
"form.integration.readeck_endpoint": "Endpoint de API do Readeck", "form.integration.readeck_endpoint": "Endpoint de API do Readeck",
"form.integration.readeck_api_key": "Chave de API do Readeck", "form.integration.readeck_api_key": "Chave de API do Readeck",
@ -528,5 +534,14 @@
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.", "error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
"error.feed_format_not_detected": "Unable to detect feed format: %v.", "error.feed_format_not_detected": "Unable to detect feed format: %v.",
"form.prefs.label.media_playback_rate": "Velocidade de reprodução do áudio/vídeo", "form.prefs.label.media_playback_rate": "Velocidade de reprodução do áudio/vídeo",
"error.settings_media_playback_rate_range": "A velocidade de reprodução está fora do intervalo" "error.settings_media_playback_rate_range": "A velocidade de reprodução está fora do intervalo",
"enclosure_media_controls.seek" : "Seek:",
"enclosure_media_controls.seek.title" : "Seek %s seconds",
"enclosure_media_controls.speed" : "Speed:",
"enclosure_media_controls.speed.faster" : "Faster",
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
"enclosure_media_controls.speed.slower" : "Slower",
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
"enclosure_media_controls.speed.reset" : "Reset",
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
} }

View File

@ -268,6 +268,7 @@
"alert.no_bookmark": "Избранное отсутствует.", "alert.no_bookmark": "Избранное отсутствует.",
"alert.no_category": "Категории отсутствуют.", "alert.no_category": "Категории отсутствуют.",
"alert.no_category_entry": "В этой категории нет статей.", "alert.no_category_entry": "В этой категории нет статей.",
"alert.no_tag_entry": "Нет записей, соответствующих этому тегу.",
"alert.no_feed_entry": "В этой подписке отсутствуют статьи.", "alert.no_feed_entry": "В этой подписке отсутствуют статьи.",
"alert.no_feed": "У вас нет ни одной подписки.", "alert.no_feed": "У вас нет ни одной подписки.",
"alert.no_feed_in_category": "Для этой категории нет подписки.", "alert.no_feed_in_category": "Для этой категории нет подписки.",
@ -326,6 +327,7 @@
"form.feed.label.title": "Название", "form.feed.label.title": "Название",
"form.feed.label.site_url": "Адрес сайта", "form.feed.label.site_url": "Адрес сайта",
"form.feed.label.feed_url": "Адрес подписки", "form.feed.label.feed_url": "Адрес подписки",
"form.feed.label.description": "Описание",
"form.feed.label.category": "Категория", "form.feed.label.category": "Категория",
"form.feed.label.crawler": "Извлечь оригинальное содержимое", "form.feed.label.crawler": "Извлечь оригинальное содержимое",
"form.feed.label.feed_username": "Имя пользователя подписки", "form.feed.label.feed_username": "Имя пользователя подписки",
@ -461,6 +463,10 @@
"form.integration.matrix_bot_password": "Пароль пользователя Matrix", "form.integration.matrix_bot_password": "Пароль пользователя Matrix",
"form.integration.matrix_bot_url": "Ссылка на сервер Matrix", "form.integration.matrix_bot_url": "Ссылка на сервер Matrix",
"form.integration.matrix_bot_chat_id": "ID комнаты Matrix", "form.integration.matrix_bot_chat_id": "ID комнаты Matrix",
"form.integration.raindrop_activate": "Save entries to Raindrop",
"form.integration.raindrop_token": "(Test) Token",
"form.integration.raindrop_collection_id": "Collection ID",
"form.integration.raindrop_tags": "Tags (comma-separated)",
"form.integration.readeck_activate": "Сохранять статьи в Readeck", "form.integration.readeck_activate": "Сохранять статьи в Readeck",
"form.integration.readeck_endpoint": "Конечная точка Readeck API", "form.integration.readeck_endpoint": "Конечная точка Readeck API",
"form.integration.readeck_api_key": "API-ключ Readeck", "form.integration.readeck_api_key": "API-ключ Readeck",
@ -545,5 +551,14 @@
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.", "error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
"error.feed_format_not_detected": "Unable to detect feed format: %v.", "error.feed_format_not_detected": "Unable to detect feed format: %v.",
"form.prefs.label.media_playback_rate": "Скорость воспроизведения аудио/видео", "form.prefs.label.media_playback_rate": "Скорость воспроизведения аудио/видео",
"error.settings_media_playback_rate_range": "Скорость воспроизведения выходит за пределы диапазона" "error.settings_media_playback_rate_range": "Скорость воспроизведения выходит за пределы диапазона",
"enclosure_media_controls.seek" : "Seek:",
"enclosure_media_controls.seek.title" : "Seek %s seconds",
"enclosure_media_controls.speed" : "Speed:",
"enclosure_media_controls.speed.faster" : "Faster",
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
"enclosure_media_controls.speed.slower" : "Slower",
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
"enclosure_media_controls.speed.reset" : "Reset",
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
} }

View File

@ -18,6 +18,7 @@
"alert.no_bookmark": "Yıldızlanmış makale yok.", "alert.no_bookmark": "Yıldızlanmış makale yok.",
"alert.no_category": "Hiç kategori yok.", "alert.no_category": "Hiç kategori yok.",
"alert.no_category_entry": "Bu kategoride hiç makele yok.", "alert.no_category_entry": "Bu kategoride hiç makele yok.",
"alert.no_tag_entry": "Bu etiketle eşleşen hiçbir giriş yok.",
"alert.no_feed": "Hiç beslemeniz yok.", "alert.no_feed": "Hiç beslemeniz yok.",
"alert.no_feed_entry": "Bu besleme için makele yok.", "alert.no_feed_entry": "Bu besleme için makele yok.",
"alert.no_feed_in_category": "Bu kategori için besleme yok.", "alert.no_feed_in_category": "Bu kategori için besleme yok.",
@ -153,6 +154,7 @@
"form.feed.label.disabled": "Bu beslemeyi yenileme", "form.feed.label.disabled": "Bu beslemeyi yenileme",
"form.feed.label.feed_password": "Besleme Parolası", "form.feed.label.feed_password": "Besleme Parolası",
"form.feed.label.feed_url": "Besleme URL'si", "form.feed.label.feed_url": "Besleme URL'si",
"form.feed.label.description": "Açıklama",
"form.feed.label.feed_username": "Besleme Kullanıcı Adı", "form.feed.label.feed_username": "Besleme Kullanıcı Adı",
"form.feed.label.fetch_via_proxy": "Proxy ile çek", "form.feed.label.fetch_via_proxy": "Proxy ile çek",
"form.feed.label.hide_globally": "Genel okunmamış listesindeki girişleri gizle", "form.feed.label.hide_globally": "Genel okunmamış listesindeki girişleri gizle",
@ -221,6 +223,10 @@
"form.integration.pocket_activate": "Makaleleri Pocket'a kaydet", "form.integration.pocket_activate": "Makaleleri Pocket'a kaydet",
"form.integration.pocket_connect_link": "Pocket hesabını bağla", "form.integration.pocket_connect_link": "Pocket hesabını bağla",
"form.integration.pocket_consumer_key": "Pocket Consumer Anahtarı", "form.integration.pocket_consumer_key": "Pocket Consumer Anahtarı",
"form.integration.raindrop_activate": "Save entries to Raindrop",
"form.integration.raindrop_token": "(Test) Token",
"form.integration.raindrop_collection_id": "Collection ID",
"form.integration.raindrop_tags": "Tags (comma-separated)",
"form.integration.readeck_activate": "Makaleleri Readeck'e kaydet", "form.integration.readeck_activate": "Makaleleri Readeck'e kaydet",
"form.integration.readeck_api_key": "Readeck API Anahtarı", "form.integration.readeck_api_key": "Readeck API Anahtarı",
"form.integration.readeck_endpoint": "Readeck API Uç Noktası", "form.integration.readeck_endpoint": "Readeck API Uç Noktası",
@ -495,5 +501,14 @@
"time_elapsed.years": ["%d yıl önce", "%d yıl önce"], "time_elapsed.years": ["%d yıl önce", "%d yıl önce"],
"time_elapsed.yesterday": "dün", "time_elapsed.yesterday": "dün",
"tooltip.keyboard_shortcuts": "Klavye Kısayolu: %s", "tooltip.keyboard_shortcuts": "Klavye Kısayolu: %s",
"tooltip.logged_user": "%s olarak giriş yapıldı" "tooltip.logged_user": "%s olarak giriş yapıldı",
"enclosure_media_controls.seek" : "Seek:",
"enclosure_media_controls.seek.title" : "Seek %s seconds",
"enclosure_media_controls.speed" : "Speed:",
"enclosure_media_controls.speed.faster" : "Faster",
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
"enclosure_media_controls.speed.slower" : "Slower",
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
"enclosure_media_controls.speed.reset" : "Reset",
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
} }

View File

@ -268,6 +268,7 @@
"alert.no_bookmark": "Наразі закладки відсутні.", "alert.no_bookmark": "Наразі закладки відсутні.",
"alert.no_category": "Немає категорії.", "alert.no_category": "Немає категорії.",
"alert.no_category_entry": "У цій категорії немає записів.", "alert.no_category_entry": "У цій категорії немає записів.",
"alert.no_tag_entry": "Немає записів, що відповідають цьому тегу.",
"alert.no_feed_entry": "У цій стрічці немає записів.", "alert.no_feed_entry": "У цій стрічці немає записів.",
"alert.no_feed": "У вас немає підписок.", "alert.no_feed": "У вас немає підписок.",
"alert.no_feed_in_category": "У цій категорії немає підписок.", "alert.no_feed_in_category": "У цій категорії немає підписок.",
@ -326,6 +327,7 @@
"form.feed.label.title": "Назва", "form.feed.label.title": "Назва",
"form.feed.label.site_url": "URL-адреса сайту", "form.feed.label.site_url": "URL-адреса сайту",
"form.feed.label.feed_url": "URL-адреса стрічки", "form.feed.label.feed_url": "URL-адреса стрічки",
"form.feed.label.description": "Опис",
"form.feed.label.category": "Категорія", "form.feed.label.category": "Категорія",
"form.feed.label.crawler": "Завантажувати оригінальний вміст", "form.feed.label.crawler": "Завантажувати оригінальний вміст",
"form.feed.label.feed_username": "Ім’я користувача для завантаження", "form.feed.label.feed_username": "Ім’я користувача для завантаження",
@ -461,6 +463,10 @@
"form.integration.matrix_bot_password": "Пароль для користувача Matrix", "form.integration.matrix_bot_password": "Пароль для користувача Matrix",
"form.integration.matrix_bot_url": "URL-адреса сервера Матриці", "form.integration.matrix_bot_url": "URL-адреса сервера Матриці",
"form.integration.matrix_bot_chat_id": "Ідентифікатор кімнати Матриці", "form.integration.matrix_bot_chat_id": "Ідентифікатор кімнати Матриці",
"form.integration.raindrop_activate": "Save entries to Raindrop",
"form.integration.raindrop_token": "(Test) Token",
"form.integration.raindrop_collection_id": "Collection ID",
"form.integration.raindrop_tags": "Tags (comma-separated)",
"form.integration.readeck_activate": "Зберігати статті до Readeck", "form.integration.readeck_activate": "Зберігати статті до Readeck",
"form.integration.readeck_endpoint": "Readeck API Endpoint", "form.integration.readeck_endpoint": "Readeck API Endpoint",
"form.integration.readeck_api_key": "Ключ API Readeck", "form.integration.readeck_api_key": "Ключ API Readeck",
@ -545,5 +551,14 @@
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.", "error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
"error.feed_format_not_detected": "Unable to detect feed format: %v.", "error.feed_format_not_detected": "Unable to detect feed format: %v.",
"form.prefs.label.media_playback_rate": "Швидкість відтворення аудіо/відео", "form.prefs.label.media_playback_rate": "Швидкість відтворення аудіо/відео",
"error.settings_media_playback_rate_range": "Швидкість відтворення виходить за межі діапазону" "error.settings_media_playback_rate_range": "Швидкість відтворення виходить за межі діапазону",
"enclosure_media_controls.seek" : "Seek:",
"enclosure_media_controls.seek.title" : "Seek %s seconds",
"enclosure_media_controls.speed" : "Speed:",
"enclosure_media_controls.speed.faster" : "Faster",
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
"enclosure_media_controls.speed.slower" : "Slower",
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
"enclosure_media_controls.speed.reset" : "Reset",
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
} }

View File

@ -248,6 +248,7 @@
"alert.no_bookmark": "目前没有收藏", "alert.no_bookmark": "目前没有收藏",
"alert.no_category": "目前没有分类", "alert.no_category": "目前没有分类",
"alert.no_category_entry": "该分类下没有文章", "alert.no_category_entry": "该分类下没有文章",
"alert.no_tag_entry": "没有与此标签匹配的条目。",
"alert.no_feed_entry": "该源中没有文章", "alert.no_feed_entry": "该源中没有文章",
"alert.no_feed": "目前没有源", "alert.no_feed": "目前没有源",
"alert.no_history": "目前没有历史", "alert.no_history": "目前没有历史",
@ -306,6 +307,7 @@
"form.feed.label.title": "标题", "form.feed.label.title": "标题",
"form.feed.label.site_url": "源网站 URL", "form.feed.label.site_url": "源网站 URL",
"form.feed.label.feed_url": "订阅源 URL", "form.feed.label.feed_url": "订阅源 URL",
"form.feed.label.description": "描述",
"form.feed.label.category": "类别", "form.feed.label.category": "类别",
"form.feed.label.crawler": "抓取全文内容", "form.feed.label.crawler": "抓取全文内容",
"form.feed.label.feed_username": "源用户名", "form.feed.label.feed_username": "源用户名",
@ -441,6 +443,10 @@
"form.integration.matrix_bot_password": "Matrix Bot 密码", "form.integration.matrix_bot_password": "Matrix Bot 密码",
"form.integration.matrix_bot_url": "Matrix 服务器 URL", "form.integration.matrix_bot_url": "Matrix 服务器 URL",
"form.integration.matrix_bot_chat_id": "Matrix 聊天 ID", "form.integration.matrix_bot_chat_id": "Matrix 聊天 ID",
"form.integration.raindrop_activate": "Save entries to Raindrop",
"form.integration.raindrop_token": "(Test) Token",
"form.integration.raindrop_collection_id": "Collection ID",
"form.integration.raindrop_tags": "Tags (comma-separated)",
"form.integration.readeck_activate": "保存文章到 Readeck", "form.integration.readeck_activate": "保存文章到 Readeck",
"form.integration.readeck_endpoint": "Readeck API 端点", "form.integration.readeck_endpoint": "Readeck API 端点",
"form.integration.readeck_api_key": "Readeck API 密钥", "form.integration.readeck_api_key": "Readeck API 密钥",
@ -511,5 +517,14 @@
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.", "error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
"error.feed_format_not_detected": "Unable to detect feed format: %v.", "error.feed_format_not_detected": "Unable to detect feed format: %v.",
"form.prefs.label.media_playback_rate": "音频/视频的播放速度", "form.prefs.label.media_playback_rate": "音频/视频的播放速度",
"error.settings_media_playback_rate_range": "播放速度超出范围" "error.settings_media_playback_rate_range": "播放速度超出范围",
"enclosure_media_controls.seek" : "Seek:",
"enclosure_media_controls.seek.title" : "Seek %s seconds",
"enclosure_media_controls.speed" : "Speed:",
"enclosure_media_controls.speed.faster" : "Faster",
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
"enclosure_media_controls.speed.slower" : "Slower",
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
"enclosure_media_controls.speed.reset" : "Reset",
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
} }

View File

@ -248,6 +248,7 @@
"alert.no_bookmark": "目前沒有收藏", "alert.no_bookmark": "目前沒有收藏",
"alert.no_category": "目前沒有分類", "alert.no_category": "目前沒有分類",
"alert.no_category_entry": "該分類下沒有文章", "alert.no_category_entry": "該分類下沒有文章",
"alert.no_tag_entry": "沒有與此標籤相符的條目。",
"alert.no_feed_entry": "該Feed中沒有文章", "alert.no_feed_entry": "該Feed中沒有文章",
"alert.no_feed": "目前沒有Feed", "alert.no_feed": "目前沒有Feed",
"alert.no_history": "目前沒有歷史", "alert.no_history": "目前沒有歷史",
@ -306,6 +307,7 @@
"form.feed.label.title": "標題", "form.feed.label.title": "標題",
"form.feed.label.site_url": "網站 URL", "form.feed.label.site_url": "網站 URL",
"form.feed.label.feed_url": "訂閱 Feed URL", "form.feed.label.feed_url": "訂閱 Feed URL",
"form.feed.label.description": "描述",
"form.feed.label.category": "類別", "form.feed.label.category": "類別",
"form.feed.label.crawler": "下載原文內容", "form.feed.label.crawler": "下載原文內容",
"form.feed.label.feed_username": "Feed 使用者名稱", "form.feed.label.feed_username": "Feed 使用者名稱",
@ -441,6 +443,10 @@
"form.integration.matrix_bot_password": "Matrix 的密碼", "form.integration.matrix_bot_password": "Matrix 的密碼",
"form.integration.matrix_bot_url": "Matrix 伺服器的 URL", "form.integration.matrix_bot_url": "Matrix 伺服器的 URL",
"form.integration.matrix_bot_chat_id": "Matrix 房間 ID", "form.integration.matrix_bot_chat_id": "Matrix 房間 ID",
"form.integration.raindrop_activate": "Save entries to Raindrop",
"form.integration.raindrop_token": "(Test) Token",
"form.integration.raindrop_collection_id": "Collection ID",
"form.integration.raindrop_tags": "Tags (comma-separated)",
"form.integration.readeck_activate": "儲存文章到 Readeck", "form.integration.readeck_activate": "儲存文章到 Readeck",
"form.integration.readeck_endpoint": "Readeck API 端點", "form.integration.readeck_endpoint": "Readeck API 端點",
"form.integration.readeck_api_key": "Readeck API 金鑰", "form.integration.readeck_api_key": "Readeck API 金鑰",
@ -511,5 +517,14 @@
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.", "error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
"error.feed_format_not_detected": "Unable to detect feed format: %v.", "error.feed_format_not_detected": "Unable to detect feed format: %v.",
"form.prefs.label.media_playback_rate": "音訊/視訊的播放速度", "form.prefs.label.media_playback_rate": "音訊/視訊的播放速度",
"error.settings_media_playback_rate_range": "播放速度超出範圍" "error.settings_media_playback_rate_range": "播放速度超出範圍",
"enclosure_media_controls.seek" : "Seek:",
"enclosure_media_controls.seek.title" : "Seek %s seconds",
"enclosure_media_controls.speed" : "Speed:",
"enclosure_media_controls.speed.faster" : "Faster",
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
"enclosure_media_controls.speed.slower" : "Slower",
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
"enclosure_media_controls.speed.reset" : "Reset",
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
} }

View File

@ -28,6 +28,7 @@ type Feed struct {
FeedURL string `json:"feed_url"` FeedURL string `json:"feed_url"`
SiteURL string `json:"site_url"` SiteURL string `json:"site_url"`
Title string `json:"title"` Title string `json:"title"`
Description string `json:"description"`
CheckedAt time.Time `json:"checked_at"` CheckedAt time.Time `json:"checked_at"`
NextCheckAt time.Time `json:"next_check_at"` NextCheckAt time.Time `json:"next_check_at"`
EtagHeader string `json:"etag_header"` EtagHeader string `json:"etag_header"`
@ -167,6 +168,7 @@ type FeedModificationRequest struct {
FeedURL *string `json:"feed_url"` FeedURL *string `json:"feed_url"`
SiteURL *string `json:"site_url"` SiteURL *string `json:"site_url"`
Title *string `json:"title"` Title *string `json:"title"`
Description *string `json:"description"`
ScraperRules *string `json:"scraper_rules"` ScraperRules *string `json:"scraper_rules"`
RewriteRules *string `json:"rewrite_rules"` RewriteRules *string `json:"rewrite_rules"`
BlocklistRules *string `json:"blocklist_rules"` BlocklistRules *string `json:"blocklist_rules"`
@ -201,6 +203,10 @@ func (f *FeedModificationRequest) Patch(feed *Feed) {
feed.Title = *f.Title feed.Title = *f.Title
} }
if f.Description != nil && *f.Description != "" {
feed.Description = *f.Description
}
if f.ScraperRules != nil { if f.ScraperRules != nil {
feed.ScraperRules = *f.ScraperRules feed.ScraperRules = *f.ScraperRules
} }

View File

@ -90,4 +90,8 @@ type Integration struct {
OmnivoreEnabled bool OmnivoreEnabled bool
OmnivoreAPIKey string OmnivoreAPIKey string
OmnivoreURL string OmnivoreURL string
RaindropEnabled bool
RaindropToken string
RaindropCollectionID string
RaindropTags string
} }

View File

@ -0,0 +1,55 @@
package fetcher
import (
"compress/gzip"
"io"
"github.com/andybalholm/brotli"
)
type brotliReadCloser struct {
body io.ReadCloser
brotliReader io.Reader
}
func NewBrotliReadCloser(body io.ReadCloser) *brotliReadCloser {
return &brotliReadCloser{
body: body,
brotliReader: brotli.NewReader(body),
}
}
func (b *brotliReadCloser) Read(p []byte) (n int, err error) {
return b.brotliReader.Read(p)
}
func (b *brotliReadCloser) Close() error {
return b.body.Close()
}
type gzipReadCloser struct {
body io.ReadCloser
gzipReader io.Reader
gzipErr error
}
func NewGzipReadCloser(body io.ReadCloser) *gzipReadCloser {
return &gzipReadCloser{body: body}
}
func (gz *gzipReadCloser) Read(p []byte) (n int, err error) {
if gz.gzipReader == nil {
if gz.gzipErr == nil {
gz.gzipReader, gz.gzipErr = gzip.NewReader(gz.body)
}
if gz.gzipErr != nil {
return 0, gz.gzipErr
}
}
return gz.gzipReader.Read(p)
}
func (gz *gzipReadCloser) Close() error {
return gz.body.Close()
}

View File

@ -109,6 +109,16 @@ func (r *RequestBuilder) IgnoreTLSErrors(value bool) *RequestBuilder {
} }
func (r *RequestBuilder) ExecuteRequest(requestURL string) (*http.Response, error) { func (r *RequestBuilder) ExecuteRequest(requestURL string) (*http.Response, error) {
// We get the safe ciphers
ciphers := tls.CipherSuites()
if r.ignoreTLSErrors {
// and the insecure ones if we are ignoring TLS errors. This allows to connect to badly configured servers anyway
ciphers = append(ciphers, tls.InsecureCipherSuites()...)
}
cipherSuites := []uint16{}
for _, cipher := range ciphers {
cipherSuites = append(cipherSuites, cipher.ID)
}
transport := &http.Transport{ transport := &http.Transport{
Proxy: http.ProxyFromEnvironment, Proxy: http.ProxyFromEnvironment,
// Setting `DialContext` disables HTTP/2, this option forces the transport to try HTTP/2 regardless. // Setting `DialContext` disables HTTP/2, this option forces the transport to try HTTP/2 regardless.
@ -128,6 +138,7 @@ func (r *RequestBuilder) ExecuteRequest(requestURL string) (*http.Response, erro
IdleConnTimeout: 10 * time.Second, IdleConnTimeout: 10 * time.Second,
TLSClientConfig: &tls.Config{ TLSClientConfig: &tls.Config{
CipherSuites: cipherSuites,
InsecureSkipVerify: r.ignoreTLSErrors, InsecureSkipVerify: r.ignoreTLSErrors,
}, },
} }
@ -169,6 +180,7 @@ func (r *RequestBuilder) ExecuteRequest(requestURL string) (*http.Response, erro
} }
req.Header = r.headers req.Header = r.headers
req.Header.Set("Accept-Encoding", "br, gzip")
req.Header.Set("Accept", defaultAcceptHeader) req.Header.Set("Accept", defaultAcceptHeader)
req.Header.Set("Connection", "close") req.Header.Set("Connection", "close")

View File

@ -8,10 +8,12 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"log/slog"
"net" "net"
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
"strings"
"miniflux.app/v2/internal/locale" "miniflux.app/v2/internal/locale"
) )
@ -71,12 +73,31 @@ func (r *ResponseHandler) Close() {
} }
} }
func (r *ResponseHandler) getReader(maxBodySize int64) io.ReadCloser {
contentEncoding := strings.ToLower(r.httpResponse.Header.Get("Content-Encoding"))
slog.Debug("Request response",
slog.String("effective_url", r.EffectiveURL()),
slog.String("content_length", r.httpResponse.Header.Get("Content-Length")),
slog.String("content_encoding", contentEncoding),
slog.String("content_type", r.httpResponse.Header.Get("Content-Type")),
)
reader := r.httpResponse.Body
switch contentEncoding {
case "br":
reader = NewBrotliReadCloser(r.httpResponse.Body)
case "gzip":
reader = NewGzipReadCloser(r.httpResponse.Body)
}
return http.MaxBytesReader(nil, reader, maxBodySize)
}
func (r *ResponseHandler) Body(maxBodySize int64) io.ReadCloser { func (r *ResponseHandler) Body(maxBodySize int64) io.ReadCloser {
return http.MaxBytesReader(nil, r.httpResponse.Body, maxBodySize) return r.getReader(maxBodySize)
} }
func (r *ResponseHandler) ReadBody(maxBodySize int64) ([]byte, *locale.LocalizedErrorWrapper) { func (r *ResponseHandler) ReadBody(maxBodySize int64) ([]byte, *locale.LocalizedErrorWrapper) {
limitedReader := http.MaxBytesReader(nil, r.httpResponse.Body, maxBodySize) limitedReader := r.getReader(maxBodySize)
buffer, err := io.ReadAll(limitedReader) buffer, err := io.ReadAll(limitedReader)
if err != nil && err != io.EOF { if err != nil && err != io.EOF {

View File

@ -29,6 +29,7 @@ func (h *Handler) Export(userID int64) (string, error) {
Title: feed.Title, Title: feed.Title,
FeedURL: feed.FeedURL, FeedURL: feed.FeedURL,
SiteURL: feed.SiteURL, SiteURL: feed.SiteURL,
Description: feed.Description,
CategoryName: feed.Category.Title, CategoryName: feed.Category.Title,
}) })
} }
@ -68,11 +69,12 @@ func (h *Handler) Import(userID int64, data io.Reader) error {
} }
feed := &model.Feed{ feed := &model.Feed{
UserID: userID, UserID: userID,
Title: subscription.Title, Title: subscription.Title,
FeedURL: subscription.FeedURL, FeedURL: subscription.FeedURL,
SiteURL: subscription.SiteURL, SiteURL: subscription.SiteURL,
Category: category, Description: subscription.Description,
Category: category,
} }
h.store.CreateFeed(feed) h.store.CreateFeed(feed)

View File

@ -27,11 +27,12 @@ type opmlHeader struct {
} }
type opmlOutline struct { type opmlOutline struct {
Title string `xml:"title,attr,omitempty"` Title string `xml:"title,attr,omitempty"`
Text string `xml:"text,attr"` Text string `xml:"text,attr"`
FeedURL string `xml:"xmlUrl,attr,omitempty"` FeedURL string `xml:"xmlUrl,attr,omitempty"`
SiteURL string `xml:"htmlUrl,attr,omitempty"` SiteURL string `xml:"htmlUrl,attr,omitempty"`
Outlines opmlOutlineCollection `xml:"outline,omitempty"` Description string `xml:"description,attr,omitempty"`
Outlines opmlOutlineCollection `xml:"outline,omitempty"`
} }
func (outline opmlOutline) MarshalXML(e *xml.Encoder, start xml.StartElement) error { func (outline opmlOutline) MarshalXML(e *xml.Encoder, start xml.StartElement) error {

View File

@ -34,6 +34,7 @@ func getSubscriptionsFromOutlines(outlines opmlOutlineCollection, category strin
Title: outline.GetTitle(), Title: outline.GetTitle(),
FeedURL: outline.FeedURL, FeedURL: outline.FeedURL,
SiteURL: outline.GetSiteURL(), SiteURL: outline.GetSiteURL(),
Description: outline.Description,
CategoryName: category, CategoryName: category,
}) })
} else if outline.Outlines.HasChildren() { } else if outline.Outlines.HasChildren() {

View File

@ -33,7 +33,7 @@ func TestParseOpmlWithoutCategories(t *testing.T) {
` `
var expected SubcriptionList var expected SubcriptionList
expected = append(expected, &Subcription{Title: "CNET News.com", FeedURL: "http://news.com.com/2547-1_3-0-5.xml", SiteURL: "http://news.com.com/"}) expected = append(expected, &Subcription{Title: "CNET News.com", FeedURL: "http://news.com.com/2547-1_3-0-5.xml", SiteURL: "http://news.com.com/", Description: "Tech news and business reports by CNET News.com. Focused on information technology, core topics include computers, hardware, software, networking, and Internet media."})
subscriptions, err := Parse(bytes.NewBufferString(data)) subscriptions, err := Parse(bytes.NewBufferString(data))
if err != nil { if err != nil {

View File

@ -48,10 +48,11 @@ func convertSubscriptionsToOPML(subscriptions SubcriptionList) *opmlDocument {
category := opmlOutline{Text: categoryName, Outlines: make(opmlOutlineCollection, 0, len(groupedSubs[categoryName]))} category := opmlOutline{Text: categoryName, Outlines: make(opmlOutlineCollection, 0, len(groupedSubs[categoryName]))}
for _, subscription := range groupedSubs[categoryName] { for _, subscription := range groupedSubs[categoryName] {
category.Outlines = append(category.Outlines, opmlOutline{ category.Outlines = append(category.Outlines, opmlOutline{
Title: subscription.Title, Title: subscription.Title,
Text: subscription.Title, Text: subscription.Title,
FeedURL: subscription.FeedURL, FeedURL: subscription.FeedURL,
SiteURL: subscription.SiteURL, SiteURL: subscription.SiteURL,
Description: subscription.Description,
}) })
} }

View File

@ -9,12 +9,14 @@ type Subcription struct {
SiteURL string SiteURL string
FeedURL string FeedURL string
CategoryName string CategoryName string
Description string
} }
// Equals compare two subscriptions. // Equals compare two subscriptions.
func (s Subcription) Equals(subscription *Subcription) bool { func (s Subcription) Equals(subscription *Subcription) bool {
return s.Title == subscription.Title && s.SiteURL == subscription.SiteURL && return s.Title == subscription.Title && s.SiteURL == subscription.SiteURL &&
s.FeedURL == subscription.FeedURL && s.CategoryName == subscription.CategoryName s.FeedURL == subscription.FeedURL && s.CategoryName == subscription.CategoryName &&
s.Description == subscription.Description
} }
// SubcriptionList is a list of subscriptions. // SubcriptionList is a list of subscriptions.

View File

@ -23,10 +23,13 @@ import (
"miniflux.app/v2/internal/storage" "miniflux.app/v2/internal/storage"
"github.com/PuerkitoBio/goquery" "github.com/PuerkitoBio/goquery"
"github.com/tdewolff/minify/v2"
"github.com/tdewolff/minify/v2/html"
) )
var ( var (
youtubeRegex = regexp.MustCompile(`youtube\.com/watch\?v=(.*)$`) youtubeRegex = regexp.MustCompile(`youtube\.com/watch\?v=(.*)$`)
nebulaRegex = regexp.MustCompile(`^https://nebula\.tv`)
odyseeRegex = regexp.MustCompile(`^https://odysee\.com`) odyseeRegex = regexp.MustCompile(`^https://odysee\.com`)
iso8601Regex = regexp.MustCompile(`^P((?P<year>\d+)Y)?((?P<month>\d+)M)?((?P<week>\d+)W)?((?P<day>\d+)D)?(T((?P<hour>\d+)H)?((?P<minute>\d+)M)?((?P<second>\d+)S)?)?$`) iso8601Regex = regexp.MustCompile(`^P((?P<year>\d+)Y)?((?P<month>\d+)M)?((?P<week>\d+)W)?((?P<day>\d+)D)?(T((?P<hour>\d+)H)?((?P<minute>\d+)M)?((?P<second>\d+)S)?)?$`)
customReplaceRuleRegex = regexp.MustCompile(`rewrite\("(.*)"\|"(.*)"\)`) customReplaceRuleRegex = regexp.MustCompile(`rewrite\("(.*)"\|"(.*)"\)`)
@ -42,8 +45,9 @@ func ProcessFeedEntries(store *storage.Storage, feed *model.Feed, user *model.Us
slog.Debug("Processing entry", slog.Debug("Processing entry",
slog.Int64("user_id", user.ID), slog.Int64("user_id", user.ID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL), slog.String("entry_url", entry.URL),
slog.String("entry_hash", entry.Hash),
slog.String("entry_title", entry.Title),
slog.Int64("feed_id", feed.ID), slog.Int64("feed_id", feed.ID),
slog.String("feed_url", feed.FeedURL), slog.String("feed_url", feed.FeedURL),
) )
@ -52,14 +56,18 @@ func ProcessFeedEntries(store *storage.Storage, feed *model.Feed, user *model.Us
} }
websiteURL := getUrlFromEntry(feed, entry) websiteURL := getUrlFromEntry(feed, entry)
entryIsNew := !store.EntryURLExists(feed.ID, entry.URL) entryIsNew := store.IsNewEntry(feed.ID, entry.Hash)
if feed.Crawler && (entryIsNew || forceRefresh) { if feed.Crawler && (entryIsNew || forceRefresh) {
slog.Debug("Scraping entry", slog.Debug("Scraping entry",
slog.Int64("user_id", user.ID), slog.Int64("user_id", user.ID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL), slog.String("entry_url", entry.URL),
slog.String("entry_hash", entry.Hash),
slog.String("entry_title", entry.Title),
slog.Int64("feed_id", feed.ID), slog.Int64("feed_id", feed.ID),
slog.String("feed_url", feed.FeedURL), slog.String("feed_url", feed.FeedURL),
slog.Bool("entry_is_new", entryIsNew),
slog.Bool("force_refresh", forceRefresh),
slog.String("website_url", websiteURL),
) )
startTime := time.Now() startTime := time.Now()
@ -90,7 +98,6 @@ func ProcessFeedEntries(store *storage.Storage, feed *model.Feed, user *model.Us
if scraperErr != nil { if scraperErr != nil {
slog.Warn("Unable to scrape entry", slog.Warn("Unable to scrape entry",
slog.Int64("user_id", user.ID), slog.Int64("user_id", user.ID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL), slog.String("entry_url", entry.URL),
slog.Int64("feed_id", feed.ID), slog.Int64("feed_id", feed.ID),
slog.String("feed_url", feed.FeedURL), slog.String("feed_url", feed.FeedURL),
@ -98,7 +105,7 @@ func ProcessFeedEntries(store *storage.Storage, feed *model.Feed, user *model.Us
) )
} else if content != "" { } else if content != "" {
// We replace the entry content only if the scraper doesn't return any error. // We replace the entry content only if the scraper doesn't return any error.
entry.Content = content entry.Content = minifyEntryContent(content)
} }
} }
@ -134,7 +141,6 @@ func isBlockedEntry(feed *model.Feed, entry *model.Entry) bool {
if compiledBlocklist.MatchString(entry.URL) || compiledBlocklist.MatchString(entry.Title) || compiledBlocklist.MatchString(entry.Author) || containsBlockedTag { if compiledBlocklist.MatchString(entry.URL) || compiledBlocklist.MatchString(entry.Title) || compiledBlocklist.MatchString(entry.Author) || containsBlockedTag {
slog.Debug("Blocking entry based on rule", slog.Debug("Blocking entry based on rule",
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL), slog.String("entry_url", entry.URL),
slog.Int64("feed_id", feed.ID), slog.Int64("feed_id", feed.ID),
slog.String("feed_url", feed.FeedURL), slog.String("feed_url", feed.FeedURL),
@ -165,7 +171,6 @@ func isAllowedEntry(feed *model.Feed, entry *model.Entry) bool {
if compiledKeeplist.MatchString(entry.URL) || compiledKeeplist.MatchString(entry.Title) || compiledKeeplist.MatchString(entry.Author) || containsAllowedTag { if compiledKeeplist.MatchString(entry.URL) || compiledKeeplist.MatchString(entry.Title) || compiledKeeplist.MatchString(entry.Author) || containsAllowedTag {
slog.Debug("Allow entry based on rule", slog.Debug("Allow entry based on rule",
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL), slog.String("entry_url", entry.URL),
slog.Int64("feed_id", feed.ID), slog.Int64("feed_id", feed.ID),
slog.String("feed_url", feed.FeedURL), slog.String("feed_url", feed.FeedURL),
@ -209,7 +214,7 @@ func ProcessEntryWebPage(feed *model.Feed, entry *model.Entry, user *model.User)
} }
if content != "" { if content != "" {
entry.Content = content entry.Content = minifyEntryContent(content)
if user.ShowReadingTime { if user.ShowReadingTime {
entry.ReadingTime = readingtime.EstimateReadingTime(entry.Content, user.DefaultReadingSpeed, user.CJKReadingSpeed) entry.ReadingTime = readingtime.EstimateReadingTime(entry.Content, user.DefaultReadingSpeed, user.CJKReadingSpeed)
} }
@ -227,10 +232,16 @@ func getUrlFromEntry(feed *model.Feed, entry *model.Entry) string {
parts := customReplaceRuleRegex.FindStringSubmatch(feed.UrlRewriteRules) parts := customReplaceRuleRegex.FindStringSubmatch(feed.UrlRewriteRules)
if len(parts) >= 3 { if len(parts) >= 3 {
re := regexp.MustCompile(parts[1]) re, err := regexp.Compile(parts[1])
if err != nil {
slog.Error("Failed on regexp compilation",
slog.String("url_rewrite_rules", feed.UrlRewriteRules),
slog.Any("error", err),
)
return url
}
url = re.ReplaceAllString(entry.URL, parts[2]) url = re.ReplaceAllString(entry.URL, parts[2])
slog.Debug("Rewriting entry URL", slog.Debug("Rewriting entry URL",
slog.Int64("entry_id", entry.ID),
slog.String("original_entry_url", entry.URL), slog.String("original_entry_url", entry.URL),
slog.String("rewritten_entry_url", url), slog.String("rewritten_entry_url", url),
slog.Int64("feed_id", feed.ID), slog.Int64("feed_id", feed.ID),
@ -238,7 +249,6 @@ func getUrlFromEntry(feed *model.Feed, entry *model.Entry) string {
) )
} else { } else {
slog.Debug("Cannot find search and replace terms for replace rule", slog.Debug("Cannot find search and replace terms for replace rule",
slog.Int64("entry_id", entry.ID),
slog.String("original_entry_url", entry.URL), slog.String("original_entry_url", entry.URL),
slog.String("rewritten_entry_url", url), slog.String("rewritten_entry_url", url),
slog.Int64("feed_id", feed.ID), slog.Int64("feed_id", feed.ID),
@ -251,6 +261,11 @@ func getUrlFromEntry(feed *model.Feed, entry *model.Entry) string {
} }
func updateEntryReadingTime(store *storage.Storage, feed *model.Feed, entry *model.Entry, entryIsNew bool, user *model.User) { func updateEntryReadingTime(store *storage.Storage, feed *model.Feed, entry *model.Entry, entryIsNew bool, user *model.User) {
if !user.ShowReadingTime {
slog.Debug("Skip reading time estimation for this user", slog.Int64("user_id", user.ID))
return
}
if shouldFetchYouTubeWatchTime(entry) { if shouldFetchYouTubeWatchTime(entry) {
if entryIsNew { if entryIsNew {
watchTime, err := fetchYouTubeWatchTime(entry.URL) watchTime, err := fetchYouTubeWatchTime(entry.URL)
@ -266,7 +281,26 @@ func updateEntryReadingTime(store *storage.Storage, feed *model.Feed, entry *mod
} }
entry.ReadingTime = watchTime entry.ReadingTime = watchTime
} else { } else {
entry.ReadingTime = store.GetReadTime(entry, feed) entry.ReadingTime = store.GetReadTime(feed.ID, entry.Hash)
}
}
if shouldFetchNebulaWatchTime(entry) {
if entryIsNew {
watchTime, err := fetchNebulaWatchTime(entry.URL)
if err != nil {
slog.Warn("Unable to fetch Nebula watch time",
slog.Int64("user_id", user.ID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.Int64("feed_id", feed.ID),
slog.String("feed_url", feed.FeedURL),
slog.Any("error", err),
)
}
entry.ReadingTime = watchTime
} else {
entry.ReadingTime = store.GetReadTime(feed.ID, entry.Hash)
} }
} }
@ -285,14 +319,13 @@ func updateEntryReadingTime(store *storage.Storage, feed *model.Feed, entry *mod
} }
entry.ReadingTime = watchTime entry.ReadingTime = watchTime
} else { } else {
entry.ReadingTime = store.GetReadTime(entry, feed) entry.ReadingTime = store.GetReadTime(feed.ID, entry.Hash)
} }
} }
// Handle YT error case and non-YT entries. // Handle YT error case and non-YT entries.
if entry.ReadingTime == 0 { if entry.ReadingTime == 0 {
if user.ShowReadingTime { entry.ReadingTime = readingtime.EstimateReadingTime(entry.Content, user.DefaultReadingSpeed, user.CJKReadingSpeed)
entry.ReadingTime = readingtime.EstimateReadingTime(entry.Content, user.DefaultReadingSpeed, user.CJKReadingSpeed)
}
} }
} }
@ -305,6 +338,14 @@ func shouldFetchYouTubeWatchTime(entry *model.Entry) bool {
return urlMatchesYouTubePattern return urlMatchesYouTubePattern
} }
func shouldFetchNebulaWatchTime(entry *model.Entry) bool {
if !config.Opts.FetchNebulaWatchTime() {
return false
}
matches := nebulaRegex.FindStringSubmatch(entry.URL)
return matches != nil
}
func shouldFetchOdyseeWatchTime(entry *model.Entry) bool { func shouldFetchOdyseeWatchTime(entry *model.Entry) bool {
if !config.Opts.FetchOdyseeWatchTime() { if !config.Opts.FetchOdyseeWatchTime() {
return false return false
@ -344,6 +385,38 @@ func fetchYouTubeWatchTime(websiteURL string) (int, error) {
return int(dur.Minutes()), nil return int(dur.Minutes()), nil
} }
func fetchNebulaWatchTime(websiteURL string) (int, error) {
requestBuilder := fetcher.NewRequestBuilder()
requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
requestBuilder.WithProxy(config.Opts.HTTPClientProxy())
responseHandler := fetcher.NewResponseHandler(requestBuilder.ExecuteRequest(websiteURL))
defer responseHandler.Close()
if localizedError := responseHandler.LocalizedError(); localizedError != nil {
slog.Warn("Unable to fetch Nebula watch time", slog.String("website_url", websiteURL), slog.Any("error", localizedError.Error()))
return 0, localizedError.Error()
}
doc, docErr := goquery.NewDocumentFromReader(responseHandler.Body(config.Opts.HTTPClientMaxBodySize()))
if docErr != nil {
return 0, docErr
}
durs, exists := doc.Find(`meta[property="video:duration"]`).First().Attr("content")
// durs contains video watch time in seconds
if !exists {
return 0, errors.New("duration has not found")
}
dur, err := strconv.ParseInt(durs, 10, 64)
if err != nil {
return 0, fmt.Errorf("unable to parse duration %s: %v", durs, err)
}
return int(dur / 60), nil
}
func fetchOdyseeWatchTime(websiteURL string) (int, error) { func fetchOdyseeWatchTime(websiteURL string) (int, error) {
requestBuilder := fetcher.NewRequestBuilder() requestBuilder := fetcher.NewRequestBuilder()
requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout()) requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
@ -419,3 +492,19 @@ func isRecentEntry(entry *model.Entry) bool {
} }
return false return false
} }
func minifyEntryContent(entryContent string) string {
m := minify.New()
// Options required to avoid breaking the HTML content.
m.Add("text/html", &html.Minifier{
KeepEndTags: true,
KeepQuotes: true,
})
if minifiedHTML, err := m.String("text/html", entryContent); err == nil {
entryContent = minifiedHTML
}
return entryContent
}

View File

@ -117,3 +117,12 @@ func TestIsRecentEntry(t *testing.T) {
} }
} }
} }
func TestMinifyEntryContent(t *testing.T) {
input := `<p> Some text with a <a href="http://example.org/"> link </a> </p>`
expected := `<p>Some text with a <a href="http://example.org/">link</a></p>`
result := minifyEntryContent(input)
if expected != result {
t.Errorf(`Unexpected result, got %q`, result)
}
}

View File

@ -1,7 +1,7 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
// Package readtime provides a function to estimate the reading time of an article. // Package readingtime provides a function to estimate the reading time of an article.
package readingtime package readingtime
import ( import (

View File

@ -24,13 +24,13 @@ var predefinedRules = map[string]string{
"monkeyuser.com": "add_image_title", "monkeyuser.com": "add_image_title",
"mrlovenstein.com": "add_image_title", "mrlovenstein.com": "add_image_title",
"nedroid.com": "add_image_title", "nedroid.com": "add_image_title",
"oglaf.com": "add_image_title", "oglaf.com": `replace("media.oglaf.com/story/tt(.+).gif"|"media.oglaf.com/comic/$1.jpg"),add_image_title`,
"optipess.com": "add_image_title", "optipess.com": "add_image_title",
"peebleslab.com": "add_image_title", "peebleslab.com": "add_image_title",
"quantamagazine.org": `add_youtube_video_from_id, remove("h6:not(.byline,.post__title__kicker), #comments, .next-post__content, .footer__section, figure .outer--content, script")`, "quantamagazine.org": `add_youtube_video_from_id, remove("h6:not(.byline,.post__title__kicker), #comments, .next-post__content, .footer__section, figure .outer--content, script")`,
"sentfromthemoon.com": "add_image_title", "sentfromthemoon.com": "add_image_title",
"thedoghousediaries.com": "add_image_title", "thedoghousediaries.com": "add_image_title",
"theverge.com": `add_dynamic_image, remove("div.duet--recirculation--related-list")`, "theverge.com": `add_dynamic_image, remove("div.duet--recirculation--related-list, .hidden")`,
"treelobsters.com": "add_image_title", "treelobsters.com": "add_image_title",
"webtoons.com": `add_dynamic_image,replace("webtoon"|"swebtoon")`, "webtoons.com": `add_dynamic_image,replace("webtoon"|"swebtoon")`,
"www.qwantz.com": "add_image_title,add_mailto_subject", "www.qwantz.com": "add_image_title,add_mailto_subject",

View File

@ -35,6 +35,7 @@ var predefinedRules = map[string]string{
"openingsource.org": "article.suxing-popup-gallery", "openingsource.org": "article.suxing-popup-gallery",
"osnews.com": "div.newscontent1", "osnews.com": "div.newscontent1",
"phoronix.com": "div.content", "phoronix.com": "div.content",
"pitchfork.com": "#main-content",
"pseudo-sciences.org": "#art_main", "pseudo-sciences.org": "#art_main",
"quantamagazine.org": ".outer--content, figure, script", "quantamagazine.org": ".outer--content, figure, script",
"raywenderlich.com": "article", "raywenderlich.com": "article",

View File

@ -5,10 +5,13 @@ package subscription // import "miniflux.app/v2/internal/reader/subscription"
import ( import (
"bytes" "bytes"
"errors"
"fmt" "fmt"
"io" "io"
"log/slog" "log/slog"
"net/url"
"regexp" "regexp"
"strings"
"miniflux.app/v2/internal/config" "miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/integration/rssbridge" "miniflux.app/v2/internal/integration/rssbridge"
@ -22,9 +25,19 @@ import (
"golang.org/x/net/html/charset" "golang.org/x/net/html/charset"
) )
type youtubeKind string
const (
youtubeIDKindChannel youtubeKind = "channel"
youtubeIDKindVideo youtubeKind = "video"
youtubeIDKindPlaylist youtubeKind = "playlist"
)
var ( var (
youtubeChannelRegex = regexp.MustCompile(`youtube\.com/channel/(.*)$`) youtubeHostRegex = regexp.MustCompile(`youtube\.com$`)
youtubeVideoRegex = regexp.MustCompile(`youtube\.com/watch\?v=(.*)$`) youtubeChannelRegex = regexp.MustCompile(`channel/(.*)$`)
errNotYoutubeUrl = fmt.Errorf("this website is not a YouTube page")
) )
type SubscriptionFinder struct { type SubscriptionFinder struct {
@ -74,31 +87,40 @@ func (f *SubscriptionFinder) FindSubscriptions(websiteURL, rssBridgeURL string)
return Subscriptions{NewSubscription(responseHandler.EffectiveURL(), responseHandler.EffectiveURL(), feedFormat)}, nil return Subscriptions{NewSubscription(responseHandler.EffectiveURL(), responseHandler.EffectiveURL(), feedFormat)}, nil
} }
// Step 2) Check if the website URL is a YouTube channel. subscriptions := make(Subscriptions, 1)
slog.Debug("Try to detect feeds from YouTube channel page", slog.String("website_url", websiteURL))
subscriptions, localizedError := f.FindSubscriptionsFromYouTubeChannelPage(websiteURL) // Step 2) Parse URL to find feeds from YouTube.
if localizedError != nil { kind, _, err := youtubeURLIDExtractor(websiteURL)
return nil, localizedError
// If YouTube url has been detected, return the YouTube feed
if err == nil || !errors.Is(err, errNotYoutubeUrl) {
switch kind {
case youtubeIDKindChannel:
slog.Debug("Try to detect feeds from YouTube channel page", slog.String("website_url", websiteURL))
subscriptions, localizedError = f.FindSubscriptionsFromYouTubeChannelPage(websiteURL)
if localizedError != nil {
return nil, localizedError
}
case youtubeIDKindVideo:
slog.Debug("Try to detect feeds from YouTube video page", slog.String("website_url", websiteURL))
subscriptions, localizedError = f.FindSubscriptionsFromYouTubeVideoPage(websiteURL)
if localizedError != nil {
return nil, localizedError
}
case youtubeIDKindPlaylist:
slog.Debug("Try to detect feeds from YouTube playlist page", slog.String("website_url", websiteURL))
subscriptions, localizedError = f.FindSubscriptionsFromYouTubePlaylistPage(websiteURL)
if localizedError != nil {
return nil, localizedError
}
}
if len(subscriptions) > 0 {
slog.Debug("Subscriptions found from YouTube page", slog.String("website_url", websiteURL), slog.Any("subscriptions", subscriptions))
return subscriptions, nil
}
} }
if len(subscriptions) > 0 { // Step 3) Parse web page to find feeds from HTML meta tags.
slog.Debug("Subscriptions found from YouTube channel page", slog.String("website_url", websiteURL), slog.Any("subscriptions", subscriptions))
return subscriptions, nil
}
// Step 3) Check if the website URL is a YouTube video.
slog.Debug("Try to detect feeds from YouTube video page", slog.String("website_url", websiteURL))
subscriptions, localizedError = f.FindSubscriptionsFromYouTubeVideoPage(websiteURL)
if localizedError != nil {
return nil, localizedError
}
if len(subscriptions) > 0 {
slog.Debug("Subscriptions found from YouTube video page", slog.String("website_url", websiteURL), slog.Any("subscriptions", subscriptions))
return subscriptions, nil
}
// Step 4) Parse web page to find feeds from HTML meta tags.
slog.Debug("Try to detect feeds from HTML meta tags", slog.Debug("Try to detect feeds from HTML meta tags",
slog.String("website_url", websiteURL), slog.String("website_url", websiteURL),
slog.String("content_type", responseHandler.ContentType()), slog.String("content_type", responseHandler.ContentType()),
@ -113,7 +135,7 @@ func (f *SubscriptionFinder) FindSubscriptions(websiteURL, rssBridgeURL string)
return subscriptions, nil return subscriptions, nil
} }
// Step 5) Check if the website URL can use RSS-Bridge. // Step 4) Check if the website URL can use RSS-Bridge.
if rssBridgeURL != "" { if rssBridgeURL != "" {
slog.Debug("Try to detect feeds with RSS-Bridge", slog.String("website_url", websiteURL)) slog.Debug("Try to detect feeds with RSS-Bridge", slog.String("website_url", websiteURL))
subscriptions, localizedError := f.FindSubscriptionsFromRSSBridge(websiteURL, rssBridgeURL) subscriptions, localizedError := f.FindSubscriptionsFromRSSBridge(websiteURL, rssBridgeURL)
@ -127,7 +149,7 @@ func (f *SubscriptionFinder) FindSubscriptions(websiteURL, rssBridgeURL string)
} }
} }
// Step 6) Check if the website has a known feed URL. // Step 5) Check if the website has a known feed URL.
slog.Debug("Try to detect feeds from well-known URLs", slog.String("website_url", websiteURL)) slog.Debug("Try to detect feeds from well-known URLs", slog.String("website_url", websiteURL))
subscriptions, localizedError = f.FindSubscriptionsFromWellKnownURLs(websiteURL) subscriptions, localizedError = f.FindSubscriptionsFromWellKnownURLs(websiteURL)
if localizedError != nil { if localizedError != nil {
@ -285,20 +307,24 @@ func (f *SubscriptionFinder) FindSubscriptionsFromRSSBridge(websiteURL, rssBridg
} }
func (f *SubscriptionFinder) FindSubscriptionsFromYouTubeChannelPage(websiteURL string) (Subscriptions, *locale.LocalizedErrorWrapper) { func (f *SubscriptionFinder) FindSubscriptionsFromYouTubeChannelPage(websiteURL string) (Subscriptions, *locale.LocalizedErrorWrapper) {
matches := youtubeChannelRegex.FindStringSubmatch(websiteURL) kind, id, _ := youtubeURLIDExtractor(websiteURL)
if len(matches) == 2 { if kind == youtubeIDKindChannel {
feedURL := fmt.Sprintf(`https://www.youtube.com/feeds/videos.xml?channel_id=%s`, matches[1]) feedURL := fmt.Sprintf(`https://www.youtube.com/feeds/videos.xml?channel_id=%s`, id)
return Subscriptions{NewSubscription(websiteURL, feedURL, parser.FormatAtom)}, nil return Subscriptions{NewSubscription(websiteURL, feedURL, parser.FormatAtom)}, nil
} }
slog.Debug("This website is not a YouTube channel page, the regex doesn't match", slog.String("website_url", websiteURL)) slog.Debug("This website is not a YouTube channel page, the regex doesn't match", slog.String("website_url", websiteURL))
return nil, nil return nil, nil
} }
func (f *SubscriptionFinder) FindSubscriptionsFromYouTubeVideoPage(websiteURL string) (Subscriptions, *locale.LocalizedErrorWrapper) { func (f *SubscriptionFinder) FindSubscriptionsFromYouTubeVideoPage(websiteURL string) (Subscriptions, *locale.LocalizedErrorWrapper) {
if !youtubeVideoRegex.MatchString(websiteURL) { kind, _, err := youtubeURLIDExtractor(websiteURL)
if err != nil {
slog.Debug("Could not parse url", slog.String("website_url", websiteURL))
}
if kind != youtubeIDKindVideo {
slog.Debug("This website is not a YouTube video page, the regex doesn't match", slog.String("website_url", websiteURL)) slog.Debug("This website is not a YouTube video page, the regex doesn't match", slog.String("website_url", websiteURL))
return nil, nil return nil, nil
} }
@ -322,3 +348,51 @@ func (f *SubscriptionFinder) FindSubscriptionsFromYouTubeVideoPage(websiteURL st
return nil, nil return nil, nil
} }
func (f *SubscriptionFinder) FindSubscriptionsFromYouTubePlaylistPage(websiteURL string) (Subscriptions, *locale.LocalizedErrorWrapper) {
kind, id, _ := youtubeURLIDExtractor(websiteURL)
if kind == youtubeIDKindPlaylist {
feedURL := fmt.Sprintf(`https://www.youtube.com/feeds/videos.xml?playlist_id=%s`, id)
return Subscriptions{NewSubscription(websiteURL, feedURL, parser.FormatAtom)}, nil
}
slog.Debug("This website is not a YouTube playlist page, the regex doesn't match", slog.String("website_url", websiteURL))
return nil, nil
}
func youtubeURLIDExtractor(websiteURL string) (idKind youtubeKind, id string, err error) {
decodedUrl, err := url.Parse(websiteURL)
if err != nil {
return
}
if !youtubeHostRegex.MatchString(decodedUrl.Host) {
slog.Debug("This website is not a YouTube page, the regex doesn't match", slog.String("website_url", websiteURL))
err = errNotYoutubeUrl
return
}
switch {
case strings.HasPrefix(decodedUrl.Path, "/channel"):
idKind = youtubeIDKindChannel
matches := youtubeChannelRegex.FindStringSubmatch(decodedUrl.Path)
id = matches[1]
return
case strings.HasPrefix(decodedUrl.Path, "/watch") && decodedUrl.Query().Has("list"):
idKind = youtubeIDKindPlaylist
id = decodedUrl.Query().Get("list")
return
case strings.HasPrefix(decodedUrl.Path, "/watch"):
idKind = youtubeIDKindVideo
id = decodedUrl.Query().Get("v")
return
case strings.HasPrefix(decodedUrl.Path, "/playlist"):
idKind = youtubeIDKindPlaylist
id = decodedUrl.Query().Get("list")
return
}
err = fmt.Errorf("unable to extract youtube id from URL: %s", websiteURL)
return
}

View File

@ -4,10 +4,99 @@
package subscription package subscription
import ( import (
"errors"
"strings" "strings"
"testing" "testing"
) )
func TestFindYoutubePlaylistFeed(t *testing.T) {
scenarios := map[string]string{
"https://www.youtube.com/playlist?list=PLOOwEPgFWm_NHcQd9aCi5JXWASHO_n5uR": "https://www.youtube.com/feeds/videos.xml?playlist_id=PLOOwEPgFWm_NHcQd9aCi5JXWASHO_n5uR",
"https://www.youtube.com/playlist?list=PLOOwEPgFWm_N42HlCLhqyJ0ZBWr5K1QDM": "https://www.youtube.com/feeds/videos.xml?playlist_id=PLOOwEPgFWm_N42HlCLhqyJ0ZBWr5K1QDM",
"https://www.youtube.com/watch?v=dQw4w9WgXcQ&list=PLOOwEPgFWm_N42HlCLhqyJ0ZBWr5K1QDM": "https://www.youtube.com/feeds/videos.xml?playlist_id=PLOOwEPgFWm_N42HlCLhqyJ0ZBWr5K1QDM",
}
for websiteURL, expectedFeedURL := range scenarios {
subscriptions, localizedError := NewSubscriptionFinder(nil).FindSubscriptionsFromYouTubePlaylistPage(websiteURL)
if localizedError != nil {
t.Fatalf(`Parsing a correctly formatted YouTube playlist page should not return any error: %v`, localizedError)
}
if len(subscriptions) != 1 {
t.Fatal(`Incorrect number of subscriptions returned`)
}
if subscriptions[0].URL != expectedFeedURL {
t.Errorf(`Unexpected Feed, got %s, instead of %s`, subscriptions[0].URL, expectedFeedURL)
}
}
}
func TestItDoesNotConsiderPlaylistWatchPageAsVideoWatchPage(t *testing.T) {
_, localizedError := NewSubscriptionFinder(nil).FindSubscriptionsFromYouTubeVideoPage("https://www.youtube.com/watch?v=dQw4w9WgXcQ&list=PLOOwEPgFWm_N42HlCLhqyJ0ZBWr5K1QDM")
if localizedError != nil {
t.Fatalf(`Should not consider a playlist watch page as a video watch page`)
}
}
func TestYoutubeIdExtractor(t *testing.T) {
type testResult struct {
ID string
Kind youtubeKind
error error
}
urls := map[string]testResult{
"https://www.youtube.com/watch?v=dQw4w9WgXcQ": {
ID: "dQw4w9WgXcQ",
Kind: youtubeIDKindVideo,
error: nil,
},
"https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=1": {
ID: "dQw4w9WgXcQ",
Kind: youtubeIDKindVideo,
error: nil,
},
"https://www.youtube.com/watch?t=1&v=dQw4w9WgXcQ": {
ID: "dQw4w9WgXcQ",
Kind: youtubeIDKindVideo,
error: nil,
},
"https://www.youtube.com/watch?v=dQw4w9WgXcQ&list=PLOOwEPgFWm_N42HlCLhqyJ0ZBWr5K1QDM": {
ID: "PLOOwEPgFWm_N42HlCLhqyJ0ZBWr5K1QDM",
Kind: youtubeIDKindPlaylist,
error: nil,
},
"https://www.youtube.com/playlist?list=PLOOwEPgFWm_NHcQd9aCi5JXWASHO_n5uR": {
ID: "PLOOwEPgFWm_NHcQd9aCi5JXWASHO_n5uR",
Kind: youtubeIDKindPlaylist,
error: nil,
},
"https://www.youtube.com/channel/UC-Qj80avWItNRjkZ41rzHyw": {
ID: "UC-Qj80avWItNRjkZ41rzHyw",
Kind: youtubeIDKindChannel,
error: nil,
},
"https://www.example.com/channel/UC-Qj80avWItNRjkZ41rzHyw": {
ID: "",
Kind: "",
error: errNotYoutubeUrl,
},
}
for websiteURL, expected := range urls {
kind, id, err := youtubeURLIDExtractor(websiteURL)
if !errors.Is(err, expected.error) {
t.Fatalf(`Unexpected error: %v got %v`, expected.error, err)
}
if id != expected.ID {
t.Fatalf(`Unexpected ID: %v got %v`, expected.ID, id)
}
if kind != expected.Kind {
t.Fatalf(`Unexpected Kind: %v got %v`, expected.Kind, kind)
}
}
}
func TestFindYoutubeChannelFeed(t *testing.T) { func TestFindYoutubeChannelFeed(t *testing.T) {
scenarios := map[string]string{ scenarios := map[string]string{
"https://www.youtube.com/channel/UC-Qj80avWItNRjkZ41rzHyw": "https://www.youtube.com/feeds/videos.xml?channel_id=UC-Qj80avWItNRjkZ41rzHyw", "https://www.youtube.com/channel/UC-Qj80avWItNRjkZ41rzHyw": "https://www.youtube.com/feeds/videos.xml?channel_id=UC-Qj80avWItNRjkZ41rzHyw",

View File

@ -225,24 +225,27 @@ func (s *Storage) entryExists(tx *sql.Tx, entry *model.Entry) (bool, error) {
return result, nil return result, nil
} }
// GetReadTime fetches the read time of an entry based on its hash, and the feed id and user id from the feed. func (s *Storage) IsNewEntry(feedID int64, entryHash string) bool {
// It's intended to be used on entries objects created by parsing a feed as they don't contain much information. var result bool
// The feed param helps to scope the search to a specific user and feed in order to avoid hash clashes. s.db.QueryRow(`SELECT true FROM entries WHERE feed_id=$1 AND hash=$2`, feedID, entryHash).Scan(&result)
func (s *Storage) GetReadTime(entry *model.Entry, feed *model.Feed) int { return !result
}
func (s *Storage) GetReadTime(feedID int64, entryHash string) int {
var result int var result int
// Note: This query uses entries_feed_id_hash_key index
s.db.QueryRow( s.db.QueryRow(
`SELECT `SELECT
reading_time reading_time
FROM FROM
entries entries
WHERE WHERE
user_id=$1 AND feed_id=$1 AND
feed_id=$2 AND hash=$2
hash=$3
`, `,
feed.UserID, feedID,
feed.ID, entryHash,
entry.Hash,
).Scan(&result) ).Scan(&result)
return result return result
} }
@ -575,14 +578,6 @@ func (s *Storage) MarkCategoryAsRead(userID, categoryID int64, before time.Time)
return nil return nil
} }
// EntryURLExists returns true if an entry with this URL already exists.
func (s *Storage) EntryURLExists(feedID int64, entryURL string) bool {
var result bool
query := `SELECT true FROM entries WHERE feed_id=$1 AND url=$2`
s.db.QueryRow(query, feedID, entryURL).Scan(&result)
return result
}
// EntryShareCode returns the share code of the provided entry. // EntryShareCode returns the share code of the provided entry.
// It generates a new one if not already defined. // It generates a new one if not already defined.
func (s *Storage) EntryShareCode(userID int64, entryID int64) (shareCode string, err error) { func (s *Storage) EntryShareCode(userID int64, entryID int64) (shareCode string, err error) {

View File

@ -58,6 +58,15 @@ func (e *EntryPaginationBuilder) WithStatus(status string) {
} }
} }
func (e *EntryPaginationBuilder) WithTags(tags []string) {
if len(tags) > 0 {
for _, tag := range tags {
e.conditions = append(e.conditions, fmt.Sprintf("LOWER($%d) = ANY(LOWER(e.tags::text)::text[])", len(e.args)+1))
e.args = append(e.args, tag)
}
}
}
// WithGloballyVisible adds global visibility to the condition. // WithGloballyVisible adds global visibility to the condition.
func (e *EntryPaginationBuilder) WithGloballyVisible() { func (e *EntryPaginationBuilder) WithGloballyVisible() {
e.conditions = append(e.conditions, "not c.hide_globally") e.conditions = append(e.conditions, "not c.hide_globally")

View File

@ -160,7 +160,7 @@ func (e *EntryQueryBuilder) WithStatuses(statuses []string) *EntryQueryBuilder {
func (e *EntryQueryBuilder) WithTags(tags []string) *EntryQueryBuilder { func (e *EntryQueryBuilder) WithTags(tags []string) *EntryQueryBuilder {
if len(tags) > 0 { if len(tags) > 0 {
for _, cat := range tags { for _, cat := range tags {
e.conditions = append(e.conditions, fmt.Sprintf("$%d = ANY(e.tags)", len(e.args)+1)) e.conditions = append(e.conditions, fmt.Sprintf("LOWER($%d) = ANY(LOWER(e.tags::text)::text[])", len(e.args)+1))
e.args = append(e.args, cat) e.args = append(e.args, cat)
} }
} }
@ -281,6 +281,7 @@ func (e *EntryQueryBuilder) GetEntries() (model.Entries, error) {
f.title as feed_title, f.title as feed_title,
f.feed_url, f.feed_url,
f.site_url, f.site_url,
f.description,
f.checked_at, f.checked_at,
f.category_id, f.category_id,
c.title as category_title, c.title as category_title,
@ -347,6 +348,7 @@ func (e *EntryQueryBuilder) GetEntries() (model.Entries, error) {
&entry.Feed.Title, &entry.Feed.Title,
&entry.Feed.FeedURL, &entry.Feed.FeedURL,
&entry.Feed.SiteURL, &entry.Feed.SiteURL,
&entry.Feed.Description,
&entry.Feed.CheckedAt, &entry.Feed.CheckedAt,
&entry.Feed.Category.ID, &entry.Feed.Category.ID,
&entry.Feed.Category.Title, &entry.Feed.Category.Title,

View File

@ -238,10 +238,11 @@ func (s *Storage) CreateFeed(feed *model.Feed) error {
url_rewrite_rules, url_rewrite_rules,
no_media_player, no_media_player,
apprise_service_urls, apprise_service_urls,
disable_http2 disable_http2,
description
) )
VALUES 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) ($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)
RETURNING RETURNING
id id
` `
@ -272,6 +273,7 @@ func (s *Storage) CreateFeed(feed *model.Feed) error {
feed.NoMediaPlayer, feed.NoMediaPlayer,
feed.AppriseServiceURLs, feed.AppriseServiceURLs,
feed.DisableHTTP2, feed.DisableHTTP2,
feed.Description,
).Scan(&feed.ID) ).Scan(&feed.ID)
if err != nil { if err != nil {
return fmt.Errorf(`store: unable to create feed %q: %v`, feed.FeedURL, err) return fmt.Errorf(`store: unable to create feed %q: %v`, feed.FeedURL, err)
@ -344,9 +346,10 @@ func (s *Storage) UpdateFeed(feed *model.Feed) (err error) {
url_rewrite_rules=$25, url_rewrite_rules=$25,
no_media_player=$26, no_media_player=$26,
apprise_service_urls=$27, apprise_service_urls=$27,
disable_http2=$28 disable_http2=$28,
description=$29
WHERE WHERE
id=$29 AND user_id=$30 id=$30 AND user_id=$31
` `
_, err = s.db.Exec(query, _, err = s.db.Exec(query,
feed.FeedURL, feed.FeedURL,
@ -377,6 +380,7 @@ func (s *Storage) UpdateFeed(feed *model.Feed) (err error) {
feed.NoMediaPlayer, feed.NoMediaPlayer,
feed.AppriseServiceURLs, feed.AppriseServiceURLs,
feed.DisableHTTP2, feed.DisableHTTP2,
feed.Description,
feed.ID, feed.ID,
feed.UserID, feed.UserID,
) )

View File

@ -135,6 +135,7 @@ func (f *FeedQueryBuilder) GetFeeds() (model.Feeds, error) {
f.feed_url, f.feed_url,
f.site_url, f.site_url,
f.title, f.title,
f.description,
f.etag_header, f.etag_header,
f.last_modified_header, f.last_modified_header,
f.user_id, f.user_id,
@ -202,6 +203,7 @@ func (f *FeedQueryBuilder) GetFeeds() (model.Feeds, error) {
&feed.FeedURL, &feed.FeedURL,
&feed.SiteURL, &feed.SiteURL,
&feed.Title, &feed.Title,
&feed.Description,
&feed.EtagHeader, &feed.EtagHeader,
&feed.LastModifiedHeader, &feed.LastModifiedHeader,
&feed.UserID, &feed.UserID,

View File

@ -193,7 +193,11 @@ func (s *Storage) Integration(userID int64) (*model.Integration, error) {
rssbridge_url, rssbridge_url,
omnivore_enabled, omnivore_enabled,
omnivore_api_key, omnivore_api_key,
omnivore_url omnivore_url,
raindrop_enabled,
raindrop_token,
raindrop_collection_id,
raindrop_tags
FROM FROM
integrations integrations
WHERE WHERE
@ -286,6 +290,10 @@ func (s *Storage) Integration(userID int64) (*model.Integration, error) {
&integration.OmnivoreEnabled, &integration.OmnivoreEnabled,
&integration.OmnivoreAPIKey, &integration.OmnivoreAPIKey,
&integration.OmnivoreURL, &integration.OmnivoreURL,
&integration.RaindropEnabled,
&integration.RaindropToken,
&integration.RaindropCollectionID,
&integration.RaindropTags,
) )
switch { switch {
case err == sql.ErrNoRows: case err == sql.ErrNoRows:
@ -386,9 +394,13 @@ func (s *Storage) UpdateIntegration(integration *model.Integration) error {
omnivore_url=$81, omnivore_url=$81,
linkwarden_enabled=$82, linkwarden_enabled=$82,
linkwarden_url=$83, linkwarden_url=$83,
linkwarden_api_key=$84 linkwarden_api_key=$84,
raindrop_enabled=$85,
raindrop_token=$86,
raindrop_collection_id=$87,
raindrop_tags=$88
WHERE WHERE
user_id=$85 user_id=$89
` `
_, err := s.db.Exec( _, err := s.db.Exec(
query, query,
@ -476,6 +488,10 @@ func (s *Storage) UpdateIntegration(integration *model.Integration) error {
integration.LinkwardenEnabled, integration.LinkwardenEnabled,
integration.LinkwardenURL, integration.LinkwardenURL,
integration.LinkwardenAPIKey, integration.LinkwardenAPIKey,
integration.RaindropEnabled,
integration.RaindropToken,
integration.RaindropCollectionID,
integration.RaindropTags,
integration.UserID, integration.UserID,
) )
@ -513,7 +529,8 @@ func (s *Storage) HasSaveEntry(userID int64) (result bool) {
readeck_enabled='t' OR readeck_enabled='t' OR
shaarli_enabled='t' OR shaarli_enabled='t' OR
webhook_enabled='t' OR webhook_enabled='t' OR
omnivore_enabled='t' omnivore_enabled='t' OR
raindrop_enabled='t'
) )
` `
if err := s.db.QueryRow(query, userID).Scan(&result); err != nil { if err := s.db.QueryRow(query, userID).Scan(&result); err != nil {

View File

@ -8,6 +8,7 @@ import (
"html/template" "html/template"
"math" "math"
"net/mail" "net/mail"
"net/url"
"slices" "slices"
"strings" "strings"
"time" "time"
@ -91,8 +92,9 @@ func (f *funcMap) Map() template.FuncMap {
"nonce": func() string { "nonce": func() string {
return crypto.GenerateRandomStringHex(16) return crypto.GenerateRandomStringHex(16)
}, },
"deRef": func(i *int) int { return *i }, "deRef": func(i *int) int { return *i },
"duration": duration, "duration": duration,
"urlEncode": url.PathEscape,
// These functions are overrode at runtime after the parsing. // These functions are overrode at runtime after the parsing.
"elapsed": func(timezone string, t time.Time) string { "elapsed": func(timezone string, t time.Time) string {

View File

@ -0,0 +1,18 @@
{{ define "enclosure_media_controls" }}
<div class="media-controls">
<div class="media-seek-control">
<div class="media-control-label">{{ t "enclosure_media_controls.seek" }} </div>
<button class="page-button" data-enclosure-id="{{.ID}}" data-enclosure-action="seek" data-action-value="-30" title="{{ t "enclosure_media_controls.seek.title" "-30" }}" ><span class="icon-label" >-30s</span></button>
<button class="page-button" data-enclosure-id="{{.ID}}" data-enclosure-action="seek" data-action-value="-10" title="{{ t "enclosure_media_controls.seek.title" "-10" }}" ><span class="icon-label" >-10s</span></button>
<button class="page-button" data-enclosure-id="{{.ID}}" data-enclosure-action="seek" data-action-value="+10" title="{{ t "enclosure_media_controls.seek.title" "+10" }}" ><span class="icon-label" >+10s</span></button>
<button class="page-button" data-enclosure-id="{{.ID}}" data-enclosure-action="seek" data-action-value="+30" title="{{ t "enclosure_media_controls.seek.title" "+30" }}" ><span class="icon-label" >+30s</span></button>
</div>
<div class="media-speed-control">
<div class="media-control-label">{{ t "enclosure_media_controls.speed" }} (<span class="speed-indicator" data-enclosure-id="{{.ID}}">1.00x</span>)</div> <!-- Need JS to display the current speed unfortunately -->
<button class="page-button" data-enclosure-id="{{.ID}}" data-enclosure-action="speed" data-action-value="-0.25" title="{{ t "enclosure_media_controls.speed.slower.title" "0.25" }}"><span class="icon-label" >{{ t "enclosure_media_controls.speed.slower" }}</span></button>
<button class="page-button" data-enclosure-id="{{.ID}}" data-enclosure-action="speed-reset" data-action-value="1" title="{{ t "enclosure_media_controls.speed.reset.title"}}"><span class="icon-label" >{{ t "enclosure_media_controls.speed.reset" }}</span></button>
<button class="page-button" data-enclosure-id="{{.ID}}" data-enclosure-action="speed" data-action-value="+0.25" title="{{ t "enclosure_media_controls.speed.faster.title" "0.25" }}"><span class="icon-label" >{{ t "enclosure_media_controls.speed.faster" }}</span></button>
</div>
</div>
{{ end }}

View File

@ -6,7 +6,7 @@
<title>{{template "title" .}} - Miniflux</title> <title>{{template "title" .}} - Miniflux</title>
<meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="mobile-web-app-capable" content="yes"> <meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-title" content="Miniflux"> <meta name="apple-mobile-web-app-title" content="Miniflux">
<link rel="manifest" href="{{ route "webManifest" }}" crossorigin="use-credentials"> <link rel="manifest" href="{{ route "webManifest" }}" crossorigin="use-credentials">

View File

@ -63,6 +63,9 @@
<label for="form-feed-url">{{ t "form.feed.label.feed_url" }}</label> <label for="form-feed-url">{{ t "form.feed.label.feed_url" }}</label>
<input type="url" name="feed_url" id="form-feed-url" placeholder="https://domain.tld/" value="{{ .form.FeedURL }}" spellcheck="false" required> <input type="url" name="feed_url" id="form-feed-url" placeholder="https://domain.tld/" value="{{ .form.FeedURL }}" spellcheck="false" required>
<label for="form-description">{{ t "form.feed.label.description" }}</label>
<textarea name="description" id="form-description" cols="40" rows="10" >{{ .form.Description }}</textarea>
{{ if not .form.CategoryHidden }} {{ if not .form.CategoryHidden }}
<label><input type="checkbox" name="hide_globally" value="1"{{ if .form.HideGlobally }} checked{{ end }}> {{ t "form.feed.label.hide_globally" }}</label> <label><input type="checkbox" name="hide_globally" value="1"{{ if .form.HideGlobally }} checked{{ end }}> {{ t "form.feed.label.hide_globally" }}</label>
{{ end }} {{ end }}

View File

@ -135,7 +135,7 @@
{{ if .entry.Tags }} {{ if .entry.Tags }}
<div class="entry-tags"> <div class="entry-tags">
{{ t "entry.tags.label" }} {{ t "entry.tags.label" }}
{{range $i, $e := .entry.Tags}}{{if $i}}, {{end}}<strong>{{ $e }}</strong>{{end}} {{range $i, $e := .entry.Tags}}{{if $i}}, {{end}}<a href="{{ route "tagEntriesAll" "tagName" (urlEncode $e) }}"><strong>{{ $e }}</strong></a>{{end}}
</div> </div>
{{ end }} {{ end }}
<div class="entry-date"> <div class="entry-date">
@ -174,6 +174,7 @@
data-last-position="{{ .MediaProgression }}" data-last-position="{{ .MediaProgression }}"
{{ if $.user.MediaPlaybackRate }}data-playback-rate="{{ $.user.MediaPlaybackRate }}"{{ end }} {{ if $.user.MediaPlaybackRate }}data-playback-rate="{{ $.user.MediaPlaybackRate }}"{{ end }}
data-save-url="{{ route "saveEnclosureProgression" "enclosureID" .ID }}" data-save-url="{{ route "saveEnclosureProgression" "enclosureID" .ID }}"
data-enclosure-id="{{.ID}}"
> >
{{ if (and $.user (mustBeProxyfied "audio")) }} {{ if (and $.user (mustBeProxyfied "audio")) }}
<source src="{{ proxyURL .URL }}" type="{{ .Html5MimeType }}"> <source src="{{ proxyURL .URL }}" type="{{ .Html5MimeType }}">
@ -181,6 +182,7 @@
<source src="{{ .URL | safeURL }}" type="{{ .Html5MimeType }}"> <source src="{{ .URL | safeURL }}" type="{{ .Html5MimeType }}">
{{ end }} {{ end }}
</audio> </audio>
{{ template "enclosure_media_controls" . }}
</div> </div>
{{ else if hasPrefix .MimeType "video/" }} {{ else if hasPrefix .MimeType "video/" }}
<div class="enclosure-video"> <div class="enclosure-video">
@ -188,6 +190,7 @@
data-last-position="{{ .MediaProgression }}" data-last-position="{{ .MediaProgression }}"
{{ if $.user.MediaPlaybackRate }}data-playback-rate="{{ $.user.MediaPlaybackRate }}"{{ end }} {{ if $.user.MediaPlaybackRate }}data-playback-rate="{{ $.user.MediaPlaybackRate }}"{{ end }}
data-save-url="{{ route "saveEnclosureProgression" "enclosureID" .ID }}" data-save-url="{{ route "saveEnclosureProgression" "enclosureID" .ID }}"
data-enclosure-id="{{.ID}}"
> >
{{ if (and $.user (mustBeProxyfied "video")) }} {{ if (and $.user (mustBeProxyfied "video")) }}
<source src="{{ proxyURL .URL }}" type="{{ .Html5MimeType }}"> <source src="{{ proxyURL .URL }}" type="{{ .Html5MimeType }}">
@ -195,6 +198,7 @@
<source src="{{ .URL | safeURL }}" type="{{ .Html5MimeType }}"> <source src="{{ .URL | safeURL }}" type="{{ .Html5MimeType }}">
{{ end }} {{ end }}
</video> </video>
{{ template "enclosure_media_controls" . }}
</div> </div>
{{ end }} {{ end }}
{{ end }} {{ end }}
@ -218,6 +222,7 @@
data-last-position="{{ .MediaProgression }}" data-last-position="{{ .MediaProgression }}"
{{ if $.user.MediaPlaybackRate }}data-playback-rate="{{ $.user.MediaPlaybackRate }}"{{ end }} {{ if $.user.MediaPlaybackRate }}data-playback-rate="{{ $.user.MediaPlaybackRate }}"{{ end }}
data-save-url="{{ route "saveEnclosureProgression" "enclosureID" .ID }}" data-save-url="{{ route "saveEnclosureProgression" "enclosureID" .ID }}"
data-enclosure-id="{{.ID}}"
> >
{{ if (and $.user (mustBeProxyfied "audio")) }} {{ if (and $.user (mustBeProxyfied "audio")) }}
<source src="{{ proxyURL .URL }}" type="{{ .Html5MimeType }}"> <source src="{{ proxyURL .URL }}" type="{{ .Html5MimeType }}">
@ -225,6 +230,7 @@
<source src="{{ .URL | safeURL }}" type="{{ .Html5MimeType }}"> <source src="{{ .URL | safeURL }}" type="{{ .Html5MimeType }}">
{{ end }} {{ end }}
</audio> </audio>
{{ template "enclosure_media_controls" . }}
</div> </div>
{{ else if hasPrefix .MimeType "video/" }} {{ else if hasPrefix .MimeType "video/" }}
<div class="enclosure-video"> <div class="enclosure-video">
@ -232,6 +238,7 @@
data-last-position="{{ .MediaProgression }}" data-last-position="{{ .MediaProgression }}"
{{ if $.user.MediaPlaybackRate }}data-playback-rate="{{ $.user.MediaPlaybackRate }}"{{ end }} {{ if $.user.MediaPlaybackRate }}data-playback-rate="{{ $.user.MediaPlaybackRate }}"{{ end }}
data-save-url="{{ route "saveEnclosureProgression" "enclosureID" .ID }}" data-save-url="{{ route "saveEnclosureProgression" "enclosureID" .ID }}"
data-enclosure-id="{{.ID}}"
> >
{{ if (and $.user (mustBeProxyfied "video")) }} {{ if (and $.user (mustBeProxyfied "video")) }}
<source src="{{ proxyURL .URL }}" type="{{ .Html5MimeType }}"> <source src="{{ proxyURL .URL }}" type="{{ .Html5MimeType }}">
@ -239,6 +246,7 @@
<source src="{{ .URL | safeURL }}" type="{{ .Html5MimeType }}"> <source src="{{ .URL | safeURL }}" type="{{ .Html5MimeType }}">
{{ end }} {{ end }}
</video> </video>
{{ template "enclosure_media_controls" . }}
</div> </div>
{{ else if hasPrefix .MimeType "image/" }} {{ else if hasPrefix .MimeType "image/" }}
<div class="enclosure-image"> <div class="enclosure-image">

View File

@ -326,6 +326,28 @@
</div> </div>
</details> </details>
<details {{ if .form.RaindropEnabled }}open{{ end }}>
<summary>Raindrop</summary>
<div class="form-section">
<label>
<input type="checkbox" name="raindrop_enabled" value="1" {{ if .form.RaindropEnabled }}checked{{ end }}> {{ t "form.integration.raindrop_activate" }}
</label>
<label for="form-raindrop-token">{{ t "form.integration.raindrop_token" }}</label>
<input type="text" name="raindrop_token" id="form-raindrop-token" value="{{ .form.RaindropToken }}" spellcheck="false">
<label for="form-raindrop-collection-id">{{ t "form.integration.raindrop_collection_id" }}</label>
<input type="text" name="raindrop_collection_id" id="form-raindrop-collection-id" value="{{ .form.RaindropCollectionID }}" spellcheck="false">
<label for="form-raindrop-tags">{{ t "form.integration.raindrop_tags" }}</label>
<input type="text" name="raindrop_tags" id="form-raindrop-tags" value="{{ .form.RaindropTags }}" spellcheck="false">
<div class="buttons">
<button type="submit" class="button button-primary" data-label-loading="{{ t "form.submit.saving" }}">{{ t "action.update" }}</button>
</div>
</div>
</details>
<details {{ if .form.ReadeckEnabled }}open{{ end }}> <details {{ if .form.ReadeckEnabled }}open{{ end }}>
<summary>Readeck</summary> <summary>Readeck</summary>
<div class="form-section"> <div class="form-section">

View File

@ -0,0 +1,52 @@
{{ define "title"}}{{ .tagName }} ({{ .total }}){{ end }}
{{ define "page_header"}}
<section class="page-header" aria-labelledby="page-header-title page-header-title-count">
<h1 id="page-header-title" dir="auto">
{{ .tagName }}
<span aria-hidden="true"> ({{ .total }})</span>
</h1>
<span id="page-header-title-count" class="sr-only">{{ plural "page.tag_entry_count" .total .total }}</span>
</section>
{{ end }}
{{ define "content"}}
{{ if not .entries }}
<p role="alert" class="alert alert-info">{{ t "alert.no_tag_entry" }}</p>
{{ else }}
<div class="pagination-top">
{{ template "pagination" .pagination }}
</div>
<div class="items">
{{ range .entries }}
<article
class="item entry-item {{ if $.user.EntrySwipe }}entry-swipe{{ end }} item-status-{{ .Status }}"
data-id="{{ .ID }}"
aria-labelledby="entry-title-{{ .ID }}"
tabindex="-1"
>
<header class="item-header" dir="auto">
<h2 id="entry-title-{{ .ID }}" class="item-title">
<a href="{{ route "tagEntry" "entryID" .ID "tagName" (urlEncode $.tagName) }}">
{{ if ne .Feed.Icon.IconID 0 }}
<img src="{{ route "icon" "iconID" .Feed.Icon.IconID }}" width="16" height="16" loading="lazy" alt="">
{{ end }}
{{ .Title }}
</a>
</h2>
<span class="category">
<a href="{{ route "categoryEntries" "categoryID" .Feed.Category.ID }}">
{{ .Feed.Category.Title }}
</a>
</span>
</header>
{{ template "item_meta" dict "user" $.user "entry" . "hasSaveEntry" $.hasSaveEntry }}
</article>
{{ end }}
</div>
<div class="pagination-bottom">
{{ template "pagination" .pagination }}
</div>
{{ end }}
{{ end }}

View File

@ -8,6 +8,7 @@ import (
"net/http" "net/http"
"time" "time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/request" "miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response/html" "miniflux.app/v2/internal/http/response/html"
"miniflux.app/v2/internal/http/route" "miniflux.app/v2/internal/http/route"
@ -32,8 +33,9 @@ func (h *handler) refreshCategory(w http.ResponseWriter, r *http.Request) int64
sess := session.New(h.store, request.SessionID(r)) sess := session.New(h.store, request.SessionID(r))
// Avoid accidental and excessive refreshes. // Avoid accidental and excessive refreshes.
if time.Now().UTC().Unix()-request.LastForceRefresh(r) < 1800 { if time.Now().UTC().Unix()-request.LastForceRefresh(r) < int64(config.Opts.ForceRefreshInterval())*60 {
sess.NewFlashErrorMessage(printer.Print("alert.too_many_feeds_refresh")) time := config.Opts.ForceRefreshInterval()
sess.NewFlashErrorMessage(printer.Plural("alert.too_many_feeds_refresh", time, time))
} else { } else {
// We allow the end-user to force refresh all its feeds in this category // We allow the end-user to force refresh all its feeds in this category
// without taking into consideration the number of errors. // without taking into consideration the number of errors.

90
internal/ui/entry_tag.go Normal file
View File

@ -0,0 +1,90 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package ui // import "miniflux.app/v2/internal/ui"
import (
"net/http"
"net/url"
"miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response/html"
"miniflux.app/v2/internal/http/route"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/storage"
"miniflux.app/v2/internal/ui/session"
"miniflux.app/v2/internal/ui/view"
)
func (h *handler) showTagEntryPage(w http.ResponseWriter, r *http.Request) {
user, err := h.store.UserByID(request.UserID(r))
if err != nil {
html.ServerError(w, r, err)
return
}
tagName, err := url.PathUnescape(request.RouteStringParam(r, "tagName"))
if err != nil {
html.ServerError(w, r, err)
return
}
entryID := request.RouteInt64Param(r, "entryID")
builder := h.store.NewEntryQueryBuilder(user.ID)
builder.WithTags([]string{tagName})
builder.WithEntryID(entryID)
builder.WithoutStatus(model.EntryStatusRemoved)
entry, err := builder.GetEntry()
if err != nil {
html.ServerError(w, r, err)
return
}
if entry == nil {
html.NotFound(w, r)
return
}
if user.MarkReadOnView && entry.Status == model.EntryStatusUnread {
err = h.store.SetEntriesStatus(user.ID, []int64{entry.ID}, model.EntryStatusRead)
if err != nil {
html.ServerError(w, r, err)
return
}
entry.Status = model.EntryStatusRead
}
entryPaginationBuilder := storage.NewEntryPaginationBuilder(h.store, user.ID, entry.ID, user.EntryOrder, user.EntryDirection)
entryPaginationBuilder.WithTags([]string{tagName})
prevEntry, nextEntry, err := entryPaginationBuilder.Entries()
if err != nil {
html.ServerError(w, r, err)
return
}
nextEntryRoute := ""
if nextEntry != nil {
nextEntryRoute = route.Path(h.router, "tagEntry", "tagName", url.PathEscape(tagName), "entryID", nextEntry.ID)
}
prevEntryRoute := ""
if prevEntry != nil {
prevEntryRoute = route.Path(h.router, "tagEntry", "tagName", url.PathEscape(tagName), "entryID", prevEntry.ID)
}
sess := session.New(h.store, request.SessionID(r))
view := view.New(h.tpl, r, sess)
view.Set("entry", entry)
view.Set("prevEntry", prevEntry)
view.Set("nextEntry", nextEntry)
view.Set("nextEntryRoute", nextEntryRoute)
view.Set("prevEntryRoute", prevEntryRoute)
view.Set("user", user)
view.Set("countUnread", h.store.CountUnreadEntries(user.ID))
view.Set("countErrorFeeds", h.store.CountUserFeedsWithErrors(user.ID))
view.Set("hasSaveEntry", h.store.HasSaveEntry(user.ID))
html.OK(w, r, view.Render("entry"))
}

View File

@ -43,6 +43,7 @@ func (h *handler) showEditFeedPage(w http.ResponseWriter, r *http.Request) {
SiteURL: feed.SiteURL, SiteURL: feed.SiteURL,
FeedURL: feed.FeedURL, FeedURL: feed.FeedURL,
Title: feed.Title, Title: feed.Title,
Description: feed.Description,
ScraperRules: feed.ScraperRules, ScraperRules: feed.ScraperRules,
RewriteRules: feed.RewriteRules, RewriteRules: feed.RewriteRules,
BlocklistRules: feed.BlocklistRules, BlocklistRules: feed.BlocklistRules,

View File

@ -29,7 +29,9 @@ func (h *handler) showIcon(w http.ResponseWriter, r *http.Request) {
b.WithHeader("Content-Security-Policy", `default-src 'self'`) b.WithHeader("Content-Security-Policy", `default-src 'self'`)
b.WithHeader("Content-Type", icon.MimeType) b.WithHeader("Content-Type", icon.MimeType)
b.WithBody(icon.Content) b.WithBody(icon.Content)
b.WithoutCompression() if icon.MimeType != "image/svg+xml" {
b.WithoutCompression()
}
b.Write() b.Write()
}) })
} }

View File

@ -59,6 +59,7 @@ func (h *handler) updateFeed(w http.ResponseWriter, r *http.Request) {
FeedURL: model.OptionalString(feedForm.FeedURL), FeedURL: model.OptionalString(feedForm.FeedURL),
SiteURL: model.OptionalString(feedForm.SiteURL), SiteURL: model.OptionalString(feedForm.SiteURL),
Title: model.OptionalString(feedForm.Title), Title: model.OptionalString(feedForm.Title),
Description: model.OptionalString(feedForm.Description),
CategoryID: model.OptionalNumber(feedForm.CategoryID), CategoryID: model.OptionalNumber(feedForm.CategoryID),
BlocklistRules: model.OptionalString(feedForm.BlocklistRules), BlocklistRules: model.OptionalString(feedForm.BlocklistRules),
KeeplistRules: model.OptionalString(feedForm.KeeplistRules), KeeplistRules: model.OptionalString(feedForm.KeeplistRules),

View File

@ -15,6 +15,7 @@ type FeedForm struct {
FeedURL string FeedURL string
SiteURL string SiteURL string
Title string Title string
Description string
ScraperRules string ScraperRules string
RewriteRules string RewriteRules string
BlocklistRules string BlocklistRules string
@ -43,6 +44,7 @@ func (f FeedForm) Merge(feed *model.Feed) *model.Feed {
feed.Title = f.Title feed.Title = f.Title
feed.SiteURL = f.SiteURL feed.SiteURL = f.SiteURL
feed.FeedURL = f.FeedURL feed.FeedURL = f.FeedURL
feed.Description = f.Description
feed.ScraperRules = f.ScraperRules feed.ScraperRules = f.ScraperRules
feed.RewriteRules = f.RewriteRules feed.RewriteRules = f.RewriteRules
feed.BlocklistRules = f.BlocklistRules feed.BlocklistRules = f.BlocklistRules
@ -76,6 +78,7 @@ func NewFeedForm(r *http.Request) *FeedForm {
FeedURL: r.FormValue("feed_url"), FeedURL: r.FormValue("feed_url"),
SiteURL: r.FormValue("site_url"), SiteURL: r.FormValue("site_url"),
Title: r.FormValue("title"), Title: r.FormValue("title"),
Description: r.FormValue("description"),
ScraperRules: r.FormValue("scraper_rules"), ScraperRules: r.FormValue("scraper_rules"),
UserAgent: r.FormValue("user_agent"), UserAgent: r.FormValue("user_agent"),
Cookie: r.FormValue("cookie"), Cookie: r.FormValue("cookie"),

View File

@ -96,6 +96,10 @@ type IntegrationForm struct {
OmnivoreEnabled bool OmnivoreEnabled bool
OmnivoreAPIKey string OmnivoreAPIKey string
OmnivoreURL string OmnivoreURL string
RaindropEnabled bool
RaindropToken string
RaindropCollectionID string
RaindropTags string
} }
// Merge copy form values to the model. // Merge copy form values to the model.
@ -181,6 +185,10 @@ func (i IntegrationForm) Merge(integration *model.Integration) {
integration.OmnivoreEnabled = i.OmnivoreEnabled integration.OmnivoreEnabled = i.OmnivoreEnabled
integration.OmnivoreAPIKey = i.OmnivoreAPIKey integration.OmnivoreAPIKey = i.OmnivoreAPIKey
integration.OmnivoreURL = i.OmnivoreURL integration.OmnivoreURL = i.OmnivoreURL
integration.RaindropEnabled = i.RaindropEnabled
integration.RaindropToken = i.RaindropToken
integration.RaindropCollectionID = i.RaindropCollectionID
integration.RaindropTags = i.RaindropTags
} }
// NewIntegrationForm returns a new IntegrationForm. // NewIntegrationForm returns a new IntegrationForm.
@ -269,6 +277,10 @@ func NewIntegrationForm(r *http.Request) *IntegrationForm {
OmnivoreEnabled: r.FormValue("omnivore_enabled") == "1", OmnivoreEnabled: r.FormValue("omnivore_enabled") == "1",
OmnivoreAPIKey: r.FormValue("omnivore_api_key"), OmnivoreAPIKey: r.FormValue("omnivore_api_key"),
OmnivoreURL: r.FormValue("omnivore_url"), OmnivoreURL: r.FormValue("omnivore_url"),
RaindropEnabled: r.FormValue("raindrop_enabled") == "1",
RaindropToken: r.FormValue("raindrop_token"),
RaindropCollectionID: r.FormValue("raindrop_collection_id"),
RaindropTags: r.FormValue("raindrop_tags"),
} }
} }

View File

@ -110,6 +110,10 @@ func (h *handler) showIntegrationPage(w http.ResponseWriter, r *http.Request) {
OmnivoreEnabled: integration.OmnivoreEnabled, OmnivoreEnabled: integration.OmnivoreEnabled,
OmnivoreAPIKey: integration.OmnivoreAPIKey, OmnivoreAPIKey: integration.OmnivoreAPIKey,
OmnivoreURL: integration.OmnivoreURL, OmnivoreURL: integration.OmnivoreURL,
RaindropEnabled: integration.RaindropEnabled,
RaindropToken: integration.RaindropToken,
RaindropCollectionID: integration.RaindropCollectionID,
RaindropTags: integration.RaindropTags,
} }
sess := session.New(h.store, request.SessionID(r)) sess := session.New(h.store, request.SessionID(r))

View File

@ -30,6 +30,12 @@ func (h *handler) updateSettings(w http.ResponseWriter, r *http.Request) {
return return
} }
creds, err := h.store.WebAuthnCredentialsByUserID(loggedUser.ID)
if err != nil {
html.ServerError(w, r, err)
return
}
settingsForm := form.NewSettingsForm(r) settingsForm := form.NewSettingsForm(r)
sess := session.New(h.store, request.SessionID(r)) sess := session.New(h.store, request.SessionID(r))
@ -42,6 +48,10 @@ func (h *handler) updateSettings(w http.ResponseWriter, r *http.Request) {
view.Set("user", loggedUser) view.Set("user", loggedUser)
view.Set("countUnread", h.store.CountUnreadEntries(loggedUser.ID)) view.Set("countUnread", h.store.CountUnreadEntries(loggedUser.ID))
view.Set("countErrorFeeds", h.store.CountUserFeedsWithErrors(loggedUser.ID)) view.Set("countErrorFeeds", h.store.CountUserFeedsWithErrors(loggedUser.ID))
view.Set("default_home_pages", model.HomePages())
view.Set("categories_sorting_options", model.CategoriesSortingOptions())
view.Set("countWebAuthnCerts", h.store.CountWebAuthnCredentialsByUserID(loggedUser.ID))
view.Set("webAuthnCerts", creds)
if validationErr := settingsForm.Validate(); validationErr != nil { if validationErr := settingsForm.Validate(); validationErr != nil {
view.Set("errorMessage", validationErr.Translate(loggedUser.Language)) view.Set("errorMessage", validationErr.Translate(loggedUser.Language))

View File

@ -1215,6 +1215,39 @@ audio, video {
width: 100%; width: 100%;
} }
.media-controls{
font-size: .9em;
display: flex;
flex-wrap: wrap;
}
.media-controls .media-control-label{
line-height: 1em;
}
.media-controls>div{
display: flex;
flex-wrap: nowrap;
justify-content:center;
min-width: 50%;
align-items: center;
}
.media-controls>div>*{
padding-left:12px;
}
.media-controls>div>*:first-child{
padding-left:0;
}
.media-controls span.speed-indicator{
/*monospace to ensure constant width even when value change. JS ensure the value is always on 4 characters (in most cases)
This reduce ui flickering due to element moving around a bit
*/
font-family: monospace;
}
.integration-form summary { .integration-form summary {
font-weight: 700; font-weight: 700;
} }

View File

@ -86,7 +86,8 @@ function onClickMainMenuListItem(event) {
if (element.tagName === "A") { if (element.tagName === "A") {
window.location.href = element.getAttribute("href"); window.location.href = element.getAttribute("href");
} else { } else {
window.location.href = element.querySelector("a").getAttribute("href"); const linkElement = element.querySelector("a") || element.closest("a");
window.location.href = linkElement.getAttribute("href");
} }
} }
@ -445,8 +446,8 @@ function goToPage(page, fallbackSelf) {
} }
/** /**
* *
* @param {(number|event)} offset - many items to jump for focus. * @param {(number|event)} offset - many items to jump for focus.
*/ */
function goToPrevious(offset) { function goToPrevious(offset) {
if (offset instanceof KeyboardEvent) { if (offset instanceof KeyboardEvent) {
@ -460,8 +461,8 @@ function goToPrevious(offset) {
} }
/** /**
* *
* @param {(number|event)} offset - How many items to jump for focus. * @param {(number|event)} offset - How many items to jump for focus.
*/ */
function goToNext(offset) { function goToNext(offset) {
if (offset instanceof KeyboardEvent) { if (offset instanceof KeyboardEvent) {
@ -520,7 +521,7 @@ function goToListItem(offset) {
items[i].classList.remove("current-item"); items[i].classList.remove("current-item");
// By default adjust selection by offset // By default adjust selection by offset
let itemOffset = (i + offset + items.length) % items.length; let itemOffset = (i + offset + items.length) % items.length;
// Allow jumping to top or bottom // Allow jumping to top or bottom
if (offset == TOP) { if (offset == TOP) {
itemOffset = 0; itemOffset = 0;
@ -741,3 +742,43 @@ function getCsrfToken() {
return ""; return "";
} }
/**
* Handle all clicks on media player controls button on enclosures.
* Will change the current speed and position of the player accordingly.
* Will not save anything, all is done client-side, however, changing the position
* will trigger the handlePlayerProgressionSave and save the new position backends side.
* @param {Element} button
*/
function handleMediaControl(button) {
const action = button.dataset.enclosureAction;
const value = parseFloat(button.dataset.actionValue);
const targetEnclosureId = button.dataset.enclosureId;
const enclosures = document.querySelectorAll(`audio[data-enclosure-id="${targetEnclosureId}"],video[data-enclosure-id="${targetEnclosureId}"]`);
const speedIndicator = document.querySelectorAll(`span.speed-indicator[data-enclosure-id="${targetEnclosureId}"]`);
enclosures.forEach((enclosure) => {
switch (action) {
case "seek":
enclosure.currentTime = enclosure.currentTime + value > 0 ? enclosure.currentTime + value : 0;
break;
case "speed":
// I set a floor speed of 0.25 to avoid too slow speed where it gives the impression it stopped.
// 0.25 was chosen because it will allow to get back to 1x in two "faster" click, and lower value with same property would be 0.
enclosure.playbackRate = Math.max(0.25, enclosure.playbackRate + value);
speedIndicator.forEach((speedI) => {
// Two digit precision to ensure we always have the same number of characters (4) to avoid controls moving when clicking buttons because of more or less characters.
// The trick only work on rate less than 10, but it feels an acceptable tread of considering the feature
speedI.innerText = `${enclosure.playbackRate.toFixed(2)}x`;
});
break;
case "speed-reset":
enclosure.playbackRate = value ;
speedIndicator.forEach((speedI) => {
// Two digit precision to ensure we always have the same number of characters (4) to avoid controls moving when clicking buttons because of more or less characters.
// The trick only work on rate less than 10, but it feels an acceptable tread of considering the feature
speedI.innerText = `${enclosure.playbackRate.toFixed(2)}x`;
});
break;
}
});
}

View File

@ -167,6 +167,20 @@ document.addEventListener("DOMContentLoaded", () => {
playbackRateElements.forEach((element) => { playbackRateElements.forEach((element) => {
if (element.dataset.playbackRate) { if (element.dataset.playbackRate) {
element.playbackRate = element.dataset.playbackRate; element.playbackRate = element.dataset.playbackRate;
if (element.dataset.enclosureId){
// In order to display properly the speed we need to do it on bootstrap.
// Could not do it backend side because I didn't know how to do it because of the template inclusion and
// the way the initial playback speed is handled. See enclosure_media_controls.html if you want to try to fix this
document.querySelectorAll(`span.speed-indicator[data-enclosure-id="${element.dataset.enclosureId}"]`).forEach((speedI)=>{
speedI.innerText = `${parseFloat(element.dataset.playbackRate).toFixed(2)}x`;
});
}
} }
}); });
// Set enclosure media controls handlers
const mediaControlsElements = document.querySelectorAll("button[data-enclosure-action]");
mediaControlsElements.forEach((element) => {
element.addEventListener("click", () => handleMediaControl(element));
});
}); });

View File

@ -31,12 +31,12 @@ func (h *handler) showAppIcon(w http.ResponseWriter, r *http.Request) {
switch filepath.Ext(filename) { switch filepath.Ext(filename) {
case ".png": case ".png":
b.WithoutCompression()
b.WithHeader("Content-Type", "image/png") b.WithHeader("Content-Type", "image/png")
case ".svg": case ".svg":
b.WithHeader("Content-Type", "image/svg+xml") b.WithHeader("Content-Type", "image/svg+xml")
} }
b.WithoutCompression()
b.WithBody(blob) b.WithBody(blob)
b.Write() b.Write()
}) })

View File

@ -0,0 +1,65 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package ui // import "miniflux.app/v2/internal/ui"
import (
"net/http"
"net/url"
"miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response/html"
"miniflux.app/v2/internal/http/route"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/ui/session"
"miniflux.app/v2/internal/ui/view"
)
func (h *handler) showTagEntriesAllPage(w http.ResponseWriter, r *http.Request) {
user, err := h.store.UserByID(request.UserID(r))
if err != nil {
html.ServerError(w, r, err)
return
}
tagName, err := url.PathUnescape(request.RouteStringParam(r, "tagName"))
if err != nil {
html.ServerError(w, r, err)
return
}
offset := request.QueryIntParam(r, "offset", 0)
builder := h.store.NewEntryQueryBuilder(user.ID)
builder.WithoutStatus(model.EntryStatusRemoved)
builder.WithTags([]string{tagName})
builder.WithSorting("status", "asc")
builder.WithSorting(user.EntryOrder, user.EntryDirection)
builder.WithOffset(offset)
builder.WithLimit(user.EntriesPerPage)
entries, err := builder.GetEntries()
if err != nil {
html.ServerError(w, r, err)
return
}
count, err := builder.CountEntries()
if err != nil {
html.ServerError(w, r, err)
return
}
sess := session.New(h.store, request.SessionID(r))
view := view.New(h.tpl, r, sess)
view.Set("tagName", tagName)
view.Set("total", count)
view.Set("entries", entries)
view.Set("pagination", getPagination(route.Path(h.router, "tagEntriesAll", "tagName", url.PathEscape(tagName)), count, offset, user.EntriesPerPage))
view.Set("user", user)
view.Set("countUnread", h.store.CountUnreadEntries(user.ID))
view.Set("countErrorFeeds", h.store.CountUserFeedsWithErrors(user.ID))
view.Set("hasSaveEntry", h.store.HasSaveEntry(user.ID))
view.Set("showOnlyUnreadEntries", false)
html.OK(w, r, view.Render("tag_entries"))
}

View File

@ -93,6 +93,10 @@ func Serve(router *mux.Router, store *storage.Storage, pool *worker.Pool) {
uiRouter.HandleFunc("/category/{categoryID}/remove", handler.removeCategory).Name("removeCategory").Methods(http.MethodPost) uiRouter.HandleFunc("/category/{categoryID}/remove", handler.removeCategory).Name("removeCategory").Methods(http.MethodPost)
uiRouter.HandleFunc("/category/{categoryID}/mark-all-as-read", handler.markCategoryAsRead).Name("markCategoryAsRead").Methods(http.MethodPost) uiRouter.HandleFunc("/category/{categoryID}/mark-all-as-read", handler.markCategoryAsRead).Name("markCategoryAsRead").Methods(http.MethodPost)
// Tag pages.
uiRouter.HandleFunc("/tags/{tagName}/entries/all", handler.showTagEntriesAllPage).Name("tagEntriesAll").Methods(http.MethodGet)
uiRouter.HandleFunc("/tags/{tagName}/entry/{entryID}", handler.showTagEntryPage).Name("tagEntry").Methods(http.MethodGet)
// Entry pages. // Entry pages.
uiRouter.HandleFunc("/entry/status", handler.updateEntriesStatus).Name("updateEntriesStatus").Methods(http.MethodPost) 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/save/{entryID}", handler.saveEntry).Name("saveEntry").Methods(http.MethodPost)

View File

@ -244,6 +244,12 @@ Set the value to 1 to disable the internal scheduler service\&.
.br .br
Default is false (The internal scheduler service is enabled)\&. Default is false (The internal scheduler service is enabled)\&.
.TP .TP
.B FETCH_NEBULA_WATCH_TIME
Set the value to 1 to scrape video duration from Nebula website and
use it as a reading time\&.
.br
Disabled by default\&.
.TP
.B FETCH_ODYSEE_WATCH_TIME .B FETCH_ODYSEE_WATCH_TIME
Set the value to 1 to scrape video duration from Odysee website and Set the value to 1 to scrape video duration from Odysee website and
use it as a reading time\&. use it as a reading time\&.

View File

@ -1,6 +1,4 @@
ARG BASE_IMAGE_ARCH="amd64" FROM docker.io/golang:1.22-bookworm AS build
FROM ${BASE_IMAGE_ARCH}/golang:1.22-bookworm AS build
ENV DEBIAN_FRONTEND=noninteractive ENV DEBIAN_FRONTEND=noninteractive

View File

@ -1,14 +1,10 @@
FROM golang:alpine AS build FROM docker.io/library/golang:alpine3.19 AS build
ENV CGO_ENABLED=0 RUN apk add --no-cache build-base git make
RUN apk add --no-cache --update git
ADD . /go/src/app ADD . /go/src/app
WORKDIR /go/src/app WORKDIR /go/src/app
RUN go build \ RUN make miniflux
-o miniflux \
-ldflags="-s -w -X 'miniflux.app/v2/internal/version.Version=`git describe --tags --abbrev=0`' -X 'miniflux.app/v2/internal/version.Commit=`git rev-parse --short HEAD`' -X 'miniflux.app/v2/internal/version.BuildDate=`date +%FT%T%z`'" \
main.go
FROM alpine:latest FROM docker.io/library/alpine:3.20
LABEL org.opencontainers.image.title=Miniflux LABEL org.opencontainers.image.title=Miniflux
LABEL org.opencontainers.image.description="Miniflux is a minimalist and opinionated feed reader" LABEL org.opencontainers.image.description="Miniflux is a minimalist and opinionated feed reader"

View File

@ -1,13 +1,9 @@
FROM golang:latest AS build FROM docker.io/library/golang:bookworm AS build
ENV CGO_ENABLED=0
ADD . /go/src/app ADD . /go/src/app
WORKDIR /go/src/app WORKDIR /go/src/app
RUN go build \ RUN make miniflux
-o miniflux \
-ldflags="-s -w -X 'miniflux.app/v2/internal/version.Version=`git describe --tags --abbrev=0`' -X 'miniflux.app/v2/internal/version.Commit=`git rev-parse --short HEAD`' -X 'miniflux.app/v2/internal/version.BuildDate=`date +%FT%T%z`'" \
main.go
FROM gcr.io/distroless/base:nonroot FROM gcr.io/distroless/base-debian12:nonroot
LABEL org.opencontainers.image.title=Miniflux LABEL org.opencontainers.image.title=Miniflux
LABEL org.opencontainers.image.description="Miniflux is a minimalist and opinionated feed reader" LABEL org.opencontainers.image.description="Miniflux is a minimalist and opinionated feed reader"