Compare commits

..
Author SHA1 Message Date
Crowdin Bot c5bae12b95 New Crowdin translations by GitHub Action
Lint / Linting Checks (push) Waiting to run
Tests / vitest (1) (push) Waiting to run
Tests / vitest (2) (push) Waiting to run
Tests / vitest (3) (push) Waiting to run
Tests / vitest (4) (push) Waiting to run
2026-09-26 12:26:06 +00:00
d001910e86 Feature: qui widget (#7192)
Docker CI / Docker Build & Push (push) Waiting to run
Lint / Linting Checks (push) Waiting to run
Release Drafter / Update Release Draft (push) Waiting to run
Release Drafter / Auto Label PR (push) Waiting to run
Tests / vitest (1) (push) Waiting to run
Tests / vitest (2) (push) Waiting to run
Tests / vitest (3) (push) Waiting to run
Tests / vitest (4) (push) Waiting to run
Co-authored-by: yorah <yoram.harmelin@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
2026-09-25 16:46:43 +00:00
shamoon 18a5c4ad73 Feature: feeds widget (#7170)
Docker CI / Docker Build & Push (push) Canceled after 0s
Lint / Linting Checks (push) Canceled after 0s
Release Drafter / Update Release Draft (push) Canceled after 0s
Release Drafter / Auto Label PR (push) Canceled after 0s
Tests / vitest (1) (push) Canceled after 0s
Tests / vitest (2) (push) Canceled after 0s
Tests / vitest (3) (push) Canceled after 0s
Tests / vitest (4) (push) Canceled after 0s
2026-09-24 21:47:15 -07:00
68 changed files with 1894 additions and 193 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 794 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 337 KiB

+46
View File
@@ -0,0 +1,46 @@
---
title: Feed
description: RSS / Atom feed widget
---
This widget shows the latest items from an RSS 2.0 or Atom feed.
```yaml
widget:
type: feed
url: https://feeds.bbci.co.uk/news/rss.xml
maxItems: 5 # optional - defaults to 5
layout: list # optional - possible values list, grid - defaults to list
images: true # optional - set to false to hide images - defaults to true
```
## Layouts
`list` shows one row per item. Items with an image get a small thumbnail.
![Feed widget list layout](../../assets/widget_feed_list.webp)
![Feed widget list layout with thumbnails](../../assets/widget_feed_thumbnails.webp)
`grid` shows image tiles that re-flow to fit the width of the widget, e.g.:
```yaml
- NASA Image of the Day:
widget:
type: feed
url: https://www.nasa.gov/feeds/iotd-feed/
layout: grid
maxItems: 8
```
![Feed widget grid layout](../../assets/widget_feed_grid.webp)
## Images
Images are taken from the feed's `media:thumbnail` / `media:content` tags or image enclosures, falling back to the first image in the item's content. Some feeds do not include images at all.
Images are loaded by your browser directly from the feed's host, at whatever size the feed provides. Feeds that link full-size originals can be slow to load; set `images: false` for those.
## Notes
Feeds are fetched by homepage, not your browser, and cached for 10 minutes.
+2 -2
View File
@@ -30,8 +30,6 @@ You can also find a list of all available service widgets in the sidebar navigat
- [Coin Market Cap](coin-market-cap.md) - [Coin Market Cap](coin-market-cap.md)
- [CrowdSec](crowdsec.md) - [CrowdSec](crowdsec.md)
- [Custom API](customapi.md) - [Custom API](customapi.md)
- [Dashdot](dashdot.md)
- [DDNS-Updater](ddns-updater.md)
- [Deluge](deluge.md) - [Deluge](deluge.md)
- [DeveLanCacheUI](develancacheui.md) - [DeveLanCacheUI](develancacheui.md)
- [DiskStation](diskstation.md) - [DiskStation](diskstation.md)
@@ -41,6 +39,7 @@ You can also find a list of all available service widgets in the sidebar navigat
- [Emby](emby.md) - [Emby](emby.md)
- [ESPHome](esphome.md) - [ESPHome](esphome.md)
- [EVCC](evcc.md) - [EVCC](evcc.md)
- [Feed](feed.md)
- [Filebrowser](filebrowser.md) - [Filebrowser](filebrowser.md)
- [Fileflows](fileflows.md) - [Fileflows](fileflows.md)
- [Firefly III](firefly.md) - [Firefly III](firefly.md)
@@ -121,6 +120,7 @@ You can also find a list of all available service widgets in the sidebar navigat
- [PyLoad](pyload.md) - [PyLoad](pyload.md)
- [qBittorrent](qbittorrent.md) - [qBittorrent](qbittorrent.md)
- [QNAP](qnap.md) - [QNAP](qnap.md)
- [qui](qui.md)
- [Radarr](radarr.md) - [Radarr](radarr.md)
- [Readarr](readarr.md) - [Readarr](readarr.md)
- [ROMM](romm.md) - [ROMM](romm.md)
+30
View File
@@ -0,0 +1,30 @@
---
title: qui
description: qui Widget Configuration
---
Learn more about [qui](https://github.com/autobrr/qui).
Generate an API key in qui under **Settings → API Keys**.
The widget has two modes:
- **Aggregated (default):** omit `instance` to show combined stats across all qBittorrent instances monitored by qui.
- **Per-instance:** set `instance` to the qui ID (visible in the qui UI) to show just that one.
```yaml
widget:
type: qui
url: http://qui.host.or.ip:7476
key: quiapikeyquiapikeyquiapikey
instance: 1 # optional; omit for aggregated stats across all instances
fields: ["leech", "download", "seed", "upload"] # optional
```
## Fields
Allowed fields: `["leech", "download", "seed", "upload", "total", "errored", "ratio", "freeSpace"]` (maximum of 4).
Default fields: `["leech", "download", "seed", "upload"]`.
Note: `ratio` and `freeSpace` are only available with the 'single instance' version of the widget.
+2
View File
@@ -65,6 +65,7 @@ nav:
- widgets/services/emby.md - widgets/services/emby.md
- widgets/services/esphome.md - widgets/services/esphome.md
- widgets/services/evcc.md - widgets/services/evcc.md
- widgets/services/feed.md
- widgets/services/filebrowser.md - widgets/services/filebrowser.md
- widgets/services/fileflows.md - widgets/services/fileflows.md
- widgets/services/firefly.md - widgets/services/firefly.md
@@ -148,6 +149,7 @@ nav:
- widgets/services/pyload.md - widgets/services/pyload.md
- widgets/services/qbittorrent.md - widgets/services/qbittorrent.md
- widgets/services/qnap.md - widgets/services/qnap.md
- widgets/services/qui.md
- widgets/services/radarr.md - widgets/services/radarr.md
- widgets/services/readarr.md - widgets/services/readarr.md
- widgets/services/romm.md - widgets/services/romm.md
+1
View File
@@ -39,6 +39,7 @@
"react-i18next": "^17.0.12", "react-i18next": "^17.0.12",
"react-icons": "^5.6.0", "react-icons": "^5.6.0",
"recharts": "^3.1.2", "recharts": "^3.1.2",
"sax": "^1.6.1",
"swr": "^2.5.1", "swr": "^2.5.1",
"systeminformation": "^5.33.1", "systeminformation": "^5.33.1",
"tough-cookie": "^6.0.2", "tough-cookie": "^6.0.2",
+3
View File
@@ -83,6 +83,9 @@ importers:
recharts: recharts:
specifier: ^3.1.2 specifier: ^3.1.2
version: 3.10.1(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react-is@17.0.2)(react@19.2.8)(redux@5.0.1) version: 3.10.1(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react-is@17.0.2)(react@19.2.8)(redux@5.0.1)
sax:
specifier: ^1.6.1
version: 1.6.1
swr: swr:
specifier: ^2.5.1 specifier: ^2.5.1
version: 2.5.1(react@19.2.8) version: 2.5.1(react@19.2.8)
+13
View File
@@ -256,6 +256,16 @@
"leech": "Seier", "leech": "Seier",
"seed": "Vul" "seed": "Vul"
}, },
"qui": {
"leech": "Leech",
"download": "Download",
"seed": "Seed",
"upload": "Upload",
"total": "Total",
"errored": "Errored",
"ratio": "Ratio",
"freeSpace": "Free"
},
"qnap": { "qnap": {
"cpuUsage": "SVE Gebruik", "cpuUsage": "SVE Gebruik",
"memUsage": "MEM Gebruik", "memUsage": "MEM Gebruik",
@@ -1258,5 +1268,8 @@
"wanted": "Gesoek", "wanted": "Gesoek",
"queued": "In ry", "queued": "In ry",
"leagues": "Ligas" "leagues": "Ligas"
},
"feed": {
"noItems": "No items"
} }
} }
+13
View File
@@ -256,6 +256,16 @@
"leech": "المُستهلكون", "leech": "المُستهلكون",
"seed": "المُزوِّدين" "seed": "المُزوِّدين"
}, },
"qui": {
"leech": "Leech",
"download": "Download",
"seed": "Seed",
"upload": "Upload",
"total": "Total",
"errored": "Errored",
"ratio": "Ratio",
"freeSpace": "Free"
},
"qnap": { "qnap": {
"cpuUsage": "استهلاك المعالج", "cpuUsage": "استهلاك المعالج",
"memUsage": "استخدام الذاكرة العشوائية", "memUsage": "استخدام الذاكرة العشوائية",
@@ -1258,5 +1268,8 @@
"wanted": "Wanted", "wanted": "Wanted",
"queued": "Queued", "queued": "Queued",
"leagues": "Leagues" "leagues": "Leagues"
},
"feed": {
"noItems": "No items"
} }
} }
+13
View File
@@ -256,6 +256,16 @@
"leech": "Leech", "leech": "Leech",
"seed": "Seed" "seed": "Seed"
}, },
"qui": {
"leech": "Leech",
"download": "Download",
"seed": "Seed",
"upload": "Upload",
"total": "Total",
"errored": "Errored",
"ratio": "Ratio",
"freeSpace": "Free"
},
"qnap": { "qnap": {
"cpuUsage": "CPU употреба", "cpuUsage": "CPU употреба",
"memUsage": "Упортеба памет", "memUsage": "Упортеба памет",
@@ -1258,5 +1268,8 @@
"wanted": "Wanted", "wanted": "Wanted",
"queued": "Queued", "queued": "Queued",
"leagues": "Leagues" "leagues": "Leagues"
},
"feed": {
"noItems": "No items"
} }
} }
+13
View File
@@ -256,6 +256,16 @@
"leech": "Sangonera", "leech": "Sangonera",
"seed": "Sembrat" "seed": "Sembrat"
}, },
"qui": {
"leech": "Sangonera",
"download": "Baixada",
"seed": "Sembrat",
"upload": "Pujada",
"total": "Total",
"errored": "Errors",
"ratio": "Ràtio",
"freeSpace": "Lliure"
},
"qnap": { "qnap": {
"cpuUsage": "Ús de CPU", "cpuUsage": "Ús de CPU",
"memUsage": "Ús de Memòria", "memUsage": "Ús de Memòria",
@@ -1258,5 +1268,8 @@
"wanted": "Pendents", "wanted": "Pendents",
"queued": "En cua", "queued": "En cua",
"leagues": "Lligues" "leagues": "Lligues"
},
"feed": {
"noItems": "Sense elements"
} }
} }
+13
View File
@@ -256,6 +256,16 @@
"leech": "Leechované", "leech": "Leechované",
"seed": "Seedované" "seed": "Seedované"
}, },
"qui": {
"leech": "Leech",
"download": "Download",
"seed": "Seed",
"upload": "Upload",
"total": "Total",
"errored": "Errored",
"ratio": "Ratio",
"freeSpace": "Free"
},
"qnap": { "qnap": {
"cpuUsage": "Využití procesoru", "cpuUsage": "Využití procesoru",
"memUsage": "Využití paměti", "memUsage": "Využití paměti",
@@ -1258,5 +1268,8 @@
"wanted": "Požadované", "wanted": "Požadované",
"queued": "Ve frontě", "queued": "Ve frontě",
"leagues": "Ligy" "leagues": "Ligy"
},
"feed": {
"noItems": "No items"
} }
} }
+204 -191
View File
@@ -21,7 +21,7 @@
"seconds": "s" "seconds": "s"
}, },
"auth": { "auth": {
"signout": "Sign out" "signout": "Log af"
}, },
"widget": { "widget": {
"missing_type": "Manglende Widget Type: {{type}}", "missing_type": "Manglende Widget Type: {{type}}",
@@ -40,8 +40,8 @@
}, },
"search": { "search": {
"placeholder": "Søg…", "placeholder": "Søg…",
"provider": "Search engine: {{name}}", "provider": "Søgemaskine: {{name}}",
"select_provider": "Select search engine" "select_provider": "Vælg søgemaskine"
}, },
"resources": { "resources": {
"cpu": "CPU", "cpu": "CPU",
@@ -66,29 +66,29 @@
"wlan_devices": "WLAN Enheder", "wlan_devices": "WLAN Enheder",
"lan_users": "LAN Brugere", "lan_users": "LAN Brugere",
"wlan_users": "WLAN Brugere", "wlan_users": "WLAN Brugere",
"up": "UP", "up": "OP",
"down": "NED", "down": "NED",
"wait": "Please wait", "wait": "Vent",
"empty_data": "Subsystem status ukendt" "empty_data": "Subsystem status ukendt"
}, },
"unifi_drive": { "unifi_drive": {
"healthy": "Healthy", "healthy": "Sund",
"degraded": "Degraded", "degraded": "Forringet",
"no_data": "No storage data available" "no_data": "Ingen lagringsdata tilgængelige"
}, },
"duplicati": { "duplicati": {
"jobs": "Jobs", "jobs": "Jobs",
"stored": "Stored", "stored": "Gemte",
"lastBackup": "Last Run", "lastBackup": "Sidste kørsel",
"nextRun": "Next Run", "nextRun": "Næste kørsel",
"running": "Running", "running": "Kører",
"warnings": "Warnings", "warnings": "Advarsel",
"errors": "Errors" "errors": "Fejl"
}, },
"docker": { "docker": {
"rx": "RX", "rx": "RX",
"tx": "TX", "tx": "TX",
"mem": "MEM", "mem": "RAM",
"cpu": "CPU", "cpu": "CPU",
"running": "Kører", "running": "Kører",
"offline": "Offline", "offline": "Offline",
@@ -100,14 +100,14 @@
"not_found": "Ikke Fundet", "not_found": "Ikke Fundet",
"exited": "Forladt", "exited": "Forladt",
"partial": "Delvis", "partial": "Delvis",
"created": "Created", "created": "Oprettet",
"paused": "Paused", "paused": "Sat på pause",
"restarting": "Restarting", "restarting": "Genstarter",
"removing": "Removing", "removing": "Fjerner",
"dead": "Dead" "dead": "Død"
}, },
"ping": { "ping": {
"error": "Error", "error": "Fejl",
"ping": "Ping", "ping": "Ping",
"down": "Ned", "down": "Ned",
"up": "Op", "up": "Op",
@@ -115,11 +115,11 @@
}, },
"siteMonitor": { "siteMonitor": {
"http_status": "HTTP-status", "http_status": "HTTP-status",
"error": "Error", "error": "Fejl",
"response": "Response", "response": "Svar",
"down": "Down", "down": "Ned",
"up": "Up", "up": "Op",
"not_available": "Not Available" "not_available": "Ikke tilgængelig"
}, },
"emby": { "emby": {
"playing": "Afspiller", "playing": "Afspiller",
@@ -132,22 +132,22 @@
"songs": "Sange" "songs": "Sange"
}, },
"jellyfin": { "jellyfin": {
"playing": "Playing", "playing": "Afspiller",
"transcoding": "Transcoding", "transcoding": "Transcoder",
"bitrate": "Bitrate", "bitrate": "Bitrate",
"no_active": "No Active Streams", "no_active": "Ingen Aktive Streams",
"movies": "Movies", "movies": "Film",
"series": "Series", "series": "Serier",
"episodes": "Episodes", "episodes": "Episoder",
"songs": "Songs", "songs": "Sange",
"albums": "Albums" "albums": "Album"
}, },
"esphome": { "esphome": {
"offline": "Offline", "offline": "Offline",
"offline_alt": "Offline", "offline_alt": "Offline",
"online": "Online", "online": "Online",
"total": "Total", "total": "Total",
"unknown": "Unknown" "unknown": "Ukendt"
}, },
"evcc": { "evcc": {
"pv_power": "Produktion", "pv_power": "Produktion",
@@ -169,22 +169,22 @@
}, },
"fritzbox": { "fritzbox": {
"connectionStatus": "Status", "connectionStatus": "Status",
"connectionStatusUnconfigured": "Unconfigured", "connectionStatusUnconfigured": "Ikke konfigureret",
"connectionStatusConnecting": "Connecting", "connectionStatusConnecting": "Forbinder",
"connectionStatusAuthenticating": "Authenticating", "connectionStatusAuthenticating": "Godkender",
"connectionStatusPendingDisconnect": "Pending Disconnect", "connectionStatusPendingDisconnect": "Afventer Afbrydelse",
"connectionStatusDisconnecting": "Disconnecting", "connectionStatusDisconnecting": "Afbryder forbindelsen",
"connectionStatusDisconnected": "Disconnected", "connectionStatusDisconnected": "Afbrudt",
"connectionStatusConnected": "Connected", "connectionStatusConnected": "Forbundet",
"uptime": "Uptime", "uptime": "Oppetid",
"maxDown": "Max. Down", "maxDown": "Maks. Ned",
"maxUp": "Max. Up", "maxUp": "Maks. Op",
"down": "Down", "down": "Ned",
"up": "Up", "up": "Op",
"received": "Modtaget", "received": "Modtaget",
"sent": "Sendt", "sent": "Sendt",
"externalIPAddress": "Ekstern IP", "externalIPAddress": "Ekstern IP",
"externalIPv6Address": "Ext. IPv6", "externalIPv6Address": "Ekstern IPv6",
"externalIPv6Prefix": "Ext. IPv6-Prefix" "externalIPv6Prefix": "Ext. IPv6-Prefix"
}, },
"caddy": { "caddy": {
@@ -203,24 +203,24 @@
"passes": "Bestået" "passes": "Bestået"
}, },
"tautulli": { "tautulli": {
"playing": "Playing", "playing": "Afspiller",
"transcoding": "Transcoding", "transcoding": "Transcoder",
"bitrate": "Bitrate", "bitrate": "Bitrate",
"no_active": "No Active Streams", "no_active": "Ingen Aktive Streams",
"plex_connection_error": "Tjek Plex-forbindelse" "plex_connection_error": "Tjek Plex-forbindelse"
}, },
"tracearr": { "tracearr": {
"no_active": "No Active Streams", "no_active": "Ingen Aktive Streams",
"streams": "Streams", "streams": "Streams",
"transcodes": "Transcodes", "transcodes": "Transkoder",
"directplay": "Direct Play", "directplay": "Direkte afspilning",
"bitrate": "Bitrate" "bitrate": "Bitrate"
}, },
"omada": { "omada": {
"connectedAp": "Forbundne APs", "connectedAp": "Forbundne APs",
"activeUser": "Aktive enheder", "activeUser": "Aktive enheder",
"alerts": "Advarsler", "alerts": "Advarsler",
"connectedGateways": "Connected gateways", "connectedGateways": "Forbundne gateways",
"connectedSwitches": "Forbundne switches" "connectedSwitches": "Forbundne switches"
}, },
"nzbget": { "nzbget": {
@@ -231,7 +231,7 @@
"plex": { "plex": {
"streams": "Aktive Streams", "streams": "Aktive Streams",
"albums": "Albums", "albums": "Albums",
"movies": "Movies", "movies": "Film",
"tv": "TV-Shows" "tv": "TV-Shows"
}, },
"sabnzbd": { "sabnzbd": {
@@ -256,6 +256,16 @@
"leech": "Leech", "leech": "Leech",
"seed": "Seed" "seed": "Seed"
}, },
"qui": {
"leech": "Leech",
"download": "Download",
"seed": "Seed",
"upload": "Upload",
"total": "Total",
"errored": "Errored",
"ratio": "Ratio",
"freeSpace": "Free"
},
"qnap": { "qnap": {
"cpuUsage": "CPU Forbrug", "cpuUsage": "CPU Forbrug",
"memUsage": "MEM Forbrug", "memUsage": "MEM Forbrug",
@@ -283,17 +293,17 @@
"sonarr": { "sonarr": {
"wanted": "Ønsket", "wanted": "Ønsket",
"queued": "I Kø", "queued": "I Kø",
"series": "Series", "series": "Serier",
"queue": "Queue", "queue": "Kø",
"unknown": "Unknown" "unknown": "Ukendt"
}, },
"radarr": { "radarr": {
"wanted": "Wanted", "wanted": "Wanted",
"missing": "Mangler", "missing": "Mangler",
"queued": "Queued", "queued": "Queued",
"movies": "Movies", "movies": "Film",
"queue": "Queue", "queue": "Kø",
"unknown": "Unknown" "unknown": "Ukendt"
}, },
"lidarr": { "lidarr": {
"wanted": "Wanted", "wanted": "Wanted",
@@ -315,18 +325,18 @@
"available": "Tilgængelig" "available": "Tilgængelig"
}, },
"seerr": { "seerr": {
"pending": "Pending", "pending": "Afventer",
"approved": "Approved", "approved": "Godkendt",
"available": "Available", "available": "Tilgængelig",
"completed": "Completed", "completed": "Udført",
"processing": "Processing", "processing": "Behandler",
"issues": "Open Issues" "issues": "Åbne problemer"
}, },
"netalertx": { "netalertx": {
"total": "Total", "total": "Total",
"connected": "Connected", "connected": "Forbundet",
"new_devices": "New Devices", "new_devices": "Nye Enheder",
"down_alerts": "Down Alerts" "down_alerts": "Nede Advarsler"
}, },
"pihole": { "pihole": {
"queries": "Forespørgsler", "queries": "Forespørgsler",
@@ -335,8 +345,8 @@
"gravity": "Tyngdekraft" "gravity": "Tyngdekraft"
}, },
"adguard": { "adguard": {
"queries": "Queries", "queries": "Forespørgsler",
"blocked": "Blocked", "blocked": "Blokerede",
"filtered": "Filtreret", "filtered": "Filtreret",
"latency": "Latenstid" "latency": "Latenstid"
}, },
@@ -346,33 +356,33 @@
"ping": "Ping" "ping": "Ping"
}, },
"portainer": { "portainer": {
"running": "Running", "running": "Kører",
"stopped": "Stoppede", "stopped": "Stoppede",
"total": "Total" "total": "Total"
}, },
"suwayomi": { "suwayomi": {
"download": "Downloaded", "download": "Hentet",
"nondownload": "Non-Downloaded", "nondownload": "Ikke-Downloadet",
"read": "Read", "read": "Læst",
"unread": "Unread", "unread": "Ulæst",
"downloadedread": "Downloaded & Read", "downloadedread": "Downloadet Og Læst",
"downloadedunread": "Downloaded & Unread", "downloadedunread": "Downloadet & Ulæst",
"nondownloadedread": "Non-Downloaded & Read", "nondownloadedread": "Ikke-Downloadet & Læst",
"nondownloadedunread": "Non-Downloaded & Unread" "nondownloadedunread": "Ikke-Downloadet & Ulæst"
}, },
"tailscale": { "tailscale": {
"address": "Adresse", "address": "Adresse",
"expires": "Udløber", "expires": "Udløber",
"never": "Aldrig", "never": "Aldrig",
"user": "User", "user": "Bruger",
"hostname": "Hostname", "hostname": "Værtsnavn",
"name": "Name", "name": "Navn",
"client_version": "Client Version", "client_version": "Klientversion",
"os": "OS", "os": "Styresystem",
"created": "Created", "created": "Oprettet",
"authorized": "Authorized", "authorized": "Autoriseret",
"is_external": "Is External", "is_external": "Er ekstern",
"update_available": "Update Available", "update_available": "Opdatering tilgængelig",
"tags": "Tags", "tags": "Tags",
"last_seen": "Sidst Set", "last_seen": "Sidst Set",
"now": "Nu", "now": "Nu",
@@ -383,24 +393,24 @@
"minutes": "{{number}}m", "minutes": "{{number}}m",
"seconds": "{{number}}s", "seconds": "{{number}}s",
"ago": "{{value}} Siden", "ago": "{{value}} Siden",
"true": "Yes", "true": "Ja",
"false": "No" "false": "Nej"
}, },
"technitium": { "technitium": {
"totalQueries": "Queries", "totalQueries": "Forespørgsler",
"totalNoError": "Success", "totalNoError": "Succes",
"totalServerFailure": "Failures", "totalServerFailure": "Fejl",
"totalNxDomain": "NX Domains", "totalNxDomain": "Domæner",
"totalRefused": "Refused", "totalRefused": "Nægtede",
"totalAuthoritative": "Authoritative", "totalAuthoritative": "Autorativ",
"totalRecursive": "Recursive", "totalRecursive": "Rekursivt",
"totalCached": "Cached", "totalCached": "Cachet",
"totalBlocked": "Blocked", "totalBlocked": "Blokeret",
"totalDropped": "Dropped", "totalDropped": "Tabt",
"totalClients": "Klienter" "totalClients": "Klienter"
}, },
"tdarr": { "tdarr": {
"queue": "Queue", "queue": "Kø",
"processed": "Behandlet", "processed": "Behandlet",
"errored": "Fejlet", "errored": "Fejlet",
"saved": "Gemt" "saved": "Gemt"
@@ -412,12 +422,12 @@
}, },
"trilium": { "trilium": {
"version": "Version", "version": "Version",
"notesCount": "Notes", "notesCount": "Noter",
"dbSize": "Database Size", "dbSize": "Databasestørrelse",
"unknown": "Unknown" "unknown": "Ukendt"
}, },
"navidrome": { "navidrome": {
"nothing_streaming": "No Active Streams", "nothing_streaming": "Ingen Aktive Streams",
"please_wait": "Vent venligst" "please_wait": "Vent venligst"
}, },
"npm": { "npm": {
@@ -482,31 +492,31 @@
"authorizationsLast24H": "Auths (24h)" "authorizationsLast24H": "Auths (24h)"
}, },
"proxmox": { "proxmox": {
"mem": "MEM", "mem": "RAM",
"cpu": "CPU", "cpu": "CPU",
"lxc": "LXC", "lxc": "LXC",
"vms": "VMs" "vms": "VMs"
}, },
"pulse": { "pulse": {
"nodes": "Nodes", "nodes": "Noder",
"vms": "VMs", "vms": "VMs",
"lxcs": "LXCs" "lxcs": "LXC"
}, },
"glances": { "glances": {
"cpu": "CPU", "cpu": "CPU",
"load": "Load", "load": "Indlæs",
"wait": "Please wait", "wait": "Vent venligst",
"temp": "TEMP", "temp": "TEMP",
"_temp": "Temp", "_temp": "Temp",
"warn": "Advar", "warn": "Advar",
"uptime": "UP", "uptime": "OP",
"total": "Total", "total": "Total",
"free": "Free", "free": "Fri",
"used": "Used", "used": "Brugt",
"days": "d", "days": "d",
"hours": "h", "hours": "t",
"crit": "Crit", "crit": "Crit",
"read": "Read", "read": "Læst",
"write": "Skriv", "write": "Skriv",
"gpu": "GPU", "gpu": "GPU",
"mem": "Ram", "mem": "Ram",
@@ -519,7 +529,7 @@
"custom": "Brugerdefinerede", "custom": "Brugerdefinerede",
"visit": "Besøg", "visit": "Besøg",
"url": "URL", "url": "URL",
"searchsuggestion": "Suggestion" "searchsuggestion": "Forslag"
}, },
"wmo": { "wmo": {
"0-day": "Solrig", "0-day": "Solrig",
@@ -527,57 +537,57 @@
"1-day": "Overvejende Solrigt", "1-day": "Overvejende Solrigt",
"1-night": "Overvejende Skyfrit", "1-night": "Overvejende Skyfrit",
"2-day": "Delvist Overskyet", "2-day": "Delvist Overskyet",
"2-night": "Partly Cloudy", "2-night": "Delvist Overskyet",
"3-day": "Skyet", "3-day": "Skyet",
"3-night": "Cloudy", "3-night": "Skyet",
"45-day": "Tåget", "45-day": "Tåget",
"45-night": "Foggy", "45-night": "Tåget",
"48-day": "Foggy", "48-day": "Tåget",
"48-night": "Foggy", "48-night": "Tåget",
"51-day": "Let Støvregn", "51-day": "Let Støvregn",
"51-night": "Light Drizzle", "51-night": "Let Støvregn",
"53-day": "Støvregn", "53-day": "Støvregn",
"53-night": "Drizzle", "53-night": "Støvregn",
"55-day": "Kraftig Støvregn", "55-day": "Kraftig Støvregn",
"55-night": "Heavy Drizzle", "55-night": "Kraftig Støvregn",
"56-day": "Let Frysende Støvregn", "56-day": "Let Frysende Støvregn",
"56-night": "Light Freezing Drizzle", "56-night": "Light Freezing Drizzle",
"57-day": "Frysende Støvregn", "57-day": "Frysende Støvregn",
"57-night": "Freezing Drizzle", "57-night": "Freezing Drizzle",
"61-day": "Let Regn", "61-day": "Let Regn",
"61-night": "Light Rain", "61-night": "Let Regn",
"63-day": "Regn", "63-day": "Regn",
"63-night": "Rain", "63-night": "Regn",
"65-day": "Kraftig Regn", "65-day": "Kraftig Regn",
"65-night": "Heavy Rain", "65-night": "Kraftig Regn",
"66-day": "Frysende Regn", "66-day": "Frysende Regn",
"66-night": "Freezing Rain", "66-night": "Freezing Rain",
"67-day": "Freezing Rain", "67-day": "Freezing Rain",
"67-night": "Freezing Rain", "67-night": "Freezing Rain",
"71-day": "Let Sne", "71-day": "Let Sne",
"71-night": "Light Snow", "71-night": "Let Sne",
"73-day": "Sne", "73-day": "Sne",
"73-night": "Snow", "73-night": "Sne",
"75-day": "Kraftig Sne", "75-day": "Kraftig Sne",
"75-night": "Heavy Snow", "75-night": "Kraftig Sne",
"77-day": "Snekorn", "77-day": "Snekorn",
"77-night": "Snow Grains", "77-night": "Mildt snefald",
"80-day": "Lette Byger", "80-day": "Lette Byger",
"80-night": "Light Showers", "80-night": "Lette Byger",
"81-day": "Byger", "81-day": "Byger",
"81-night": "Showers", "81-night": "Byger",
"82-day": "Kraftige Byger", "82-day": "Kraftige Byger",
"82-night": "Heavy Showers", "82-night": "Kraftige Byger",
"85-day": "Snebyger", "85-day": "Snebyger",
"85-night": "Snow Showers", "85-night": "Snebyger",
"86-day": "Snow Showers", "86-day": "Snebyger",
"86-night": "Snow Showers", "86-night": "Snebyger",
"95-day": "Tordenvejr", "95-day": "Tordenvejr",
"95-night": "Thunderstorm", "95-night": "Tordenvejr",
"96-day": "Tordenvejr Med Hagl", "96-day": "Tordenvejr Med Hagl",
"96-night": "Thunderstorm With Hail", "96-night": "Tordenvejr Med Hagl",
"99-day": "Thunderstorm With Hail", "99-day": "Tordenvejr Med Hagl",
"99-night": "Thunderstorm With Hail" "99-night": "Tordenvejr Med Hagl"
}, },
"homebridge": { "homebridge": {
"available_update": "System", "available_update": "System",
@@ -586,16 +596,16 @@
"up_to_date": "Opdateret", "up_to_date": "Opdateret",
"child_bridges": "Underbroer", "child_bridges": "Underbroer",
"child_bridges_status": "{{ok}}/{{total}}", "child_bridges_status": "{{ok}}/{{total}}",
"up": "Up", "up": "Op",
"pending": "Pending", "pending": "Afventer",
"down": "Down", "down": "Ned",
"ok": "Ok" "ok": "Ok"
}, },
"healthchecks": { "healthchecks": {
"new": "Ny", "new": "Ny",
"up": "Up", "up": "Op",
"grace": "Henstandsperiode", "grace": "Henstandsperiode",
"down": "Down", "down": "Ned",
"paused": "Pause", "paused": "Pause",
"status": "Status", "status": "Status",
"last_ping": "Sidste Ping", "last_ping": "Sidste Ping",
@@ -607,85 +617,85 @@
"containers_failed": "Fejlet" "containers_failed": "Fejlet"
}, },
"autobrr": { "autobrr": {
"approvedPushes": "Approved", "approvedPushes": "Godkendt",
"rejectedPushes": "Afviste", "rejectedPushes": "Afviste",
"filters": "Filtre", "filters": "Filtre",
"indexers": "Indexers" "indexers": "Indeksører"
}, },
"tubearchivist": { "tubearchivist": {
"downloads": "Queue", "downloads": "Kø",
"videos": "Videoer", "videos": "Videoer",
"channels": "Kanaler", "channels": "Kanaler",
"playlists": "Afspilningslister" "playlists": "Afspilningslister"
}, },
"truenas": { "truenas": {
"load": "Systembelastning", "load": "Systembelastning",
"uptime": "Uptime", "uptime": "Oppetid",
"alerts": "Alerts" "alerts": "Advarsler"
}, },
"pyload": { "pyload": {
"speed": "Hastighed", "speed": "Hastighed",
"active": "Active", "active": "Aktive",
"queue": "Queue", "queue": "Kø",
"total": "Total" "total": "Total"
}, },
"gluetun": { "gluetun": {
"public_ip": "Offentlig IP", "public_ip": "Offentlig IP",
"region": "Område", "region": "Område",
"country": "Land", "country": "Land",
"port_forwarded": "Port Forwarded" "port_forwarded": "Port Viderestillet"
}, },
"hdhomerun": { "hdhomerun": {
"channels": "Channels", "channels": "Kanaler",
"hd": "HD", "hd": "HD",
"tunerCount": "Tuners", "tunerCount": "Tunere",
"channelNumber": "Channel", "channelNumber": "Kanal",
"channelNetwork": "Network", "channelNetwork": "Netværk",
"signalStrength": "Strength", "signalStrength": "Styrke",
"signalQuality": "Quality", "signalQuality": "Kvalitet",
"symbolQuality": "Quality", "symbolQuality": "Kvalitet",
"networkRate": "Bitrate", "networkRate": "Bitrate",
"clientIP": "Client" "clientIP": "Klient"
}, },
"scrutiny": { "scrutiny": {
"passed": "Bestået", "passed": "Bestået",
"failed": "Failed", "failed": "Fejlet",
"unknown": "Unknown" "unknown": "Ukendt"
}, },
"paperlessngx": { "paperlessngx": {
"inbox": "Indbakke", "inbox": "Indbakke",
"total": "Total" "total": "Total"
}, },
"pangolin": { "pangolin": {
"orgs": "Orgs", "orgs": "Organisationer",
"sites": "Sites", "sites": "Websteder",
"resources": "Resources", "resources": "Ressourcer",
"targets": "Targets", "targets": "Mål",
"traffic": "Traffic", "traffic": "Trafik",
"in": "In", "in": "Ind",
"out": "Out" "out": "Ud"
}, },
"peanut": { "peanut": {
"battery_charge": "Batteriniveau", "battery_charge": "Batteriniveau",
"ups_load": "UPS Load", "ups_load": "Ups Belastning",
"ups_status": "UPS Status", "ups_status": "UPS Status",
"online": "Online", "online": "Online",
"on_battery": "På batteri", "on_battery": "På batteri",
"low_battery": "Lavt batteriniveau" "low_battery": "Lavt batteriniveau"
}, },
"nextdns": { "nextdns": {
"wait": "Please Wait", "wait": "Vent venligst",
"no_devices": "Ingen Enhedsdata Modtaget" "no_devices": "Ingen Enhedsdata Modtaget"
}, },
"mikrotik": { "mikrotik": {
"cpuLoad": "CPU Belastning", "cpuLoad": "CPU Belastning",
"memoryUsed": "Hukommelse Brugt", "memoryUsed": "Hukommelse Brugt",
"uptime": "Uptime", "uptime": "Oppetid",
"numberOfLeases": "Leasinger" "numberOfLeases": "Leasinger"
}, },
"xteve": { "xteve": {
"streams_all": "Alle Streams", "streams_all": "Alle Streams",
"streams_active": "Active Streams", "streams_active": "Aktive Streams",
"streams_xepg": "XEPG Kanaler" "streams_xepg": "XEPG Kanaler"
}, },
"opendtu": { "opendtu": {
@@ -695,7 +705,7 @@
"limit": "Begrænsning" "limit": "Begrænsning"
}, },
"opnsense": { "opnsense": {
"cpu": "CPU Load", "cpu": "CPU Belastning",
"memory": "Aktiv Hukommelse", "memory": "Aktiv Hukommelse",
"wanUpload": "WAN Upload", "wanUpload": "WAN Upload",
"wanDownload": "WAN Download" "wanDownload": "WAN Download"
@@ -720,8 +730,8 @@
"load": "Belastning Gns", "load": "Belastning Gns",
"memory": "Hukommelse Forbrug", "memory": "Hukommelse Forbrug",
"wanStatus": "WAN Status", "wanStatus": "WAN Status",
"up": "Up", "up": "Op",
"down": "Down", "down": "Ned",
"temp": "Temp", "temp": "Temp",
"disk": "Disk Forbrug", "disk": "Disk Forbrug",
"wanIP": "WAN IP" "wanIP": "WAN IP"
@@ -733,15 +743,15 @@
"memory_usage": "Hukommelse" "memory_usage": "Hukommelse"
}, },
"immich": { "immich": {
"users": "Users", "users": "Brugere",
"photos": "Billeder", "photos": "Billeder",
"videos": "Videos", "videos": "Videoer",
"storage": "Lager" "storage": "Lager"
}, },
"uptimekuma": { "uptimekuma": {
"up": "Sider Oppe", "up": "Sider Oppe",
"down": "Sider Nede", "down": "Sider Nede",
"uptime": "Uptime", "uptime": "Oppetid",
"incident": "Hændelse", "incident": "Hændelse",
"m": "m" "m": "m"
}, },
@@ -889,7 +899,7 @@
"name": "Navn", "name": "Navn",
"map": "Kort", "map": "Kort",
"currentPlayers": "Nuværende Spillere", "currentPlayers": "Nuværende Spillere",
"players": "Players", "players": "Spillere",
"maxPlayers": "Maks spillere", "maxPlayers": "Maks spillere",
"bots": "Bots", "bots": "Bots",
"ping": "Ping" "ping": "Ping"
@@ -902,22 +912,22 @@
}, },
"mealie": { "mealie": {
"recipes": "Opskrifter", "recipes": "Opskrifter",
"users": "Users", "users": "Brugere",
"categories": "Categories", "categories": "Kategorier",
"tags": "Tags" "tags": "Tags"
}, },
"openmediavault": { "openmediavault": {
"downloading": "Downloader", "downloading": "Downloader",
"total": "Total", "total": "Total",
"running": "Running", "running": "Kører",
"stopped": "Stopped", "stopped": "Stopped",
"passed": "Passed", "passed": "Passed",
"failed": "Failed" "failed": "Fejlet"
}, },
"openwrt": { "openwrt": {
"uptime": "Uptime", "uptime": "Oppetid",
"cpuLoad": "CPU Load Avg (5m)", "cpuLoad": "CPU Load Avg (5m)",
"up": "Up", "up": "Op",
"down": "Down", "down": "Down",
"bytesTx": "Transmitted", "bytesTx": "Transmitted",
"bytesRx": "Received" "bytesRx": "Received"
@@ -1258,5 +1268,8 @@
"wanted": "Wanted", "wanted": "Wanted",
"queued": "Queued", "queued": "Queued",
"leagues": "Leagues" "leagues": "Leagues"
},
"feed": {
"noItems": "No items"
} }
} }
+13
View File
@@ -256,6 +256,16 @@
"leech": "Leech", "leech": "Leech",
"seed": "Seed" "seed": "Seed"
}, },
"qui": {
"leech": "Leech",
"download": "Download",
"seed": "Seed",
"upload": "Upload",
"total": "Total",
"errored": "Errored",
"ratio": "Ratio",
"freeSpace": "Free"
},
"qnap": { "qnap": {
"cpuUsage": "CPU-Nutzung", "cpuUsage": "CPU-Nutzung",
"memUsage": "Speichernutzung", "memUsage": "Speichernutzung",
@@ -1258,5 +1268,8 @@
"wanted": "Wanted", "wanted": "Wanted",
"queued": "Queued", "queued": "Queued",
"leagues": "Leagues" "leagues": "Leagues"
},
"feed": {
"noItems": "No items"
} }
} }
+13
View File
@@ -256,6 +256,16 @@
"leech": "Leech", "leech": "Leech",
"seed": "Seed" "seed": "Seed"
}, },
"qui": {
"leech": "Leech",
"download": "Download",
"seed": "Seed",
"upload": "Upload",
"total": "Total",
"errored": "Errored",
"ratio": "Ratio",
"freeSpace": "Free"
},
"qnap": { "qnap": {
"cpuUsage": "Χρήση επεξεργαστή", "cpuUsage": "Χρήση επεξεργαστή",
"memUsage": "Χρήση μνήμης", "memUsage": "Χρήση μνήμης",
@@ -1258,5 +1268,8 @@
"wanted": "Wanted", "wanted": "Wanted",
"queued": "Queued", "queued": "Queued",
"leagues": "Leagues" "leagues": "Leagues"
},
"feed": {
"noItems": "No items"
} }
} }
+13
View File
@@ -256,6 +256,16 @@
"leech": "Leech", "leech": "Leech",
"seed": "Seed" "seed": "Seed"
}, },
"qui": {
"leech": "Leech",
"download": "Download",
"seed": "Seed",
"upload": "Upload",
"total": "Total",
"errored": "Errored",
"ratio": "Ratio",
"freeSpace": "Free"
},
"qnap": { "qnap": {
"cpuUsage": "CPU Usage", "cpuUsage": "CPU Usage",
"memUsage": "MEM Usage", "memUsage": "MEM Usage",
@@ -1258,5 +1268,8 @@
"wanted": "Wanted", "wanted": "Wanted",
"queued": "Queued", "queued": "Queued",
"leagues": "Leagues" "leagues": "Leagues"
},
"feed": {
"noItems": "No items"
} }
} }
+13
View File
@@ -256,6 +256,16 @@
"leech": "Leech", "leech": "Leech",
"seed": "Seed" "seed": "Seed"
}, },
"qui": {
"leech": "Leech",
"download": "Download",
"seed": "Seed",
"upload": "Upload",
"total": "Total",
"errored": "Errored",
"ratio": "Ratio",
"freeSpace": "Free"
},
"qnap": { "qnap": {
"cpuUsage": "CPU Usage", "cpuUsage": "CPU Usage",
"memUsage": "MEM Usage", "memUsage": "MEM Usage",
@@ -1258,5 +1268,8 @@
"wanted": "Wanted", "wanted": "Wanted",
"queued": "Queued", "queued": "Queued",
"leagues": "Leagues" "leagues": "Leagues"
},
"feed": {
"noItems": "No items"
} }
} }
+13
View File
@@ -256,6 +256,16 @@
"leech": "Descargando", "leech": "Descargando",
"seed": "Semillas" "seed": "Semillas"
}, },
"qui": {
"leech": "Leech",
"download": "Download",
"seed": "Seed",
"upload": "Upload",
"total": "Total",
"errored": "Errored",
"ratio": "Ratio",
"freeSpace": "Free"
},
"qnap": { "qnap": {
"cpuUsage": "Uso de la CPU", "cpuUsage": "Uso de la CPU",
"memUsage": "Uso de la memoria", "memUsage": "Uso de la memoria",
@@ -1258,5 +1268,8 @@
"wanted": "Buscado", "wanted": "Buscado",
"queued": "En cola", "queued": "En cola",
"leagues": "Ligas" "leagues": "Ligas"
},
"feed": {
"noItems": "No items"
} }
} }
+13
View File
@@ -256,6 +256,16 @@
"leech": "Leech", "leech": "Leech",
"seed": "Seed" "seed": "Seed"
}, },
"qui": {
"leech": "Leech",
"download": "Download",
"seed": "Seed",
"upload": "Upload",
"total": "Total",
"errored": "Errored",
"ratio": "Ratio",
"freeSpace": "Free"
},
"qnap": { "qnap": {
"cpuUsage": "CPU Usage", "cpuUsage": "CPU Usage",
"memUsage": "MEM Usage", "memUsage": "MEM Usage",
@@ -1258,5 +1268,8 @@
"wanted": "Wanted", "wanted": "Wanted",
"queued": "Queued", "queued": "Queued",
"leagues": "Leagues" "leagues": "Leagues"
},
"feed": {
"noItems": "No items"
} }
} }
+13
View File
@@ -256,6 +256,16 @@
"leech": "Leech", "leech": "Leech",
"seed": "Seed" "seed": "Seed"
}, },
"qui": {
"leech": "Leech",
"download": "Download",
"seed": "Seed",
"upload": "Upload",
"total": "Total",
"errored": "Errored",
"ratio": "Ratio",
"freeSpace": "Free"
},
"qnap": { "qnap": {
"cpuUsage": "CPU Usage", "cpuUsage": "CPU Usage",
"memUsage": "MEM Usage", "memUsage": "MEM Usage",
@@ -1258,5 +1268,8 @@
"wanted": "Wanted", "wanted": "Wanted",
"queued": "Queued", "queued": "Queued",
"leagues": "Leagues" "leagues": "Leagues"
},
"feed": {
"noItems": "No items"
} }
} }
+13
View File
@@ -256,6 +256,16 @@
"leech": "En téléchargement", "leech": "En téléchargement",
"seed": "En partage" "seed": "En partage"
}, },
"qui": {
"leech": "Leech",
"download": "Download",
"seed": "Seed",
"upload": "Upload",
"total": "Total",
"errored": "Errored",
"ratio": "Ratio",
"freeSpace": "Free"
},
"qnap": { "qnap": {
"cpuUsage": "Utilisation CPU", "cpuUsage": "Utilisation CPU",
"memUsage": "RAM utilisée", "memUsage": "RAM utilisée",
@@ -1258,5 +1268,8 @@
"wanted": "Recherché", "wanted": "Recherché",
"queued": "En file d'attente", "queued": "En file d'attente",
"leagues": "Ligues" "leagues": "Ligues"
},
"feed": {
"noItems": "No items"
} }
} }
+13
View File
@@ -256,6 +256,16 @@
"leech": "עלוקה", "leech": "עלוקה",
"seed": "זורע" "seed": "זורע"
}, },
"qui": {
"leech": "Leech",
"download": "Download",
"seed": "Seed",
"upload": "Upload",
"total": "Total",
"errored": "Errored",
"ratio": "Ratio",
"freeSpace": "Free"
},
"qnap": { "qnap": {
"cpuUsage": "שימוש במעבד", "cpuUsage": "שימוש במעבד",
"memUsage": "שימוש בזיכרון", "memUsage": "שימוש בזיכרון",
@@ -1258,5 +1268,8 @@
"wanted": "Wanted", "wanted": "Wanted",
"queued": "Queued", "queued": "Queued",
"leagues": "Leagues" "leagues": "Leagues"
},
"feed": {
"noItems": "No items"
} }
} }
+13
View File
@@ -256,6 +256,16 @@
"leech": "Leech", "leech": "Leech",
"seed": "Seed" "seed": "Seed"
}, },
"qui": {
"leech": "Leech",
"download": "Download",
"seed": "Seed",
"upload": "Upload",
"total": "Total",
"errored": "Errored",
"ratio": "Ratio",
"freeSpace": "Free"
},
"qnap": { "qnap": {
"cpuUsage": "CPU Usage", "cpuUsage": "CPU Usage",
"memUsage": "MEM Usage", "memUsage": "MEM Usage",
@@ -1258,5 +1268,8 @@
"wanted": "Wanted", "wanted": "Wanted",
"queued": "Queued", "queued": "Queued",
"leagues": "Leagues" "leagues": "Leagues"
},
"feed": {
"noItems": "No items"
} }
} }
+13
View File
@@ -256,6 +256,16 @@
"leech": "Korištenje tuđeg sadržaja", "leech": "Korištenje tuđeg sadržaja",
"seed": "Prenošenje preuzetog sadržaja" "seed": "Prenošenje preuzetog sadržaja"
}, },
"qui": {
"leech": "Leech",
"download": "Download",
"seed": "Seed",
"upload": "Upload",
"total": "Total",
"errored": "Errored",
"ratio": "Ratio",
"freeSpace": "Free"
},
"qnap": { "qnap": {
"cpuUsage": "Korištenje procesora", "cpuUsage": "Korištenje procesora",
"memUsage": "Korištenje memorije", "memUsage": "Korištenje memorije",
@@ -1258,5 +1268,8 @@
"wanted": "Željeno", "wanted": "Željeno",
"queued": "U redu čekanja", "queued": "U redu čekanja",
"leagues": "Lige" "leagues": "Lige"
},
"feed": {
"noItems": "No items"
} }
} }
+13
View File
@@ -256,6 +256,16 @@
"leech": "Leech", "leech": "Leech",
"seed": "Seed" "seed": "Seed"
}, },
"qui": {
"leech": "Leech",
"download": "Download",
"seed": "Seed",
"upload": "Upload",
"total": "Total",
"errored": "Errored",
"ratio": "Ratio",
"freeSpace": "Free"
},
"qnap": { "qnap": {
"cpuUsage": "Processzor Használat", "cpuUsage": "Processzor Használat",
"memUsage": "Memória Használat", "memUsage": "Memória Használat",
@@ -1258,5 +1268,8 @@
"wanted": "Wanted", "wanted": "Wanted",
"queued": "Queued", "queued": "Queued",
"leagues": "Leagues" "leagues": "Leagues"
},
"feed": {
"noItems": "No items"
} }
} }
+13
View File
@@ -256,6 +256,16 @@
"leech": "Leech", "leech": "Leech",
"seed": "Seed" "seed": "Seed"
}, },
"qui": {
"leech": "Leech",
"download": "Download",
"seed": "Seed",
"upload": "Upload",
"total": "Total",
"errored": "Errored",
"ratio": "Ratio",
"freeSpace": "Free"
},
"qnap": { "qnap": {
"cpuUsage": "Penggunaan CPU", "cpuUsage": "Penggunaan CPU",
"memUsage": "Penggunaan MEM", "memUsage": "Penggunaan MEM",
@@ -1258,5 +1268,8 @@
"wanted": "Wanted", "wanted": "Wanted",
"queued": "Queued", "queued": "Queued",
"leagues": "Leagues" "leagues": "Leagues"
},
"feed": {
"noItems": "No items"
} }
} }
+13
View File
@@ -256,6 +256,16 @@
"leech": "In download", "leech": "In download",
"seed": "Seed" "seed": "Seed"
}, },
"qui": {
"leech": "Leech",
"download": "Download",
"seed": "Seed",
"upload": "Upload",
"total": "Total",
"errored": "Errored",
"ratio": "Ratio",
"freeSpace": "Free"
},
"qnap": { "qnap": {
"cpuUsage": "Utilizzo CPU", "cpuUsage": "Utilizzo CPU",
"memUsage": "Utilizzo MEM", "memUsage": "Utilizzo MEM",
@@ -1258,5 +1268,8 @@
"wanted": "Wanted", "wanted": "Wanted",
"queued": "Queued", "queued": "Queued",
"leagues": "Leagues" "leagues": "Leagues"
},
"feed": {
"noItems": "No items"
} }
} }
+13
View File
@@ -256,6 +256,16 @@
"leech": "Leech", "leech": "Leech",
"seed": "Seed" "seed": "Seed"
}, },
"qui": {
"leech": "Leech",
"download": "Download",
"seed": "Seed",
"upload": "Upload",
"total": "Total",
"errored": "Errored",
"ratio": "Ratio",
"freeSpace": "Free"
},
"qnap": { "qnap": {
"cpuUsage": "CPU使用量", "cpuUsage": "CPU使用量",
"memUsage": "MEM使用量", "memUsage": "MEM使用量",
@@ -1258,5 +1268,8 @@
"wanted": "募集中", "wanted": "募集中",
"queued": "処理待ち", "queued": "処理待ち",
"leagues": "リーグ" "leagues": "リーグ"
},
"feed": {
"noItems": "No items"
} }
} }
+13
View File
@@ -256,6 +256,16 @@
"leech": "리치", "leech": "리치",
"seed": "시드" "seed": "시드"
}, },
"qui": {
"leech": "Leech",
"download": "Download",
"seed": "Seed",
"upload": "Upload",
"total": "Total",
"errored": "Errored",
"ratio": "Ratio",
"freeSpace": "Free"
},
"qnap": { "qnap": {
"cpuUsage": "CPU 사용량", "cpuUsage": "CPU 사용량",
"memUsage": "메모리 사용량", "memUsage": "메모리 사용량",
@@ -1258,5 +1268,8 @@
"wanted": "Wanted", "wanted": "Wanted",
"queued": "Queued", "queued": "Queued",
"leagues": "Leagues" "leagues": "Leagues"
},
"feed": {
"noItems": "No items"
} }
} }
+13
View File
@@ -256,6 +256,16 @@
"leech": "Leech", "leech": "Leech",
"seed": "Seed" "seed": "Seed"
}, },
"qui": {
"leech": "Leech",
"download": "Download",
"seed": "Seed",
"upload": "Upload",
"total": "Total",
"errored": "Errored",
"ratio": "Ratio",
"freeSpace": "Free"
},
"qnap": { "qnap": {
"cpuUsage": "CPU Usage", "cpuUsage": "CPU Usage",
"memUsage": "MEM Usage", "memUsage": "MEM Usage",
@@ -1258,5 +1268,8 @@
"wanted": "Wanted", "wanted": "Wanted",
"queued": "Queued", "queued": "Queued",
"leagues": "Leagues" "leagues": "Leagues"
},
"feed": {
"noItems": "No items"
} }
} }
+13
View File
@@ -256,6 +256,16 @@
"leech": "Leech", "leech": "Leech",
"seed": "Seed" "seed": "Seed"
}, },
"qui": {
"leech": "Leech",
"download": "Download",
"seed": "Seed",
"upload": "Upload",
"total": "Total",
"errored": "Errored",
"ratio": "Ratio",
"freeSpace": "Free"
},
"qnap": { "qnap": {
"cpuUsage": "CPU Usage", "cpuUsage": "CPU Usage",
"memUsage": "MEM Usage", "memUsage": "MEM Usage",
@@ -1258,5 +1268,8 @@
"wanted": "Wanted", "wanted": "Wanted",
"queued": "Queued", "queued": "Queued",
"leagues": "Leagues" "leagues": "Leagues"
},
"feed": {
"noItems": "No items"
} }
} }
+13
View File
@@ -256,6 +256,16 @@
"leech": "Leech", "leech": "Leech",
"seed": "Delen" "seed": "Delen"
}, },
"qui": {
"leech": "Leech",
"download": "Download",
"seed": "Seed",
"upload": "Upload",
"total": "Total",
"errored": "Errored",
"ratio": "Ratio",
"freeSpace": "Free"
},
"qnap": { "qnap": {
"cpuUsage": "CPU Verbruik", "cpuUsage": "CPU Verbruik",
"memUsage": "MEM Gebruik", "memUsage": "MEM Gebruik",
@@ -1258,5 +1268,8 @@
"wanted": "Gewenst", "wanted": "Gewenst",
"queued": "In de wachtrij", "queued": "In de wachtrij",
"leagues": "Competities" "leagues": "Competities"
},
"feed": {
"noItems": "No items"
} }
} }
+13
View File
@@ -256,6 +256,16 @@
"leech": "Leech", "leech": "Leech",
"seed": "Seed" "seed": "Seed"
}, },
"qui": {
"leech": "Leech",
"download": "Download",
"seed": "Seed",
"upload": "Upload",
"total": "Total",
"errored": "Errored",
"ratio": "Ratio",
"freeSpace": "Free"
},
"qnap": { "qnap": {
"cpuUsage": "CPU Bruk", "cpuUsage": "CPU Bruk",
"memUsage": "Minnebruk", "memUsage": "Minnebruk",
@@ -1258,5 +1268,8 @@
"wanted": "Wanted", "wanted": "Wanted",
"queued": "Queued", "queued": "Queued",
"leagues": "Leagues" "leagues": "Leagues"
},
"feed": {
"noItems": "No items"
} }
} }
+13
View File
@@ -256,6 +256,16 @@
"leech": "Leech", "leech": "Leech",
"seed": "Seed" "seed": "Seed"
}, },
"qui": {
"leech": "Leech",
"download": "Download",
"seed": "Seed",
"upload": "Upload",
"total": "Total",
"errored": "Errored",
"ratio": "Ratio",
"freeSpace": "Free"
},
"qnap": { "qnap": {
"cpuUsage": "Użycie CPU", "cpuUsage": "Użycie CPU",
"memUsage": "Użycie pamięci", "memUsage": "Użycie pamięci",
@@ -1258,5 +1268,8 @@
"wanted": "Poszukiwane", "wanted": "Poszukiwane",
"queued": "W kolejce", "queued": "W kolejce",
"leagues": "Ligi" "leagues": "Ligi"
},
"feed": {
"noItems": "No items"
} }
} }
+13
View File
@@ -256,6 +256,16 @@
"leech": "Fuga", "leech": "Fuga",
"seed": "Semear" "seed": "Semear"
}, },
"qui": {
"leech": "Leech",
"download": "Download",
"seed": "Seed",
"upload": "Upload",
"total": "Total",
"errored": "Errored",
"ratio": "Ratio",
"freeSpace": "Free"
},
"qnap": { "qnap": {
"cpuUsage": "Utilização do CPU", "cpuUsage": "Utilização do CPU",
"memUsage": "Utilização de Memória", "memUsage": "Utilização de Memória",
@@ -1258,5 +1268,8 @@
"wanted": "Procurado", "wanted": "Procurado",
"queued": "Em fila de espera", "queued": "Em fila de espera",
"leagues": "Ligas" "leagues": "Ligas"
},
"feed": {
"noItems": "No items"
} }
} }
+13
View File
@@ -256,6 +256,16 @@
"leech": "Leech", "leech": "Leech",
"seed": "Seed" "seed": "Seed"
}, },
"qui": {
"leech": "Leech",
"download": "Download",
"seed": "Seed",
"upload": "Upload",
"total": "Total",
"errored": "Errored",
"ratio": "Ratio",
"freeSpace": "Free"
},
"qnap": { "qnap": {
"cpuUsage": "Uso de CPU", "cpuUsage": "Uso de CPU",
"memUsage": "Uso de Memória", "memUsage": "Uso de Memória",
@@ -1258,5 +1268,8 @@
"wanted": "Desejado", "wanted": "Desejado",
"queued": "Na fila", "queued": "Na fila",
"leagues": "Ligas" "leagues": "Ligas"
},
"feed": {
"noItems": "No items"
} }
} }
+13
View File
@@ -256,6 +256,16 @@
"leech": "Leech", "leech": "Leech",
"seed": "Seed" "seed": "Seed"
}, },
"qui": {
"leech": "Leech",
"download": "Download",
"seed": "Seed",
"upload": "Upload",
"total": "Total",
"errored": "Errored",
"ratio": "Ratio",
"freeSpace": "Free"
},
"qnap": { "qnap": {
"cpuUsage": "CPU Usage", "cpuUsage": "CPU Usage",
"memUsage": "MEM Usage", "memUsage": "MEM Usage",
@@ -1258,5 +1268,8 @@
"wanted": "Wanted", "wanted": "Wanted",
"queued": "Queued", "queued": "Queued",
"leagues": "Leagues" "leagues": "Leagues"
},
"feed": {
"noItems": "No items"
} }
} }
+13
View File
@@ -256,6 +256,16 @@
"leech": "Лич", "leech": "Лич",
"seed": "Сид" "seed": "Сид"
}, },
"qui": {
"leech": "Leech",
"download": "Download",
"seed": "Seed",
"upload": "Upload",
"total": "Total",
"errored": "Errored",
"ratio": "Ratio",
"freeSpace": "Free"
},
"qnap": { "qnap": {
"cpuUsage": "Использование ЦП", "cpuUsage": "Использование ЦП",
"memUsage": "Использование ОЗУ", "memUsage": "Использование ОЗУ",
@@ -1258,5 +1268,8 @@
"wanted": "Требуется", "wanted": "Требуется",
"queued": "В очереди", "queued": "В очереди",
"leagues": "Лиги" "leagues": "Лиги"
},
"feed": {
"noItems": "No items"
} }
} }
+13
View File
@@ -256,6 +256,16 @@
"leech": "Leech", "leech": "Leech",
"seed": "Seed" "seed": "Seed"
}, },
"qui": {
"leech": "Leech",
"download": "Download",
"seed": "Seed",
"upload": "Upload",
"total": "Total",
"errored": "Errored",
"ratio": "Ratio",
"freeSpace": "Free"
},
"qnap": { "qnap": {
"cpuUsage": "Využitie CPU", "cpuUsage": "Využitie CPU",
"memUsage": "Využitie pamäte", "memUsage": "Využitie pamäte",
@@ -1258,5 +1268,8 @@
"wanted": "Wanted", "wanted": "Wanted",
"queued": "V poradí", "queued": "V poradí",
"leagues": "Leagues" "leagues": "Leagues"
},
"feed": {
"noItems": "No items"
} }
} }
+13
View File
@@ -256,6 +256,16 @@
"leech": "Leech", "leech": "Leech",
"seed": "Seed" "seed": "Seed"
}, },
"qui": {
"leech": "Leech",
"download": "Download",
"seed": "Seed",
"upload": "Upload",
"total": "Total",
"errored": "Errored",
"ratio": "Ratio",
"freeSpace": "Free"
},
"qnap": { "qnap": {
"cpuUsage": "CPU", "cpuUsage": "CPU",
"memUsage": "MEM", "memUsage": "MEM",
@@ -1258,5 +1268,8 @@
"wanted": "Wanted", "wanted": "Wanted",
"queued": "Queued", "queued": "Queued",
"leagues": "Leagues" "leagues": "Leagues"
},
"feed": {
"noItems": "No items"
} }
} }
+13
View File
@@ -256,6 +256,16 @@
"leech": "Leech", "leech": "Leech",
"seed": "Seed" "seed": "Seed"
}, },
"qui": {
"leech": "Leech",
"download": "Download",
"seed": "Seed",
"upload": "Upload",
"total": "Total",
"errored": "Errored",
"ratio": "Ratio",
"freeSpace": "Free"
},
"qnap": { "qnap": {
"cpuUsage": "Искоришћеност процесора", "cpuUsage": "Искоришћеност процесора",
"memUsage": "Заузеће меморије", "memUsage": "Заузеће меморије",
@@ -1258,5 +1268,8 @@
"wanted": "Wanted", "wanted": "Wanted",
"queued": "Queued", "queued": "Queued",
"leagues": "Leagues" "leagues": "Leagues"
},
"feed": {
"noItems": "No items"
} }
} }
+13
View File
@@ -256,6 +256,16 @@
"leech": "Leech", "leech": "Leech",
"seed": "Seed" "seed": "Seed"
}, },
"qui": {
"leech": "Leech",
"download": "Download",
"seed": "Seed",
"upload": "Upload",
"total": "Total",
"errored": "Errored",
"ratio": "Ratio",
"freeSpace": "Free"
},
"qnap": { "qnap": {
"cpuUsage": "CPU Usage", "cpuUsage": "CPU Usage",
"memUsage": "MEM Usage", "memUsage": "MEM Usage",
@@ -1258,5 +1268,8 @@
"wanted": "Wanted", "wanted": "Wanted",
"queued": "Queued", "queued": "Queued",
"leagues": "Leagues" "leagues": "Leagues"
},
"feed": {
"noItems": "No items"
} }
} }
+13
View File
@@ -256,6 +256,16 @@
"leech": "Leech", "leech": "Leech",
"seed": "Seed" "seed": "Seed"
}, },
"qui": {
"leech": "Leech",
"download": "Download",
"seed": "Seed",
"upload": "Upload",
"total": "Total",
"errored": "Errored",
"ratio": "Ratio",
"freeSpace": "Free"
},
"qnap": { "qnap": {
"cpuUsage": "CPU Usage", "cpuUsage": "CPU Usage",
"memUsage": "MEM Usage", "memUsage": "MEM Usage",
@@ -1258,5 +1268,8 @@
"wanted": "Wanted", "wanted": "Wanted",
"queued": "Queued", "queued": "Queued",
"leagues": "Leagues" "leagues": "Leagues"
},
"feed": {
"noItems": "No items"
} }
} }
+13
View File
@@ -256,6 +256,16 @@
"leech": "Leech", "leech": "Leech",
"seed": "Seed" "seed": "Seed"
}, },
"qui": {
"leech": "Leech",
"download": "Download",
"seed": "Seed",
"upload": "Upload",
"total": "Total",
"errored": "Errored",
"ratio": "Ratio",
"freeSpace": "Free"
},
"qnap": { "qnap": {
"cpuUsage": "CPU Usage", "cpuUsage": "CPU Usage",
"memUsage": "MEM Usage", "memUsage": "MEM Usage",
@@ -1258,5 +1268,8 @@
"wanted": "Wanted", "wanted": "Wanted",
"queued": "Queued", "queued": "Queued",
"leagues": "Leagues" "leagues": "Leagues"
},
"feed": {
"noItems": "No items"
} }
} }
+13
View File
@@ -256,6 +256,16 @@
"leech": "İndirilen", "leech": "İndirilen",
"seed": "Gönderilen" "seed": "Gönderilen"
}, },
"qui": {
"leech": "Leech",
"download": "Download",
"seed": "Seed",
"upload": "Upload",
"total": "Total",
"errored": "Errored",
"ratio": "Ratio",
"freeSpace": "Free"
},
"qnap": { "qnap": {
"cpuUsage": "İşlemci Kullanımı", "cpuUsage": "İşlemci Kullanımı",
"memUsage": "Bellek Kullanımı", "memUsage": "Bellek Kullanımı",
@@ -1258,5 +1268,8 @@
"wanted": "Wanted", "wanted": "Wanted",
"queued": "Queued", "queued": "Queued",
"leagues": "Leagues" "leagues": "Leagues"
},
"feed": {
"noItems": "No items"
} }
} }
+13
View File
@@ -256,6 +256,16 @@
"leech": "Ліч", "leech": "Ліч",
"seed": "Сід" "seed": "Сід"
}, },
"qui": {
"leech": "Leech",
"download": "Download",
"seed": "Seed",
"upload": "Upload",
"total": "Total",
"errored": "Errored",
"ratio": "Ratio",
"freeSpace": "Free"
},
"qnap": { "qnap": {
"cpuUsage": "Використання CPU", "cpuUsage": "Використання CPU",
"memUsage": "Використання пам'яті", "memUsage": "Використання пам'яті",
@@ -1258,5 +1268,8 @@
"wanted": "Wanted", "wanted": "Wanted",
"queued": "Queued", "queued": "Queued",
"leagues": "Leagues" "leagues": "Leagues"
},
"feed": {
"noItems": "No items"
} }
} }
+13
View File
@@ -256,6 +256,16 @@
"leech": "Leecher", "leech": "Leecher",
"seed": "Seeder" "seed": "Seeder"
}, },
"qui": {
"leech": "Leech",
"download": "Download",
"seed": "Seed",
"upload": "Upload",
"total": "Total",
"errored": "Errored",
"ratio": "Ratio",
"freeSpace": "Free"
},
"qnap": { "qnap": {
"cpuUsage": "Mức sử dụng CPU", "cpuUsage": "Mức sử dụng CPU",
"memUsage": "Mức sử dụng bộ nhớ", "memUsage": "Mức sử dụng bộ nhớ",
@@ -1258,5 +1268,8 @@
"wanted": "Cần tải", "wanted": "Cần tải",
"queued": "Queued", "queued": "Queued",
"leagues": "Leagues" "leagues": "Leagues"
},
"feed": {
"noItems": "No items"
} }
} }
+13
View File
@@ -256,6 +256,16 @@
"leech": "未完成下載", "leech": "未完成下載",
"seed": "已完成下載" "seed": "已完成下載"
}, },
"qui": {
"leech": "Leech",
"download": "Download",
"seed": "Seed",
"upload": "Upload",
"total": "Total",
"errored": "Errored",
"ratio": "Ratio",
"freeSpace": "Free"
},
"qnap": { "qnap": {
"cpuUsage": "CPU 使用率", "cpuUsage": "CPU 使用率",
"memUsage": "記憶體使用率", "memUsage": "記憶體使用率",
@@ -1258,5 +1268,8 @@
"wanted": "Wanted", "wanted": "Wanted",
"queued": "Queued", "queued": "Queued",
"leagues": "Leagues" "leagues": "Leagues"
},
"feed": {
"noItems": "No items"
} }
} }
+13
View File
@@ -256,6 +256,16 @@
"leech": "下载中", "leech": "下载中",
"seed": "做种" "seed": "做种"
}, },
"qui": {
"leech": "Leech",
"download": "Download",
"seed": "Seed",
"upload": "Upload",
"total": "Total",
"errored": "Errored",
"ratio": "Ratio",
"freeSpace": "Free"
},
"qnap": { "qnap": {
"cpuUsage": "处理器", "cpuUsage": "处理器",
"memUsage": "内存使用", "memUsage": "内存使用",
@@ -1258,5 +1268,8 @@
"wanted": "Wanted", "wanted": "Wanted",
"queued": "Queued", "queued": "Queued",
"leagues": "Leagues" "leagues": "Leagues"
},
"feed": {
"noItems": "No items"
} }
} }
+13
View File
@@ -256,6 +256,16 @@
"leech": "未完成下載", "leech": "未完成下載",
"seed": "已完成下載" "seed": "已完成下載"
}, },
"qui": {
"leech": "Leech",
"download": "Download",
"seed": "Seed",
"upload": "Upload",
"total": "Total",
"errored": "Errored",
"ratio": "Ratio",
"freeSpace": "Free"
},
"qnap": { "qnap": {
"cpuUsage": "CPU 使用率", "cpuUsage": "CPU 使用率",
"memUsage": "記憶體使用率", "memUsage": "記憶體使用率",
@@ -1258,5 +1268,8 @@
"wanted": "Wanted", "wanted": "Wanted",
"queued": "Queued", "queued": "Queued",
"leagues": "Leagues" "leagues": "Leagues"
},
"feed": {
"noItems": "No items"
} }
} }
+12
View File
@@ -353,6 +353,9 @@ export function cleanServiceGroups(groups) {
expandOneStreamToTwoRows, expandOneStreamToTwoRows,
showEpisodeNumber, showEpisodeNumber,
// feed
layout,
// frigate // frigate
enableRecentEvents, enableRecentEvents,
@@ -430,6 +433,9 @@ export function cleanServiceGroups(groups) {
// proxmoxbackupserver // proxmoxbackupserver
datastore, datastore,
// qui
instance,
// speedtest // speedtest
bitratePrecision, bitratePrecision,
@@ -540,6 +546,9 @@ export function cleanServiceGroups(groups) {
if (type === "portainer") { if (type === "portainer") {
if (kubernetes) widget.kubernetes = !!JSON.parse(kubernetes); if (kubernetes) widget.kubernetes = !!JSON.parse(kubernetes);
} }
if (type === "qui") {
if (instance !== undefined) widget.instance = instance;
}
if (type === "proxmox") { if (type === "proxmox") {
if (node) widget.node = node; if (node) widget.node = node;
} }
@@ -675,6 +684,9 @@ export function cleanServiceGroups(groups) {
if (showTime) widget.showTime = showTime; if (showTime) widget.showTime = showTime;
if (timezone) widget.timezone = timezone; if (timezone) widget.timezone = timezone;
} }
if (type === "feed") {
if (layout) widget.layout = layout;
}
if (type === "dockhand") { if (type === "dockhand") {
if (environment) widget.environment = environment; if (environment) widget.environment = environment;
} }
+34
View File
@@ -403,6 +403,7 @@ describe("utils/config/service-helpers", () => {
{ type: "radarr", enableQueue: "true" }, { type: "radarr", enableQueue: "true" },
{ type: "truenas", enablePools: "true", nasType: "scale" }, { type: "truenas", enablePools: "true", nasType: "scale" },
{ type: "qnap", volume: "vol1" }, { type: "qnap", volume: "vol1" },
{ type: "qui", instance: 2 },
{ type: "dispatcharr", enableActiveStreams: "true" }, { type: "dispatcharr", enableActiveStreams: "true" },
{ type: "gamedig", gameToken: "t" }, { type: "gamedig", gameToken: "t" },
{ type: "kopia", snapshotHost: "h", snapshotPath: "/p" }, { type: "kopia", snapshotHost: "h", snapshotPath: "/p" },
@@ -468,6 +469,7 @@ describe("utils/config/service-helpers", () => {
expect.objectContaining({ namespace: "default", app: "app", podSelector: "app=test" }), expect.objectContaining({ namespace: "default", app: "app", podSelector: "app=test" }),
); );
expect(widgets.find((w) => w.type === "qnap")).toEqual(expect.objectContaining({ volume: "vol1" })); expect(widgets.find((w) => w.type === "qnap")).toEqual(expect.objectContaining({ volume: "vol1" }));
expect(widgets.find((w) => w.type === "qui")).toEqual(expect.objectContaining({ instance: 2 }));
expect(widgets.find((w) => w.type === "speedtest")).toEqual( expect(widgets.find((w) => w.type === "speedtest")).toEqual(
expect.objectContaining({ bitratePrecision: 3, version: 1 }), expect.objectContaining({ bitratePrecision: 3, version: 1 }),
); );
@@ -524,6 +526,38 @@ describe("utils/config/service-helpers", () => {
]); ]);
}); });
it("cleanServiceGroups keeps feed layout and drops server-side options", async () => {
const mod = await import("./service-helpers");
const { cleanServiceGroups } = mod;
const rawGroups = [
{
name: "Core",
services: [
{
name: "News",
widgets: [
{
type: "feed",
url: "https://example.com/feed.xml?token=secret",
maxItems: 3,
images: false,
layout: "grid",
},
],
},
],
groups: [],
},
];
const feedWidget = cleanServiceGroups(rawGroups)[0].services[0].widgets[0];
expect(feedWidget).toEqual(expect.objectContaining({ type: "feed", layout: "grid" }));
expect(feedWidget).not.toHaveProperty("url");
expect(feedWidget).not.toHaveProperty("maxItems");
expect(feedWidget).not.toHaveProperty("images");
});
it("findGroupByName deep-searches and annotates parent", async () => { it("findGroupByName deep-searches and annotates parent", async () => {
const mod = await import("./service-helpers"); const mod = await import("./service-helpers");
const { findGroupByName } = mod; const { findGroupByName } = mod;
+2
View File
@@ -37,6 +37,7 @@ const components = {
emby: dynamic(() => import("./emby/component")), emby: dynamic(() => import("./emby/component")),
esphome: dynamic(() => import("./esphome/component")), esphome: dynamic(() => import("./esphome/component")),
evcc: dynamic(() => import("./evcc/component")), evcc: dynamic(() => import("./evcc/component")),
feed: dynamic(() => import("./feed/component")),
filebrowser: dynamic(() => import("./filebrowser/component")), filebrowser: dynamic(() => import("./filebrowser/component")),
fileflows: dynamic(() => import("./fileflows/component")), fileflows: dynamic(() => import("./fileflows/component")),
firefly: dynamic(() => import("./firefly/component")), firefly: dynamic(() => import("./firefly/component")),
@@ -122,6 +123,7 @@ const components = {
pyload: dynamic(() => import("./pyload/component")), pyload: dynamic(() => import("./pyload/component")),
qbittorrent: dynamic(() => import("./qbittorrent/component")), qbittorrent: dynamic(() => import("./qbittorrent/component")),
qnap: dynamic(() => import("./qnap/component")), qnap: dynamic(() => import("./qnap/component")),
qui: dynamic(() => import("./qui/component")),
radarr: dynamic(() => import("./radarr/component")), radarr: dynamic(() => import("./radarr/component")),
readarr: dynamic(() => import("./readarr/component")), readarr: dynamic(() => import("./readarr/component")),
romm: dynamic(() => import("./romm/component")), romm: dynamic(() => import("./romm/component")),
+125
View File
@@ -0,0 +1,125 @@
/* eslint-disable @next/next/no-img-element */
import classNames from "classnames";
import { useTranslation } from "next-i18next/pages";
import { useContext } from "react";
import Container from "components/services/widget/container";
import { SettingsContext } from "utils/contexts/settings";
import useWidgetAPI from "utils/proxy/use-widget-api";
const rowClassName = "rounded-md bg-theme-200/50 dark:bg-theme-900/20 text-theme-700 dark:text-theme-200 text-xs";
const hideBrokenImage = (e) => {
e.currentTarget.style.visibility = "hidden";
};
function FeedLink({ item, target, className, children }) {
if (!item.link) return <div className={className}>{children}</div>;
return (
<a
href={item.link}
target={target}
rel="noopener noreferrer"
className={classNames(className, "hover:bg-theme-300/50 dark:hover:bg-theme-800/20")}
>
{children}
</a>
);
}
function RelativeDate({ date }) {
const { t } = useTranslation();
if (!date) return null;
return (
<span className="shrink-0 text-theme-500 dark:text-theme-400">
{t("common.relativeDate", { value: date, formatParams: { value: { style: "narrow", numeric: "auto" } } })}
</span>
);
}
function ListItem({ item, target }) {
return (
<FeedLink
item={item}
target={target}
className={classNames(rowClassName, "flex items-center gap-2 px-2", item.image ? "py-1" : "h-5")}
>
{item.image && (
<img
src={item.image}
alt=""
loading="lazy"
decoding="async"
referrerPolicy="no-referrer"
onError={hideBrokenImage}
className="w-12 h-8 shrink-0 rounded-sm object-cover"
/>
)}
<span className="grow truncate text-left">{item.title}</span>
<RelativeDate date={item.date} />
</FeedLink>
);
}
function GridItem({ item, target }) {
return (
<FeedLink item={item} target={target} className={classNames(rowClassName, "flex flex-col overflow-hidden")}>
<div className="relative aspect-video w-full overflow-hidden bg-theme-200/50 dark:bg-theme-900/30">
{item.image && (
<img
src={item.image}
alt=""
loading="lazy"
decoding="async"
referrerPolicy="no-referrer"
onError={hideBrokenImage}
className="absolute inset-0 w-full h-full object-cover"
/>
)}
</div>
<div className="flex flex-col gap-0.5 px-2 py-1.5 text-left">
<span className="line-clamp-2">{item.title}</span>
<RelativeDate date={item.date} />
</div>
</FeedLink>
);
}
export default function Component({ service }) {
const { t } = useTranslation();
const { settings } = useContext(SettingsContext);
const { widget } = service;
const { data, error } = useWidgetAPI(widget, undefined, undefined, { refreshInterval: 10 * 60 * 1000 });
if (error) {
return <Container service={service} error={error} />;
}
const target = service.target ?? settings?.target ?? "_blank";
const isGrid = widget.layout === "grid";
const Item = isGrid ? GridItem : ListItem;
let content;
if (!data) {
content = [0, 1, 2].map((i) => (
<div key={i} className={classNames(rowClassName, "animate-pulse", isGrid ? "aspect-video" : "h-5")} />
));
} else if (!data.items?.length) {
content = <div className={classNames(rowClassName, "h-5 px-2 flex items-center")}>{t("feed.noItems")}</div>;
} else {
content = data.items.map((item, i) => <Item key={`${item.link ?? item.title}-${i}`} item={item} target={target} />);
}
return (
<Container service={service}>
<div
className={classNames(
"w-full p-1",
isGrid ? "grid grid-cols-[repeat(auto-fill,minmax(8rem,1fr))] gap-2" : "flex flex-col gap-1",
)}
>
{content}
</div>
</Container>
);
}
+80
View File
@@ -0,0 +1,80 @@
// @vitest-environment jsdom
import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
const items = [
{
title: "With image",
link: "https://example.com/1",
date: "2020-01-01T00:00:00Z",
image: "https://example.com/1.jpg",
},
{ title: "No link", link: null, date: null },
];
function render(widget = {}, settings = {}) {
return renderWithProviders(<Component service={{ widget: { type: "feed", ...widget } }} />, {
settings: { hideErrors: false, ...settings },
});
}
describe("widgets/feed/component", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders placeholders while loading", () => {
useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
const { container } = render();
expect(container.querySelectorAll(".animate-pulse")).toHaveLength(3);
});
it("renders an error", () => {
useWidgetAPI.mockReturnValue({ data: undefined, error: { message: "Invalid feed" } });
render();
expect(screen.getAllByText(/Invalid feed/).length).toBeGreaterThan(0);
});
it("renders an empty feed", () => {
useWidgetAPI.mockReturnValue({ data: { items: [] }, error: undefined });
render();
expect(screen.getByText("feed.noItems")).toBeInTheDocument();
});
it("renders list items with links and images", () => {
useWidgetAPI.mockReturnValue({ data: { items }, error: undefined });
const { container } = render({}, { target: "_self" });
const link = screen.getByText("With image").closest("a");
expect(link).toHaveAttribute("href", "https://example.com/1");
expect(link).toHaveAttribute("target", "_self");
expect(link.querySelector("img")).toHaveAttribute("src", "https://example.com/1.jpg");
expect(screen.getByText("No link").closest("a")).toBeNull();
expect(container.querySelectorAll("img")).toHaveLength(1);
});
it("renders the grid layout with image placeholders", () => {
useWidgetAPI.mockReturnValue({ data: { items }, error: undefined });
const { container } = render({ layout: "grid" });
expect(container.querySelector(".grid")).not.toBeNull();
expect(container.querySelectorAll(".aspect-video")).toHaveLength(2);
expect(container.querySelectorAll("img")).toHaveLength(1);
});
it("hides images that fail to load", () => {
useWidgetAPI.mockReturnValue({ data: { items }, error: undefined });
const { container } = render();
const img = container.querySelector("img");
img.dispatchEvent(new Event("error"));
expect(img.style.visibility).toBe("hidden");
});
});
+61
View File
@@ -0,0 +1,61 @@
import cache from "memory-cache";
import { httpUrl, parseFeed } from "./utils";
import getServiceWidget from "utils/config/service-helpers";
import createLogger from "utils/logger";
import { sanitizeErrorURL } from "utils/proxy/api-helpers";
import { httpProxy } from "utils/proxy/http";
const logger = createLogger("feedProxyHandler");
const CACHE_MS = 10 * 60 * 1000;
export default async function feedProxyHandler(req, res) {
const { group, service, index } = req.query;
const widget = await getServiceWidget(group, service, index);
if (!widget?.url) {
return res.status(400).json({ error: "Missing feed URL" });
}
const href = httpUrl(widget.url);
if (!href) {
return res.status(400).json({ error: "Invalid feed URL" });
}
const url = new URL(href);
const cacheKey = `feed:${url.href}`;
let items = cache.get(cacheKey);
if (!items) {
const [status, , data] = await httpProxy(url, {
headers: {
"User-Agent": `gethomepage/${process.env.NEXT_PUBLIC_VERSION || "dev"}`,
Accept: "application/rss+xml, application/atom+xml, application/xml;q=0.9, text/xml;q=0.9, */*;q=0.8",
},
});
if (status !== 200) {
logger.debug("HTTP %d retrieving feed %s//%s%s", status, url.protocol, url.host, url.pathname);
return res.status(status).json({ error: { message: "HTTP Error", url: sanitizeErrorURL(url) } });
}
try {
items = parseFeed(Buffer.from(data).toString());
} catch (e) {
logger.debug("Error parsing feed %s//%s%s: %s", url.protocol, url.host, url.pathname, e.message);
return res.status(500).json({ error: { message: "Invalid feed", url: sanitizeErrorURL(url) } });
}
// only successful fetches are cached
cache.put(cacheKey, items, CACHE_MS);
}
const limit = parseInt(widget.maxItems, 10);
const maxItems = limit > 0 ? limit : 5;
const showImages = widget.images !== false && widget.images !== "false";
return res.status(200).json({
items: items.slice(0, maxItems).map(({ image, ...item }) => (showImages && image ? { ...item, image } : item)),
});
}
+183
View File
@@ -0,0 +1,183 @@
import cache from "memory-cache";
import { beforeEach, describe, expect, it, vi } from "vitest";
import createMockRes from "test-utils/create-mock-res";
const { httpProxy, getServiceWidget, logger } = vi.hoisted(() => ({
httpProxy: vi.fn(),
getServiceWidget: vi.fn(),
logger: {
debug: vi.fn(),
},
}));
vi.mock("utils/logger", () => ({
default: () => logger,
}));
vi.mock("utils/config/service-helpers", () => ({
default: getServiceWidget,
}));
vi.mock("utils/proxy/http", () => ({
httpProxy,
}));
import feedProxyHandler from "./proxy";
const items = Array.from(
{ length: 7 },
(_, i) => `<item><title>Item ${i}</title><link>https://example.com/${i}</link>
<media:thumbnail url="https://example.com/${i}.jpg" /></item>`,
).join("");
const feed = `<rss xmlns:media="http://search.yahoo.com/mrss/"><channel>${items}</channel></rss>`;
const req = { query: { group: "g", service: "svc", index: "0" } };
describe("widgets/feed/proxy", () => {
beforeEach(() => {
vi.clearAllMocks();
cache.clear();
});
it("returns 400 when the url is missing or invalid", async () => {
getServiceWidget.mockResolvedValueOnce({ type: "feed" });
let res = createMockRes();
await feedProxyHandler(req, res);
expect(res.statusCode).toBe(400);
expect(res.body).toEqual({ error: "Missing feed URL" });
getServiceWidget.mockResolvedValueOnce({ type: "feed", url: "nope" });
res = createMockRes();
await feedProxyHandler(req, res);
expect(res.statusCode).toBe(400);
expect(res.body).toEqual({ error: "Invalid feed URL" });
getServiceWidget.mockResolvedValueOnce({ type: "feed", url: "file:///etc/passwd" });
res = createMockRes();
await feedProxyHandler(req, res);
expect(res.statusCode).toBe(400);
expect(res.body).toEqual({ error: "Invalid feed URL" });
expect(httpProxy).not.toHaveBeenCalled();
});
it("returns the first 5 items by default", async () => {
getServiceWidget.mockResolvedValue({ type: "feed", url: "https://example.com/feed.xml" });
httpProxy.mockResolvedValueOnce([200, "application/rss+xml", Buffer.from(feed)]);
const res = createMockRes();
await feedProxyHandler(req, res);
expect(httpProxy.mock.calls[0][0].href).toBe("https://example.com/feed.xml");
expect(res.statusCode).toBe(200);
expect(res.body.items).toHaveLength(5);
expect(res.body.items[0]).toEqual({
title: "Item 0",
link: "https://example.com/0",
date: null,
image: "https://example.com/0.jpg",
});
});
it.each([-1, 0, "abc"])("falls back to 5 items for maxItems %j", async (maxItems) => {
getServiceWidget.mockResolvedValue({ type: "feed", url: "https://example.com/feed.xml", maxItems });
httpProxy.mockResolvedValueOnce([200, "application/rss+xml", Buffer.from(feed)]);
const res = createMockRes();
await feedProxyHandler(req, res);
expect(res.body.items).toHaveLength(5);
});
it("respects maxItems and drops images when disabled", async () => {
getServiceWidget.mockResolvedValue({
type: "feed",
url: "https://example.com/feed.xml",
maxItems: "2",
images: false,
});
httpProxy.mockResolvedValueOnce([200, "application/rss+xml", Buffer.from(feed)]);
const res = createMockRes();
await feedProxyHandler(req, res);
expect(res.body.items).toEqual([
{ title: "Item 0", link: "https://example.com/0", date: null },
{ title: "Item 1", link: "https://example.com/1", date: null },
]);
});
it("passes through http errors without leaking the url", async () => {
getServiceWidget.mockResolvedValue({ type: "feed", url: "https://example.com/feed.xml?token=secret" });
httpProxy.mockResolvedValueOnce([404, "text/html", Buffer.from("Not found")]);
const res = createMockRes();
await feedProxyHandler(req, res);
expect(res.statusCode).toBe(404);
expect(res.body).toEqual({ error: { message: "HTTP Error", url: "example.com (see logs for details)" } });
});
it("never includes any part of the configured url in the response", async () => {
getServiceWidget.mockResolvedValue({
type: "feed",
url: "https://user:hunter2@nas.lan/feeds/s3cr3t/rss.xml?token=abc123",
});
const relative = `<rss><channel>
<item><title>Path</title><link>item/1</link><enclosure url="img.jpg" type="image/jpeg" /></item>
<item><title>Root</title><link>/x</link></item>
<item><title>Fragment</title><link>#top</link></item>
</channel></rss>`;
httpProxy.mockResolvedValueOnce([200, "application/rss+xml", Buffer.from(relative)]);
const res = createMockRes();
await feedProxyHandler(req, res);
expect(res.body.items.map((item) => item.link)).toEqual([null, null, null]);
expect(JSON.stringify(res.body)).not.toMatch(/user|hunter2|nas\.lan|s3cr3t|token|abc123/);
});
it("returns 500 for unparseable feeds", async () => {
getServiceWidget.mockResolvedValue({ type: "feed", url: "https://example.com/feed.xml" });
httpProxy.mockResolvedValueOnce([200, "text/html", Buffer.from("<html></html>")]);
const res = createMockRes();
await feedProxyHandler(req, res);
expect(res.statusCode).toBe(500);
expect(res.body).toEqual({ error: { message: "Invalid feed", url: "example.com (see logs for details)" } });
});
it("shares one cached fetch across widgets with different options", async () => {
getServiceWidget
.mockResolvedValueOnce({ type: "feed", url: "https://example.com/feed.xml", maxItems: 1 })
.mockResolvedValueOnce({ type: "feed", url: "https://example.com/feed.xml", images: false });
httpProxy.mockResolvedValue([200, "application/rss+xml", Buffer.from(feed)]);
const first = createMockRes();
await feedProxyHandler(req, first);
const second = createMockRes();
await feedProxyHandler(req, second);
expect(httpProxy).toHaveBeenCalledTimes(1);
expect(first.body.items).toHaveLength(1);
expect(second.body.items).toHaveLength(5);
expect(second.body.items[0].image).toBeUndefined();
});
it("does not cache failures", async () => {
getServiceWidget.mockResolvedValue({ type: "feed", url: "https://example.com/feed.xml" });
httpProxy
.mockResolvedValueOnce([404, "text/html", Buffer.from("Not found")])
.mockResolvedValueOnce([200, "application/rss+xml", Buffer.from(feed)]);
const failed = createMockRes();
await feedProxyHandler(req, failed);
const recovered = createMockRes();
await feedProxyHandler(req, recovered);
expect(failed.statusCode).toBe(404);
expect(recovered.statusCode).toBe(200);
expect(httpProxy).toHaveBeenCalledTimes(2);
});
});
+114
View File
@@ -0,0 +1,114 @@
import sax from "sax";
import { xml2js } from "xml-js";
const asArray = (value) => (value === undefined || value === null ? [] : [].concat(value));
function getText(node) {
const first = asArray(node)[0];
if (first === undefined) return "";
if (typeof first !== "object") return String(first);
return asArray(first._cdata ?? first._text).join("");
}
export function httpUrl(value, baseUrl) {
const trimmed = value?.trim();
if (!trimmed) return null;
try {
const url = new URL(trimmed, baseUrl);
return ["http:", "https:"].includes(url.protocol) ? url.href : null;
} catch {
return null;
}
}
function parseDate(value) {
const time = Date.parse(value);
return Number.isNaN(time) ? null : new Date(time).toISOString();
}
const attrs = (node) => node?._attributes ?? {};
const isImage = (node) => attrs(node).medium === "image" || attrs(node).type?.startsWith("image/");
function findImage(item) {
const groups = [item, ...asArray(item["media:group"])];
const candidates = [
...groups.flatMap((group) => asArray(group["media:thumbnail"])),
...groups.flatMap((group) => asArray(group["media:content"])).filter(isImage),
...asArray(item.enclosure).filter(isImage),
...asArray(item.link).filter((link) => attrs(link).rel === "enclosure" && isImage(link)),
];
const image = candidates.find((node) => attrs(node).url || attrs(node).href);
return image ? attrs(image).url || attrs(image).href : null;
}
const isPixel = ({ width, height }) => ["0", "1"].includes(width) || ["0", "1"].includes(height);
// first usable <img> in item html using sax tokenizer
function findHtmlImage(htmls, baseUrl) {
for (const html of htmls) {
if (!html) continue;
let image = null;
const parser = sax.parser(false, { lowercase: true });
parser.onopentag = ({ name, attributes }) => {
if (!image && name === "img" && !isPixel(attributes)) image = httpUrl(attributes.src, baseUrl);
};
parser.onerror = () => {
parser.error = null;
parser.resume();
};
parser.write(html).close();
if (image) return image;
}
return null;
}
function parseRssItem(item) {
const guid = asArray(item.guid)[0];
const guidLink = attrs(guid).isPermaLink !== "false" ? getText(guid) : null;
return {
title: getText(item.title),
link: getText(item.link) || guidLink,
date: getText(item.pubDate) || getText(item["dc:date"]),
image: findImage(item),
html: [getText(item["content:encoded"]), getText(item.description)],
};
}
const alternateLink = (node) => asArray(node.link).find((l) => (attrs(l).rel ?? "alternate") === "alternate");
function parseAtomEntry(entry) {
const link = alternateLink(entry) ?? asArray(entry.link)[0];
return {
title: getText(entry.title),
link: attrs(link).href,
date: getText(entry.published) || getText(entry.updated),
image: findImage(entry),
html: [getText(entry.content), getText(entry.summary)],
};
}
export function parseFeed(xml) {
const doc = xml2js(xml, { compact: true });
let items;
let site;
if (doc.rss) {
items = asArray(doc.rss.channel?.item).map(parseRssItem);
site = getText(doc.rss.channel?.link);
} else if (doc.feed) {
items = asArray(doc.feed.entry).map(parseAtomEntry);
site = attrs(alternateLink(doc.feed)).href;
} else throw new Error("Unsupported feed format");
// resolve against the feed's own site link, never the configured url
const baseUrl = httpUrl(site) ?? undefined;
return items
.map((item) => ({
title: item.title.trim(),
link: httpUrl(item.link, baseUrl),
date: parseDate(item.date),
image: httpUrl(item.image, baseUrl) ?? findHtmlImage(item.html, baseUrl),
}))
.filter((item) => item.title);
}
+169
View File
@@ -0,0 +1,169 @@
import { describe, expect, it } from "vitest";
import { httpUrl, parseFeed } from "./utils";
const rss = `<?xml version="1.0"?>
<rss version="2.0" xmlns:media="http://search.yahoo.com/mrss/" xmlns:dc="http://purl.org/dc/elements/1.1/">
<channel>
<title>Example</title>
<link>https://example.com/</link>
<item>
<title><![CDATA[Tom & Jerry]]></title>
<link>https://example.com/one</link>
<pubDate>Tue, 22 Sep 2026 10:00:00 GMT</pubDate>
<media:thumbnail url="https://img.example.com/one.jpg" />
</item>
<item>
<title>Enclosure image</title>
<guid>https://example.com/two</guid>
<dc:date>2026-09-21T10:00:00Z</dc:date>
<enclosure url="/two.png" type="image/png" length="1" />
</item>
<item>
<title>Audio only</title>
<link>javascript:alert(1)</link>
<guid isPermaLink="false">abc-123</guid>
<enclosure url="https://example.com/three.mp3" type="audio/mpeg" length="1" />
</item>
<item>
<description>No title, skipped</description>
</item>
</channel>
</rss>`;
const atom = `<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/">
<title>Example</title>
<entry>
<title> Fish &amp; chips
</title>
<link rel="enclosure" type="image/jpeg" href="https://example.com/one.jpg" />
<link rel="alternate" href="https://example.com/one" />
<updated>2026-09-22T12:00:00Z</updated>
</entry>
<entry>
<title>YouTube style</title>
<link href="https://example.com/two" />
<published>2026-09-20T12:00:00Z</published>
<updated>not a date</updated>
<media:group>
<media:content url="https://example.com/two.mp4" type="video/mp4" />
<media:thumbnail url="https://example.com/two.jpg" />
</media:group>
</entry>
</feed>`;
describe("widgets/feed/utils", () => {
it("parses rss 2.0 items", () => {
expect(parseFeed(rss)).toEqual([
{
title: "Tom & Jerry",
link: "https://example.com/one",
date: "2026-09-22T10:00:00.000Z",
image: "https://img.example.com/one.jpg",
},
{
title: "Enclosure image",
link: "https://example.com/two",
date: "2026-09-21T10:00:00.000Z",
image: "https://example.com/two.png",
},
{ title: "Audio only", link: null, date: null, image: null },
]);
});
it("parses atom entries", () => {
expect(parseFeed(atom)).toEqual([
{
title: "Fish & chips",
link: "https://example.com/one",
date: "2026-09-22T12:00:00.000Z",
image: "https://example.com/one.jpg",
},
{
title: "YouTube style",
link: "https://example.com/two",
date: "2026-09-20T12:00:00.000Z",
image: "https://example.com/two.jpg",
},
]);
});
it("falls back to the first usable image in item html", () => {
const xml = `<rss xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:media="http://search.yahoo.com/mrss/"><channel>
<link>https://example.com/</link>
<item>
<title>Content image</title>
<content:encoded><![CDATA[<p>Hi <img src="https://t.example.com/p.gif" width="1" height="1">
<img src="data:image/gif;base64,R0"><IMG SRC="/big.jpg?w=925&#038;h=925"><img src="/second.jpg">]]></content:encoded>
</item>
<item>
<title>Description image</title>
<description>&lt;img src="https://example.com/desc.png"&gt; unclosed &lt;b&gt;</description>
</item>
<item>
<title>Media wins</title>
<media:thumbnail url="https://example.com/thumb.jpg" />
<description><![CDATA[<img src="https://example.com/html.jpg">]]></description>
</item>
<item>
<title>No usable image</title>
<description><![CDATA[<img src="javascript:alert(1)"><img>]]></description>
</item>
</channel></rss>`;
expect(parseFeed(xml).map((item) => item.image)).toEqual([
"https://example.com/big.jpg?w=925&h=925",
"https://example.com/desc.png",
"https://example.com/thumb.jpg",
null,
]);
});
it("finds images in atom html content and summary", () => {
const xml = `<feed xmlns="http://www.w3.org/2005/Atom">
<entry><title>Content</title><content type="html">&lt;img src="https://example.com/c.jpg"&gt;</content></entry>
<entry><title>Summary</title><summary type="html">&lt;img src="https://example.com/s.jpg"&gt;</summary></entry>
</feed>`;
expect(parseFeed(xml).map((item) => item.image)).toEqual([
"https://example.com/c.jpg",
"https://example.com/s.jpg",
]);
});
it("resolves relative urls against the feed's alternate link, never its self link", () => {
const entry = `<entry><title>Relative</title><link href="/posts/1" /></entry>`;
const selfOnly = `<feed xmlns="http://www.w3.org/2005/Atom">
<link rel="self" href="https://example.com/user.private.atom?token=abc123" />${entry}</feed>`;
const withSite = `<feed xmlns="http://www.w3.org/2005/Atom">
<link rel="self" href="https://example.com/user.private.atom?token=abc123" />
<link rel="alternate" href="https://example.com/blog/" />${entry}</feed>`;
expect(parseFeed(selfOnly)[0].link).toBeNull();
expect(parseFeed(withSite)[0].link).toBe("https://example.com/posts/1");
});
it("handles single-item and empty feeds", () => {
const single = "<rss><channel><item><title>Only</title></item></channel></rss>";
expect(parseFeed(single)).toEqual([{ title: "Only", link: null, date: null, image: null }]);
expect(parseFeed("<feed></feed>")).toEqual([]);
});
it("rejects unsupported documents", () => {
expect(() => parseFeed("<html><body>nope</body></html>")).toThrow("Unsupported feed format");
expect(() => parseFeed("not xml <")).toThrow();
});
it.each([
["https://a.com/x", undefined, "https://a.com/x"],
["/x", "https://a.com/feed", "https://a.com/x"],
["data:image/png;base64,AAAA", undefined, null],
["not a url", undefined, null],
["", undefined, null],
[" \n ", "https://a.com/feed", null],
[" /x ", "https://a.com/feed", "https://a.com/x"],
])("httpUrl(%j, %j) is %j", (value, base, expected) => {
expect(httpUrl(value, base)).toBe(expected);
});
});
+8
View File
@@ -0,0 +1,8 @@
import feedProxyHandler from "./proxy";
const widget = {
api: "{url}",
proxyHandler: feedProxyHandler,
};
export default widget;
+12
View File
@@ -0,0 +1,12 @@
import { describe, expect, it } from "vitest";
import { expectWidgetConfigShape } from "test-utils/widget-config";
import widget from "./widget";
describe("feed widget config", () => {
it("exports a valid widget config", () => {
expectWidgetConfigShape(widget);
expect(widget.api).toBe("{url}");
});
});
+77
View File
@@ -0,0 +1,77 @@
import { useTranslation } from "next-i18next/pages";
import Block from "components/services/widget/block";
import Container from "components/services/widget/container";
import useWidgetAPI from "utils/proxy/use-widget-api";
import withWidgetFields from "utils/widget-fields";
const DEFAULT_FIELDS = ["leech", "download", "seed", "upload"];
export default function Component({ service: configuredService }) {
const { t } = useTranslation();
const service = withWidgetFields(configuredService, DEFAULT_FIELDS);
const { widget } = service;
// A specific instance id selects per-instance stats; otherwise aggregate all instances.
const perInstance = widget.instance != null && widget.instance !== "";
const mapping = perInstance ? "torrents" : "torrentsAll";
const { data: torrentData, error: torrentError } = useWidgetAPI(widget, mapping);
if (torrentError) {
return <Container service={service} error={torrentError} />;
}
if (!torrentData || !torrentData.stats) {
return (
<Container service={service}>
<Block label="qui.leech" />
<Block label="qui.download" />
<Block label="qui.seed" />
<Block label="qui.upload" />
<Block label="qui.total" />
<Block label="qui.errored" />
<Block label="qui.ratio" />
<Block label="qui.freeSpace" />
</Container>
);
}
const { stats } = torrentData;
const status = torrentData.counts?.status;
const serverState = torrentData.serverState;
// counts.status uses qBittorrent's sidebar semantics (complete vs incomplete). Seed/Leech show
// "active / total" — actively transferring over complete/incomplete. Fall back to stats-only.
const completed = status ? status.completed : stats.seeding;
const incomplete = status ? status.all - status.completed : stats.downloading;
const seedValue = `${t("common.number", { value: stats.seeding })} / ${t("common.number", { value: completed })}`;
const leechValue = `${t("common.number", { value: stats.downloading })} / ${t("common.number", { value: incomplete })}`;
return (
<Container service={service}>
<Block label="qui.leech" value={leechValue} />
<Block
label="qui.download"
value={t("common.bibyterate", { value: stats.totalDownloadSpeed, decimals: 1 })}
highlightValue={stats.totalDownloadSpeed}
/>
<Block label="qui.seed" value={seedValue} />
<Block
label="qui.upload"
value={t("common.bibyterate", { value: stats.totalUploadSpeed, decimals: 1 })}
highlightValue={stats.totalUploadSpeed}
/>
<Block label="qui.total" value={t("common.number", { value: status ? status.all : stats.total })} />
<Block label="qui.errored" value={t("common.number", { value: status ? status.errored : stats.error })} />
{serverState && (
<Block label="qui.ratio" value={t("common.number", { value: parseFloat(serverState.global_ratio) })} />
)}
{serverState && (
<Block
label="qui.freeSpace"
value={t("common.bbytes", { value: serverState.free_space_on_disk, maximumFractionDigits: 1 })}
/>
)}
</Container>
);
}
+149
View File
@@ -0,0 +1,149 @@
// @vitest-environment jsdom
import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
// Aggregated response (cross-instance) — no serverState, as qui can't merge it.
const aggregatedData = {
stats: { downloading: 1, seeding: 2, totalDownloadSpeed: 100, totalUploadSpeed: 200, total: 10, error: 0 },
counts: { status: { all: 10, completed: 8, errored: 3 } },
};
// Per-instance response — includes serverState (ratio / free space).
const perInstanceData = {
stats: { downloading: 0, seeding: 3, totalDownloadSpeed: 0, totalUploadSpeed: 141939, total: 6477, error: 0 },
counts: { status: { all: 6477, completed: 6477, errored: 0 } },
serverState: { global_ratio: "8.58", free_space_on_disk: 49820224065536 },
};
describe("widgets/qui/component", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders four placeholder blocks while loading", () => {
useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
const { container } = renderWithProviders(<Component service={{ widget: { type: "qui" } }} />, {
settings: { hideErrors: false },
});
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
expect(screen.getByText("qui.leech")).toBeInTheDocument();
expect(screen.getByText("qui.download")).toBeInTheDocument();
expect(screen.getByText("qui.seed")).toBeInTheDocument();
expect(screen.getByText("qui.upload")).toBeInTheDocument();
});
it("shows placeholders for selected optional fields while loading", () => {
useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
const service = { widget: { type: "qui", instance: 1, fields: ["seed", "ratio"] } };
const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });
// Container filters placeholders by fields, so an optional placeholder must exist to be shown.
expect(container.querySelectorAll(".service-block")).toHaveLength(2);
expect(screen.getByText("qui.seed")).toBeInTheDocument();
expect(screen.getByText("qui.ratio")).toBeInTheDocument();
});
it("renders error UI when the endpoint errors", () => {
useWidgetAPI.mockReturnValue({ data: undefined, error: { message: "nope" } });
renderWithProviders(<Component service={{ widget: { type: "qui" } }} />, { settings: { hideErrors: false } });
expect(screen.getAllByText(/widget\.api_error/i).length).toBeGreaterThan(0);
expect(screen.getByText("nope")).toBeInTheDocument();
});
it("uses the aggregated endpoint and shows active/total for seed and leech", () => {
useWidgetAPI.mockReturnValue({ data: aggregatedData, error: undefined });
const { container } = renderWithProviders(<Component service={{ widget: { type: "qui" } }} />, {
settings: { hideErrors: false },
});
// No instance id -> aggregated (cross-instance) mapping.
expect(useWidgetAPI).toHaveBeenCalledWith(expect.anything(), "torrentsAll");
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
expectBlockValue(container, "qui.leech", "1 / 2"); // active downloading / incomplete (all - completed)
expectBlockValue(container, "qui.download", "100");
expectBlockValue(container, "qui.seed", "2 / 8"); // active seeding / completed
expectBlockValue(container, "qui.upload", "200");
});
it("uses the per-instance endpoint and renders serverState-backed ratio and freeSpace", () => {
useWidgetAPI.mockReturnValue({ data: perInstanceData, error: undefined });
const service = { widget: { type: "qui", instance: 1, fields: ["seed", "upload", "ratio", "freeSpace"] } };
const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });
// Instance id set -> per-instance mapping.
expect(useWidgetAPI).toHaveBeenCalledWith(expect.anything(), "torrents");
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
expectBlockValue(container, "qui.seed", "3 / 6477");
expectBlockValue(container, "qui.upload", "141939");
expectBlockValue(container, "qui.ratio", "8.58");
expectBlockValue(container, "qui.freeSpace", "49820224065536");
});
it("renders the optional total and errored fields when selected", () => {
useWidgetAPI.mockReturnValue({ data: aggregatedData, error: undefined });
const service = { widget: { type: "qui", fields: ["total", "errored"] } };
const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });
expect(container.querySelectorAll(".service-block")).toHaveLength(2);
expectBlockValue(container, "qui.total", "10");
expectBlockValue(container, "qui.errored", "3");
});
it("falls back to stats when counts are missing", () => {
const { counts, ...statsOnly } = aggregatedData;
useWidgetAPI.mockReturnValue({ data: statsOnly, error: undefined });
const service = { widget: { type: "qui", instance: "", fields: ["leech", "seed", "total", "errored"] } };
const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });
expect(useWidgetAPI).toHaveBeenCalledWith(expect.anything(), "torrentsAll");
expectBlockValue(container, "qui.leech", "1 / 1");
expectBlockValue(container, "qui.seed", "2 / 2");
expectBlockValue(container, "qui.total", "10");
expectBlockValue(container, "qui.errored", "0");
});
it("caps configured fields at four blocks", () => {
useWidgetAPI.mockReturnValue({ data: perInstanceData, error: undefined });
const service = {
widget: { type: "qui", instance: 1, fields: ["total", "errored", "ratio", "freeSpace", "leech", "seed"] },
};
const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
expect(screen.queryByText("qui.leech")).toBeNull();
expect(screen.queryByText("qui.seed")).toBeNull();
});
it("omits ratio/freeSpace in aggregated mode where serverState is absent", () => {
useWidgetAPI.mockReturnValue({ data: aggregatedData, error: undefined });
const service = { widget: { type: "qui", fields: ["seed", "ratio", "freeSpace"] } };
const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });
expect(screen.getByText("qui.seed")).toBeInTheDocument();
expect(screen.queryByText("qui.ratio")).toBeNull();
expect(screen.queryByText("qui.freeSpace")).toBeNull();
expect(container.querySelectorAll(".service-block")).toHaveLength(1);
});
});
+19
View File
@@ -0,0 +1,19 @@
import credentialedProxyHandler from "utils/proxy/handlers/credentialed";
const widget = {
api: "{url}/api/{endpoint}",
proxyHandler: credentialedProxyHandler,
mappings: {
torrents: {
endpoint: "instances/{instance}/torrents?limit=1",
validate: ["stats"],
},
torrentsAll: {
endpoint: "torrents/cross-instance?limit=1",
validate: ["stats"],
},
},
};
export default widget;
+11
View File
@@ -0,0 +1,11 @@
import { describe, it } from "vitest";
import { expectWidgetConfigShape } from "test-utils/widget-config";
import widget from "./widget";
describe("qui widget config", () => {
it("exports a valid widget config", () => {
expectWidgetConfigShape(widget);
});
});
+4
View File
@@ -31,6 +31,7 @@ import duplicati from "./duplicati/widget";
import emby from "./emby/widget"; import emby from "./emby/widget";
import esphome from "./esphome/widget"; import esphome from "./esphome/widget";
import evcc from "./evcc/widget"; import evcc from "./evcc/widget";
import feed from "./feed/widget";
import filebrowser from "./filebrowser/widget"; import filebrowser from "./filebrowser/widget";
import fileflows from "./fileflows/widget"; import fileflows from "./fileflows/widget";
import firefly from "./firefly/widget"; import firefly from "./firefly/widget";
@@ -112,6 +113,7 @@ import pulse from "./pulse/widget";
import pyload from "./pyload/widget"; import pyload from "./pyload/widget";
import qbittorrent from "./qbittorrent/widget"; import qbittorrent from "./qbittorrent/widget";
import qnap from "./qnap/widget"; import qnap from "./qnap/widget";
import qui from "./qui/widget";
import radarr from "./radarr/widget"; import radarr from "./radarr/widget";
import readarr from "./readarr/widget"; import readarr from "./readarr/widget";
import romm from "./romm/widget"; import romm from "./romm/widget";
@@ -191,6 +193,7 @@ const widgets = {
emby, emby,
esphome, esphome,
evcc, evcc,
feed,
filebrowser, filebrowser,
fileflows, fileflows,
firefly, firefly,
@@ -277,6 +280,7 @@ const widgets = {
pyload, pyload,
qbittorrent, qbittorrent,
qnap, qnap,
qui,
radarr, radarr,
readarr, readarr,
romm, romm,