Compare commits

..
Author SHA1 Message Date
dosubot[bot] 612d21f093 docs: add Dashdot and DDNS-Updater to service index 2026-09-24 23:40:43 +00:00
68 changed files with 193 additions and 1894 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 794 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 337 KiB

-46
View File
@@ -1,46 +0,0 @@
---
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,6 +30,8 @@ You can also find a list of all available service widgets in the sidebar navigat
- [Coin Market Cap](coin-market-cap.md)
- [CrowdSec](crowdsec.md)
- [Custom API](customapi.md)
- [Dashdot](dashdot.md)
- [DDNS-Updater](ddns-updater.md)
- [Deluge](deluge.md)
- [DeveLanCacheUI](develancacheui.md)
- [DiskStation](diskstation.md)
@@ -39,7 +41,6 @@ You can also find a list of all available service widgets in the sidebar navigat
- [Emby](emby.md)
- [ESPHome](esphome.md)
- [EVCC](evcc.md)
- [Feed](feed.md)
- [Filebrowser](filebrowser.md)
- [Fileflows](fileflows.md)
- [Firefly III](firefly.md)
@@ -120,7 +121,6 @@ You can also find a list of all available service widgets in the sidebar navigat
- [PyLoad](pyload.md)
- [qBittorrent](qbittorrent.md)
- [QNAP](qnap.md)
- [qui](qui.md)
- [Radarr](radarr.md)
- [Readarr](readarr.md)
- [ROMM](romm.md)
-30
View File
@@ -1,30 +0,0 @@
---
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,7 +65,6 @@ nav:
- widgets/services/emby.md
- widgets/services/esphome.md
- widgets/services/evcc.md
- widgets/services/feed.md
- widgets/services/filebrowser.md
- widgets/services/fileflows.md
- widgets/services/firefly.md
@@ -149,7 +148,6 @@ nav:
- widgets/services/pyload.md
- widgets/services/qbittorrent.md
- widgets/services/qnap.md
- widgets/services/qui.md
- widgets/services/radarr.md
- widgets/services/readarr.md
- widgets/services/romm.md
-1
View File
@@ -39,7 +39,6 @@
"react-i18next": "^17.0.12",
"react-icons": "^5.6.0",
"recharts": "^3.1.2",
"sax": "^1.6.1",
"swr": "^2.5.1",
"systeminformation": "^5.33.1",
"tough-cookie": "^6.0.2",
-3
View File
@@ -83,9 +83,6 @@ importers:
recharts:
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)
sax:
specifier: ^1.6.1
version: 1.6.1
swr:
specifier: ^2.5.1
version: 2.5.1(react@19.2.8)
-13
View File
@@ -256,16 +256,6 @@
"leech": "Seier",
"seed": "Vul"
},
"qui": {
"leech": "Leech",
"download": "Download",
"seed": "Seed",
"upload": "Upload",
"total": "Total",
"errored": "Errored",
"ratio": "Ratio",
"freeSpace": "Free"
},
"qnap": {
"cpuUsage": "SVE Gebruik",
"memUsage": "MEM Gebruik",
@@ -1268,8 +1258,5 @@
"wanted": "Gesoek",
"queued": "In ry",
"leagues": "Ligas"
},
"feed": {
"noItems": "No items"
}
}
-13
View File
@@ -256,16 +256,6 @@
"leech": "المُستهلكون",
"seed": "المُزوِّدين"
},
"qui": {
"leech": "Leech",
"download": "Download",
"seed": "Seed",
"upload": "Upload",
"total": "Total",
"errored": "Errored",
"ratio": "Ratio",
"freeSpace": "Free"
},
"qnap": {
"cpuUsage": "استهلاك المعالج",
"memUsage": "استخدام الذاكرة العشوائية",
@@ -1268,8 +1258,5 @@
"wanted": "Wanted",
"queued": "Queued",
"leagues": "Leagues"
},
"feed": {
"noItems": "No items"
}
}
-13
View File
@@ -256,16 +256,6 @@
"leech": "Leech",
"seed": "Seed"
},
"qui": {
"leech": "Leech",
"download": "Download",
"seed": "Seed",
"upload": "Upload",
"total": "Total",
"errored": "Errored",
"ratio": "Ratio",
"freeSpace": "Free"
},
"qnap": {
"cpuUsage": "CPU употреба",
"memUsage": "Упортеба памет",
@@ -1268,8 +1258,5 @@
"wanted": "Wanted",
"queued": "Queued",
"leagues": "Leagues"
},
"feed": {
"noItems": "No items"
}
}
-13
View File
@@ -256,16 +256,6 @@
"leech": "Sangonera",
"seed": "Sembrat"
},
"qui": {
"leech": "Sangonera",
"download": "Baixada",
"seed": "Sembrat",
"upload": "Pujada",
"total": "Total",
"errored": "Errors",
"ratio": "Ràtio",
"freeSpace": "Lliure"
},
"qnap": {
"cpuUsage": "Ús de CPU",
"memUsage": "Ús de Memòria",
@@ -1268,8 +1258,5 @@
"wanted": "Pendents",
"queued": "En cua",
"leagues": "Lligues"
},
"feed": {
"noItems": "Sense elements"
}
}
-13
View File
@@ -256,16 +256,6 @@
"leech": "Leechované",
"seed": "Seedované"
},
"qui": {
"leech": "Leech",
"download": "Download",
"seed": "Seed",
"upload": "Upload",
"total": "Total",
"errored": "Errored",
"ratio": "Ratio",
"freeSpace": "Free"
},
"qnap": {
"cpuUsage": "Využití procesoru",
"memUsage": "Využití paměti",
@@ -1268,8 +1258,5 @@
"wanted": "Požadované",
"queued": "Ve frontě",
"leagues": "Ligy"
},
"feed": {
"noItems": "No items"
}
}
+191 -204
View File
@@ -21,7 +21,7 @@
"seconds": "s"
},
"auth": {
"signout": "Log af"
"signout": "Sign out"
},
"widget": {
"missing_type": "Manglende Widget Type: {{type}}",
@@ -40,8 +40,8 @@
},
"search": {
"placeholder": "Søg…",
"provider": "Søgemaskine: {{name}}",
"select_provider": "Vælg søgemaskine"
"provider": "Search engine: {{name}}",
"select_provider": "Select search engine"
},
"resources": {
"cpu": "CPU",
@@ -66,29 +66,29 @@
"wlan_devices": "WLAN Enheder",
"lan_users": "LAN Brugere",
"wlan_users": "WLAN Brugere",
"up": "OP",
"up": "UP",
"down": "NED",
"wait": "Vent",
"wait": "Please wait",
"empty_data": "Subsystem status ukendt"
},
"unifi_drive": {
"healthy": "Sund",
"degraded": "Forringet",
"no_data": "Ingen lagringsdata tilgængelige"
"healthy": "Healthy",
"degraded": "Degraded",
"no_data": "No storage data available"
},
"duplicati": {
"jobs": "Jobs",
"stored": "Gemte",
"lastBackup": "Sidste kørsel",
"nextRun": "Næste kørsel",
"running": "Kører",
"warnings": "Advarsel",
"errors": "Fejl"
"stored": "Stored",
"lastBackup": "Last Run",
"nextRun": "Next Run",
"running": "Running",
"warnings": "Warnings",
"errors": "Errors"
},
"docker": {
"rx": "RX",
"tx": "TX",
"mem": "RAM",
"mem": "MEM",
"cpu": "CPU",
"running": "Kører",
"offline": "Offline",
@@ -100,14 +100,14 @@
"not_found": "Ikke Fundet",
"exited": "Forladt",
"partial": "Delvis",
"created": "Oprettet",
"paused": "Sat på pause",
"restarting": "Genstarter",
"removing": "Fjerner",
"dead": "Død"
"created": "Created",
"paused": "Paused",
"restarting": "Restarting",
"removing": "Removing",
"dead": "Dead"
},
"ping": {
"error": "Fejl",
"error": "Error",
"ping": "Ping",
"down": "Ned",
"up": "Op",
@@ -115,11 +115,11 @@
},
"siteMonitor": {
"http_status": "HTTP-status",
"error": "Fejl",
"response": "Svar",
"down": "Ned",
"up": "Op",
"not_available": "Ikke tilgængelig"
"error": "Error",
"response": "Response",
"down": "Down",
"up": "Up",
"not_available": "Not Available"
},
"emby": {
"playing": "Afspiller",
@@ -132,22 +132,22 @@
"songs": "Sange"
},
"jellyfin": {
"playing": "Afspiller",
"transcoding": "Transcoder",
"playing": "Playing",
"transcoding": "Transcoding",
"bitrate": "Bitrate",
"no_active": "Ingen Aktive Streams",
"movies": "Film",
"series": "Serier",
"episodes": "Episoder",
"songs": "Sange",
"albums": "Album"
"no_active": "No Active Streams",
"movies": "Movies",
"series": "Series",
"episodes": "Episodes",
"songs": "Songs",
"albums": "Albums"
},
"esphome": {
"offline": "Offline",
"offline_alt": "Offline",
"online": "Online",
"total": "Total",
"unknown": "Ukendt"
"unknown": "Unknown"
},
"evcc": {
"pv_power": "Produktion",
@@ -169,22 +169,22 @@
},
"fritzbox": {
"connectionStatus": "Status",
"connectionStatusUnconfigured": "Ikke konfigureret",
"connectionStatusConnecting": "Forbinder",
"connectionStatusAuthenticating": "Godkender",
"connectionStatusPendingDisconnect": "Afventer Afbrydelse",
"connectionStatusDisconnecting": "Afbryder forbindelsen",
"connectionStatusDisconnected": "Afbrudt",
"connectionStatusConnected": "Forbundet",
"uptime": "Oppetid",
"maxDown": "Maks. Ned",
"maxUp": "Maks. Op",
"down": "Ned",
"up": "Op",
"connectionStatusUnconfigured": "Unconfigured",
"connectionStatusConnecting": "Connecting",
"connectionStatusAuthenticating": "Authenticating",
"connectionStatusPendingDisconnect": "Pending Disconnect",
"connectionStatusDisconnecting": "Disconnecting",
"connectionStatusDisconnected": "Disconnected",
"connectionStatusConnected": "Connected",
"uptime": "Uptime",
"maxDown": "Max. Down",
"maxUp": "Max. Up",
"down": "Down",
"up": "Up",
"received": "Modtaget",
"sent": "Sendt",
"externalIPAddress": "Ekstern IP",
"externalIPv6Address": "Ekstern IPv6",
"externalIPv6Address": "Ext. IPv6",
"externalIPv6Prefix": "Ext. IPv6-Prefix"
},
"caddy": {
@@ -203,24 +203,24 @@
"passes": "Bestået"
},
"tautulli": {
"playing": "Afspiller",
"transcoding": "Transcoder",
"playing": "Playing",
"transcoding": "Transcoding",
"bitrate": "Bitrate",
"no_active": "Ingen Aktive Streams",
"no_active": "No Active Streams",
"plex_connection_error": "Tjek Plex-forbindelse"
},
"tracearr": {
"no_active": "Ingen Aktive Streams",
"no_active": "No Active Streams",
"streams": "Streams",
"transcodes": "Transkoder",
"directplay": "Direkte afspilning",
"transcodes": "Transcodes",
"directplay": "Direct Play",
"bitrate": "Bitrate"
},
"omada": {
"connectedAp": "Forbundne APs",
"activeUser": "Aktive enheder",
"alerts": "Advarsler",
"connectedGateways": "Forbundne gateways",
"connectedGateways": "Connected gateways",
"connectedSwitches": "Forbundne switches"
},
"nzbget": {
@@ -231,7 +231,7 @@
"plex": {
"streams": "Aktive Streams",
"albums": "Albums",
"movies": "Film",
"movies": "Movies",
"tv": "TV-Shows"
},
"sabnzbd": {
@@ -256,16 +256,6 @@
"leech": "Leech",
"seed": "Seed"
},
"qui": {
"leech": "Leech",
"download": "Download",
"seed": "Seed",
"upload": "Upload",
"total": "Total",
"errored": "Errored",
"ratio": "Ratio",
"freeSpace": "Free"
},
"qnap": {
"cpuUsage": "CPU Forbrug",
"memUsage": "MEM Forbrug",
@@ -293,17 +283,17 @@
"sonarr": {
"wanted": "Ønsket",
"queued": "I Kø",
"series": "Serier",
"queue": "Kø",
"unknown": "Ukendt"
"series": "Series",
"queue": "Queue",
"unknown": "Unknown"
},
"radarr": {
"wanted": "Wanted",
"missing": "Mangler",
"queued": "Queued",
"movies": "Film",
"queue": "Kø",
"unknown": "Ukendt"
"movies": "Movies",
"queue": "Queue",
"unknown": "Unknown"
},
"lidarr": {
"wanted": "Wanted",
@@ -325,18 +315,18 @@
"available": "Tilgængelig"
},
"seerr": {
"pending": "Afventer",
"approved": "Godkendt",
"available": "Tilgængelig",
"completed": "Udført",
"processing": "Behandler",
"issues": "Åbne problemer"
"pending": "Pending",
"approved": "Approved",
"available": "Available",
"completed": "Completed",
"processing": "Processing",
"issues": "Open Issues"
},
"netalertx": {
"total": "Total",
"connected": "Forbundet",
"new_devices": "Nye Enheder",
"down_alerts": "Nede Advarsler"
"connected": "Connected",
"new_devices": "New Devices",
"down_alerts": "Down Alerts"
},
"pihole": {
"queries": "Forespørgsler",
@@ -345,8 +335,8 @@
"gravity": "Tyngdekraft"
},
"adguard": {
"queries": "Forespørgsler",
"blocked": "Blokerede",
"queries": "Queries",
"blocked": "Blocked",
"filtered": "Filtreret",
"latency": "Latenstid"
},
@@ -356,33 +346,33 @@
"ping": "Ping"
},
"portainer": {
"running": "Kører",
"running": "Running",
"stopped": "Stoppede",
"total": "Total"
},
"suwayomi": {
"download": "Hentet",
"nondownload": "Ikke-Downloadet",
"read": "Læst",
"unread": "Ulæst",
"downloadedread": "Downloadet Og Læst",
"downloadedunread": "Downloadet & Ulæst",
"nondownloadedread": "Ikke-Downloadet & Læst",
"nondownloadedunread": "Ikke-Downloadet & Ulæst"
"download": "Downloaded",
"nondownload": "Non-Downloaded",
"read": "Read",
"unread": "Unread",
"downloadedread": "Downloaded & Read",
"downloadedunread": "Downloaded & Unread",
"nondownloadedread": "Non-Downloaded & Read",
"nondownloadedunread": "Non-Downloaded & Unread"
},
"tailscale": {
"address": "Adresse",
"expires": "Udløber",
"never": "Aldrig",
"user": "Bruger",
"hostname": "Værtsnavn",
"name": "Navn",
"client_version": "Klientversion",
"os": "Styresystem",
"created": "Oprettet",
"authorized": "Autoriseret",
"is_external": "Er ekstern",
"update_available": "Opdatering tilgængelig",
"user": "User",
"hostname": "Hostname",
"name": "Name",
"client_version": "Client Version",
"os": "OS",
"created": "Created",
"authorized": "Authorized",
"is_external": "Is External",
"update_available": "Update Available",
"tags": "Tags",
"last_seen": "Sidst Set",
"now": "Nu",
@@ -393,24 +383,24 @@
"minutes": "{{number}}m",
"seconds": "{{number}}s",
"ago": "{{value}} Siden",
"true": "Ja",
"false": "Nej"
"true": "Yes",
"false": "No"
},
"technitium": {
"totalQueries": "Forespørgsler",
"totalNoError": "Succes",
"totalServerFailure": "Fejl",
"totalNxDomain": "Domæner",
"totalRefused": "Nægtede",
"totalAuthoritative": "Autorativ",
"totalRecursive": "Rekursivt",
"totalCached": "Cachet",
"totalBlocked": "Blokeret",
"totalDropped": "Tabt",
"totalQueries": "Queries",
"totalNoError": "Success",
"totalServerFailure": "Failures",
"totalNxDomain": "NX Domains",
"totalRefused": "Refused",
"totalAuthoritative": "Authoritative",
"totalRecursive": "Recursive",
"totalCached": "Cached",
"totalBlocked": "Blocked",
"totalDropped": "Dropped",
"totalClients": "Klienter"
},
"tdarr": {
"queue": "Kø",
"queue": "Queue",
"processed": "Behandlet",
"errored": "Fejlet",
"saved": "Gemt"
@@ -422,12 +412,12 @@
},
"trilium": {
"version": "Version",
"notesCount": "Noter",
"dbSize": "Databasestørrelse",
"unknown": "Ukendt"
"notesCount": "Notes",
"dbSize": "Database Size",
"unknown": "Unknown"
},
"navidrome": {
"nothing_streaming": "Ingen Aktive Streams",
"nothing_streaming": "No Active Streams",
"please_wait": "Vent venligst"
},
"npm": {
@@ -492,31 +482,31 @@
"authorizationsLast24H": "Auths (24h)"
},
"proxmox": {
"mem": "RAM",
"mem": "MEM",
"cpu": "CPU",
"lxc": "LXC",
"vms": "VMs"
},
"pulse": {
"nodes": "Noder",
"nodes": "Nodes",
"vms": "VMs",
"lxcs": "LXC"
"lxcs": "LXCs"
},
"glances": {
"cpu": "CPU",
"load": "Indlæs",
"wait": "Vent venligst",
"load": "Load",
"wait": "Please wait",
"temp": "TEMP",
"_temp": "Temp",
"warn": "Advar",
"uptime": "OP",
"uptime": "UP",
"total": "Total",
"free": "Fri",
"used": "Brugt",
"free": "Free",
"used": "Used",
"days": "d",
"hours": "t",
"hours": "h",
"crit": "Crit",
"read": "Læst",
"read": "Read",
"write": "Skriv",
"gpu": "GPU",
"mem": "Ram",
@@ -529,7 +519,7 @@
"custom": "Brugerdefinerede",
"visit": "Besøg",
"url": "URL",
"searchsuggestion": "Forslag"
"searchsuggestion": "Suggestion"
},
"wmo": {
"0-day": "Solrig",
@@ -537,57 +527,57 @@
"1-day": "Overvejende Solrigt",
"1-night": "Overvejende Skyfrit",
"2-day": "Delvist Overskyet",
"2-night": "Delvist Overskyet",
"2-night": "Partly Cloudy",
"3-day": "Skyet",
"3-night": "Skyet",
"3-night": "Cloudy",
"45-day": "Tåget",
"45-night": "Tåget",
"48-day": "Tåget",
"48-night": "Tåget",
"45-night": "Foggy",
"48-day": "Foggy",
"48-night": "Foggy",
"51-day": "Let Støvregn",
"51-night": "Let Støvregn",
"51-night": "Light Drizzle",
"53-day": "Støvregn",
"53-night": "Støvregn",
"53-night": "Drizzle",
"55-day": "Kraftig Støvregn",
"55-night": "Kraftig Støvregn",
"55-night": "Heavy Drizzle",
"56-day": "Let Frysende Støvregn",
"56-night": "Light Freezing Drizzle",
"57-day": "Frysende Støvregn",
"57-night": "Freezing Drizzle",
"61-day": "Let Regn",
"61-night": "Let Regn",
"61-night": "Light Rain",
"63-day": "Regn",
"63-night": "Regn",
"63-night": "Rain",
"65-day": "Kraftig Regn",
"65-night": "Kraftig Regn",
"65-night": "Heavy Rain",
"66-day": "Frysende Regn",
"66-night": "Freezing Rain",
"67-day": "Freezing Rain",
"67-night": "Freezing Rain",
"71-day": "Let Sne",
"71-night": "Let Sne",
"71-night": "Light Snow",
"73-day": "Sne",
"73-night": "Sne",
"73-night": "Snow",
"75-day": "Kraftig Sne",
"75-night": "Kraftig Sne",
"75-night": "Heavy Snow",
"77-day": "Snekorn",
"77-night": "Mildt snefald",
"77-night": "Snow Grains",
"80-day": "Lette Byger",
"80-night": "Lette Byger",
"80-night": "Light Showers",
"81-day": "Byger",
"81-night": "Byger",
"81-night": "Showers",
"82-day": "Kraftige Byger",
"82-night": "Kraftige Byger",
"82-night": "Heavy Showers",
"85-day": "Snebyger",
"85-night": "Snebyger",
"86-day": "Snebyger",
"86-night": "Snebyger",
"85-night": "Snow Showers",
"86-day": "Snow Showers",
"86-night": "Snow Showers",
"95-day": "Tordenvejr",
"95-night": "Tordenvejr",
"95-night": "Thunderstorm",
"96-day": "Tordenvejr Med Hagl",
"96-night": "Tordenvejr Med Hagl",
"99-day": "Tordenvejr Med Hagl",
"99-night": "Tordenvejr Med Hagl"
"96-night": "Thunderstorm With Hail",
"99-day": "Thunderstorm With Hail",
"99-night": "Thunderstorm With Hail"
},
"homebridge": {
"available_update": "System",
@@ -596,16 +586,16 @@
"up_to_date": "Opdateret",
"child_bridges": "Underbroer",
"child_bridges_status": "{{ok}}/{{total}}",
"up": "Op",
"pending": "Afventer",
"down": "Ned",
"up": "Up",
"pending": "Pending",
"down": "Down",
"ok": "Ok"
},
"healthchecks": {
"new": "Ny",
"up": "Op",
"up": "Up",
"grace": "Henstandsperiode",
"down": "Ned",
"down": "Down",
"paused": "Pause",
"status": "Status",
"last_ping": "Sidste Ping",
@@ -617,85 +607,85 @@
"containers_failed": "Fejlet"
},
"autobrr": {
"approvedPushes": "Godkendt",
"approvedPushes": "Approved",
"rejectedPushes": "Afviste",
"filters": "Filtre",
"indexers": "Indeksører"
"indexers": "Indexers"
},
"tubearchivist": {
"downloads": "Kø",
"downloads": "Queue",
"videos": "Videoer",
"channels": "Kanaler",
"playlists": "Afspilningslister"
},
"truenas": {
"load": "Systembelastning",
"uptime": "Oppetid",
"alerts": "Advarsler"
"uptime": "Uptime",
"alerts": "Alerts"
},
"pyload": {
"speed": "Hastighed",
"active": "Aktive",
"queue": "Kø",
"active": "Active",
"queue": "Queue",
"total": "Total"
},
"gluetun": {
"public_ip": "Offentlig IP",
"region": "Område",
"country": "Land",
"port_forwarded": "Port Viderestillet"
"port_forwarded": "Port Forwarded"
},
"hdhomerun": {
"channels": "Kanaler",
"channels": "Channels",
"hd": "HD",
"tunerCount": "Tunere",
"channelNumber": "Kanal",
"channelNetwork": "Netværk",
"signalStrength": "Styrke",
"signalQuality": "Kvalitet",
"symbolQuality": "Kvalitet",
"tunerCount": "Tuners",
"channelNumber": "Channel",
"channelNetwork": "Network",
"signalStrength": "Strength",
"signalQuality": "Quality",
"symbolQuality": "Quality",
"networkRate": "Bitrate",
"clientIP": "Klient"
"clientIP": "Client"
},
"scrutiny": {
"passed": "Bestået",
"failed": "Fejlet",
"unknown": "Ukendt"
"failed": "Failed",
"unknown": "Unknown"
},
"paperlessngx": {
"inbox": "Indbakke",
"total": "Total"
},
"pangolin": {
"orgs": "Organisationer",
"sites": "Websteder",
"resources": "Ressourcer",
"targets": "Mål",
"traffic": "Trafik",
"in": "Ind",
"out": "Ud"
"orgs": "Orgs",
"sites": "Sites",
"resources": "Resources",
"targets": "Targets",
"traffic": "Traffic",
"in": "In",
"out": "Out"
},
"peanut": {
"battery_charge": "Batteriniveau",
"ups_load": "Ups Belastning",
"ups_load": "UPS Load",
"ups_status": "UPS Status",
"online": "Online",
"on_battery": "På batteri",
"low_battery": "Lavt batteriniveau"
},
"nextdns": {
"wait": "Vent venligst",
"wait": "Please Wait",
"no_devices": "Ingen Enhedsdata Modtaget"
},
"mikrotik": {
"cpuLoad": "CPU Belastning",
"memoryUsed": "Hukommelse Brugt",
"uptime": "Oppetid",
"uptime": "Uptime",
"numberOfLeases": "Leasinger"
},
"xteve": {
"streams_all": "Alle Streams",
"streams_active": "Aktive Streams",
"streams_active": "Active Streams",
"streams_xepg": "XEPG Kanaler"
},
"opendtu": {
@@ -705,7 +695,7 @@
"limit": "Begrænsning"
},
"opnsense": {
"cpu": "CPU Belastning",
"cpu": "CPU Load",
"memory": "Aktiv Hukommelse",
"wanUpload": "WAN Upload",
"wanDownload": "WAN Download"
@@ -730,8 +720,8 @@
"load": "Belastning Gns",
"memory": "Hukommelse Forbrug",
"wanStatus": "WAN Status",
"up": "Op",
"down": "Ned",
"up": "Up",
"down": "Down",
"temp": "Temp",
"disk": "Disk Forbrug",
"wanIP": "WAN IP"
@@ -743,15 +733,15 @@
"memory_usage": "Hukommelse"
},
"immich": {
"users": "Brugere",
"users": "Users",
"photos": "Billeder",
"videos": "Videoer",
"videos": "Videos",
"storage": "Lager"
},
"uptimekuma": {
"up": "Sider Oppe",
"down": "Sider Nede",
"uptime": "Oppetid",
"uptime": "Uptime",
"incident": "Hændelse",
"m": "m"
},
@@ -899,7 +889,7 @@
"name": "Navn",
"map": "Kort",
"currentPlayers": "Nuværende Spillere",
"players": "Spillere",
"players": "Players",
"maxPlayers": "Maks spillere",
"bots": "Bots",
"ping": "Ping"
@@ -912,22 +902,22 @@
},
"mealie": {
"recipes": "Opskrifter",
"users": "Brugere",
"categories": "Kategorier",
"users": "Users",
"categories": "Categories",
"tags": "Tags"
},
"openmediavault": {
"downloading": "Downloader",
"total": "Total",
"running": "Kører",
"running": "Running",
"stopped": "Stopped",
"passed": "Passed",
"failed": "Fejlet"
"failed": "Failed"
},
"openwrt": {
"uptime": "Oppetid",
"uptime": "Uptime",
"cpuLoad": "CPU Load Avg (5m)",
"up": "Op",
"up": "Up",
"down": "Down",
"bytesTx": "Transmitted",
"bytesRx": "Received"
@@ -1268,8 +1258,5 @@
"wanted": "Wanted",
"queued": "Queued",
"leagues": "Leagues"
},
"feed": {
"noItems": "No items"
}
}
-13
View File
@@ -256,16 +256,6 @@
"leech": "Leech",
"seed": "Seed"
},
"qui": {
"leech": "Leech",
"download": "Download",
"seed": "Seed",
"upload": "Upload",
"total": "Total",
"errored": "Errored",
"ratio": "Ratio",
"freeSpace": "Free"
},
"qnap": {
"cpuUsage": "CPU-Nutzung",
"memUsage": "Speichernutzung",
@@ -1268,8 +1258,5 @@
"wanted": "Wanted",
"queued": "Queued",
"leagues": "Leagues"
},
"feed": {
"noItems": "No items"
}
}
-13
View File
@@ -256,16 +256,6 @@
"leech": "Leech",
"seed": "Seed"
},
"qui": {
"leech": "Leech",
"download": "Download",
"seed": "Seed",
"upload": "Upload",
"total": "Total",
"errored": "Errored",
"ratio": "Ratio",
"freeSpace": "Free"
},
"qnap": {
"cpuUsage": "Χρήση επεξεργαστή",
"memUsage": "Χρήση μνήμης",
@@ -1268,8 +1258,5 @@
"wanted": "Wanted",
"queued": "Queued",
"leagues": "Leagues"
},
"feed": {
"noItems": "No items"
}
}
-13
View File
@@ -256,16 +256,6 @@
"leech": "Leech",
"seed": "Seed"
},
"qui": {
"leech": "Leech",
"download": "Download",
"seed": "Seed",
"upload": "Upload",
"total": "Total",
"errored": "Errored",
"ratio": "Ratio",
"freeSpace": "Free"
},
"qnap": {
"cpuUsage": "CPU Usage",
"memUsage": "MEM Usage",
@@ -1268,8 +1258,5 @@
"wanted": "Wanted",
"queued": "Queued",
"leagues": "Leagues"
},
"feed": {
"noItems": "No items"
}
}
-13
View File
@@ -256,16 +256,6 @@
"leech": "Leech",
"seed": "Seed"
},
"qui": {
"leech": "Leech",
"download": "Download",
"seed": "Seed",
"upload": "Upload",
"total": "Total",
"errored": "Errored",
"ratio": "Ratio",
"freeSpace": "Free"
},
"qnap": {
"cpuUsage": "CPU Usage",
"memUsage": "MEM Usage",
@@ -1268,8 +1258,5 @@
"wanted": "Wanted",
"queued": "Queued",
"leagues": "Leagues"
},
"feed": {
"noItems": "No items"
}
}
-13
View File
@@ -256,16 +256,6 @@
"leech": "Descargando",
"seed": "Semillas"
},
"qui": {
"leech": "Leech",
"download": "Download",
"seed": "Seed",
"upload": "Upload",
"total": "Total",
"errored": "Errored",
"ratio": "Ratio",
"freeSpace": "Free"
},
"qnap": {
"cpuUsage": "Uso de la CPU",
"memUsage": "Uso de la memoria",
@@ -1268,8 +1258,5 @@
"wanted": "Buscado",
"queued": "En cola",
"leagues": "Ligas"
},
"feed": {
"noItems": "No items"
}
}
-13
View File
@@ -256,16 +256,6 @@
"leech": "Leech",
"seed": "Seed"
},
"qui": {
"leech": "Leech",
"download": "Download",
"seed": "Seed",
"upload": "Upload",
"total": "Total",
"errored": "Errored",
"ratio": "Ratio",
"freeSpace": "Free"
},
"qnap": {
"cpuUsage": "CPU Usage",
"memUsage": "MEM Usage",
@@ -1268,8 +1258,5 @@
"wanted": "Wanted",
"queued": "Queued",
"leagues": "Leagues"
},
"feed": {
"noItems": "No items"
}
}
-13
View File
@@ -256,16 +256,6 @@
"leech": "Leech",
"seed": "Seed"
},
"qui": {
"leech": "Leech",
"download": "Download",
"seed": "Seed",
"upload": "Upload",
"total": "Total",
"errored": "Errored",
"ratio": "Ratio",
"freeSpace": "Free"
},
"qnap": {
"cpuUsage": "CPU Usage",
"memUsage": "MEM Usage",
@@ -1268,8 +1258,5 @@
"wanted": "Wanted",
"queued": "Queued",
"leagues": "Leagues"
},
"feed": {
"noItems": "No items"
}
}
-13
View File
@@ -256,16 +256,6 @@
"leech": "En téléchargement",
"seed": "En partage"
},
"qui": {
"leech": "Leech",
"download": "Download",
"seed": "Seed",
"upload": "Upload",
"total": "Total",
"errored": "Errored",
"ratio": "Ratio",
"freeSpace": "Free"
},
"qnap": {
"cpuUsage": "Utilisation CPU",
"memUsage": "RAM utilisée",
@@ -1268,8 +1258,5 @@
"wanted": "Recherché",
"queued": "En file d'attente",
"leagues": "Ligues"
},
"feed": {
"noItems": "No items"
}
}
-13
View File
@@ -256,16 +256,6 @@
"leech": "עלוקה",
"seed": "זורע"
},
"qui": {
"leech": "Leech",
"download": "Download",
"seed": "Seed",
"upload": "Upload",
"total": "Total",
"errored": "Errored",
"ratio": "Ratio",
"freeSpace": "Free"
},
"qnap": {
"cpuUsage": "שימוש במעבד",
"memUsage": "שימוש בזיכרון",
@@ -1268,8 +1258,5 @@
"wanted": "Wanted",
"queued": "Queued",
"leagues": "Leagues"
},
"feed": {
"noItems": "No items"
}
}
-13
View File
@@ -256,16 +256,6 @@
"leech": "Leech",
"seed": "Seed"
},
"qui": {
"leech": "Leech",
"download": "Download",
"seed": "Seed",
"upload": "Upload",
"total": "Total",
"errored": "Errored",
"ratio": "Ratio",
"freeSpace": "Free"
},
"qnap": {
"cpuUsage": "CPU Usage",
"memUsage": "MEM Usage",
@@ -1268,8 +1258,5 @@
"wanted": "Wanted",
"queued": "Queued",
"leagues": "Leagues"
},
"feed": {
"noItems": "No items"
}
}
-13
View File
@@ -256,16 +256,6 @@
"leech": "Korištenje tuđeg 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": {
"cpuUsage": "Korištenje procesora",
"memUsage": "Korištenje memorije",
@@ -1268,8 +1258,5 @@
"wanted": "Željeno",
"queued": "U redu čekanja",
"leagues": "Lige"
},
"feed": {
"noItems": "No items"
}
}
-13
View File
@@ -256,16 +256,6 @@
"leech": "Leech",
"seed": "Seed"
},
"qui": {
"leech": "Leech",
"download": "Download",
"seed": "Seed",
"upload": "Upload",
"total": "Total",
"errored": "Errored",
"ratio": "Ratio",
"freeSpace": "Free"
},
"qnap": {
"cpuUsage": "Processzor Használat",
"memUsage": "Memória Használat",
@@ -1268,8 +1258,5 @@
"wanted": "Wanted",
"queued": "Queued",
"leagues": "Leagues"
},
"feed": {
"noItems": "No items"
}
}
-13
View File
@@ -256,16 +256,6 @@
"leech": "Leech",
"seed": "Seed"
},
"qui": {
"leech": "Leech",
"download": "Download",
"seed": "Seed",
"upload": "Upload",
"total": "Total",
"errored": "Errored",
"ratio": "Ratio",
"freeSpace": "Free"
},
"qnap": {
"cpuUsage": "Penggunaan CPU",
"memUsage": "Penggunaan MEM",
@@ -1268,8 +1258,5 @@
"wanted": "Wanted",
"queued": "Queued",
"leagues": "Leagues"
},
"feed": {
"noItems": "No items"
}
}
-13
View File
@@ -256,16 +256,6 @@
"leech": "In download",
"seed": "Seed"
},
"qui": {
"leech": "Leech",
"download": "Download",
"seed": "Seed",
"upload": "Upload",
"total": "Total",
"errored": "Errored",
"ratio": "Ratio",
"freeSpace": "Free"
},
"qnap": {
"cpuUsage": "Utilizzo CPU",
"memUsage": "Utilizzo MEM",
@@ -1268,8 +1258,5 @@
"wanted": "Wanted",
"queued": "Queued",
"leagues": "Leagues"
},
"feed": {
"noItems": "No items"
}
}
-13
View File
@@ -256,16 +256,6 @@
"leech": "Leech",
"seed": "Seed"
},
"qui": {
"leech": "Leech",
"download": "Download",
"seed": "Seed",
"upload": "Upload",
"total": "Total",
"errored": "Errored",
"ratio": "Ratio",
"freeSpace": "Free"
},
"qnap": {
"cpuUsage": "CPU使用量",
"memUsage": "MEM使用量",
@@ -1268,8 +1258,5 @@
"wanted": "募集中",
"queued": "処理待ち",
"leagues": "リーグ"
},
"feed": {
"noItems": "No items"
}
}
-13
View File
@@ -256,16 +256,6 @@
"leech": "리치",
"seed": "시드"
},
"qui": {
"leech": "Leech",
"download": "Download",
"seed": "Seed",
"upload": "Upload",
"total": "Total",
"errored": "Errored",
"ratio": "Ratio",
"freeSpace": "Free"
},
"qnap": {
"cpuUsage": "CPU 사용량",
"memUsage": "메모리 사용량",
@@ -1268,8 +1258,5 @@
"wanted": "Wanted",
"queued": "Queued",
"leagues": "Leagues"
},
"feed": {
"noItems": "No items"
}
}
-13
View File
@@ -256,16 +256,6 @@
"leech": "Leech",
"seed": "Seed"
},
"qui": {
"leech": "Leech",
"download": "Download",
"seed": "Seed",
"upload": "Upload",
"total": "Total",
"errored": "Errored",
"ratio": "Ratio",
"freeSpace": "Free"
},
"qnap": {
"cpuUsage": "CPU Usage",
"memUsage": "MEM Usage",
@@ -1268,8 +1258,5 @@
"wanted": "Wanted",
"queued": "Queued",
"leagues": "Leagues"
},
"feed": {
"noItems": "No items"
}
}
-13
View File
@@ -256,16 +256,6 @@
"leech": "Leech",
"seed": "Seed"
},
"qui": {
"leech": "Leech",
"download": "Download",
"seed": "Seed",
"upload": "Upload",
"total": "Total",
"errored": "Errored",
"ratio": "Ratio",
"freeSpace": "Free"
},
"qnap": {
"cpuUsage": "CPU Usage",
"memUsage": "MEM Usage",
@@ -1268,8 +1258,5 @@
"wanted": "Wanted",
"queued": "Queued",
"leagues": "Leagues"
},
"feed": {
"noItems": "No items"
}
}
-13
View File
@@ -256,16 +256,6 @@
"leech": "Leech",
"seed": "Delen"
},
"qui": {
"leech": "Leech",
"download": "Download",
"seed": "Seed",
"upload": "Upload",
"total": "Total",
"errored": "Errored",
"ratio": "Ratio",
"freeSpace": "Free"
},
"qnap": {
"cpuUsage": "CPU Verbruik",
"memUsage": "MEM Gebruik",
@@ -1268,8 +1258,5 @@
"wanted": "Gewenst",
"queued": "In de wachtrij",
"leagues": "Competities"
},
"feed": {
"noItems": "No items"
}
}
-13
View File
@@ -256,16 +256,6 @@
"leech": "Leech",
"seed": "Seed"
},
"qui": {
"leech": "Leech",
"download": "Download",
"seed": "Seed",
"upload": "Upload",
"total": "Total",
"errored": "Errored",
"ratio": "Ratio",
"freeSpace": "Free"
},
"qnap": {
"cpuUsage": "CPU Bruk",
"memUsage": "Minnebruk",
@@ -1268,8 +1258,5 @@
"wanted": "Wanted",
"queued": "Queued",
"leagues": "Leagues"
},
"feed": {
"noItems": "No items"
}
}
-13
View File
@@ -256,16 +256,6 @@
"leech": "Leech",
"seed": "Seed"
},
"qui": {
"leech": "Leech",
"download": "Download",
"seed": "Seed",
"upload": "Upload",
"total": "Total",
"errored": "Errored",
"ratio": "Ratio",
"freeSpace": "Free"
},
"qnap": {
"cpuUsage": "Użycie CPU",
"memUsage": "Użycie pamięci",
@@ -1268,8 +1258,5 @@
"wanted": "Poszukiwane",
"queued": "W kolejce",
"leagues": "Ligi"
},
"feed": {
"noItems": "No items"
}
}
-13
View File
@@ -256,16 +256,6 @@
"leech": "Fuga",
"seed": "Semear"
},
"qui": {
"leech": "Leech",
"download": "Download",
"seed": "Seed",
"upload": "Upload",
"total": "Total",
"errored": "Errored",
"ratio": "Ratio",
"freeSpace": "Free"
},
"qnap": {
"cpuUsage": "Utilização do CPU",
"memUsage": "Utilização de Memória",
@@ -1268,8 +1258,5 @@
"wanted": "Procurado",
"queued": "Em fila de espera",
"leagues": "Ligas"
},
"feed": {
"noItems": "No items"
}
}
-13
View File
@@ -256,16 +256,6 @@
"leech": "Leech",
"seed": "Seed"
},
"qui": {
"leech": "Leech",
"download": "Download",
"seed": "Seed",
"upload": "Upload",
"total": "Total",
"errored": "Errored",
"ratio": "Ratio",
"freeSpace": "Free"
},
"qnap": {
"cpuUsage": "Uso de CPU",
"memUsage": "Uso de Memória",
@@ -1268,8 +1258,5 @@
"wanted": "Desejado",
"queued": "Na fila",
"leagues": "Ligas"
},
"feed": {
"noItems": "No items"
}
}
-13
View File
@@ -256,16 +256,6 @@
"leech": "Leech",
"seed": "Seed"
},
"qui": {
"leech": "Leech",
"download": "Download",
"seed": "Seed",
"upload": "Upload",
"total": "Total",
"errored": "Errored",
"ratio": "Ratio",
"freeSpace": "Free"
},
"qnap": {
"cpuUsage": "CPU Usage",
"memUsage": "MEM Usage",
@@ -1268,8 +1258,5 @@
"wanted": "Wanted",
"queued": "Queued",
"leagues": "Leagues"
},
"feed": {
"noItems": "No items"
}
}
-13
View File
@@ -256,16 +256,6 @@
"leech": "Лич",
"seed": "Сид"
},
"qui": {
"leech": "Leech",
"download": "Download",
"seed": "Seed",
"upload": "Upload",
"total": "Total",
"errored": "Errored",
"ratio": "Ratio",
"freeSpace": "Free"
},
"qnap": {
"cpuUsage": "Использование ЦП",
"memUsage": "Использование ОЗУ",
@@ -1268,8 +1258,5 @@
"wanted": "Требуется",
"queued": "В очереди",
"leagues": "Лиги"
},
"feed": {
"noItems": "No items"
}
}
-13
View File
@@ -256,16 +256,6 @@
"leech": "Leech",
"seed": "Seed"
},
"qui": {
"leech": "Leech",
"download": "Download",
"seed": "Seed",
"upload": "Upload",
"total": "Total",
"errored": "Errored",
"ratio": "Ratio",
"freeSpace": "Free"
},
"qnap": {
"cpuUsage": "Využitie CPU",
"memUsage": "Využitie pamäte",
@@ -1268,8 +1258,5 @@
"wanted": "Wanted",
"queued": "V poradí",
"leagues": "Leagues"
},
"feed": {
"noItems": "No items"
}
}
-13
View File
@@ -256,16 +256,6 @@
"leech": "Leech",
"seed": "Seed"
},
"qui": {
"leech": "Leech",
"download": "Download",
"seed": "Seed",
"upload": "Upload",
"total": "Total",
"errored": "Errored",
"ratio": "Ratio",
"freeSpace": "Free"
},
"qnap": {
"cpuUsage": "CPU",
"memUsage": "MEM",
@@ -1268,8 +1258,5 @@
"wanted": "Wanted",
"queued": "Queued",
"leagues": "Leagues"
},
"feed": {
"noItems": "No items"
}
}
-13
View File
@@ -256,16 +256,6 @@
"leech": "Leech",
"seed": "Seed"
},
"qui": {
"leech": "Leech",
"download": "Download",
"seed": "Seed",
"upload": "Upload",
"total": "Total",
"errored": "Errored",
"ratio": "Ratio",
"freeSpace": "Free"
},
"qnap": {
"cpuUsage": "Искоришћеност процесора",
"memUsage": "Заузеће меморије",
@@ -1268,8 +1258,5 @@
"wanted": "Wanted",
"queued": "Queued",
"leagues": "Leagues"
},
"feed": {
"noItems": "No items"
}
}
-13
View File
@@ -256,16 +256,6 @@
"leech": "Leech",
"seed": "Seed"
},
"qui": {
"leech": "Leech",
"download": "Download",
"seed": "Seed",
"upload": "Upload",
"total": "Total",
"errored": "Errored",
"ratio": "Ratio",
"freeSpace": "Free"
},
"qnap": {
"cpuUsage": "CPU Usage",
"memUsage": "MEM Usage",
@@ -1268,8 +1258,5 @@
"wanted": "Wanted",
"queued": "Queued",
"leagues": "Leagues"
},
"feed": {
"noItems": "No items"
}
}
-13
View File
@@ -256,16 +256,6 @@
"leech": "Leech",
"seed": "Seed"
},
"qui": {
"leech": "Leech",
"download": "Download",
"seed": "Seed",
"upload": "Upload",
"total": "Total",
"errored": "Errored",
"ratio": "Ratio",
"freeSpace": "Free"
},
"qnap": {
"cpuUsage": "CPU Usage",
"memUsage": "MEM Usage",
@@ -1268,8 +1258,5 @@
"wanted": "Wanted",
"queued": "Queued",
"leagues": "Leagues"
},
"feed": {
"noItems": "No items"
}
}
-13
View File
@@ -256,16 +256,6 @@
"leech": "Leech",
"seed": "Seed"
},
"qui": {
"leech": "Leech",
"download": "Download",
"seed": "Seed",
"upload": "Upload",
"total": "Total",
"errored": "Errored",
"ratio": "Ratio",
"freeSpace": "Free"
},
"qnap": {
"cpuUsage": "CPU Usage",
"memUsage": "MEM Usage",
@@ -1268,8 +1258,5 @@
"wanted": "Wanted",
"queued": "Queued",
"leagues": "Leagues"
},
"feed": {
"noItems": "No items"
}
}
-13
View File
@@ -256,16 +256,6 @@
"leech": "İndirilen",
"seed": "Gönderilen"
},
"qui": {
"leech": "Leech",
"download": "Download",
"seed": "Seed",
"upload": "Upload",
"total": "Total",
"errored": "Errored",
"ratio": "Ratio",
"freeSpace": "Free"
},
"qnap": {
"cpuUsage": "İşlemci Kullanımı",
"memUsage": "Bellek Kullanımı",
@@ -1268,8 +1258,5 @@
"wanted": "Wanted",
"queued": "Queued",
"leagues": "Leagues"
},
"feed": {
"noItems": "No items"
}
}
-13
View File
@@ -256,16 +256,6 @@
"leech": "Ліч",
"seed": "Сід"
},
"qui": {
"leech": "Leech",
"download": "Download",
"seed": "Seed",
"upload": "Upload",
"total": "Total",
"errored": "Errored",
"ratio": "Ratio",
"freeSpace": "Free"
},
"qnap": {
"cpuUsage": "Використання CPU",
"memUsage": "Використання пам'яті",
@@ -1268,8 +1258,5 @@
"wanted": "Wanted",
"queued": "Queued",
"leagues": "Leagues"
},
"feed": {
"noItems": "No items"
}
}
-13
View File
@@ -256,16 +256,6 @@
"leech": "Leecher",
"seed": "Seeder"
},
"qui": {
"leech": "Leech",
"download": "Download",
"seed": "Seed",
"upload": "Upload",
"total": "Total",
"errored": "Errored",
"ratio": "Ratio",
"freeSpace": "Free"
},
"qnap": {
"cpuUsage": "Mức sử dụng CPU",
"memUsage": "Mức sử dụng bộ nhớ",
@@ -1268,8 +1258,5 @@
"wanted": "Cần tải",
"queued": "Queued",
"leagues": "Leagues"
},
"feed": {
"noItems": "No items"
}
}
-13
View File
@@ -256,16 +256,6 @@
"leech": "未完成下載",
"seed": "已完成下載"
},
"qui": {
"leech": "Leech",
"download": "Download",
"seed": "Seed",
"upload": "Upload",
"total": "Total",
"errored": "Errored",
"ratio": "Ratio",
"freeSpace": "Free"
},
"qnap": {
"cpuUsage": "CPU 使用率",
"memUsage": "記憶體使用率",
@@ -1268,8 +1258,5 @@
"wanted": "Wanted",
"queued": "Queued",
"leagues": "Leagues"
},
"feed": {
"noItems": "No items"
}
}
-13
View File
@@ -256,16 +256,6 @@
"leech": "下载中",
"seed": "做种"
},
"qui": {
"leech": "Leech",
"download": "Download",
"seed": "Seed",
"upload": "Upload",
"total": "Total",
"errored": "Errored",
"ratio": "Ratio",
"freeSpace": "Free"
},
"qnap": {
"cpuUsage": "处理器",
"memUsage": "内存使用",
@@ -1268,8 +1258,5 @@
"wanted": "Wanted",
"queued": "Queued",
"leagues": "Leagues"
},
"feed": {
"noItems": "No items"
}
}
-13
View File
@@ -256,16 +256,6 @@
"leech": "未完成下載",
"seed": "已完成下載"
},
"qui": {
"leech": "Leech",
"download": "Download",
"seed": "Seed",
"upload": "Upload",
"total": "Total",
"errored": "Errored",
"ratio": "Ratio",
"freeSpace": "Free"
},
"qnap": {
"cpuUsage": "CPU 使用率",
"memUsage": "記憶體使用率",
@@ -1268,8 +1258,5 @@
"wanted": "Wanted",
"queued": "Queued",
"leagues": "Leagues"
},
"feed": {
"noItems": "No items"
}
}
-12
View File
@@ -353,9 +353,6 @@ export function cleanServiceGroups(groups) {
expandOneStreamToTwoRows,
showEpisodeNumber,
// feed
layout,
// frigate
enableRecentEvents,
@@ -433,9 +430,6 @@ export function cleanServiceGroups(groups) {
// proxmoxbackupserver
datastore,
// qui
instance,
// speedtest
bitratePrecision,
@@ -546,9 +540,6 @@ export function cleanServiceGroups(groups) {
if (type === "portainer") {
if (kubernetes) widget.kubernetes = !!JSON.parse(kubernetes);
}
if (type === "qui") {
if (instance !== undefined) widget.instance = instance;
}
if (type === "proxmox") {
if (node) widget.node = node;
}
@@ -684,9 +675,6 @@ export function cleanServiceGroups(groups) {
if (showTime) widget.showTime = showTime;
if (timezone) widget.timezone = timezone;
}
if (type === "feed") {
if (layout) widget.layout = layout;
}
if (type === "dockhand") {
if (environment) widget.environment = environment;
}
-34
View File
@@ -403,7 +403,6 @@ describe("utils/config/service-helpers", () => {
{ type: "radarr", enableQueue: "true" },
{ type: "truenas", enablePools: "true", nasType: "scale" },
{ type: "qnap", volume: "vol1" },
{ type: "qui", instance: 2 },
{ type: "dispatcharr", enableActiveStreams: "true" },
{ type: "gamedig", gameToken: "t" },
{ type: "kopia", snapshotHost: "h", snapshotPath: "/p" },
@@ -469,7 +468,6 @@ describe("utils/config/service-helpers", () => {
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 === "qui")).toEqual(expect.objectContaining({ instance: 2 }));
expect(widgets.find((w) => w.type === "speedtest")).toEqual(
expect.objectContaining({ bitratePrecision: 3, version: 1 }),
);
@@ -526,38 +524,6 @@ 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 () => {
const mod = await import("./service-helpers");
const { findGroupByName } = mod;
-2
View File
@@ -37,7 +37,6 @@ const components = {
emby: dynamic(() => import("./emby/component")),
esphome: dynamic(() => import("./esphome/component")),
evcc: dynamic(() => import("./evcc/component")),
feed: dynamic(() => import("./feed/component")),
filebrowser: dynamic(() => import("./filebrowser/component")),
fileflows: dynamic(() => import("./fileflows/component")),
firefly: dynamic(() => import("./firefly/component")),
@@ -123,7 +122,6 @@ const components = {
pyload: dynamic(() => import("./pyload/component")),
qbittorrent: dynamic(() => import("./qbittorrent/component")),
qnap: dynamic(() => import("./qnap/component")),
qui: dynamic(() => import("./qui/component")),
radarr: dynamic(() => import("./radarr/component")),
readarr: dynamic(() => import("./readarr/component")),
romm: dynamic(() => import("./romm/component")),
-125
View File
@@ -1,125 +0,0 @@
/* 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
@@ -1,80 +0,0 @@
// @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
@@ -1,61 +0,0 @@
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
@@ -1,183 +0,0 @@
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
@@ -1,114 +0,0 @@
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
@@ -1,169 +0,0 @@
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
@@ -1,8 +0,0 @@
import feedProxyHandler from "./proxy";
const widget = {
api: "{url}",
proxyHandler: feedProxyHandler,
};
export default widget;
-12
View File
@@ -1,12 +0,0 @@
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
@@ -1,77 +0,0 @@
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
@@ -1,149 +0,0 @@
// @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
@@ -1,19 +0,0 @@
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
@@ -1,11 +0,0 @@
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,7 +31,6 @@ import duplicati from "./duplicati/widget";
import emby from "./emby/widget";
import esphome from "./esphome/widget";
import evcc from "./evcc/widget";
import feed from "./feed/widget";
import filebrowser from "./filebrowser/widget";
import fileflows from "./fileflows/widget";
import firefly from "./firefly/widget";
@@ -113,7 +112,6 @@ import pulse from "./pulse/widget";
import pyload from "./pyload/widget";
import qbittorrent from "./qbittorrent/widget";
import qnap from "./qnap/widget";
import qui from "./qui/widget";
import radarr from "./radarr/widget";
import readarr from "./readarr/widget";
import romm from "./romm/widget";
@@ -193,7 +191,6 @@ const widgets = {
emby,
esphome,
evcc,
feed,
filebrowser,
fileflows,
firefly,
@@ -280,7 +277,6 @@ const widgets = {
pyload,
qbittorrent,
qnap,
qui,
radarr,
readarr,
romm,