mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 21:56:18 -08:00
feat(self-hosted): keep playlists on the server for every device (#1903)
* feat(services): server storage connection state and access token Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(services): server kind for the playlist storage backend Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(services): server storage API client The @iptvnator/services/server-storage sub-entry keeps server mode out of the eager services barrel. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(services): playlist document store over the server API Full documents are cached by ETag (weak tags included) and served as private copies; an upsert whose payload is the one the store handed out sends keepPayload=1 without it and retries in full on payload_missing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(services): server-synced user data storage Synced Xtream maps live in a write-through cache under the iptvnator-server: prefix; writes send only the playlist entries that changed, debounced, and flush with keepalive when the page hides. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(services): server storage session and token interceptor The session plugs the HTTP document store into PlaylistStorageBackend, hydrates synced user data before the first read (bounded to 3 s), retries while offline, flushes on hide, and routes to sign-in when the server answers 401. The interceptor adds the access token to HttpClient calls to the backend. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(services): move browser playlists to the server An empty server gets the browser playlists with their ids plus the legacy Xtream user-data maps; a server that already has playlists goes through the backup export and import, so duplicates merge. Browser data is never deleted; the decision is remembered per server instance. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(pwa): probe for server storage and wire server mode Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(pwa): server sign-in page and migration dialog Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(settings): server section for self-hosted storage Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(workspace): server status in the rail and an offline banner Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(settings): warn that delete-all clears the server Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(i18n): server storage strings Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(e2e): server storage across browsers and the migration dialog Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(self-hosted): server storage mode, sign-in and the release note Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(pwa): stay in server mode when a known server does not answer A failed start-up probe used to fall back to browser mode, which showed an empty library while the backend restarted and let new playlists land in the browser. The browser now remembers the server; without an answer it starts in server mode, offline, and reloads once the server is back. Settings → Server shows the backend address instead of the page's. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): keep Xtream data, ordered sync, sign-in and stale-cache fixes - Moving browser playlists matches them to the server's by id or source and carries the legacy Xtream maps under the server id; it no longer goes through the backup format, whose PWA export has no Xtream user data. A partial move stays on offer. - User-data batches go out in order, and a re-read keeps entries edited while it was in flight. - A keepPayload write keeps its cached copy only when the server replaced the version its payload came from (new X-Replaced-ETag header). - 502/503/504 from the proxy count as offline. - The start-up screen lets the sign-in page through while a token is required. - Settings → Server offers a reload after a move. - The probe detects Electron through RuntimeCapabilitiesService. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): merge user data on move, keep failed writes, sign-in route gate - A merged playlist gains the browser document's favorites and history; Xtream values for one server entry are combined (lists unioned, maps merged, the server winning conflicts) and written once. - A failed user-data write stays queued and survives a re-read; the page-hide flush waits for a batch still travelling. - The start-up screen lets the sign-in route through whatever its check reports. - The backend refuses IPTVNATOR_ACCESS_TOKEN without server storage. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): positions by content, acknowledged keepalive, serial re-reads, token changes - Moved playback positions are matched by content type and Xtream id and name the server playlist. - Page-hide writes report whether the server took them; refused ones go back into the queue. Re-reads run one at a time. - The access guard's 401 carries WWW-Authenticate: Bearer realm="iptvnator"; the interceptor and PwaService's raw fetches turn it into sign-in, so a token changed on the server is noticed on provider calls too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): Stalker keys by category, keepalive ordering, cut-off probe - Moving Stalker favorites and history keys items by id and category, so a channel and a movie with one id both survive. - Writes after a page-hide flush wait for its keepalive requests. - A probe whose body is cut off counts as unreachable and keeps the remembered server. - Settings → Server renders without @defer so settings search can reveal its rows on the first visit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): cache baseline before first read, versioned retries, M3U favorite ids - Before the first user-data read finishes, an edit is compared with the cache, so it sends only its own playlist entry. - Queued entries carry their edit number; a failed write is retried only when nothing newer replaced it. - Moving M3U favorites and history translates channel ids through the stream URL (ids are random per import). - The server-storage E2E runs in Chromium only: one shared database. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): keep URL favorites, flush before moving, cache-full and cut-off bodies - Moving M3U favorites keeps URL favorites and anything the server cannot resolve; only older channel ids are translated. - Queued user-data edits are sent before the move writes combined values. - A re-read that does not fit the cache keeps the old cache and baseline together. - A response body cut off after its headers counts as offline. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): unsent writes survive reloads, guarded move, lazy storage - User-data writes the server has not confirmed are kept under iptvnator-server:unsent-writes and sent first on the next visit. - Moving browser data does not start while user-data writes are unsent, and a duplicate of a failed upload counts as failed, not merged. - The session reaches localStorage through the lazy adapter, so a browser that denies storage still boots. - Raise launch/renderer.domMutationsToFirstCard by 2 (569 → 571): the rail server shortcut and the offline banner each leave an @if anchor in every build. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): locks follow merged playlists, typed legacy Xtream ids, instance-scoped cache - Moving a playlist under another server id takes its parental locks along (they stay in the browser). - Bare legacy Xtream ids in each copy are typed from that copy's own snapshots before favorites, history and snapshots are combined, so a live channel and a movie sharing an id both survive. - Writes still travelling are saved on page hide too; saved writes and the cache belong to one server database (cache owner = instanceId). - A late successful answer no longer turns unauthorized back to online. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): apply early edits to the server value, newest unsent write, sticky sign-out - Edits made before the first successful user-data read are kept as before → after and applied to the server's value once it arrives (applyEdit), so a new device or a stale cache adds a favorite instead of replacing the server's list. - Saved unsent writes keep only the newest per entry; an answered older write no longer retires a newer one still travelling. - A late failure after sign-out no longer turns unauthorized into offline. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): keep early edits across reloads and tabs, numeric-string legacy ids - Edits made before the first read are saved (before → after) and restored after a reload; a second early edit keeps the first one's baseline, even an empty one; a cache-full first read still applies them to the server's value. - Queued writes are saved as soon as they are queued, and a re-read keeps entries another tab has not confirmed yet. - Legacy Xtream ids stored as numeric strings are typed too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): per-tab saved writes, memory overflow cache, snapshot aliases - Unconfirmed writes and early edits are kept in the tab's own sessionStorage, so tabs never clear each other's; a shared registry of unconfirmed entries (one record per tab, ignored after ten minutes) keeps a re-read in one tab from replacing another tab's values. - A cache value too big for localStorage stays readable from memory, so later edits are diffed against what the app reads. - Typing legacy Xtream ids keeps each snapshot's bare key as an alias, so a reference not yet rewritten still resolves and a retry can type it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): conditional user-data writes, scoped token, deletes - User-state entries carry a content tag; PUT/DELETE accept If-Match or If-None-Match: * and answer 412 with the current value and tag. The PWA sends each edit on the tag it read and replays it (before → after) on the server's value after a conflict, so another device's change to the same playlist's favorites or positions is kept instead of overwritten. - The access token goes only to the backend URL (same origin and path), never to another host or a sibling path. - Deleting a playlist (or all) first drops its queued user-data edits and waits for writes in flight, so none recreates deleted data. - The migration copy writes on the tags it read. - The write queue moves out of ServerUserDataStorage into UserDataWriteQueue. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(server-storage): conditional user-state writes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): keep tags with their values, rebase page-hide writes, hold deletes - A re-read that keeps an entry from the cache (another tab's or this tab's unconfirmed value) no longer takes the server's tag for it, so the next edit conflicts and is replayed instead of overwriting another device's change. - Page-hide writes behind a travelling one are taken from the queue when it is answered, so its new version becomes their baseline. - A playlist delete holds that playlist's user-data edits instead of discarding them up front; only a successful delete drops them, a failed one sends them as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): replay edits behind travelling writes in order, hold writes after an offline start - An edit queued behind a travelling write keeps its own before → after and carries the travelling write's edit as `prior`, also in the saved writes. A conflict replays both in order, so adding and then removing a favorite removes it whether or not the first write landed, and a lost write is not dropped. - Page-hide writes now report their new tag or a 412 like normal writes: a confirmed one rebases the next edit, a conflict is replayed. - After an offline start from a remembered instance, writes are held and the retry only checks /instance before reloading, so saved writes never reach another database behind the same address. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): durable per-page journal, conflicts for edits on other tabs' changes, lock readability - Unconfirmed writes and early edits are saved in localStorage per page load; the tab remembers its page load in sessionStorage, so a reload takes them over, and another tab replays a closed tab's writes once its record is stale. Live tabs renew their record every 30 s. - An edit made on another tab's unsent change, and an entry a re-read keeps from the cache, are sent on a version no entry has, so the write conflicts and is replayed instead of carrying another tab's change past a later removal. - Moving browser data counts the parental-lock step as failed while the lock store cannot be read, so the move stays on offer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): keep edits as ordered steps, treat probe refusals as transient, read the cache through memory - A queued write keeps its edits as steps: an edit continuing the last one joins it, one made behind a travelling write or on another tab's change stays apart, and a conflict replays them in order. Saved writes and early edits store the steps too. - The instance probe treats any refusal but 404 (a proxy's 429 or 403) as unreachable, so a browser keeps its remembered server. - Cache reads that browser storage refuses fall back to memory, so a re-read and later edits still work. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): keep steps of possibly-landed writes apart, queue closed tabs' edits even when the cache shows them - Steps of a write whose answer was lost, of saved writes taken over after a reload, and of a closed tab's saved edits are marked uncertain, so a later edit never joins them: adding and then removing an item after a lost answer still removes it. - A closed tab's saved edits are queued ahead of this tab's own even when the cache already shows them, so a conflict replays them too. - The tag map moves into UserStateTags. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * perf(workspace): style the server banner from the shell, baseline 570 The offline banner's own stylesheet added a <style> element to every launch, one of the two mutations the launch journey counted on top of master. Its rules move into the workspace shell's stylesheet. The other one is a parser-inserted modulepreload link: the new lazy server-storage chunks split one shared initial chunk in two. A/B on macOS: master 555, this branch 556 (was 557); the baseline note now says so instead of blaming @if anchors, which cost nothing here. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): apply a caller's edits to the cache as it is now ServerUserDataStorage remembers the map getItem handed out. When the cache changed since (another tab saved, a re-read), a write applies only the entries the caller changed to the current cache, so a map read before another tab's save no longer reads as deleting that save. The cache moves into UserDataCache. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): retry unsynced user data while online, keep newer typed snapshots, explain a refused token store - The 30 s retry also re-reads and flushes while online when the first user-data read failed or writes the server refused (a 5xx) are queued; the same failure notice shows at most every five minutes. - Moving browser data adds a legacy snapshot alias only where no typed snapshot exists, so a newer typed copy is not overwritten. - writeServerAccessToken reports whether storage kept the token; the sign-in page says so (SERVER_STORAGE.SIGN_IN_STORAGE, 19 locales) instead of calling a right token rejected. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): conditional playlist metadata writes, retried on a fresh read A favorite or rename (keepPayload PUT) is sent with If-Match on the version it was read at. The server answers 412 when another device wrote since; the store throws PlaylistWriteConflict and PlaylistsService's per-playlist write queue runs the mutation again, which reads the playlist afresh, so two devices' metadata edits both survive. A retry on the same unread copy writes unconditionally, as before; refresh and import writes stay last write wins. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): conditional writes for every playlist mutation, ordered adoption, keep journals until copied - Every playlist mutation is sent on the version it was read at: the payload's ETag, else the playlist's last read (portal documents have no payload). Refused versions are tracked per playlist, so a conflict on one never disables the check on another at the same revision. Imports and start-up migrations (dbUpsertAppPlaylists) stay unconditional. - A closed tab's adopted edits replay before this tab's queued ones, from the base of its first step, so newer progress is not replaced by older. - A reload or adoption removes the old page load's saved writes only when this page saved its copy; each closed tab is adopted once per page. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): copy-bound portal versions, stale 401s, unreadable browser maps, early edits on recovered writes - A served copy carries the version it came from (a symbol property that spreads keep and JSON drops), so a mutation of a portal document names the version it was built on, not the latest read. - A 401 for a token replaced since (a new sign-in) neither clears the new token nor reports unauthorized, in the API client, the HttpClient interceptor and PwaService's proxy calls. - Moving browser data counts a browser user-data map that cannot be read as a failure, so the move stays on offer. - An edit made before the first read on top of this tab's recovered unsent write is applied to that write, not to the server's value. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): favorite toggles as deltas, early edits as steps on recovered writes, release kept journal copies - PlaylistEffects.updateFavorites$ applies the toggled channel through transformPlaylistFavorites, on the favorites read at write time, instead of writing the reducer's whole array: a conflict retry keeps another device's favorites (#1919). - Edits made before the first read on top of this tab's recovered unsent write are appended to it as their own steps, so a removal of an item the recovered write adds survives a conflict replay. - Journal copies of earlier page loads kept because this page could not save its own are removed once it can. - adopt/extend move into server-user-data-adopt.ts over a small queue API (unsent, isQueued, put); the queue exposes its tag map. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): refuse user state for deleted playlists PUT /v1/user-state/:namespace/:playlistId answers 404 playlist_missing when the server has no such playlist, so an edit queued on another device (including its conflict replay with If-None-Match: *) cannot recreate the state of a playlist deleted meanwhile. The client drops such an edit and its cache entry instead of retrying; page-hide writes report it as gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): keep duplicated tabs off live journals, drain writes around remove-all, normalize legacy Stalker rows on the move - A page marks its unconfirmed-writes record closed on pagehide (not into the back-forward cache); a new page takes over the previous page load's saved writes only when that record is closed, stale or missing, so a duplicated tab, which copies sessionStorage, leaves the running tab its writes. - PlaylistsService.removeAll lets queued playlist writes land first and makes writes issued meanwhile wait until it finished; the server store refuses a document without an id. - Moving browser data sets the explicit Stalker portal-mode flag on rows from before it (withExplicitStalkerPortalFlag, now shared with PlaylistsService). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): take over a reload's writes once the previous page load stops looking live When the close flag could not be saved (storage full), a reload took the previous page load for a running duplicated tab and left its saved writes unsent. The page now remembers that page load and its 30 s touch takes it over as soon as its record is closed, stale or gone, so the session's retry sends the writes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): adopt a late previous page load instead of restoring it When touch() finds that the previous page load no longer looks live, this page may already have read and queued newer edits. Its saved writes and early edits are now adopted like a closed tab's (their steps ahead of this page's newer ones, queued at once) instead of restored as if this page had just started, which replaced newer progress with older and parked early edits that were never sent after the first read. Cache forgetting moves into UserDataCache.forget(). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): adopted writes without a known version conflict instead of overwriting A page that never read the server has no version tags, so an adopted write went out unconditionally and replaced newer server data with its cached value. Adopted edits now use the never-matching version when this page knows none (and for a queued write without a tag), so the server answers 412 and the edits are replayed on its value. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): a re-read sent before a playlist delete skips that playlist ServerUserDataStorage records each successful delete; a re-read sent before it drops the deleted playlist's entries and tags from the server's answer (withoutDeleted), so a late answer cannot write the deleted favorites and positions back into the cache. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): keep a playlist id like __proto__ when filtering a read withoutDeleted() builds its maps without a prototype, so a surviving playlist whose id is __proto__ stays a key instead of changing the object's prototype and losing its favorites and tag. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): reorders on the stored favorites, URL-insensitive remembered server, prototype-free id maps - PlaylistEffects.setFavorites$ applies the drag's order through transformPlaylistFavorites (reorderFavorites): favorites added on another device stay, ones removed there stay out. - The remembered server matches the backend URL by origin and path without trailing slashes, so /api and /api/ start offline in server mode alike. - Maps keyed by playlist id are built without a prototype (idMap), so an id such as __proto__ is an ordinary key in the cache, conflict replays, re-reads and tags. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): one registry key per page load, server token only in server mode - The unconfirmed-writes registry stores each page load's record under its own key (iptvnator-server:unconfirmed:<instance>:<page>), listed through a new keys() on the user-data storage adapter, so tabs never overwrite each other's records. Saved writes without a fresh record, also when the record was lost, are adopted as a closed tab's (#1920). - PwaService sends the stored server token on its raw fetch calls only while server mode runs, and a backend that answers without server storage makes the browser forget the token. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): forget the token for another server, listen before sign-in, adopt only stale records - A server that answers as another backend URL or instance id than the remembered one makes the browser forget the stored token, so server A's token never reaches server B. - The session installs its page-hide and heartbeat listeners before it returns for sign-in, so a page holding another page load's writes still marks itself closed on unload. - Closed tabs are found by stale records only again: saved writes without a record may belong to a running tab whose record storage refused, and are left to that tab's own reload. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): no token for an unverified instance, sign-in remembers its server - ServerStorageStatusService.accessToken() returns the stored token only while server mode runs on an instance this start verified. The API client, the HttpClient interceptor and PwaService's raw fetch calls use it, so after an offline start from a remembered instance no request (including the /instance reachability check) carries the token to whatever answers at that address, and a 401 then does not clear it. - Sign-in remembers the server along with the token; when storage refuses either, it reports SIGN_IN_STORAGE instead of reloading into a start that would drop the token. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): check a remembered instance before sign-in sends the token A page started from memory (startedOffline) withholds the token until the instance is verified, so sign-in on that page sent no token and the server's 401 cleared the right one. Sign-in now first calls ServerStorageSession.ensureVerified(): it reads /instance without a token, marks the status verified when the instance id matches, and reloads the page when another server answers at the address. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): read migration maps by own playlist ids only The browser's legacy maps and the server's user-state entries are read into null-prototype maps, so a playlist id such as `toString` is not taken for an entry and a missing server value no longer resolves to an inherited function. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): bind the token to its instance; adopted edits yield to newer changes The access token is stored with the instance id it was given for, and a page reads it only for its own instance: a tab still open on the old backend after BACKEND_URL changed no longer sends the new server's token there. The API client hands every 401 to reportAccessDenied(), which now also sends a page without a token for its instance to sign-in, while one that holds its token back until the instance is checked keeps it. A closed tab's adopted edits carry `yields`: replayed, they change only what is still as that tab saw it, so they never replace progress this tab or another device saved since. The flag survives the journal. The sync spec's fake server and memory storage move to a test-helpers file to keep the spec under its line limit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): per-instance cache, idle tabs adopt closed ones, backups wait for the first read The user-data cache is keyed by server instance (`iptvnator-server:cache:<instanceId>:<namespace>`): a tab still open on the old backend after BACKEND_URL changed no longer writes over the new server's cache, which the new tab's next edit would have sent there. claimCache also removes the old owner's keys and the unscoped keys of earlier builds. touch(), which runs every 30 s, adopts closed tabs' saved writes once this tab has read: a tab with nothing to sync never re-reads, so they waited until a tab switch or reload. UserDataStorage gains an optional ready(); the server storage resolves it after its first read and rejects while that fails. A PWA backup export awaits it, so a fresh browser whose /user-state read is slow or failed cannot export empty favorites, history and positions. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): token only to backend URLs in raw fetches; Stalker series keys in the move PwaService joins paths to the backend URL without its trailing slashes, so BACKEND_URL=/ no longer yields a protocol-relative //… URL that names another host, and it attaches the server token only when the request URL resolves to the backend (isBackendUrl), like the interceptor. The move's item key adds the Stalker series flag to id and category: a provider category can hold a movie and a series of one id, and the series favorite was dropped as a duplicate. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): Xtream reads and backups wait for the first read; keepalive deadline Startup waits for the first user-data read only a few seconds. ready() now waits for a first read in flight without starting one (an offline page is not held up) and tells whether one succeeded: - PwaXtreamDataSource awaits it before reading favorites, recent items and playback positions, so a new device whose /user-state read is slow does not show empty lists or start playback without its resume point. - A PWA backup export and restore fail while no read succeeded; the restore stays parked, since its replacement lists would otherwise merge into the server's (an empty favorites list changed nothing). A page-hide keepalive write gets the request timeout: a server that takes it and never answers no longer holds every later write, re-read and delete behind it until reload. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): a failed sign-in keeps another tab's token; moved history newest first A sign-in that fails clears the token only while it is still the one this attempt stored: another tab that signed in meanwhile keeps its valid token. The move orders the combined Xtream recent items by viewedAt, newest first, as the app keeps them: browser views appended after the server's history were cut by the next view once the server held 50. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): recheck the instance after offline, back-forward cache pages, Stalker server rows Going offline now marks the instance unchecked: the token and writes wait until /instance answers with this page's instance (another one reloads the page). A replaced volume at the same address no longer gets the old token and queued user-state writes on reconnect. The session checks as soon as it goes offline, so a blip costs no retry interval. A page restored from the back-forward cache whose record another tab adopted meanwhile (its timers were frozen, so the record went stale) reloads instead of sending those writes again after newer edits. The Stalker playlist metadata migration also runs on server rows, once per server: a desktop database copied to the server can predate it, and its Stalker rows lacked isFullStalkerPortal. The take-over and change-diff loops move out of the user-data facade, and the adoption specs get their own file. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): adopted edits keep yielding through conflicts; newest view per item; same-token sign-ins A conflict replay collapsed every step into one edit, so a closed tab's adopted step lost `yields` after the first conflict and a second one replayed its old position over a newer one. Its steps now stay as they are, and only this page's own collapse into one edit on top of them. The move combines recently viewed items one per item, keeping the newest view and its artwork, then newest first; before, the server's older copy of an item won over a later browser view. A failed sign-in clears the stored token only on a 401: a network failure says nothing about the token, and another tab may have stored the same one meanwhile. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): suspended tabs give up adopted writes; history stays newest first; favorite checks wait A background tab the browser suspended past the record lifetime can have its writes adopted elsewhere, like a back-forward cache page: shown again, it now checks resumed() and reloads instead of sending them. Conflict replays appended a newly viewed item at the end of the history, where the next 50-item cut dropped it; a replayed recently viewed list is now kept newest first. isFavorite() waits for the first user-data read like the list reads, so a detail page opened during a slow first read does not toggle an existing favorite the wrong way. A parked backup restore replayed when the playlist opens waits too, and stays parked while no read succeeded. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): offline recheck checks only the instance; sign-out always navigates The recheck on going offline ran a full refresh: when /instance answered but /user-state kept failing, each failed read set the page offline again and started the next one at once, bypassing the retry interval. It now checks the instance only; reads wait for the retry. Sign-out navigates to sign-in itself, also when the page is signed out already: a guard (unsaved Settings) may have cancelled the redirect the first time, and the state change that drives it does not repeat. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): a superseded page sends and saves nothing; re-reads keep other tabs' newer saves A suspended tab's retry timer could run before it was shown again: touch() recreated the record another tab had removed when it adopted the page's writes, so the page sent them again over newer edits. The facade now treats a page whose record is gone as superseded: the queue sends nothing (maySend hook), its journal and record are not written again, and the retry timer reloads it like a page shown again. A re-read answered after another tab saved an entry no longer writes the older answer over the shared cache: entries changed while the read travelled are kept, with an unknown tag, so the next write here conflicts and replays instead of removing what the other tab saved. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(web-e2e): never reuse another backend as the storage E2E backend server-storage.e2e.ts deletes every playlist before each test. Reusing a backend already listening on STORAGE_BACKEND_PORT outside CI could have wiped a real library; an occupied port now fails the run instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): an outage on the sign-in page also unverifies the instance reportOffline() left `verified` set while the page was unauthorized, so a sign-in retried after an outage skipped the instance check and could send the old instance's token to a replacement database. Verification is now cleared on any outage; only the state change stays guarded, so the workspace remains locked. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): interceptor and raw fetches report backend outages too Only the storage API client reported an unreachable backend as offline. When the first sign of an outage was an HttpClient call through the interceptor (provider targets, playlist parsing) or one of PwaService's raw fetches (Stalker, connectivity reset), the instance stayed verified, and the next request after a replacement instance came up at the same address would carry the old token. Both now report no answer (status 0, a failed fetch) and a gateway's 502/503/504 as offline, so the session checks /instance first. The backend's own JSON 502 for a failed provider is an answer and changes nothing; neither does a fetch this page aborted on its own timeout. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): the immediate offline check runs once per retry interval A response whose headers arrived but whose body was cut off reports success and then offline, which started the next immediate instance check at once: repeated cut-off answers kept requests going and the connection indicator flickering. The check on going offline now runs at most once per retry interval; later failures wait for the timed retry. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): a saved write without a readable tag counts as saved; unverified online pages keep retrying A write whose headers said saved but whose body (with the new tag) was cut off was retired without confirmation: a newer queued edit kept its steps and old tag, and a later conflict replayed the saved step over a newer value from another device. It now counts as saved on an unknown version: the landed steps drop out and the next write conflicts, so only edits made after it replay. Writes that were unconditional stay so. The keepalive path does the same. A late answer to a request sent before an outage can set an unverified page online; with nothing to sync the retry then never ran and the token stayed withheld. The retry now also runs for online pages that are not verified. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): keep server storage off the startup bundle; form-field defaults on sign-in PwaService imported isBackendUrl from the @iptvnator/services/server-storage barrel, which pulled the whole storage subsystem (sync, migration, API client) into main, though server mode loads it by dynamic import. The helper now lives with the token helpers in @iptvnator/services (re-exported from the interceptor): renderer.initialBytes 1,392,902 -> 1,357,795 bytes locally. The remaining 11,988 bytes over #1931's baseline are what must run before the first route (instance probe, connection status, token bound to its instance, storage backend and write serialization); the baseline is raised with a note, under the PR's perf-baseline-increase label. The sign-in component provides the form-field defaults per component, as master's check (#1928) now requires. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): conflict-safe Stalker metadata migration on the server; newest document views in the move The Stalker metadata migration wrote server rows back through the unconditional bulk upsert, from the list read: a device that edited a Stalker playlist in between (or a second browser migrating the same copied database) lost its change. Server rows are now read again one by one and written as conditional mutations on that version; a conflict leaves the flag unset, so the next start retries. Moving a document's history kept the server's copy of an item both held, even when the browser had viewed it later, and skipped the write when the length did not change. The combined history is now newest first by added_at, one entry per item, and any content change is written. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): M3U favorites by URL and history channel ids in the move; restores wait for the server The move's M3U remap swapped references for server channel ids, but the app looks favorites up by stream URL and history entries carry the URL as their id: legacy favorites became invisible and history duplicated. Legacy id favorites now become their channel's URL on both sides, and history keeps its URL id with the server's channel id as channel_id. A restore in server mode reported success once its lists were queued: a list the server refused (413) left the old data while the parked restore was consumed. Both restore paths now wait for that playlist's writes (UserDataStorage.flushed, a few flush rounds for conflict replays) and stay parked when the server did not take them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): a closed page record stays closed when a late write rewrites it On pagehide the page flushes with keepalive and then marks its record closed. When writes were held (offline), the keepalive write fails at once and its requeue saved the journal again a microtask later, which rewrote the record without `closed`: the reload took the previous page load for live and left its favorites and progress unsent until the record aged out (ten minutes). markUnconfirmed now keeps an existing record's closed flag. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): the move uploads playlists create-only and merges into a copy moved first Two tabs or devices moving browser copies with the same playlist id both planned an upload; the browser documents carry no served version, so both PUTs were unconditional and the later one overwrote the first copy (and any change made on it since), with both moves reporting success. The backend's playlist PUT now honours `If-None-Match: *` (412 when the playlist exists; checked and written in one synchronous handler). The move uploads with it through ServerPlaylistDocumentStore .dbCreateAppPlaylist, and an upload that finds its id taken merges its favorites and history into that copy instead, counted as merged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): Delete All waits for repairs holding a reservation; backups read the server again Delete All raised its write barrier before it held the exclusive authority reset. A Stalker repair already holding its row reservation then waited on that barrier for its conditional write, while the reset waited for the reservation: neither finished, and later playlist operations stalled behind them. The barrier now goes up inside the reset, once repairs in flight have finished; writes queued so far land first and new ones wait for the delete, as before. ready() said yes after any earlier read, so an export could miss what another device added since, and restoring an empty list did nothing when the cache was already empty. Backup export, restore and the restore replay now ask for a fresh read (ready(true)) and fail, the restore staying parked, when it does not succeed. The facade's adoption loop moves to the adopt module to keep the facade under its line limit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(services): Delete All drains queued writes before its reset; reserved writes skip its barrier The previous commit raised Delete All's barrier inside the exclusive authority reset and drained queued writes there, but a queued deletion or import takes the shared side of that lock: the reset waited for it while it waited for the reset. Delete All drains the writes queued before it outside the lock again, then takes the reset, with its barrier up from the start. The deadlock with a Stalker repair (or Edit) that holds a row reservation and writes under it is broken on the other side: such a write does not wait for the barrier (holdsPlaylistAuthority, counted per reservation in this context), and the reset waits for the reservation as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): a re-read keeps writes another tab had unconfirmed when it went out A re-read checked other tabs' unconfirmed entries only after its answer arrived. A tab whose write was unconfirmed when the read went out (its edit already in the snapshot) and confirmed before the old answer came back matched neither check, so the stale answer replaced its saved value in the shared cache; that tab's next edit, holding the newer tag, then wrote the stale list and deleted the item on the server. The entries unconfirmed when the read is sent are kept too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(services): writes wait for Delete All before they queue; restores replace only settled lists Delete All, third variant: a write that arrived after Delete All waited for it inside its playlist's queue. A Stalker repair that reserved that playlist meanwhile skipped the barrier, but its write still queued behind that waiting one, while the reset waited for the repair's reservation. While a Delete All runs, writes now wait for it before they join their queue, so no queue holds work blocked on the reset; a write under a reservation held here joins at once. A restore replaced lists while this page's own write for the playlist was still unsent (refused, or lost): that write's edits joined the replacement and a conflict replay kept items the backup did not have. Both restore paths now require the playlist's writes to be on the server first (flushed), and otherwise stay parked. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): a reload finds page loads a reload before it kept; restore master's tick baselines When storage refused a reload's copy of the last page load's saved writes, the old copy stayed but only this page's memory named it: the tab's pointer already named the new page, so a second reload before any copy was saved recovered from the empty new page, and without a registry record no other tab could find the old one. The pointer now also lists the page loads a page keeps (keepPages), and a reload takes them over, oldest first, before its last page load. The pointer helpers move to server-page-pointer.ts. Both merges of master resolved journey-baselines.json by taking this branch's whole file, which dropped #1929's cdTicksToFirstCard, cdTicksIdle30s and cdTicksToFirstPage. The file is master's again with only this branch's initialBytes and domMutationsToFirstCard raises. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): a duplicate leaves a running tab's kept page loads to it A duplicated tab copies the tab's session storage, so after a reload that kept older page loads its pointer lists them too, and the duplicate took them over at once, although the tab still running owns those edits and may have newer ones queued; its replay could then write older watch progress over newer. When the previous page load still runs, the kept ones now wait with it and `touch` adopts them, oldest first, once it no longer looks live. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): a restore merged by a conflict stays parked; first-read tests wait for the read A restore whose write conflicted with an edit another device made during it had the conflict replay merge that edit in, and flushed() still said the lists were taken, so the restore was consumed with lists that were not the backup's. Both restore paths now compare the stored lists with the ones they wrote once the server took them, and a difference keeps the restore parked to run again on a fresh read. The two first-read tests released the mocked read after a fixed number of microtask turns, which could come before the read started; they now wait until it has. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): page-hide keepalive writes report an outage too sendKeepalive() turned a failed fetch, a timeout or a gateway's 502/503/504 into a failed outcome without telling the status, so the instance stayed verified: when the hidden tab came back, its refresh skipped the /instance check and resent the queued writes, which could reach a replacement database behind the same address. Like requests, a keepalive write now reports these as offline, so the instance is checked before anything goes out again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): a keepalive answer cut off after its headers counts as offline A page-hide write whose headers said saved but whose body broke off was returned as saved with the read error dropped, so the instance stayed verified. Like readJson() for requests, it now reports offline (the outcome stays saved on an unknown version), and the instance is checked before the next token or write goes out. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): kept page loads replay oldest first; restore checks compare normalized reads A page load and an older one it kept could both hold steps for one entry. Adopting them, each adopted page went ahead of what was queued, so adopting the oldest first put the newer page's steps before the older's; restoring them after a reload collapsed the newer page's steps into one, so its removal of an item the older page had added was lost on a conflict replay. Adoption now goes newest first, and a reload appends a later page's steps (and early edits) after an older copy's. restoreUserData() compared the lists it wrote with reads that drop duplicate favorites, so a saved restore listing one twice never finished, also in browser-only mode. It now compares reads right after writing with reads after the save. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): flushes wait for a page-hide batch sent at once; keepalive 401s; parked restores move A flush scheduled in the same turn as a page-hide flush was chained on the queue before the keepalive batch, which went out at once beside it: a later edit (removing a favorite the keepalive batch added) could land first and be undone when the keepalive write arrived. A scheduled flush now waits for a page-hide batch sent at once before it takes entries. A keepalive write refused with 401 returned failed without telling the status; like requests it now reports the token it sent as refused. The move copies a parked Xtream restore (a browser backup import that could not finish) to the server playlist id a playlist merged into, so it is applied and exported there; one parked there already wins. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): a slow server does not reload a page started from memory forever The start probe gives /v1/instance 2.5 s. A remembered server slower than that started the page from memory; the recovery check (with the request timeout) then got an answer and reloaded, and the reloaded page timed out again: an endless reload loop for a server answering in 2.5 to 20 s. The reload now leaves a one-time mark in sessionStorage, and the next probe waits as long as requests do. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2139ddd5c0
commit
8098ea8bb8
147 files changed
+15337
-189
No files matched your search
@@ -6,6 +6,7 @@ import {
|
||||
selectPlaylistsLoadFailed,
|
||||
selectPlaylistsLoadingFlag,
|
||||
} from '@iptvnator/m3u-state';
|
||||
import { provideRouter, Router } from '@angular/router';
|
||||
import { AppStartupStatusComponent } from './app-startup-status.component';
|
||||
|
||||
describe('AppStartupStatusComponent', () => {
|
||||
@@ -13,6 +14,7 @@ describe('AppStartupStatusComponent', () => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [AppStartupStatusComponent, TranslateModule.forRoot()],
|
||||
providers: [
|
||||
provideRouter([{ path: 'server-sign-in', children: [] }]),
|
||||
provideMockStore({
|
||||
selectors: [
|
||||
{ selector: selectPlaylistsLoadingFlag, value: false },
|
||||
@@ -48,6 +50,23 @@ describe('AppStartupStatusComponent', () => {
|
||||
expect(fixture.componentInstance.complete()).toBe(true);
|
||||
});
|
||||
|
||||
it('shows the server sign-in page although sources could not load', async () => {
|
||||
TestBed.inject(MockStore).overrideSelector(
|
||||
selectPlaylistsLoadFailed,
|
||||
true
|
||||
);
|
||||
const fixture = TestBed.createComponent(AppStartupStatusComponent);
|
||||
fixture.componentRef.setInput('routeReady', true);
|
||||
fixture.detectChanges();
|
||||
expect(fixture.componentInstance.complete()).toBe(false);
|
||||
|
||||
await TestBed.inject(Router).navigateByUrl('/server-sign-in');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.componentInstance.complete()).toBe(true);
|
||||
expect(fixture.nativeElement.querySelector('section')).toBeNull();
|
||||
});
|
||||
|
||||
it('offers a retry on failure without rendering raw storage errors', () => {
|
||||
TestBed.inject(MockStore).overrideSelector(
|
||||
selectPlaylistsLoadFailed,
|
||||
|
||||
@@ -5,14 +5,18 @@ import {
|
||||
inject,
|
||||
input,
|
||||
} from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { NavigationEnd, Router } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { filter, map } from 'rxjs';
|
||||
import {
|
||||
PlaylistActions,
|
||||
selectPlaylistsLoadFailed,
|
||||
selectPlaylistsLoadingFlag,
|
||||
} from '@iptvnator/m3u-state';
|
||||
import { SERVER_SIGN_IN_PATH } from '@iptvnator/services';
|
||||
|
||||
/** The first route also waits for settings and XMLTV source reconciliation. */
|
||||
@Component({
|
||||
@@ -304,8 +308,23 @@ export class AppStartupStatusComponent {
|
||||
selectPlaylistsLoadingFlag
|
||||
);
|
||||
readonly failed = this.store.selectSignal(selectPlaylistsLoadFailed);
|
||||
private readonly router = inject(Router);
|
||||
private readonly url = toSignal(
|
||||
this.router.events.pipe(
|
||||
filter((event) => event instanceof NavigationEnd),
|
||||
map((event) => event.urlAfterRedirects)
|
||||
),
|
||||
{ initialValue: this.router.url }
|
||||
);
|
||||
/**
|
||||
* Server storage asking for a token: the playlists cannot load, and the
|
||||
* sign-in page must stay up whatever its check reports.
|
||||
*/
|
||||
private readonly onSignInPage = computed(() =>
|
||||
this.url().startsWith(SERVER_SIGN_IN_PATH)
|
||||
);
|
||||
readonly complete = computed(
|
||||
() => this.routeReady() && this.sourcesReady()
|
||||
() => this.routeReady() && (this.sourcesReady() || this.onSignInPage())
|
||||
);
|
||||
|
||||
retry(): void {
|
||||
|
||||
@@ -6,6 +6,10 @@ import {
|
||||
SettingsStore,
|
||||
} from '@iptvnator/services';
|
||||
import { WorkspaceStartupPreferencesService } from '@iptvnator/workspace/shell/util';
|
||||
import {
|
||||
serverAccessGuard,
|
||||
serverSignInGuard,
|
||||
} from './server-storage/server-access.guard';
|
||||
import { settingsUnsavedChangesGuard } from './settings/settings-unsaved-changes.guard';
|
||||
|
||||
// The workspace activates only once settings AND the parental lock state
|
||||
@@ -77,6 +81,7 @@ export const routes: Routes = [
|
||||
},
|
||||
{
|
||||
path: 'workspace',
|
||||
canActivate: [serverAccessGuard],
|
||||
resolve: {
|
||||
settingsReady: settingsReadyResolver,
|
||||
},
|
||||
@@ -207,6 +212,14 @@ export const routes: Routes = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: 'server-sign-in',
|
||||
canActivate: [serverSignInGuard],
|
||||
loadComponent: () =>
|
||||
import('./server-storage/server-sign-in.component').then(
|
||||
(c) => c.ServerSignInComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
redirectTo: '/workspace/settings',
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideRouter, Router, UrlTree } from '@angular/router';
|
||||
import { ServerStorageStatusService } from '@iptvnator/services';
|
||||
import { serverAccessGuard, serverSignInGuard } from './server-access.guard';
|
||||
|
||||
describe('server access guards', () => {
|
||||
function run(guard: typeof serverAccessGuard, state: string) {
|
||||
TestBed.resetTestingModule();
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{
|
||||
provide: ServerStorageStatusService,
|
||||
useValue: { state: () => state },
|
||||
},
|
||||
],
|
||||
});
|
||||
return TestBed.runInInjectionContext(() =>
|
||||
guard({} as never, {} as never)
|
||||
);
|
||||
}
|
||||
|
||||
const serialize = (result: unknown) =>
|
||||
TestBed.inject(Router).serializeUrl(result as UrlTree);
|
||||
|
||||
afterEach(() => TestBed.resetTestingModule());
|
||||
|
||||
it('sends an unauthorized workspace visit to the sign-in page', () => {
|
||||
expect(serialize(run(serverAccessGuard, 'unauthorized'))).toBe(
|
||||
'/server-sign-in'
|
||||
);
|
||||
});
|
||||
|
||||
it('lets everyone else into the workspace', () => {
|
||||
expect(run(serverAccessGuard, 'online')).toBe(true);
|
||||
expect(run(serverAccessGuard, 'offline')).toBe(true);
|
||||
expect(run(serverAccessGuard, 'inactive')).toBe(true);
|
||||
});
|
||||
|
||||
it('opens the sign-in page only while unauthorized', () => {
|
||||
expect(run(serverSignInGuard, 'unauthorized')).toBe(true);
|
||||
expect(serialize(run(serverSignInGuard, 'online'))).toBe('/workspace');
|
||||
expect(serialize(run(serverSignInGuard, 'inactive'))).toBe(
|
||||
'/workspace'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { CanActivateFn, Router } from '@angular/router';
|
||||
import {
|
||||
SERVER_SIGN_IN_PATH,
|
||||
ServerStorageStatusService,
|
||||
} from '@iptvnator/services';
|
||||
|
||||
/** Without a valid token the workspace would show an empty library. */
|
||||
export const serverAccessGuard: CanActivateFn = () =>
|
||||
inject(ServerStorageStatusService).state() === 'unauthorized'
|
||||
? inject(Router).parseUrl(SERVER_SIGN_IN_PATH)
|
||||
: true;
|
||||
|
||||
export const serverSignInGuard: CanActivateFn = () =>
|
||||
inject(ServerStorageStatusService).state() === 'unauthorized'
|
||||
? true
|
||||
: inject(Router).parseUrl('/workspace');
|
||||
@@ -0,0 +1,120 @@
|
||||
import { markSlowStart } from '@iptvnator/services';
|
||||
import { probeServerInstance } from './server-instance-probe';
|
||||
|
||||
const instance = {
|
||||
service: 'iptvnator',
|
||||
apiVersion: 1,
|
||||
instanceId: 'i-1',
|
||||
storage: 'server',
|
||||
auth: 'none',
|
||||
};
|
||||
|
||||
describe('probeServerInstance', () => {
|
||||
const answer = (body: unknown, status = 200) =>
|
||||
jest.fn().mockResolvedValue({
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: async () => body,
|
||||
});
|
||||
const config = {
|
||||
runtimeConfig: { BACKEND_URL: '/api' },
|
||||
isElectron: false,
|
||||
};
|
||||
|
||||
it('skips the probe without a runtime backend URL', async () => {
|
||||
const fetchFn = answer(instance);
|
||||
await expect(
|
||||
probeServerInstance({
|
||||
runtimeConfig: {},
|
||||
fetchFn: fetchFn as never,
|
||||
})
|
||||
).resolves.toEqual({ kind: 'skipped' });
|
||||
expect(fetchFn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips the probe in Electron', async () => {
|
||||
const fetchFn = answer(instance);
|
||||
await expect(
|
||||
probeServerInstance({
|
||||
...config,
|
||||
isElectron: true,
|
||||
fetchFn: fetchFn as never,
|
||||
})
|
||||
).resolves.toEqual({ kind: 'skipped' });
|
||||
expect(fetchFn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns the instance when it reports server storage', async () => {
|
||||
const fetchFn = answer(instance);
|
||||
await expect(
|
||||
probeServerInstance({
|
||||
runtimeConfig: { BACKEND_URL: '/api/' },
|
||||
isElectron: false,
|
||||
fetchFn: fetchFn as never,
|
||||
})
|
||||
).resolves.toEqual({ kind: 'server', instance });
|
||||
expect(fetchFn.mock.calls[0][0]).toBe('/api/v1/instance');
|
||||
});
|
||||
|
||||
it('waits longer once after a reload for a server that answered late', async () => {
|
||||
const timeout = jest.spyOn(AbortSignal, 'timeout');
|
||||
try {
|
||||
markSlowStart();
|
||||
await probeServerInstance({
|
||||
...config,
|
||||
fetchFn: answer(instance) as never,
|
||||
});
|
||||
await probeServerInstance({
|
||||
...config,
|
||||
fetchFn: answer(instance) as never,
|
||||
});
|
||||
|
||||
// A slow server otherwise times out again and reloads forever.
|
||||
expect(timeout.mock.calls.map(([ms]) => ms)).toEqual([
|
||||
20_000, 2_500,
|
||||
]);
|
||||
} finally {
|
||||
timeout.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('reports no server storage for an answer without it', async () => {
|
||||
for (const fetchFn of [
|
||||
answer({ ...instance, storage: 'none' }),
|
||||
answer({ hello: 1 }),
|
||||
answer({ error: 'not found' }, 404),
|
||||
jest.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => {
|
||||
throw new SyntaxError('not json');
|
||||
},
|
||||
}),
|
||||
]) {
|
||||
await expect(
|
||||
probeServerInstance({ ...config, fetchFn: fetchFn as never })
|
||||
).resolves.toEqual({ kind: 'none' });
|
||||
}
|
||||
});
|
||||
|
||||
it('reports an unreachable server for failures, 5xx and refusals', async () => {
|
||||
for (const fetchFn of [
|
||||
jest.fn().mockRejectedValue(new TypeError('Failed to fetch')),
|
||||
answer('Bad Gateway', 502),
|
||||
answer('Too Many Requests', 429),
|
||||
answer('Forbidden', 403),
|
||||
// Headers arrived, then the body was cut off.
|
||||
jest.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => {
|
||||
throw new DOMException('aborted', 'AbortError');
|
||||
},
|
||||
}),
|
||||
]) {
|
||||
await expect(
|
||||
probeServerInstance({ ...config, fetchFn: fetchFn as never })
|
||||
).resolves.toEqual({ kind: 'unreachable' });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { RuntimeCapabilitiesService, takeSlowStart } from '@iptvnator/services';
|
||||
import type { ServerInstanceInfo } from '@iptvnator/shared/interfaces';
|
||||
import type { IptvnatorRuntimeConfig } from '../services/runtime-config';
|
||||
|
||||
const PROBE_TIMEOUT_MS = 2_500;
|
||||
/** After a reload for a server that answered later: as long as requests. */
|
||||
const SLOW_PROBE_TIMEOUT_MS = 20_000;
|
||||
|
||||
/**
|
||||
* - `skipped`: no runtime backend URL, or Electron.
|
||||
* - `server` / `none`: the backend answered (a 404 is an older backend).
|
||||
* - `unreachable`: no answer, a timeout, a 5xx (nginx answers 502 while
|
||||
* the backend restarts) or another refusal such as a proxy's 429, so the
|
||||
* result says nothing about storage.
|
||||
*/
|
||||
export type ServerInstanceProbe =
|
||||
| { readonly kind: 'skipped' | 'none' | 'unreachable' }
|
||||
| { readonly kind: 'server'; readonly instance: ServerInstanceInfo };
|
||||
|
||||
export interface ServerInstanceProbeOptions {
|
||||
readonly runtimeConfig?: IptvnatorRuntimeConfig;
|
||||
readonly isElectron?: boolean;
|
||||
readonly fetchFn?: typeof fetch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks the backend whether it offers server storage. Runs only when the
|
||||
* deployment wrote a runtime `BACKEND_URL` (the Docker entrypoint does), so
|
||||
* hosted builds never call an external parser API for it.
|
||||
*/
|
||||
export async function probeServerInstance(
|
||||
options: ServerInstanceProbeOptions = {}
|
||||
): Promise<ServerInstanceProbe> {
|
||||
const runtimeConfig =
|
||||
options.runtimeConfig ?? globalThis.window?.__IPTVNATOR_CONFIG__;
|
||||
// The capability service has no dependencies, so it works before
|
||||
// bootstrap and keeps one definition of the desktop runtime.
|
||||
const isElectron =
|
||||
options.isElectron ?? new RuntimeCapabilitiesService().isElectron;
|
||||
const baseUrl = runtimeConfig?.BACKEND_URL?.trim();
|
||||
if (isElectron || !baseUrl) {
|
||||
return { kind: 'skipped' };
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
const fetchFn = options.fetchFn ?? globalThis.fetch.bind(globalThis);
|
||||
response = await fetchFn(`${baseUrl.replace(/\/+$/, '')}/v1/instance`, {
|
||||
cache: 'no-store',
|
||||
signal:
|
||||
typeof AbortSignal.timeout === 'function'
|
||||
? AbortSignal.timeout(
|
||||
takeSlowStart()
|
||||
? SLOW_PROBE_TIMEOUT_MS
|
||||
: PROBE_TIMEOUT_MS
|
||||
)
|
||||
: undefined,
|
||||
});
|
||||
} catch {
|
||||
return { kind: 'unreachable' };
|
||||
}
|
||||
if (response.status === 404) {
|
||||
// A backend from before server storage has no /v1 routes.
|
||||
return { kind: 'none' };
|
||||
}
|
||||
if (!response.ok) {
|
||||
return { kind: 'unreachable' };
|
||||
}
|
||||
try {
|
||||
const body: unknown = await response.json();
|
||||
return isServerInstance(body) && body.storage === 'server'
|
||||
? { kind: 'server', instance: body }
|
||||
: { kind: 'none' };
|
||||
} catch (error) {
|
||||
// Unparseable JSON is an answer; a cut-off body is an outage.
|
||||
return error instanceof SyntaxError
|
||||
? { kind: 'none' }
|
||||
: { kind: 'unreachable' };
|
||||
}
|
||||
}
|
||||
|
||||
function isServerInstance(value: unknown): value is ServerInstanceInfo {
|
||||
const record = value as Partial<ServerInstanceInfo> | null;
|
||||
return (
|
||||
!!record &&
|
||||
record.service === 'iptvnator' &&
|
||||
typeof record.apiVersion === 'number' &&
|
||||
typeof record.instanceId === 'string' &&
|
||||
(record.storage === 'server' || record.storage === 'none') &&
|
||||
(record.auth === 'none' || record.auth === 'token')
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<h2 mat-dialog-title>
|
||||
{{
|
||||
'SERVER_STORAGE.MIGRATION_TITLE'
|
||||
| translate: { count: playlists.length }
|
||||
}}
|
||||
</h2>
|
||||
|
||||
<mat-dialog-content class="server-migration">
|
||||
@if (result(); as done) {
|
||||
<p data-test-id="server-migration-result">
|
||||
{{ 'SERVER_STORAGE.MIGRATION_DONE' | translate: done }}
|
||||
</p>
|
||||
} @else {
|
||||
<p>{{ 'SERVER_STORAGE.MIGRATION_DESCRIPTION' | translate }}</p>
|
||||
<ul class="server-migration__list">
|
||||
@for (playlist of playlists; track playlist.id) {
|
||||
<li>
|
||||
<mat-icon aria-hidden="true">{{
|
||||
icons[playlist.kind]
|
||||
}}</mat-icon>
|
||||
<span>{{ playlist.title }}</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
<p class="server-migration__hint">
|
||||
{{ 'SERVER_STORAGE.MIGRATION_LATER_HINT' | translate }}
|
||||
</p>
|
||||
@if (failed()) {
|
||||
<p class="server-migration__error" role="status">
|
||||
{{ 'SERVER_STORAGE.MIGRATION_FAILED' | translate }}
|
||||
</p>
|
||||
}
|
||||
}
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions align="end">
|
||||
@if (result()) {
|
||||
<button
|
||||
mat-flat-button
|
||||
type="button"
|
||||
(click)="reload()"
|
||||
data-test-id="server-migration-reload"
|
||||
>
|
||||
{{ 'SERVER_STORAGE.MIGRATION_RELOAD' | translate }}
|
||||
</button>
|
||||
} @else {
|
||||
<button
|
||||
mat-button
|
||||
type="button"
|
||||
[disabled]="busy()"
|
||||
(click)="notNow()"
|
||||
data-test-id="server-migration-not-now"
|
||||
>
|
||||
{{ 'SERVER_STORAGE.MIGRATION_NOT_NOW' | translate }}
|
||||
</button>
|
||||
<button
|
||||
mat-flat-button
|
||||
type="button"
|
||||
[disabled]="busy()"
|
||||
(click)="move()"
|
||||
data-test-id="server-migration-move"
|
||||
>
|
||||
@if (busy()) {
|
||||
<mat-progress-spinner diameter="16" mode="indeterminate" />
|
||||
}
|
||||
{{ 'SERVER_STORAGE.MIGRATION_MOVE' | translate }}
|
||||
</button>
|
||||
}
|
||||
</mat-dialog-actions>
|
||||
@@ -0,0 +1,46 @@
|
||||
.server-migration {
|
||||
p {
|
||||
margin: 0 0 12px;
|
||||
color: var(--app-body-color);
|
||||
}
|
||||
}
|
||||
|
||||
.server-migration__list {
|
||||
list-style: none;
|
||||
margin: 0 0 12px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
background: var(--app-card-hover-bg);
|
||||
color: var(--app-heading-color);
|
||||
|
||||
mat-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.server-migration .server-migration__hint {
|
||||
color: var(--app-muted-color);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.server-migration .server-migration__error {
|
||||
color: var(--app-expiry-expired-text);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { ServerMigrationService } from '@iptvnator/services/server-storage';
|
||||
import { ServerMigrationDialogComponent } from './server-migration-dialog.component';
|
||||
|
||||
describe('ServerMigrationDialogComponent', () => {
|
||||
let fixture: ComponentFixture<ServerMigrationDialogComponent>;
|
||||
const migration = { skip: jest.fn(), moveBrowserData: jest.fn() };
|
||||
const dialogRef = { close: jest.fn() };
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [
|
||||
ServerMigrationDialogComponent,
|
||||
TranslateModule.forRoot(),
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: MAT_DIALOG_DATA,
|
||||
useValue: [{ id: 'm', title: 'Family', kind: 'm3u' }],
|
||||
},
|
||||
{ provide: ServerMigrationService, useValue: migration },
|
||||
{ provide: MatDialogRef, useValue: dialogRef },
|
||||
],
|
||||
}).compileComponents();
|
||||
fixture = TestBed.createComponent(ServerMigrationDialogComponent);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
afterEach(() => jest.resetAllMocks());
|
||||
|
||||
const byTestId = (id: string) =>
|
||||
fixture.nativeElement.querySelector(`[data-test-id="${id}"]`);
|
||||
|
||||
it('lists the browser playlists', () => {
|
||||
expect(fixture.nativeElement.textContent).toContain('Family');
|
||||
});
|
||||
|
||||
it('records "not now" and closes', () => {
|
||||
byTestId('server-migration-not-now').click();
|
||||
|
||||
expect(migration.skip).toHaveBeenCalled();
|
||||
expect(dialogRef.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('moves the data and offers a reload', async () => {
|
||||
const result = { imported: 1, merged: 0, failed: 0 };
|
||||
migration.moveBrowserData.mockResolvedValue(result);
|
||||
|
||||
await fixture.componentInstance.move();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.componentInstance.result()).toEqual(result);
|
||||
expect(byTestId('server-migration-result')).not.toBeNull();
|
||||
expect(byTestId('server-migration-reload')).not.toBeNull();
|
||||
expect(byTestId('server-migration-move')).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the dialog open with a message when moving fails', async () => {
|
||||
migration.moveBrowserData.mockRejectedValue(new Error('offline'));
|
||||
|
||||
await fixture.componentInstance.move();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.componentInstance.failed()).toBe(true);
|
||||
expect(fixture.nativeElement.textContent).toContain(
|
||||
'SERVER_STORAGE.MIGRATION_FAILED'
|
||||
);
|
||||
expect(dialogRef.close).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import {
|
||||
MAT_DIALOG_DATA,
|
||||
MatDialogModule,
|
||||
MatDialogRef,
|
||||
} from '@angular/material/dialog';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import {
|
||||
ServerMigrationService,
|
||||
type ServerMigrationPlaylist,
|
||||
type ServerMigrationResult,
|
||||
} from '@iptvnator/services/server-storage';
|
||||
import { SOURCE_TYPE_ICONS } from '@iptvnator/shared/interfaces';
|
||||
|
||||
@Component({
|
||||
selector: 'app-server-migration-dialog',
|
||||
imports: [
|
||||
MatButtonModule,
|
||||
MatDialogModule,
|
||||
MatIconModule,
|
||||
MatProgressSpinnerModule,
|
||||
TranslatePipe,
|
||||
],
|
||||
templateUrl: './server-migration-dialog.component.html',
|
||||
styleUrl: './server-migration-dialog.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ServerMigrationDialogComponent {
|
||||
private readonly migration = inject(ServerMigrationService);
|
||||
private readonly dialogRef = inject(
|
||||
MatDialogRef<ServerMigrationDialogComponent>
|
||||
);
|
||||
|
||||
readonly playlists =
|
||||
inject<readonly ServerMigrationPlaylist[]>(MAT_DIALOG_DATA);
|
||||
readonly icons = SOURCE_TYPE_ICONS;
|
||||
readonly busy = signal(false);
|
||||
readonly failed = signal(false);
|
||||
readonly result = signal<ServerMigrationResult | null>(null);
|
||||
|
||||
notNow(): void {
|
||||
this.migration.skip();
|
||||
this.dialogRef.close();
|
||||
}
|
||||
|
||||
async move(): Promise<void> {
|
||||
this.busy.set(true);
|
||||
this.failed.set(false);
|
||||
try {
|
||||
this.result.set(await this.migration.moveBrowserData());
|
||||
} catch {
|
||||
this.failed.set(true);
|
||||
} finally {
|
||||
this.busy.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
/** The library was replaced underneath every open view. */
|
||||
reload(): void {
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { NavigationEnd, Router } from '@angular/router';
|
||||
import { filter, take } from 'rxjs';
|
||||
import { ServerStorageStatusService } from '@iptvnator/services';
|
||||
import {
|
||||
ServerMigrationService,
|
||||
type ServerMigrationPlaylist,
|
||||
} from '@iptvnator/services/server-storage';
|
||||
|
||||
/** Opens the migration dialog once, after the first navigation settles. */
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ServerMigrationPrompt {
|
||||
private readonly router = inject(Router);
|
||||
private readonly dialog = inject(MatDialog);
|
||||
private readonly status = inject(ServerStorageStatusService);
|
||||
private readonly migration = inject(ServerMigrationService);
|
||||
|
||||
schedule(): void {
|
||||
this.router.events
|
||||
.pipe(
|
||||
filter((event) => event instanceof NavigationEnd),
|
||||
take(1)
|
||||
)
|
||||
.subscribe(() => void this.offer());
|
||||
}
|
||||
|
||||
private async offer(): Promise<void> {
|
||||
if (this.status.state() !== 'online') {
|
||||
return;
|
||||
}
|
||||
let playlists: ServerMigrationPlaylist[] | null;
|
||||
try {
|
||||
playlists = await this.migration.offerOnStartup();
|
||||
} catch {
|
||||
return; // IndexedDB or server unreadable: ask again next start.
|
||||
}
|
||||
if (!playlists) {
|
||||
return;
|
||||
}
|
||||
const { ServerMigrationDialogComponent } =
|
||||
await import('./server-migration-dialog.component');
|
||||
this.dialog.open(ServerMigrationDialogComponent, {
|
||||
data: playlists,
|
||||
width: '540px',
|
||||
maxWidth: 'calc(100vw - 32px)',
|
||||
disableClose: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<main class="server-sign-in">
|
||||
<form class="server-sign-in__card" (submit)="submit($event)">
|
||||
<img
|
||||
class="server-sign-in__logo"
|
||||
src="./assets/icons/icon-tv-256.png"
|
||||
alt=""
|
||||
width="40"
|
||||
height="40"
|
||||
/>
|
||||
<h1>{{ 'SERVER_STORAGE.SIGN_IN_TITLE' | translate }}</h1>
|
||||
<p>{{ 'SERVER_STORAGE.SIGN_IN_DESCRIPTION' | translate }}</p>
|
||||
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>{{
|
||||
'SERVER_STORAGE.SIGN_IN_TOKEN_LABEL' | translate
|
||||
}}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
id="server-access-token"
|
||||
autocomplete="current-password"
|
||||
[type]="tokenToggle.inputType()"
|
||||
[value]="token()"
|
||||
(input)="edit($any($event.target).value)"
|
||||
data-test-id="server-sign-in-token"
|
||||
/>
|
||||
<button
|
||||
mat-icon-button
|
||||
matSuffix
|
||||
appPasswordVisibilityToggle
|
||||
#tokenToggle="appPasswordVisibilityToggle"
|
||||
[attr.aria-label]="'HOME.SHOW_PASSWORD' | translate"
|
||||
>
|
||||
<mat-icon>{{ tokenToggle.icon() }}</mat-icon>
|
||||
</button>
|
||||
<mat-hint>{{ 'SERVER_STORAGE.SIGN_IN_HINT' | translate }}</mat-hint>
|
||||
</mat-form-field>
|
||||
|
||||
<p
|
||||
class="server-sign-in__feedback"
|
||||
role="status"
|
||||
data-test-id="server-sign-in-feedback"
|
||||
>
|
||||
@switch (feedback()) {
|
||||
@case ('rejected') {
|
||||
{{ 'SERVER_STORAGE.SIGN_IN_REJECTED' | translate }}
|
||||
}
|
||||
@case ('unreachable') {
|
||||
{{ 'SERVER_STORAGE.SIGN_IN_UNREACHABLE' | translate }}
|
||||
}
|
||||
@case ('storage') {
|
||||
{{ 'SERVER_STORAGE.SIGN_IN_STORAGE' | translate }}
|
||||
}
|
||||
}
|
||||
</p>
|
||||
|
||||
<button
|
||||
mat-flat-button
|
||||
type="submit"
|
||||
[disabled]="busy() || !token().trim()"
|
||||
data-test-id="server-sign-in-submit"
|
||||
>
|
||||
{{ 'SERVER_STORAGE.SIGN_IN_CONTINUE' | translate }}
|
||||
</button>
|
||||
</form>
|
||||
</main>
|
||||
@@ -0,0 +1,56 @@
|
||||
:host {
|
||||
display: block;
|
||||
min-height: 100%;
|
||||
background: var(--app-shell-bg);
|
||||
}
|
||||
|
||||
.server-sign-in {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 16px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.server-sign-in__card {
|
||||
width: min(100%, 420px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
padding: 28px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 20px;
|
||||
background: var(--app-widget-bg);
|
||||
box-shadow: var(--app-widget-shadow);
|
||||
color: var(--app-on-surface);
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 1.3rem;
|
||||
font-weight: 600;
|
||||
color: var(--app-heading-color);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--app-body-color);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
button[type='submit'] {
|
||||
align-self: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
.server-sign-in__logo {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.server-sign-in .server-sign-in__feedback {
|
||||
color: var(--app-expiry-expired-text);
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import {
|
||||
readServerAccessToken,
|
||||
writeServerAccessToken,
|
||||
} from '@iptvnator/services';
|
||||
import {
|
||||
SERVER_STORAGE_CONFIG,
|
||||
ServerApiError,
|
||||
ServerStorageSession,
|
||||
} from '@iptvnator/services/server-storage';
|
||||
import { REMEMBERED_SERVER_KEY } from './server-storage-start';
|
||||
import { ServerSignInComponent } from './server-sign-in.component';
|
||||
|
||||
const TOKEN_KEY = 'iptvnator-server:access-token';
|
||||
|
||||
describe('ServerSignInComponent', () => {
|
||||
let fixture: ComponentFixture<ServerSignInComponent>;
|
||||
let component: ServerSignInComponent;
|
||||
const session = { fetchStatus: jest.fn(), ensureVerified: jest.fn() };
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ServerSignInComponent, TranslateModule.forRoot()],
|
||||
providers: [
|
||||
{ provide: ServerStorageSession, useValue: session },
|
||||
{
|
||||
provide: SERVER_STORAGE_CONFIG,
|
||||
useValue: {
|
||||
baseUrl: '/api',
|
||||
instance: {
|
||||
service: 'iptvnator',
|
||||
apiVersion: 1,
|
||||
instanceId: 'i-1',
|
||||
storage: 'server',
|
||||
auth: 'token',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
fixture = TestBed.createComponent(ServerSignInComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
const feedbackText = () =>
|
||||
fixture.nativeElement
|
||||
.querySelector('[data-test-id="server-sign-in-feedback"]')
|
||||
.textContent.trim();
|
||||
|
||||
it('keeps an accepted token and reloads into the workspace', async () => {
|
||||
session.fetchStatus.mockResolvedValue({ playlists: 0 });
|
||||
const open = jest
|
||||
.spyOn(component, 'openWorkspace')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
component.edit(' right ');
|
||||
await component.submit(new Event('submit'));
|
||||
|
||||
expect(localStorage.getItem(TOKEN_KEY)).toBe('right');
|
||||
// Only for the instance it was given for.
|
||||
expect(readServerAccessToken('i-1')).toBe('right');
|
||||
expect(readServerAccessToken('i-2')).toBeNull();
|
||||
expect(open).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('says the token was refused and forgets it', async () => {
|
||||
session.fetchStatus.mockRejectedValue(
|
||||
new ServerApiError(401, 'unauthorized', 'refused')
|
||||
);
|
||||
|
||||
component.edit('wrong');
|
||||
await component.submit(new Event('submit'));
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.feedback()).toBe('rejected');
|
||||
expect(feedbackText()).toBe('SERVER_STORAGE.SIGN_IN_REJECTED');
|
||||
expect(localStorage.getItem(TOKEN_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps another tab's newer sign-in when this attempt fails", async () => {
|
||||
session.fetchStatus.mockImplementation(async () => {
|
||||
// Another tab signs in while this answer is on its way.
|
||||
writeServerAccessToken('right', 'i-1');
|
||||
throw new ServerApiError(401, 'unauthorized', 'refused');
|
||||
});
|
||||
|
||||
component.edit('wrong');
|
||||
await component.submit(new Event('submit'));
|
||||
|
||||
expect(component.feedback()).toBe('rejected');
|
||||
expect(readServerAccessToken('i-1')).toBe('right');
|
||||
});
|
||||
|
||||
it('leaves a token another tab stored to it after a network failure', async () => {
|
||||
session.fetchStatus.mockImplementation(async () => {
|
||||
// Another tab signs in with the same token meanwhile.
|
||||
writeServerAccessToken('right', 'i-1');
|
||||
throw new ServerApiError(0, 'network', 'offline');
|
||||
});
|
||||
|
||||
component.edit('right');
|
||||
await component.submit(new Event('submit'));
|
||||
|
||||
expect(component.feedback()).toBe('unreachable');
|
||||
expect(readServerAccessToken('i-1')).toBe('right');
|
||||
});
|
||||
|
||||
it('tells an unreachable server apart and clears the message on edit', async () => {
|
||||
session.fetchStatus.mockRejectedValue(
|
||||
new ServerApiError(0, undefined, 'offline')
|
||||
);
|
||||
|
||||
component.edit('token');
|
||||
await component.submit(new Event('submit'));
|
||||
fixture.detectChanges();
|
||||
expect(feedbackText()).toBe('SERVER_STORAGE.SIGN_IN_UNREACHABLE');
|
||||
|
||||
component.edit('token2');
|
||||
fixture.detectChanges();
|
||||
expect(feedbackText()).toBe('');
|
||||
});
|
||||
|
||||
it('does not submit an empty token', async () => {
|
||||
component.edit(' ');
|
||||
await component.submit(new Event('submit'));
|
||||
|
||||
expect(session.fetchStatus).not.toHaveBeenCalled();
|
||||
expect(localStorage.getItem(TOKEN_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('says when the browser will not keep the token', async () => {
|
||||
const refuse = jest
|
||||
.spyOn(Storage.prototype, 'setItem')
|
||||
.mockImplementation(() => {
|
||||
throw new DOMException('full', 'QuotaExceededError');
|
||||
});
|
||||
try {
|
||||
component.edit('right');
|
||||
await component.submit(new Event('submit'));
|
||||
fixture.detectChanges();
|
||||
} finally {
|
||||
refuse.mockRestore();
|
||||
}
|
||||
|
||||
expect(session.fetchStatus).not.toHaveBeenCalled();
|
||||
expect(feedbackText()).toBe('SERVER_STORAGE.SIGN_IN_STORAGE');
|
||||
});
|
||||
|
||||
it('remembers the server the token was accepted for', async () => {
|
||||
session.fetchStatus.mockResolvedValue({ playlists: 0 });
|
||||
jest.spyOn(component, 'openWorkspace').mockImplementation(
|
||||
() => undefined
|
||||
);
|
||||
|
||||
component.edit('right');
|
||||
await component.submit(new Event('submit'));
|
||||
|
||||
expect(
|
||||
JSON.parse(localStorage.getItem(REMEMBERED_SERVER_KEY) ?? '{}')
|
||||
).toMatchObject({ baseUrl: '/api', instance: { instanceId: 'i-1' } });
|
||||
});
|
||||
|
||||
it('signs in only when the server can be remembered too', async () => {
|
||||
const refuse = jest
|
||||
.spyOn(Storage.prototype, 'setItem')
|
||||
.mockImplementation((key: string) => {
|
||||
if (key === REMEMBERED_SERVER_KEY) {
|
||||
throw new DOMException('full', 'QuotaExceededError');
|
||||
}
|
||||
});
|
||||
try {
|
||||
component.edit('right');
|
||||
await component.submit(new Event('submit'));
|
||||
fixture.detectChanges();
|
||||
} finally {
|
||||
refuse.mockRestore();
|
||||
}
|
||||
|
||||
expect(session.fetchStatus).not.toHaveBeenCalled();
|
||||
expect(localStorage.getItem(TOKEN_KEY)).toBeNull();
|
||||
expect(feedbackText()).toBe('SERVER_STORAGE.SIGN_IN_STORAGE');
|
||||
});
|
||||
|
||||
it('sends the token only after the server proved to be the remembered one', async () => {
|
||||
session.ensureVerified.mockRejectedValue(new Error('another server'));
|
||||
|
||||
component.edit('right');
|
||||
await component.submit(new Event('submit'));
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(session.fetchStatus).not.toHaveBeenCalled();
|
||||
expect(localStorage.getItem(TOKEN_KEY)).toBeNull();
|
||||
expect(feedbackText()).toBe('SERVER_STORAGE.SIGN_IN_UNREACHABLE');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import {
|
||||
clearServerAccessToken,
|
||||
readServerAccessToken,
|
||||
writeServerAccessToken,
|
||||
} from '@iptvnator/services';
|
||||
import {
|
||||
SERVER_STORAGE_CONFIG,
|
||||
ServerApiError,
|
||||
ServerStorageSession,
|
||||
} from '@iptvnator/services/server-storage';
|
||||
import { rememberServer } from './server-storage-start';
|
||||
import { provideFormFieldDefaults } from '@iptvnator/ui/components/form-field-defaults';
|
||||
import { PasswordVisibilityToggleDirective } from '@iptvnator/ui/components/password-visibility-toggle';
|
||||
|
||||
export type ServerSignInFeedback = 'rejected' | 'unreachable' | 'storage';
|
||||
|
||||
@Component({
|
||||
selector: 'app-server-sign-in',
|
||||
imports: [
|
||||
MatButtonModule,
|
||||
MatFormFieldModule,
|
||||
MatIconModule,
|
||||
MatInputModule,
|
||||
PasswordVisibilityToggleDirective,
|
||||
TranslatePipe,
|
||||
],
|
||||
providers: [provideFormFieldDefaults()],
|
||||
templateUrl: './server-sign-in.component.html',
|
||||
styleUrl: './server-sign-in.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ServerSignInComponent {
|
||||
private readonly session = inject(ServerStorageSession);
|
||||
private readonly config = inject(SERVER_STORAGE_CONFIG);
|
||||
|
||||
readonly token = signal('');
|
||||
readonly busy = signal(false);
|
||||
readonly feedback = signal<ServerSignInFeedback | null>(null);
|
||||
|
||||
edit(value: string): void {
|
||||
this.token.set(value);
|
||||
this.feedback.set(null);
|
||||
}
|
||||
|
||||
async submit(event: Event): Promise<void> {
|
||||
event.preventDefault();
|
||||
const token = this.token().trim();
|
||||
if (!token || this.busy()) {
|
||||
return;
|
||||
}
|
||||
this.busy.set(true);
|
||||
this.feedback.set(null);
|
||||
try {
|
||||
// A page started from memory checks the server first: the token
|
||||
// must reach only the one it is meant for.
|
||||
await this.session.ensureVerified();
|
||||
} catch {
|
||||
this.feedback.set('unreachable');
|
||||
this.busy.set(false);
|
||||
return;
|
||||
}
|
||||
// Every request and the reload read the token from storage, and the
|
||||
// next start keeps it only for the server it remembers.
|
||||
if (
|
||||
!writeServerAccessToken(token, this.config.instance.instanceId) ||
|
||||
!rememberServer(this.config.baseUrl, this.config.instance)
|
||||
) {
|
||||
forgetAttempt(token);
|
||||
this.feedback.set('storage');
|
||||
this.busy.set(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this.session.fetchStatus();
|
||||
this.openWorkspace();
|
||||
} catch (error) {
|
||||
const rejected =
|
||||
error instanceof ServerApiError && error.status === 401;
|
||||
// A refused token is no one's. A network failure says nothing
|
||||
// about it, and another tab may have stored the same one since.
|
||||
if (rejected) {
|
||||
forgetAttempt(token);
|
||||
}
|
||||
this.feedback.set(rejected ? 'rejected' : 'unreachable');
|
||||
} finally {
|
||||
this.busy.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
/** Full reload so every service starts with the token. */
|
||||
openWorkspace(): void {
|
||||
window.location.assign(new URL('workspace', document.baseURI).href);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Forgets a token whose sign-in failed, unless another tab has signed in
|
||||
* since: its token replaced this one and is not this attempt's to clear.
|
||||
*/
|
||||
function forgetAttempt(token: string): void {
|
||||
if (readServerAccessToken() === token) {
|
||||
clearServerAccessToken();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import type { ServerInstanceInfo } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
REMEMBERED_SERVER_KEY,
|
||||
resolveServerStorageStart,
|
||||
} from './server-storage-start';
|
||||
|
||||
const instance: ServerInstanceInfo = {
|
||||
service: 'iptvnator',
|
||||
apiVersion: 1,
|
||||
instanceId: 'i-1',
|
||||
storage: 'server',
|
||||
auth: 'none',
|
||||
};
|
||||
|
||||
describe('resolveServerStorageStart', () => {
|
||||
afterEach(() => localStorage.clear());
|
||||
|
||||
it('starts online and remembers a server that answered', () => {
|
||||
expect(
|
||||
resolveServerStorageStart({ kind: 'server', instance }, '/api')
|
||||
).toEqual({ instance, startedOffline: false });
|
||||
expect(
|
||||
JSON.parse(localStorage.getItem(REMEMBERED_SERVER_KEY) ?? '')
|
||||
).toEqual({ baseUrl: '/api', instance });
|
||||
});
|
||||
|
||||
it('starts offline when a remembered server does not answer', () => {
|
||||
resolveServerStorageStart({ kind: 'server', instance }, '/api');
|
||||
|
||||
expect(
|
||||
resolveServerStorageStart({ kind: 'unreachable' }, '/api')
|
||||
).toEqual({ instance, startedOffline: true });
|
||||
});
|
||||
|
||||
it('matches the remembered server however its URL is written', () => {
|
||||
resolveServerStorageStart({ kind: 'server', instance }, '/api');
|
||||
|
||||
for (const spelling of ['/api/', ' /api', `${location.origin}/api//`]) {
|
||||
expect(
|
||||
resolveServerStorageStart({ kind: 'unreachable' }, spelling)
|
||||
).toEqual({ instance, startedOffline: true });
|
||||
}
|
||||
expect(
|
||||
resolveServerStorageStart({ kind: 'unreachable' }, '/apis')
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the token only for the same server and database', () => {
|
||||
const token = 'iptvnator-server:access-token';
|
||||
resolveServerStorageStart({ kind: 'server', instance }, '/api');
|
||||
localStorage.setItem(token, 'secret');
|
||||
|
||||
resolveServerStorageStart({ kind: 'server', instance }, '/api/');
|
||||
expect(localStorage.getItem(token)).toBe('secret');
|
||||
|
||||
resolveServerStorageStart(
|
||||
{ kind: 'server', instance: { ...instance, instanceId: 'i-2' } },
|
||||
'/api'
|
||||
);
|
||||
expect(localStorage.getItem(token)).toBeNull();
|
||||
|
||||
localStorage.setItem(token, 'secret');
|
||||
resolveServerStorageStart(
|
||||
{ kind: 'server', instance: { ...instance, instanceId: 'i-2' } },
|
||||
'/other'
|
||||
);
|
||||
expect(localStorage.getItem(token)).toBeNull();
|
||||
});
|
||||
|
||||
it('forgets the token of a server that no longer offers storage', () => {
|
||||
localStorage.setItem('iptvnator-server:access-token', 'old');
|
||||
|
||||
resolveServerStorageStart({ kind: 'none' }, '/api');
|
||||
|
||||
expect(
|
||||
localStorage.getItem('iptvnator-server:access-token')
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('uses browser mode for an unknown or different server', () => {
|
||||
expect(
|
||||
resolveServerStorageStart({ kind: 'unreachable' }, '/api')
|
||||
).toBeNull();
|
||||
|
||||
resolveServerStorageStart({ kind: 'server', instance }, '/api');
|
||||
expect(
|
||||
resolveServerStorageStart(
|
||||
{ kind: 'unreachable' },
|
||||
'http://other:3000'
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('forgets the server once it answers without server storage', () => {
|
||||
resolveServerStorageStart({ kind: 'server', instance }, '/api');
|
||||
|
||||
expect(resolveServerStorageStart({ kind: 'none' }, '/api')).toBeNull();
|
||||
expect(localStorage.getItem(REMEMBERED_SERVER_KEY)).toBeNull();
|
||||
expect(
|
||||
resolveServerStorageStart({ kind: 'unreachable' }, '/api')
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores a skipped probe and unreadable memory', () => {
|
||||
resolveServerStorageStart({ kind: 'server', instance }, '/api');
|
||||
expect(
|
||||
resolveServerStorageStart({ kind: 'skipped' }, '/api')
|
||||
).toBeNull();
|
||||
expect(localStorage.getItem(REMEMBERED_SERVER_KEY)).not.toBeNull();
|
||||
|
||||
localStorage.setItem(REMEMBERED_SERVER_KEY, '{broken');
|
||||
expect(
|
||||
resolveServerStorageStart({ kind: 'unreachable' }, '/api')
|
||||
).toBeNull();
|
||||
expect(
|
||||
resolveServerStorageStart({ kind: 'unreachable' }, '/api', null)
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import { clearServerAccessToken } from '@iptvnator/services';
|
||||
import type { ServerInstanceInfo } from '@iptvnator/shared/interfaces';
|
||||
import type { ServerInstanceProbe } from './server-instance-probe';
|
||||
|
||||
/** Last instance this browser saw, per backend URL. */
|
||||
export const REMEMBERED_SERVER_KEY = 'iptvnator-server:instance';
|
||||
|
||||
export interface ServerStorageStart {
|
||||
readonly instance: ServerInstanceInfo;
|
||||
/** No answer at start; the instance comes from this browser's memory. */
|
||||
readonly startedOffline: boolean;
|
||||
}
|
||||
|
||||
interface RememberedServer {
|
||||
readonly baseUrl: string;
|
||||
readonly instance: ServerInstanceInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decides whether the app starts in server mode. A browser that has used
|
||||
* this server before stays in server mode while it does not answer (shown
|
||||
* as offline), so it never falls back to an empty browser library and
|
||||
* collects playlists the server will not see. An answer without server
|
||||
* storage forgets the server.
|
||||
*/
|
||||
export function resolveServerStorageStart(
|
||||
probe: ServerInstanceProbe,
|
||||
baseUrl: string,
|
||||
storage: Pick<
|
||||
Storage,
|
||||
'getItem' | 'setItem' | 'removeItem'
|
||||
> | null = safeLocalStorage()
|
||||
): ServerStorageStart | null {
|
||||
switch (probe.kind) {
|
||||
case 'server': {
|
||||
const remembered = read(storage);
|
||||
// Another server, or another database behind this one: the
|
||||
// stored token was not given for it.
|
||||
if (
|
||||
!remembered ||
|
||||
backendIdentity(remembered.baseUrl) !==
|
||||
backendIdentity(baseUrl) ||
|
||||
remembered.instance.instanceId !== probe.instance.instanceId
|
||||
) {
|
||||
clearServerAccessToken();
|
||||
}
|
||||
write(storage, { baseUrl, instance: probe.instance });
|
||||
return { instance: probe.instance, startedOffline: false };
|
||||
}
|
||||
case 'none':
|
||||
remove(storage);
|
||||
// The token belonged to that server; another must not see it.
|
||||
clearServerAccessToken();
|
||||
return null;
|
||||
case 'unreachable': {
|
||||
const remembered = read(storage);
|
||||
return remembered &&
|
||||
backendIdentity(remembered.baseUrl) === backendIdentity(baseUrl)
|
||||
? { instance: remembered.instance, startedOffline: true }
|
||||
: null;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Origin and path of a backend URL, without trailing slashes: `/api` and
|
||||
* `/api/` (or the same URL written absolute) name one server, as they do
|
||||
* for the probe and the API client.
|
||||
*/
|
||||
function backendIdentity(url: string): string {
|
||||
try {
|
||||
const resolved = new URL(url.trim(), globalThis.location?.href);
|
||||
return `${resolved.origin}${resolved.pathname.replace(/\/+$/, '')}`;
|
||||
} catch {
|
||||
return url.trim().replace(/\/+$/, '');
|
||||
}
|
||||
}
|
||||
|
||||
function read(
|
||||
storage: Pick<Storage, 'getItem'> | null
|
||||
): RememberedServer | null {
|
||||
try {
|
||||
const raw = storage?.getItem(REMEMBERED_SERVER_KEY);
|
||||
const parsed = raw ? (JSON.parse(raw) as RememberedServer) : null;
|
||||
return parsed &&
|
||||
typeof parsed.baseUrl === 'string' &&
|
||||
parsed.instance?.storage === 'server' &&
|
||||
typeof parsed.instance.instanceId === 'string'
|
||||
? parsed
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remembers the server a token was accepted for; false when storage
|
||||
* refused it (the next start would take the token for another server's).
|
||||
*/
|
||||
export function rememberServer(
|
||||
baseUrl: string,
|
||||
instance: ServerInstanceInfo,
|
||||
storage: Pick<Storage, 'setItem'> | null = safeLocalStorage()
|
||||
): boolean {
|
||||
return write(storage, { baseUrl, instance });
|
||||
}
|
||||
|
||||
function write(
|
||||
storage: Pick<Storage, 'setItem'> | null,
|
||||
value: RememberedServer
|
||||
): boolean {
|
||||
try {
|
||||
storage?.setItem(REMEMBERED_SERVER_KEY, JSON.stringify(value));
|
||||
return !!storage;
|
||||
} catch {
|
||||
// Without storage an offline start falls back to browser mode.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function remove(storage: Pick<Storage, 'removeItem'> | null): void {
|
||||
try {
|
||||
storage?.removeItem(REMEMBERED_SERVER_KEY);
|
||||
} catch {
|
||||
// Nothing stored.
|
||||
}
|
||||
}
|
||||
|
||||
function safeLocalStorage(): Storage | null {
|
||||
try {
|
||||
return globalThis.localStorage ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { HTTP_INTERCEPTORS } from '@angular/common/http';
|
||||
import {
|
||||
EnvironmentProviders,
|
||||
inject,
|
||||
provideAppInitializer,
|
||||
Provider,
|
||||
} from '@angular/core';
|
||||
import { USER_DATA_STORAGE } from '@iptvnator/services';
|
||||
import {
|
||||
SERVER_STORAGE_CONFIG,
|
||||
ServerAccessTokenInterceptor,
|
||||
ServerStorageSession,
|
||||
} from '@iptvnator/services/server-storage';
|
||||
import { ServerMigrationPrompt } from './server-migration-prompt.service';
|
||||
import type { ServerStorageStart } from './server-storage-start';
|
||||
|
||||
/** Loaded with a dynamic import from `main.ts`, only in server mode. */
|
||||
export function provideServerStorage(
|
||||
start: ServerStorageStart,
|
||||
baseUrl: string
|
||||
): (Provider | EnvironmentProviders)[] {
|
||||
return [
|
||||
{
|
||||
provide: SERVER_STORAGE_CONFIG,
|
||||
useValue: {
|
||||
instance: start.instance,
|
||||
startedOffline: start.startedOffline,
|
||||
baseUrl,
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: USER_DATA_STORAGE,
|
||||
useFactory: () => inject(ServerStorageSession).userData,
|
||||
},
|
||||
{
|
||||
provide: HTTP_INTERCEPTORS,
|
||||
useClass: ServerAccessTokenInterceptor,
|
||||
multi: true,
|
||||
},
|
||||
provideAppInitializer(() => inject(ServerStorageSession).start()),
|
||||
provideAppInitializer(() => inject(ServerMigrationPrompt).schedule()),
|
||||
];
|
||||
}
|
||||
@@ -20,6 +20,11 @@ import {
|
||||
getStalkerRequestErrorStatus,
|
||||
isStalkerProbeTimeout,
|
||||
} from '@iptvnator/portal/stalker/data-access';
|
||||
import {
|
||||
clearServerAccessToken,
|
||||
ServerStorageStatusService,
|
||||
writeServerAccessToken,
|
||||
} from '@iptvnator/services';
|
||||
import { PwaService } from './pwa.service';
|
||||
|
||||
describe('PwaService', () => {
|
||||
@@ -328,6 +333,103 @@ describe('PwaService', () => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it('sends a stored server token only while server mode runs', async () => {
|
||||
writeServerAccessToken('kept-token', 'i');
|
||||
const originalFetch = globalThis.fetch;
|
||||
const fetchMock = jest.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
headers: new Headers(),
|
||||
json: async () => ({ js: [] }),
|
||||
} as unknown as Response);
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
const call = async () => {
|
||||
const outcome = (
|
||||
service.sendIpcEvent(STALKER_REQUEST, {
|
||||
url: 'http://portal.example/portal.php',
|
||||
macAddress: '00:1A:79:AA:BB:CC',
|
||||
params: { action: 'get_genres' },
|
||||
silent: true,
|
||||
}) as Promise<unknown>
|
||||
).catch((error: unknown) => error);
|
||||
await Promise.resolve();
|
||||
for (const request of http.match((candidate) =>
|
||||
candidate.url.endsWith('/provider-targets')
|
||||
)) {
|
||||
request.flush({ targetId: 'target-1' });
|
||||
}
|
||||
await outcome;
|
||||
};
|
||||
|
||||
try {
|
||||
// A token kept from another backend: server mode is not active.
|
||||
await call();
|
||||
expect(fetchMock.mock.calls[0][1].headers).not.toHaveProperty(
|
||||
'Authorization'
|
||||
);
|
||||
|
||||
TestBed.inject(ServerStorageStatusService).activate(
|
||||
{
|
||||
service: 'iptvnator',
|
||||
apiVersion: 1,
|
||||
instanceId: 'i',
|
||||
storage: 'server',
|
||||
auth: 'token',
|
||||
},
|
||||
() => undefined
|
||||
);
|
||||
await call();
|
||||
expect(fetchMock.mock.calls[1][1].headers).toMatchObject({
|
||||
Authorization: 'Bearer kept-token',
|
||||
});
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
clearServerAccessToken();
|
||||
}
|
||||
});
|
||||
|
||||
it('asks for a new server token when the access guard refuses a Stalker call', async () => {
|
||||
const status = TestBed.inject(ServerStorageStatusService);
|
||||
status.activate(
|
||||
{
|
||||
service: 'iptvnator',
|
||||
apiVersion: 1,
|
||||
instanceId: 'i',
|
||||
storage: 'server',
|
||||
auth: 'token',
|
||||
},
|
||||
() => undefined
|
||||
);
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = jest.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 401,
|
||||
statusText: 'Unauthorized',
|
||||
headers: new Headers({
|
||||
'WWW-Authenticate': 'Bearer realm="iptvnator"',
|
||||
}),
|
||||
json: async () => ({ code: 'unauthorized' }),
|
||||
} as unknown as Response) as unknown as typeof fetch;
|
||||
|
||||
const outcome = (
|
||||
service.sendIpcEvent(STALKER_REQUEST, {
|
||||
url: 'http://portal.example/portal.php',
|
||||
macAddress: '00:1A:79:AA:BB:CC',
|
||||
params: { action: 'get_genres' },
|
||||
silent: true,
|
||||
}) as Promise<unknown>
|
||||
).catch((error: unknown) => error);
|
||||
await Promise.resolve();
|
||||
http.expectOne((candidate) =>
|
||||
candidate.url.endsWith('/provider-targets')
|
||||
).flush({ targetId: 'target-1' });
|
||||
await outcome;
|
||||
|
||||
expect(status.state()).toBe('unauthorized');
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it('sends Stalker credentials as /stalker control params, including the serial', async () => {
|
||||
// JSDOM ships no fetch; install one for the proxy call.
|
||||
const fetchMock = jest.fn().mockResolvedValue({
|
||||
@@ -518,6 +620,118 @@ describe('PwaService', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('sends the server token to the backend only, also with BACKEND_URL=/', async () => {
|
||||
writeServerAccessToken('kept-token', 'i');
|
||||
TestBed.inject(ServerStorageStatusService).activate(
|
||||
{
|
||||
service: 'iptvnator',
|
||||
apiVersion: 1,
|
||||
instanceId: 'i',
|
||||
storage: 'server',
|
||||
auth: 'token',
|
||||
},
|
||||
() => undefined
|
||||
);
|
||||
window.__IPTVNATOR_CONFIG__ = { BACKEND_URL: '/' };
|
||||
const atRoot = TestBed.runInInjectionContext(() => new PwaService());
|
||||
delete window.__IPTVNATOR_CONFIG__;
|
||||
const originalFetch = globalThis.fetch;
|
||||
const fetchMock = jest.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: new Headers(),
|
||||
json: async () => ({ reset: true }),
|
||||
} as unknown as Response);
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
const reset = () =>
|
||||
atRoot.sendIpcEvent(CONNECTIVITY_GUARD_RESET, {
|
||||
url: 'http://portal.example:8080/portal.php',
|
||||
});
|
||||
|
||||
try {
|
||||
await reset();
|
||||
// Not `//connectivity-guard/reset`: that is another host.
|
||||
expect(fetchMock.mock.calls[0][0]).toBe(
|
||||
'/connectivity-guard/reset'
|
||||
);
|
||||
expect(fetchMock.mock.calls[0][1].headers).toMatchObject({
|
||||
Authorization: 'Bearer kept-token',
|
||||
});
|
||||
|
||||
atRoot.corsProxyUrl = 'https://elsewhere.example';
|
||||
await reset();
|
||||
expect(fetchMock.mock.calls[1][1].headers).not.toHaveProperty(
|
||||
'Authorization'
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
clearServerAccessToken();
|
||||
}
|
||||
});
|
||||
|
||||
it('reports a backend outage seen by its raw fetches', async () => {
|
||||
const status = TestBed.inject(ServerStorageStatusService);
|
||||
const activate = () =>
|
||||
status.activate(
|
||||
{
|
||||
service: 'iptvnator',
|
||||
apiVersion: 1,
|
||||
instanceId: 'i',
|
||||
storage: 'server',
|
||||
auth: 'none',
|
||||
},
|
||||
() => undefined
|
||||
);
|
||||
const originalFetch = globalThis.fetch;
|
||||
const reset = () =>
|
||||
(
|
||||
service.sendIpcEvent(CONNECTIVITY_GUARD_RESET, {
|
||||
url: 'http://portal.example:8080/portal.php',
|
||||
}) as Promise<unknown>
|
||||
).catch(() => undefined);
|
||||
const answer = (code: number, contentType: string) =>
|
||||
({
|
||||
ok: false,
|
||||
status: code,
|
||||
headers: new Headers({ 'content-type': contentType }),
|
||||
json: async () => ({}),
|
||||
}) as unknown as Response;
|
||||
|
||||
try {
|
||||
activate();
|
||||
// The backend's own JSON error: it answered.
|
||||
globalThis.fetch = jest
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
answer(502, 'application/json')
|
||||
) as unknown as typeof fetch;
|
||||
await reset();
|
||||
expect(status.state()).toBe('online');
|
||||
|
||||
// A gateway's page in front of a backend that is down.
|
||||
globalThis.fetch = jest
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
answer(502, 'text/html')
|
||||
) as unknown as typeof fetch;
|
||||
await reset();
|
||||
expect(status.state()).toBe('offline');
|
||||
expect(status.isVerified()).toBe(false);
|
||||
|
||||
// No answer at all.
|
||||
activate();
|
||||
globalThis.fetch = jest
|
||||
.fn()
|
||||
.mockRejectedValue(
|
||||
new TypeError('Failed to fetch')
|
||||
) as unknown as typeof fetch;
|
||||
await reset();
|
||||
expect(status.state()).toBe('offline');
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it('asks the backend to forget a host when the connectivity guard is reset', async () => {
|
||||
const fetchMock = jest.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
|
||||
@@ -13,7 +13,13 @@ import {
|
||||
switchMap,
|
||||
throwError,
|
||||
} from 'rxjs';
|
||||
import { DataService } from '@iptvnator/services';
|
||||
import {
|
||||
DataService,
|
||||
isBackendUrl,
|
||||
isGatewayOutage,
|
||||
isServerAccessChallenge,
|
||||
ServerStorageStatusService,
|
||||
} from '@iptvnator/services';
|
||||
import {
|
||||
CONNECTIVITY_GUARD_RESET,
|
||||
ERROR,
|
||||
@@ -83,6 +89,7 @@ export class PwaService extends DataService {
|
||||
private readonly store = inject(Store);
|
||||
private readonly swUpdate = inject(SwUpdate);
|
||||
private readonly translateService = inject(TranslateService);
|
||||
private readonly serverStatus = inject(ServerStorageStatusService);
|
||||
private readonly logger = createLogger('PwaService');
|
||||
private readonly providerTargetIds = new Map<string, Promise<string>>();
|
||||
private readonly silentXtreamActions = new Set<string>([
|
||||
@@ -95,8 +102,15 @@ export class PwaService extends DataService {
|
||||
XtreamCodeActions.GetSimpleDateTable,
|
||||
]);
|
||||
|
||||
/** Proxy URL to avoid CORS issues */
|
||||
corsProxyUrl = getRuntimeBackendUrl();
|
||||
/** The backend's URL as configured: `/` for the page's own origin. */
|
||||
private readonly backendUrl = getRuntimeBackendUrl();
|
||||
|
||||
/**
|
||||
* Proxy URL to avoid CORS issues, without trailing slashes: paths are
|
||||
* appended to it, and `BACKEND_URL=/` must give `/xtream`, never the
|
||||
* protocol-relative `//xtream` (another host).
|
||||
*/
|
||||
corsProxyUrl = this.backendUrl.replace(/\/+$/, '');
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
@@ -197,7 +211,7 @@ export class PwaService extends DataService {
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
const response = await this.backendFetch(
|
||||
`${this.corsProxyUrl}/connectivity-guard/reset`,
|
||||
{
|
||||
body: JSON.stringify({ url: payload.url }),
|
||||
@@ -647,7 +661,7 @@ export class PwaService extends DataService {
|
||||
logPortalDebugRequest(context);
|
||||
|
||||
// Make the fetch request
|
||||
const response = await fetch(requestUrl);
|
||||
const response = await this.backendFetch(requestUrl, {});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
@@ -799,4 +813,67 @@ export class PwaService extends DataService {
|
||||
getAppEnvironment(): string {
|
||||
return 'pwa';
|
||||
}
|
||||
|
||||
/**
|
||||
* The stored server token, only while server mode runs for this
|
||||
* backend and only for a request that goes to it: a token kept from
|
||||
* another backend must not reach this one, nor any other host.
|
||||
*/
|
||||
private serverAccessToken(url: string): string | null {
|
||||
return isBackendUrl(url, this.backendUrl)
|
||||
? this.serverStatus.accessToken()
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* `fetch` to the backend with the server token, telling the server
|
||||
* session what any backend call can show: a token changed on the
|
||||
* server, or an outage (no answer, or a gateway's 502/503/504), after
|
||||
* which the instance is checked before the token goes out again. A
|
||||
* timeout of this page's own is no outage: the backend may be slow.
|
||||
*/
|
||||
private async backendFetch(
|
||||
url: string,
|
||||
init: RequestInit
|
||||
): Promise<Response> {
|
||||
const token = this.serverAccessToken(url);
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
...init,
|
||||
headers: {
|
||||
...(init.headers as Record<string, string> | undefined),
|
||||
...bearer(token),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (!(
|
||||
error instanceof DOMException && error.name === 'AbortError'
|
||||
)) {
|
||||
this.serverStatus.reportOffline();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (
|
||||
isGatewayOutage(
|
||||
response.status,
|
||||
response.headers?.get('content-type')
|
||||
)
|
||||
) {
|
||||
this.serverStatus.reportOffline();
|
||||
} else if (
|
||||
response.status === 401 &&
|
||||
isServerAccessChallenge(
|
||||
response.status,
|
||||
response.headers.get('WWW-Authenticate')
|
||||
)
|
||||
) {
|
||||
this.serverStatus.reportAccessDenied(token);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
function bearer(token: string | null): Record<string, string> {
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
}
|
||||
@@ -9,6 +9,14 @@
|
||||
| translate: { count: dialogData.summary.total }
|
||||
}}
|
||||
</p>
|
||||
@if (clearsServer()) {
|
||||
<p
|
||||
class="settings-delete-dialog__lead"
|
||||
data-test-id="delete-all-server-warning"
|
||||
>
|
||||
{{ 'SERVER_STORAGE.DELETE_ALL_WARNING' | translate }}
|
||||
</p>
|
||||
}
|
||||
|
||||
<section class="settings-delete-dialog__section">
|
||||
<h3 class="settings-delete-dialog__section-title">
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { MAT_DIALOG_DATA } from '@angular/material/dialog';
|
||||
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||
import { ServerStorageStatusService } from '@iptvnator/services';
|
||||
import { SettingsDeleteAllPlaylistsDialogComponent } from './settings-delete-all-playlists-dialog.component';
|
||||
|
||||
describe('SettingsDeleteAllPlaylistsDialogComponent', () => {
|
||||
@@ -93,4 +94,26 @@ describe('SettingsDeleteAllPlaylistsDialogComponent', () => {
|
||||
|
||||
expect(buttons).toEqual(['Cancel', 'Delete everything']);
|
||||
});
|
||||
|
||||
it('warns about every device only with server storage', () => {
|
||||
const warning = () =>
|
||||
fixture.nativeElement.querySelector(
|
||||
'[data-test-id="delete-all-server-warning"]'
|
||||
);
|
||||
expect(warning()).toBeNull();
|
||||
|
||||
TestBed.inject(ServerStorageStatusService).activate(
|
||||
{
|
||||
service: 'iptvnator',
|
||||
apiVersion: 1,
|
||||
instanceId: 'i',
|
||||
storage: 'server',
|
||||
auth: 'none',
|
||||
},
|
||||
() => undefined
|
||||
);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(warning()).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatDialogModule, MAT_DIALOG_DATA } from '@angular/material/dialog';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { ServerStorageStatusService } from '@iptvnator/services';
|
||||
import { SOURCE_TYPE_ICONS } from '@iptvnator/shared/interfaces';
|
||||
|
||||
export interface SettingsDeleteAllPlaylistsDialogData {
|
||||
@@ -43,6 +44,8 @@ type SettingsDeleteSummaryItem = {
|
||||
export class SettingsDeleteAllPlaylistsDialogComponent {
|
||||
readonly dialogData =
|
||||
inject<SettingsDeleteAllPlaylistsDialogData>(MAT_DIALOG_DATA);
|
||||
/** Self-hosted server storage: the playlists go for every device. */
|
||||
readonly clearsServer = inject(ServerStorageStatusService).isActive;
|
||||
|
||||
readonly summaryItems = computed<SettingsDeleteSummaryItem[]>(() => [
|
||||
{
|
||||
|
||||
@@ -42,10 +42,12 @@ describe('buildSettingsSectionNavItems', () => {
|
||||
const supportedItems = buildSettingsSectionNavItems({
|
||||
supportsEpg: true,
|
||||
supportsRemoteControl: true,
|
||||
supportsServerStorage: true,
|
||||
});
|
||||
const unsupportedItems = buildSettingsSectionNavItems({
|
||||
supportsEpg: false,
|
||||
supportsRemoteControl: false,
|
||||
supportsServerStorage: false,
|
||||
});
|
||||
|
||||
expect(supportedItems.map((item) => item.id)).toEqual(
|
||||
@@ -65,6 +67,7 @@ describe('buildSettingsSectionNavItems', () => {
|
||||
buildSettingsSectionNavItems({
|
||||
supportsEpg: true,
|
||||
supportsRemoteControl: true,
|
||||
supportsServerStorage: true,
|
||||
}).map((item) => item.id)
|
||||
);
|
||||
const renderableIds = collectRenderableSectionIds();
|
||||
@@ -86,6 +89,7 @@ describe('buildSettingsSectionNavItems', () => {
|
||||
buildSettingsSectionNavItems({
|
||||
supportsEpg: true,
|
||||
supportsRemoteControl: true,
|
||||
supportsServerStorage: true,
|
||||
}).map((item) => item.id)
|
||||
).toEqual([
|
||||
'general',
|
||||
@@ -95,6 +99,7 @@ describe('buildSettingsSectionNavItems', () => {
|
||||
'tmdb',
|
||||
'parental',
|
||||
'remote-control',
|
||||
'server',
|
||||
'backup',
|
||||
'about',
|
||||
]);
|
||||
@@ -104,6 +109,7 @@ describe('buildSettingsSectionNavItems', () => {
|
||||
const items = buildSettingsSectionNavItems({
|
||||
supportsEpg: true,
|
||||
supportsRemoteControl: true,
|
||||
supportsServerStorage: true,
|
||||
});
|
||||
const groupOf = (id: string) =>
|
||||
items.find((item) => item.id === id)?.group;
|
||||
@@ -111,7 +117,24 @@ describe('buildSettingsSectionNavItems', () => {
|
||||
expect(groupOf('general')).toBe('app');
|
||||
expect(groupOf('tmdb')).toBe('library');
|
||||
expect(groupOf('remote-control')).toBe('devices');
|
||||
expect(groupOf('server')).toBe('data');
|
||||
expect(groupOf('backup')).toBe('data');
|
||||
expect(groupOf('about')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('shows the server section only with server storage', () => {
|
||||
const build = (supportsServerStorage: boolean) =>
|
||||
buildSettingsSectionNavItems({
|
||||
supportsEpg: false,
|
||||
supportsRemoteControl: false,
|
||||
supportsServerStorage,
|
||||
}).find((item) => item.id === 'server');
|
||||
|
||||
expect(build(true)).toMatchObject({
|
||||
visible: true,
|
||||
group: 'data',
|
||||
icon: 'dns',
|
||||
});
|
||||
expect(build(false)?.visible).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -162,15 +162,18 @@ export function buildSettingsPlayerOptions({
|
||||
export interface SettingsSectionVisibility {
|
||||
supportsEpg: boolean;
|
||||
supportsRemoteControl: boolean;
|
||||
supportsServerStorage: boolean;
|
||||
}
|
||||
|
||||
export function buildSettingsSectionNavItems({
|
||||
supportsEpg,
|
||||
supportsRemoteControl,
|
||||
supportsServerStorage,
|
||||
}: SettingsSectionVisibility): SettingsSection[] {
|
||||
const capabilities = {
|
||||
epg: supportsEpg,
|
||||
'remote-control': supportsRemoteControl,
|
||||
'server-storage': supportsServerStorage,
|
||||
};
|
||||
return SETTINGS_SECTION_DEFINITIONS.map((section) => ({
|
||||
id: section.id,
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
<section class="settings-group" id="server">
|
||||
<header class="settings-group__header">
|
||||
<h2>{{ 'SETTINGS.NAV_SERVER' | translate }}</h2>
|
||||
<p>{{ 'SETTINGS.SERVER_DESCRIPTION' | translate }}</p>
|
||||
</header>
|
||||
|
||||
<div class="settings-card">
|
||||
<div class="setting-item" data-setting-id="server-connection">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.SERVER_CONNECTION' | translate }}</h4>
|
||||
<p>
|
||||
{{ 'SETTINGS.SERVER_CONNECTION_DESCRIPTION' | translate }}
|
||||
</p>
|
||||
<p class="server-detail" data-test-id="server-address">
|
||||
{{ address }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control">
|
||||
<span
|
||||
class="server-state"
|
||||
[attr.data-state]="state()"
|
||||
data-test-id="server-state"
|
||||
>
|
||||
{{
|
||||
(state() === 'online'
|
||||
? 'SETTINGS.SERVER_CONNECTED'
|
||||
: 'SETTINGS.SERVER_OFFLINE'
|
||||
) | translate
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item" data-setting-id="server-storage">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.SERVER_STORAGE' | translate }}</h4>
|
||||
<p>{{ 'SETTINGS.SERVER_STORAGE_DESCRIPTION' | translate }}</p>
|
||||
@if (details(); as info) {
|
||||
<p class="server-detail" data-test-id="server-summary">
|
||||
{{
|
||||
'SETTINGS.SERVER_STORAGE_SUMMARY'
|
||||
| translate
|
||||
: {
|
||||
playlists: info.playlists,
|
||||
size: sizeLabel(),
|
||||
}
|
||||
}}
|
||||
</p>
|
||||
<p class="server-detail server-detail--path">
|
||||
{{ info.databasePath }}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
<div class="setting-item__control inline-actions">
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
class="inline-actions__button"
|
||||
(click)="exportData.emit()"
|
||||
data-test-id="server-export-backup"
|
||||
>
|
||||
{{ 'SETTINGS.SERVER_EXPORT_BACKUP' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (showBrowserImport()) {
|
||||
<div class="setting-item" data-setting-id="server-import-browser">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.SERVER_IMPORT_BROWSER' | translate }}</h4>
|
||||
<p>
|
||||
{{
|
||||
'SETTINGS.SERVER_IMPORT_BROWSER_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
<p class="server-detail" role="status">
|
||||
@if (importResult(); as done) {
|
||||
{{
|
||||
'SERVER_STORAGE.MIGRATION_DONE'
|
||||
| translate: done
|
||||
}}
|
||||
} @else if (importFailed()) {
|
||||
{{ 'SERVER_STORAGE.MIGRATION_FAILED' | translate }}
|
||||
} @else {
|
||||
{{
|
||||
'SETTINGS.SERVER_IMPORT_BROWSER_COUNT'
|
||||
| translate
|
||||
: { count: browserPlaylistCount() }
|
||||
}}
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control inline-actions">
|
||||
@if (movedSomething()) {
|
||||
<!-- The library on screen predates the move. -->
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
(click)="reload()"
|
||||
data-test-id="server-import-reload"
|
||||
>
|
||||
{{ 'SERVER_STORAGE.MIGRATION_RELOAD' | translate }}
|
||||
</button>
|
||||
}
|
||||
@if (browserPlaylistCount() > 0) {
|
||||
<button
|
||||
mat-flat-button
|
||||
type="button"
|
||||
[disabled]="importing()"
|
||||
[attr.aria-busy]="importing()"
|
||||
(click)="importBrowserPlaylists()"
|
||||
data-test-id="server-import-browser"
|
||||
>
|
||||
@if (importing()) {
|
||||
<mat-progress-spinner
|
||||
class="button-spinner"
|
||||
diameter="16"
|
||||
mode="indeterminate"
|
||||
/>
|
||||
}
|
||||
{{ 'SERVER_STORAGE.MIGRATION_MOVE' | translate }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="setting-item" data-setting-id="server-access">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.SERVER_ACCESS' | translate }}</h4>
|
||||
<p>{{ 'SETTINGS.SERVER_ACCESS_DESCRIPTION' | translate }}</p>
|
||||
<p class="server-detail">
|
||||
{{
|
||||
(usesToken()
|
||||
? 'SETTINGS.SERVER_ACCESS_TOKEN'
|
||||
: 'SETTINGS.SERVER_ACCESS_OPEN'
|
||||
) | translate
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
@if (usesToken()) {
|
||||
<div class="setting-item__control">
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
(click)="signOut()"
|
||||
data-test-id="server-sign-out"
|
||||
>
|
||||
{{ 'SETTINGS.SERVER_SIGN_OUT' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,128 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { ServerStorageStatusService } from '@iptvnator/services';
|
||||
import {
|
||||
SERVER_STORAGE_CONFIG,
|
||||
ServerMigrationService,
|
||||
ServerStorageSession,
|
||||
} from '@iptvnator/services/server-storage';
|
||||
import { SettingsServerSectionComponent } from './settings-server-section.component';
|
||||
|
||||
describe('SettingsServerSectionComponent', () => {
|
||||
let fixture: ComponentFixture<SettingsServerSectionComponent>;
|
||||
const session = { fetchStatus: jest.fn(), signOut: jest.fn() };
|
||||
const migration = {
|
||||
browserPlaylists: jest.fn(),
|
||||
browserDataMoved: jest.fn(),
|
||||
moveBrowserData: jest.fn(),
|
||||
};
|
||||
|
||||
async function render(
|
||||
auth: 'none' | 'token',
|
||||
browserPlaylists = [{ id: 'a', title: 'A', kind: 'm3u' }]
|
||||
) {
|
||||
session.fetchStatus.mockResolvedValue({
|
||||
playlists: 4,
|
||||
databaseBytes: 38 * 1024 * 1024,
|
||||
databasePath: '/data/databases/iptvnator.db',
|
||||
startedAt: '2026-10-11T08:00:00.000Z',
|
||||
});
|
||||
migration.browserDataMoved.mockReturnValue(false);
|
||||
migration.browserPlaylists.mockResolvedValue(browserPlaylists);
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [
|
||||
SettingsServerSectionComponent,
|
||||
TranslateModule.forRoot(),
|
||||
],
|
||||
providers: [
|
||||
{ provide: ServerStorageSession, useValue: session },
|
||||
{ provide: ServerMigrationService, useValue: migration },
|
||||
{
|
||||
provide: SERVER_STORAGE_CONFIG,
|
||||
useValue: { baseUrl: 'http://nas.local:4333/api' },
|
||||
},
|
||||
{
|
||||
provide: ServerStorageStatusService,
|
||||
useValue: {
|
||||
state: signal('online'),
|
||||
instance: signal({ auth, instanceId: 'i-1' }),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
fixture = TestBed.createComponent(SettingsServerSectionComponent);
|
||||
await fixture.componentInstance.load();
|
||||
fixture.detectChanges();
|
||||
}
|
||||
|
||||
const byTestId = (id: string): HTMLElement | null =>
|
||||
fixture.nativeElement.querySelector(`[data-test-id="${id}"]`);
|
||||
|
||||
afterEach(() => {
|
||||
TestBed.resetTestingModule();
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('shows the storage figures and the database path', async () => {
|
||||
await render('none');
|
||||
const text = fixture.nativeElement.textContent;
|
||||
|
||||
expect(text).toContain('/data/databases/iptvnator.db');
|
||||
expect(byTestId('server-address')?.textContent?.trim()).toBe(
|
||||
'http://nas.local:4333'
|
||||
);
|
||||
expect(byTestId('server-state')?.dataset['state']).toBe('online');
|
||||
expect(fixture.componentInstance.sizeLabel()).toBe('38.0 MB');
|
||||
expect(byTestId('server-sign-out')).toBeNull();
|
||||
});
|
||||
|
||||
it('moves browser playlists and reports the result', async () => {
|
||||
await render('none');
|
||||
migration.moveBrowserData.mockResolvedValue({
|
||||
imported: 1,
|
||||
merged: 0,
|
||||
failed: 0,
|
||||
});
|
||||
migration.browserDataMoved.mockReturnValue(true);
|
||||
|
||||
byTestId('server-import-browser')?.click();
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(migration.moveBrowserData).toHaveBeenCalled();
|
||||
expect(byTestId('server-import-browser')).toBeNull();
|
||||
expect(byTestId('server-import-reload')).not.toBeNull();
|
||||
expect(fixture.nativeElement.textContent).toContain(
|
||||
'SERVER_STORAGE.MIGRATION_DONE'
|
||||
);
|
||||
});
|
||||
|
||||
it('hides the browser import when there is nothing to move', async () => {
|
||||
await render('none', []);
|
||||
|
||||
expect(
|
||||
fixture.nativeElement.querySelector(
|
||||
'[data-setting-id="server-import-browser"]'
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('offers sign out when a token is in use', async () => {
|
||||
await render('token');
|
||||
|
||||
byTestId('server-sign-out')?.click();
|
||||
|
||||
expect(session.signOut).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('asks the page to export a backup', async () => {
|
||||
await render('none');
|
||||
const exported = jest.fn();
|
||||
fixture.componentInstance.exportData.subscribe(exported);
|
||||
|
||||
byTestId('server-export-backup')?.click();
|
||||
|
||||
expect(exported).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
output,
|
||||
signal,
|
||||
ViewEncapsulation,
|
||||
} from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { ServerStorageStatusService } from '@iptvnator/services';
|
||||
import {
|
||||
SERVER_STORAGE_CONFIG,
|
||||
ServerMigrationService,
|
||||
ServerStorageSession,
|
||||
type ServerMigrationResult,
|
||||
} from '@iptvnator/services/server-storage';
|
||||
import type { ServerStorageStatusResponse } from '@iptvnator/shared/interfaces';
|
||||
|
||||
/**
|
||||
* Settings → Server: shown only when the PWA runs against self-hosted
|
||||
* server storage. Rendered without `@defer` so settings search can reveal
|
||||
* its rows on the first visit; the code rides in the lazy settings chunk.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-settings-server-section',
|
||||
imports: [
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatProgressSpinnerModule,
|
||||
TranslatePipe,
|
||||
],
|
||||
templateUrl: './settings-server-section.component.html',
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class SettingsServerSectionComponent {
|
||||
private readonly status = inject(ServerStorageStatusService);
|
||||
private readonly session = inject(ServerStorageSession);
|
||||
private readonly migration = inject(ServerMigrationService);
|
||||
|
||||
readonly exportData = output<void>();
|
||||
|
||||
readonly state = this.status.state;
|
||||
readonly usesToken = computed(
|
||||
() => this.status.instance()?.auth === 'token'
|
||||
);
|
||||
/** The backend's origin; the page's own in Docker (`/api`). */
|
||||
readonly address = backendOrigin(inject(SERVER_STORAGE_CONFIG).baseUrl);
|
||||
readonly details = signal<ServerStorageStatusResponse | null>(null);
|
||||
readonly browserPlaylistCount = signal(0);
|
||||
readonly importing = signal(false);
|
||||
readonly importFailed = signal(false);
|
||||
readonly importResult = signal<ServerMigrationResult | null>(null);
|
||||
readonly sizeLabel = computed(() =>
|
||||
formatMegabytes(this.details()?.databaseBytes ?? 0)
|
||||
);
|
||||
readonly showBrowserImport = computed(
|
||||
() => this.browserPlaylistCount() > 0 || this.importResult() !== null
|
||||
);
|
||||
readonly movedSomething = computed(() => {
|
||||
const result = this.importResult();
|
||||
return !!result && result.imported + result.merged > 0;
|
||||
});
|
||||
|
||||
constructor() {
|
||||
void this.load();
|
||||
}
|
||||
|
||||
async load(): Promise<void> {
|
||||
try {
|
||||
this.details.set(await this.session.fetchStatus());
|
||||
} catch {
|
||||
this.details.set(null);
|
||||
}
|
||||
try {
|
||||
this.browserPlaylistCount.set(
|
||||
this.migration.browserDataMoved()
|
||||
? 0
|
||||
: (await this.migration.browserPlaylists()).length
|
||||
);
|
||||
} catch {
|
||||
this.browserPlaylistCount.set(0);
|
||||
}
|
||||
}
|
||||
|
||||
async importBrowserPlaylists(): Promise<void> {
|
||||
this.importing.set(true);
|
||||
this.importFailed.set(false);
|
||||
try {
|
||||
this.importResult.set(await this.migration.moveBrowserData());
|
||||
await this.load();
|
||||
} catch {
|
||||
this.importFailed.set(true);
|
||||
} finally {
|
||||
this.importing.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
signOut(): void {
|
||||
this.session.signOut();
|
||||
}
|
||||
|
||||
reload(): void {
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
function backendOrigin(baseUrl: string): string {
|
||||
try {
|
||||
return new URL(baseUrl, globalThis.location?.href).origin;
|
||||
} catch {
|
||||
return baseUrl;
|
||||
}
|
||||
}
|
||||
|
||||
function formatMegabytes(bytes: number): string {
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
@@ -119,6 +119,11 @@
|
||||
(unlock)="parentalLock.unlock()"
|
||||
/>
|
||||
}
|
||||
@case ('server') {
|
||||
<app-settings-server-section
|
||||
(exportData)="exportData()"
|
||||
/>
|
||||
}
|
||||
@case ('backup') {
|
||||
<!-- Backup & data: the former Reset page is this
|
||||
page's last card, since it is one action. -->
|
||||
|
||||
@@ -606,3 +606,31 @@
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.server-state {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--app-selection-color);
|
||||
background: var(--app-selection-surface);
|
||||
|
||||
&[data-state='offline'],
|
||||
&[data-state='unauthorized'] {
|
||||
color: var(--app-expiry-warn-text);
|
||||
background: var(--app-expiry-warn-surface);
|
||||
}
|
||||
}
|
||||
|
||||
.setting-item__meta .server-detail {
|
||||
color: var(--app-muted-color);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.setting-item__meta .server-detail--path {
|
||||
font-family: 'JetBrains Mono', 'Roboto Mono', monospace;
|
||||
font-weight: 500;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||
import {
|
||||
EpgSourceReconciliationError,
|
||||
RuntimeCapabilitiesService,
|
||||
ServerStorageStatusService,
|
||||
injectTranslationTick,
|
||||
} from '@iptvnator/services';
|
||||
import { VodSourceDiscoveryService } from '@iptvnator/portal/shared/data-access';
|
||||
@@ -61,6 +62,7 @@ import { SettingsParentalLockSectionComponent } from './settings-parental-sectio
|
||||
import { SettingsPlaybackSectionComponent } from './settings-playback-section.component';
|
||||
import { SettingsRemoteControlFacade } from './settings-remote-control.facade';
|
||||
import { SettingsRemoteControlSectionComponent } from './settings-remote-control-section.component';
|
||||
import { SettingsServerSectionComponent } from './settings-server-section.component';
|
||||
import { SettingsSearchFacade } from './settings-search.facade';
|
||||
import { SettingsSearchResultsComponent } from './settings-search-results.component';
|
||||
import { SettingsTmdbSectionComponent } from './settings-tmdb-section.component';
|
||||
@@ -108,6 +110,7 @@ export const SETTINGS_DEFAULT_SECTION = 'general';
|
||||
SettingsPlaybackSectionComponent,
|
||||
SettingsRemoteControlSectionComponent,
|
||||
SettingsSearchResultsComponent,
|
||||
SettingsServerSectionComponent,
|
||||
SettingsTmdbSectionComponent,
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
@@ -166,6 +169,9 @@ export class SettingsComponent
|
||||
this.runtime.supportsExternalPlayerPathSettings;
|
||||
readonly supportsVodMultiSource = this.vodSourceDiscovery.isAvailable;
|
||||
readonly supportsRemoteControl = this.runtime.supportsRemoteControl;
|
||||
readonly supportsServerStorage = inject(
|
||||
ServerStorageStatusService
|
||||
).isActive();
|
||||
readonly supportsStartupWindowMode = this.runtime.supportsStartupWindowMode;
|
||||
readonly supportsPortalConnectivityGuard =
|
||||
this.runtime.supportsPortalConnectivityGuard;
|
||||
@@ -230,6 +236,7 @@ export class SettingsComponent
|
||||
readonly sectionNavItems: SettingsSection[] = buildSettingsSectionNavItems({
|
||||
supportsEpg: this.supportsEpg,
|
||||
supportsRemoteControl: this.supportsRemoteControl,
|
||||
supportsServerStorage: this.supportsServerStorage,
|
||||
});
|
||||
|
||||
get sectionNav(): SettingsSection[] {
|
||||
|
||||
@@ -316,6 +316,24 @@
|
||||
"NAV_REMOTE": "عن بعد",
|
||||
"NAV_DATA": "البيانات",
|
||||
"NAV_BACKUP": "النسخ الاحتياطي والبيانات",
|
||||
"NAV_SERVER": "الخادم",
|
||||
"SERVER_DESCRIPTION": "تُحفظ قوائم التشغيل والمفضلة وتقدّم المشاهدة على هذا الخادم وتتشاركها كل الأجهزة التي تفتحه.",
|
||||
"SERVER_CONNECTION": "الاتصال",
|
||||
"SERVER_CONNECTION_DESCRIPTION": "خادم IPTVnator الذي يحفظ فيه هذا التطبيق بياناته.",
|
||||
"SERVER_CONNECTED": "متصل",
|
||||
"SERVER_OFFLINE": "غير متاح",
|
||||
"SERVER_STORAGE": "التخزين",
|
||||
"SERVER_STORAGE_DESCRIPTION": "تُحفظ كل البيانات في ملف قاعدة بيانات واحد على الخادم. صدّر نسخة احتياطية للاحتفاظ بنسخة في مكان آخر.",
|
||||
"SERVER_STORAGE_SUMMARY": "قوائم التشغيل: {{playlists}} · {{size}}",
|
||||
"SERVER_EXPORT_BACKUP": "تصدير نسخة احتياطية",
|
||||
"SERVER_IMPORT_BROWSER": "قوائم التشغيل في هذا المتصفح",
|
||||
"SERVER_IMPORT_BROWSER_DESCRIPTION": "قوائم تشغيل حُفظت في هذا المتصفح قبل أن يستخدم الخادم. انقلها لتظهر على كل الأجهزة؛ وتُدمج القوائم الموجودة على الخادم مسبقًا.",
|
||||
"SERVER_IMPORT_BROWSER_COUNT": "قوائم التشغيل المحفوظة في هذا المتصفح فقط: {{count}}.",
|
||||
"SERVER_ACCESS": "الوصول",
|
||||
"SERVER_ACCESS_DESCRIPTION": "من يمكنه فتح مكتبة هذا الخادم.",
|
||||
"SERVER_ACCESS_OPEN": "يمكن لأي شخص يصل إلى هذا العنوان استخدامه. اضبط IPTVNATOR_ACCESS_TOKEN على الخادم لطلب رمز وصول.",
|
||||
"SERVER_ACCESS_TOKEN": "يتطلب هذا الخادم رمز وصول.",
|
||||
"SERVER_SIGN_OUT": "تسجيل الخروج",
|
||||
"NAV_ABOUT": "حول",
|
||||
"NAV_GROUP_APP": "التطبيق",
|
||||
"NAV_GROUP_LIBRARY": "المكتبة",
|
||||
@@ -643,6 +661,32 @@
|
||||
"LOCKED_TOAST": "تم القفل"
|
||||
}
|
||||
},
|
||||
"SERVER_STORAGE": {
|
||||
"WRITE_FAILED": "تعذّر الحفظ على الخادم. تحقق من الاتصال وحاول مرة أخرى.",
|
||||
"TOO_LARGE": "قائمة التشغيل هذه أكبر مما يقبله الخادم.",
|
||||
"MIGRATION_TITLE": "تم العثور على قوائم تشغيل في هذا المتصفح: {{count}}",
|
||||
"MIGRATION_DESCRIPTION": "إنها محفوظة في هذا المتصفح فقط. انقلها إلى الخادم لتراها كل الأجهزة التي تفتح هذا العنوان. لن يُحذف شيء من هذا المتصفح.",
|
||||
"MIGRATION_LATER_HINT": "يمكنك فعل ذلك لاحقًا من الإعدادات ← الخادم.",
|
||||
"MIGRATION_NOT_NOW": "ليس الآن",
|
||||
"MIGRATION_MOVE": "نقل إلى الخادم",
|
||||
"MIGRATION_DONE": "تم النقل: {{imported}}، الدمج: {{merged}}، الفشل: {{failed}}.",
|
||||
"MIGRATION_FAILED": "فشل النقل. ما زالت قوائم التشغيل في هذا المتصفح؛ حاول مجددًا من الإعدادات ← الخادم.",
|
||||
"MIGRATION_RELOAD": "إعادة التحميل",
|
||||
"SIGN_IN_TITLE": "مطلوب رمز وصول",
|
||||
"SIGN_IN_DESCRIPTION": "يطلب خادم IPTVnator هذا رمز وصول قبل عرض قوائم التشغيل.",
|
||||
"SIGN_IN_TOKEN_LABEL": "رمز الوصول",
|
||||
"SIGN_IN_HINT": "تجده في إعداد IPTVNATOR_ACCESS_TOKEN على الخادم.",
|
||||
"SIGN_IN_REJECTED": "تم رفض الرمز. تحقق منه وحاول مرة أخرى.",
|
||||
"SIGN_IN_UNREACHABLE": "تعذّر الوصول إلى الخادم. تحقق من الاتصال وحاول مرة أخرى.",
|
||||
"SIGN_IN_STORAGE": "لا يسمح هذا المتصفح لـ IPTVnator بحفظ الرمز. اسمح ببيانات الموقع لهذه الصفحة وحاول مرة أخرى.",
|
||||
"SIGN_IN_CONTINUE": "متابعة",
|
||||
"STATUS_ONLINE": "الخادم متصل",
|
||||
"STATUS_OFFLINE": "الخادم غير متاح",
|
||||
"STATUS_UNAUTHORIZED": "الخادم يحتاج إلى رمز وصول",
|
||||
"OFFLINE_BANNER": "الخادم غير متاح. لا يمكن حفظ التغييرات حتى يعود.",
|
||||
"RETRY": "إعادة المحاولة",
|
||||
"DELETE_ALL_WARNING": "سيؤدي هذا إلى حذف قوائم التشغيل من الخادم لكل الأجهزة."
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "داكن",
|
||||
"LIGHT_THEME": "فاتح",
|
||||
|
||||
@@ -316,6 +316,24 @@
|
||||
"NAV_REMOTE": "التحكم عن بعد",
|
||||
"NAV_DATA": "البيانات",
|
||||
"NAV_BACKUP": "النسخة الاحتياطية والمعطيات",
|
||||
"NAV_SERVER": "السيرفر",
|
||||
"SERVER_DESCRIPTION": "لوائح التشغيل والمفضلة وفين وصلتي فالمشاهدة كيتحفظو فهاد السيرفر وكيتشاركو مع كاع الأجهزة اللي كتحلو.",
|
||||
"SERVER_CONNECTION": "الاتصال",
|
||||
"SERVER_CONNECTION_DESCRIPTION": "سيرفر IPTVnator اللي كيحفظ فيه هاد التطبيق.",
|
||||
"SERVER_CONNECTED": "متاصل",
|
||||
"SERVER_OFFLINE": "ماشي متوفر",
|
||||
"SERVER_STORAGE": "التخزين",
|
||||
"SERVER_STORAGE_DESCRIPTION": "كلشي محفوظ فملف واحد ديال قاعدة المعطيات فالسيرفر. صدّر نسخة احتياطية باش تخلي نسخة فبلاصة خرا.",
|
||||
"SERVER_STORAGE_SUMMARY": "لوائح التشغيل: {{playlists}} · {{size}}",
|
||||
"SERVER_EXPORT_BACKUP": "صدّر نسخة احتياطية",
|
||||
"SERVER_IMPORT_BROWSER": "لوائح التشغيل فهاد المتصفح",
|
||||
"SERVER_IMPORT_BROWSER_DESCRIPTION": "لوائح تشغيل تحفظو فهاد المتصفح قبل ما يولي يخدم بالسيرفر. نقلهم باش يبانو فكاع الأجهزة؛ اللوائح اللي ديجا كاينين فالسيرفر كيتجمعو.",
|
||||
"SERVER_IMPORT_BROWSER_COUNT": "لوائح التشغيل المحفوظين غير فهاد المتصفح: {{count}}.",
|
||||
"SERVER_ACCESS": "الدخول",
|
||||
"SERVER_ACCESS_DESCRIPTION": "شكون يقدر يحل المكتبة ديال هاد السيرفر.",
|
||||
"SERVER_ACCESS_OPEN": "أي واحد يوصل لهاد العنوان يقدر يستعملو. حط IPTVNATOR_ACCESS_TOKEN فالسيرفر باش تطلب رمز.",
|
||||
"SERVER_ACCESS_TOKEN": "هاد السيرفر كيطلب رمز الدخول.",
|
||||
"SERVER_SIGN_OUT": "خرج",
|
||||
"NAV_ABOUT": "حول",
|
||||
"NAV_GROUP_APP": "التطبيق",
|
||||
"NAV_GROUP_LIBRARY": "المكتبة",
|
||||
@@ -643,6 +661,32 @@
|
||||
"LOCKED_TOAST": "تقفل"
|
||||
}
|
||||
},
|
||||
"SERVER_STORAGE": {
|
||||
"WRITE_FAILED": "ما قدرناش نحفظو فالسيرفر. شوف الاتصال وعاود جرب.",
|
||||
"TOO_LARGE": "هاد لائحة التشغيل كبيرة على اللي كيقبل السيرفر.",
|
||||
"MIGRATION_TITLE": "لقينا لوائح تشغيل فهاد المتصفح: {{count}}",
|
||||
"MIGRATION_DESCRIPTION": "محفوظين غير فهاد المتصفح. نقلهم للسيرفر باش يشوفهم كاع الأجهزة اللي كتحل هاد العنوان. والو ما غادي يتحيد من هاد المتصفح.",
|
||||
"MIGRATION_LATER_HINT": "تقدر دير هادشي من بعد فالإعدادات ← السيرفر.",
|
||||
"MIGRATION_NOT_NOW": "ماشي دابا",
|
||||
"MIGRATION_MOVE": "نقل للسيرفر",
|
||||
"MIGRATION_DONE": "تنقلو: {{imported}}، تجمعو: {{merged}}، فشلو: {{failed}}.",
|
||||
"MIGRATION_FAILED": "النقل فشل. لوائح التشغيل ديالك باقيين فهاد المتصفح؛ عاود جرب من الإعدادات ← السيرفر.",
|
||||
"MIGRATION_RELOAD": "عاود التحميل",
|
||||
"SIGN_IN_TITLE": "خاص رمز الدخول",
|
||||
"SIGN_IN_DESCRIPTION": "هاد سيرفر IPTVnator كيطلب رمز الدخول قبل ما يوريك لوائح التشغيل ديالك.",
|
||||
"SIGN_IN_TOKEN_LABEL": "رمز الدخول",
|
||||
"SIGN_IN_HINT": "تلقاه فالإعداد IPTVNATOR_ACCESS_TOKEN ديال السيرفر.",
|
||||
"SIGN_IN_REJECTED": "الرمز ترفض. تأكد منو وعاود جرب.",
|
||||
"SIGN_IN_UNREACHABLE": "ما قدرناش نوصلو للسيرفر. شوف الاتصال وعاود جرب.",
|
||||
"SIGN_IN_STORAGE": "هاد المتصفح ما كيخليش IPTVnator يحفظ الرمز. سمح بداتا ديال الموقع لهاد الصفحة وعاود جرب.",
|
||||
"SIGN_IN_CONTINUE": "كمّل",
|
||||
"STATUS_ONLINE": "السيرفر متاصل",
|
||||
"STATUS_OFFLINE": "السيرفر ماشي متوفر",
|
||||
"STATUS_UNAUTHORIZED": "السيرفر خاصو رمز الدخول",
|
||||
"OFFLINE_BANNER": "السيرفر ماشي متوفر. التغييرات ما يمكنش تتحفظ حتى يرجع.",
|
||||
"RETRY": "عاود جرب",
|
||||
"DELETE_ALL_WARNING": "هادشي غادي يحيد لوائح التشغيل من السيرفر لكاع الأجهزة."
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "داكن",
|
||||
"LIGHT_THEME": "فاتح",
|
||||
|
||||
@@ -316,6 +316,24 @@
|
||||
"NAV_REMOTE": "Кіраванне",
|
||||
"NAV_DATA": "Дадзеныя",
|
||||
"NAV_BACKUP": "Рэзервовая копія і дадзеныя",
|
||||
"NAV_SERVER": "Сервер",
|
||||
"SERVER_DESCRIPTION": "Плэйлісты, выбранае і прагрэс прагляду захоўваюцца на гэтым серверы і даступныя на кожнай прыладзе, якая яго адкрывае.",
|
||||
"SERVER_CONNECTION": "Злучэнне",
|
||||
"SERVER_CONNECTION_DESCRIPTION": "Сервер IPTVnator, на які гэта праграма захоўвае даныя.",
|
||||
"SERVER_CONNECTED": "Падключана",
|
||||
"SERVER_OFFLINE": "Недаступны",
|
||||
"SERVER_STORAGE": "Сховішча",
|
||||
"SERVER_STORAGE_DESCRIPTION": "Усё захоўваецца ў адным файле базы даных на серверы. Экспартуйце рэзервовую копію, каб мець копію ў іншым месцы.",
|
||||
"SERVER_STORAGE_SUMMARY": "Плэйлістоў: {{playlists}} · {{size}}",
|
||||
"SERVER_EXPORT_BACKUP": "Экспартаваць копію",
|
||||
"SERVER_IMPORT_BROWSER": "Плэйлісты ў гэтым браўзеры",
|
||||
"SERVER_IMPORT_BROWSER_DESCRIPTION": "Плэйлісты, захаваныя ў гэтым браўзеры да пераходу на сервер. Перанясіце іх, каб іх бачыла кожная прылада; плэйлісты, якія ўжо ёсць на серверы, будуць аб’яднаны.",
|
||||
"SERVER_IMPORT_BROWSER_COUNT": "Плэйлістоў, захаваных толькі ў гэтым браўзеры: {{count}}.",
|
||||
"SERVER_ACCESS": "Доступ",
|
||||
"SERVER_ACCESS_DESCRIPTION": "Хто можа адкрыць бібліятэку гэтага сервера.",
|
||||
"SERVER_ACCESS_OPEN": "Карыстацца можа кожны, хто мае доступ да гэтага адраса. Задайце IPTVNATOR_ACCESS_TOKEN на серверы, каб патрабаваць токен.",
|
||||
"SERVER_ACCESS_TOKEN": "Гэты сервер патрабуе токен доступу.",
|
||||
"SERVER_SIGN_OUT": "Выйсці",
|
||||
"NAV_ABOUT": "Аб праграме",
|
||||
"NAV_GROUP_APP": "Праграма",
|
||||
"NAV_GROUP_LIBRARY": "Бібліятэка",
|
||||
@@ -643,6 +661,32 @@
|
||||
"LOCKED_TOAST": "Заблакіравана"
|
||||
}
|
||||
},
|
||||
"SERVER_STORAGE": {
|
||||
"WRITE_FAILED": "Не ўдалося захаваць на сервер. Праверце злучэнне і паспрабуйце зноў.",
|
||||
"TOO_LARGE": "Гэты плэйліст большы, чым прымае сервер.",
|
||||
"MIGRATION_TITLE": "У гэтым браўзеры знойдзена плэйлістоў: {{count}}",
|
||||
"MIGRATION_DESCRIPTION": "Яны захоўваюцца толькі ў гэтым браўзеры. Перанясіце іх на сервер, каб іх бачыла кожная прылада, што адкрывае гэты адрас. З гэтага браўзера нічога не выдаляецца.",
|
||||
"MIGRATION_LATER_HINT": "Гэта можна зрабіць пазней у Налады → Сервер.",
|
||||
"MIGRATION_NOT_NOW": "Не зараз",
|
||||
"MIGRATION_MOVE": "Перанесці на сервер",
|
||||
"MIGRATION_DONE": "Перанесена: {{imported}}, аб’яднана: {{merged}}, з памылкай: {{failed}}.",
|
||||
"MIGRATION_FAILED": "Не ўдалося перанесці. Плэйлісты засталіся ў гэтым браўзеры; паспрабуйце зноў у Налады → Сервер.",
|
||||
"MIGRATION_RELOAD": "Перазагрузіць",
|
||||
"SIGN_IN_TITLE": "Патрэбны токен доступу",
|
||||
"SIGN_IN_DESCRIPTION": "Гэты сервер IPTVnator патрабуе токен доступу, перш чым паказаць вашы плэйлісты.",
|
||||
"SIGN_IN_TOKEN_LABEL": "Токен доступу",
|
||||
"SIGN_IN_HINT": "Ён зададзены ў наладзе IPTVNATOR_ACCESS_TOKEN на серверы.",
|
||||
"SIGN_IN_REJECTED": "Токен адхілены. Праверце яго і паспрабуйце зноў.",
|
||||
"SIGN_IN_UNREACHABLE": "Сервер недаступны. Праверце злучэнне і паспрабуйце зноў.",
|
||||
"SIGN_IN_STORAGE": "Браўзер не дазваляе IPTVnator захаваць токен. Дазвольце даныя сайта для гэтай старонкі і паспрабуйце зноў.",
|
||||
"SIGN_IN_CONTINUE": "Працягнуць",
|
||||
"STATUS_ONLINE": "Сервер падключаны",
|
||||
"STATUS_OFFLINE": "Сервер недаступны",
|
||||
"STATUS_UNAUTHORIZED": "Серверу патрэбны токен доступу",
|
||||
"OFFLINE_BANNER": "Сервер недаступны. Змены нельга захаваць, пакуль ён не вернецца.",
|
||||
"RETRY": "Паўтарыць",
|
||||
"DELETE_ALL_WARNING": "Плэйлісты будуць выдалены з сервера для ўсіх прылад."
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "Цёмная",
|
||||
"LIGHT_THEME": "Светлая",
|
||||
|
||||
@@ -316,6 +316,24 @@
|
||||
"NAV_REMOTE": "Remote",
|
||||
"NAV_DATA": "Daten",
|
||||
"NAV_BACKUP": "Datensicherung & Daten",
|
||||
"NAV_SERVER": "Server",
|
||||
"SERVER_DESCRIPTION": "Wiedergabelisten, Favoriten und Wiedergabefortschritt werden auf diesem Server gespeichert und mit jedem Gerät geteilt, das ihn öffnet.",
|
||||
"SERVER_CONNECTION": "Verbindung",
|
||||
"SERVER_CONNECTION_DESCRIPTION": "Der IPTVnator-Server, auf dem diese App speichert.",
|
||||
"SERVER_CONNECTED": "Verbunden",
|
||||
"SERVER_OFFLINE": "Nicht erreichbar",
|
||||
"SERVER_STORAGE": "Speicher",
|
||||
"SERVER_STORAGE_DESCRIPTION": "Alles liegt in einer Datenbankdatei auf dem Server. Exportieren Sie eine Datensicherung, um eine Kopie an anderer Stelle aufzubewahren.",
|
||||
"SERVER_STORAGE_SUMMARY": "{{playlists}} Wiedergabelisten · {{size}}",
|
||||
"SERVER_EXPORT_BACKUP": "Sicherung exportieren",
|
||||
"SERVER_IMPORT_BROWSER": "Wiedergabelisten in diesem Browser",
|
||||
"SERVER_IMPORT_BROWSER_DESCRIPTION": "Wiedergabelisten, die in diesem Browser gespeichert wurden, bevor er den Server nutzte. Verschieben Sie sie, damit jedes Gerät sie sieht; Wiedergabelisten, die der Server schon hat, werden zusammengeführt.",
|
||||
"SERVER_IMPORT_BROWSER_COUNT": "{{count}} Wiedergabelisten sind nur in diesem Browser gespeichert.",
|
||||
"SERVER_ACCESS": "Zugriff",
|
||||
"SERVER_ACCESS_DESCRIPTION": "Wer die Bibliothek dieses Servers öffnen kann.",
|
||||
"SERVER_ACCESS_OPEN": "Jeder, der diese Adresse erreicht, kann ihn nutzen. Setzen Sie IPTVNATOR_ACCESS_TOKEN auf dem Server, um ein Token zu verlangen.",
|
||||
"SERVER_ACCESS_TOKEN": "Dieser Server verlangt ein Zugriffstoken.",
|
||||
"SERVER_SIGN_OUT": "Abmelden",
|
||||
"NAV_ABOUT": "Info",
|
||||
"NAV_GROUP_APP": "App",
|
||||
"NAV_GROUP_LIBRARY": "Bibliothek",
|
||||
@@ -643,6 +661,32 @@
|
||||
"LOCKED_TOAST": "Gesperrt"
|
||||
}
|
||||
},
|
||||
"SERVER_STORAGE": {
|
||||
"WRITE_FAILED": "Speichern auf dem Server fehlgeschlagen. Prüfen Sie die Verbindung und versuchen Sie es erneut.",
|
||||
"TOO_LARGE": "Diese Wiedergabeliste ist größer, als der Server annimmt.",
|
||||
"MIGRATION_TITLE": "{{count}} Wiedergabelisten in diesem Browser gefunden",
|
||||
"MIGRATION_DESCRIPTION": "Sie sind nur in diesem Browser gespeichert. Verschieben Sie sie auf den Server, damit jedes Gerät, das diese Adresse öffnet, sie sieht. In diesem Browser wird nichts gelöscht.",
|
||||
"MIGRATION_LATER_HINT": "Das geht auch später unter Einstellungen → Server.",
|
||||
"MIGRATION_NOT_NOW": "Nicht jetzt",
|
||||
"MIGRATION_MOVE": "Auf den Server verschieben",
|
||||
"MIGRATION_DONE": "Verschoben: {{imported}}, zusammengeführt: {{merged}}, fehlgeschlagen: {{failed}}.",
|
||||
"MIGRATION_FAILED": "Verschieben fehlgeschlagen. Ihre Wiedergabelisten sind weiterhin in diesem Browser; versuchen Sie es unter Einstellungen → Server erneut.",
|
||||
"MIGRATION_RELOAD": "Neu laden",
|
||||
"SIGN_IN_TITLE": "Zugriffstoken erforderlich",
|
||||
"SIGN_IN_DESCRIPTION": "Dieser IPTVnator-Server verlangt ein Zugriffstoken, bevor er Ihre Wiedergabelisten zeigt.",
|
||||
"SIGN_IN_TOKEN_LABEL": "Zugriffstoken",
|
||||
"SIGN_IN_HINT": "Es steht in der Einstellung IPTVNATOR_ACCESS_TOKEN des Servers.",
|
||||
"SIGN_IN_REJECTED": "Token abgelehnt. Prüfen Sie es und versuchen Sie es erneut.",
|
||||
"SIGN_IN_UNREACHABLE": "Server nicht erreichbar. Prüfen Sie die Verbindung und versuchen Sie es erneut.",
|
||||
"SIGN_IN_STORAGE": "Dieser Browser lässt IPTVnator das Token nicht speichern. Erlauben Sie Websitedaten für diese Seite und versuchen Sie es erneut.",
|
||||
"SIGN_IN_CONTINUE": "Weiter",
|
||||
"STATUS_ONLINE": "Server verbunden",
|
||||
"STATUS_OFFLINE": "Server nicht erreichbar",
|
||||
"STATUS_UNAUTHORIZED": "Server verlangt ein Zugriffstoken",
|
||||
"OFFLINE_BANNER": "Server nicht erreichbar. Änderungen können erst gespeichert werden, wenn er wieder verfügbar ist.",
|
||||
"RETRY": "Erneut versuchen",
|
||||
"DELETE_ALL_WARNING": "Damit werden die Wiedergabelisten für alle Geräte vom Server entfernt."
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "Dunkel",
|
||||
"LIGHT_THEME": "Hell",
|
||||
|
||||
@@ -316,6 +316,24 @@
|
||||
"NAV_REMOTE": "Τηλεχειριστήριο",
|
||||
"NAV_DATA": "Δεδομένα",
|
||||
"NAV_BACKUP": "Αντίγραφο ασφαλείας και δεδομένα",
|
||||
"NAV_SERVER": "Διακομιστής",
|
||||
"SERVER_DESCRIPTION": "Οι λίστες αναπαραγωγής, τα αγαπημένα και η πρόοδος παρακολούθησης αποθηκεύονται σε αυτόν τον διακομιστή και είναι κοινά για κάθε συσκευή που τον ανοίγει.",
|
||||
"SERVER_CONNECTION": "Σύνδεση",
|
||||
"SERVER_CONNECTION_DESCRIPTION": "Ο διακομιστής IPTVnator στον οποίο αποθηκεύει αυτή η εφαρμογή.",
|
||||
"SERVER_CONNECTED": "Συνδεδεμένος",
|
||||
"SERVER_OFFLINE": "Μη διαθέσιμος",
|
||||
"SERVER_STORAGE": "Αποθήκευση",
|
||||
"SERVER_STORAGE_DESCRIPTION": "Όλα φυλάσσονται σε ένα αρχείο βάσης δεδομένων στον διακομιστή. Εξαγάγετε αντίγραφο ασφαλείας για να κρατήσετε ένα αντίγραφο αλλού.",
|
||||
"SERVER_STORAGE_SUMMARY": "{{playlists}} λίστες αναπαραγωγής · {{size}}",
|
||||
"SERVER_EXPORT_BACKUP": "Εξαγωγή αντιγράφου",
|
||||
"SERVER_IMPORT_BROWSER": "Λίστες σε αυτό το πρόγραμμα περιήγησης",
|
||||
"SERVER_IMPORT_BROWSER_DESCRIPTION": "Λίστες αναπαραγωγής που αποθηκεύτηκαν σε αυτό το πρόγραμμα περιήγησης πριν χρησιμοποιήσει τον διακομιστή. Μεταφέρετέ τες ώστε να τις βλέπει κάθε συσκευή· όσες έχει ήδη ο διακομιστής συγχωνεύονται.",
|
||||
"SERVER_IMPORT_BROWSER_COUNT": "{{count}} λίστες αναπαραγωγής είναι αποθηκευμένες μόνο σε αυτό το πρόγραμμα περιήγησης.",
|
||||
"SERVER_ACCESS": "Πρόσβαση",
|
||||
"SERVER_ACCESS_DESCRIPTION": "Ποιος μπορεί να ανοίξει τη βιβλιοθήκη αυτού του διακομιστή.",
|
||||
"SERVER_ACCESS_OPEN": "Όποιος φτάνει σε αυτή τη διεύθυνση μπορεί να τον χρησιμοποιήσει. Ορίστε IPTVNATOR_ACCESS_TOKEN στον διακομιστή για να απαιτείται διακριτικό.",
|
||||
"SERVER_ACCESS_TOKEN": "Αυτός ο διακομιστής απαιτεί διακριτικό πρόσβασης.",
|
||||
"SERVER_SIGN_OUT": "Αποσύνδεση",
|
||||
"NAV_ABOUT": "Πληροφορίες",
|
||||
"NAV_GROUP_APP": "Εφαρμογή",
|
||||
"NAV_GROUP_LIBRARY": "Βιβλιοθήκη",
|
||||
@@ -643,6 +661,32 @@
|
||||
"LOCKED_TOAST": "Κλειδώθηκε"
|
||||
}
|
||||
},
|
||||
"SERVER_STORAGE": {
|
||||
"WRITE_FAILED": "Δεν ήταν δυνατή η αποθήκευση στον διακομιστή. Ελέγξτε τη σύνδεση και δοκιμάστε ξανά.",
|
||||
"TOO_LARGE": "Αυτή η λίστα αναπαραγωγής είναι μεγαλύτερη από όσο δέχεται ο διακομιστής.",
|
||||
"MIGRATION_TITLE": "Βρέθηκαν {{count}} λίστες αναπαραγωγής σε αυτό το πρόγραμμα περιήγησης",
|
||||
"MIGRATION_DESCRIPTION": "Είναι αποθηκευμένες μόνο σε αυτό το πρόγραμμα περιήγησης. Μεταφέρετέ τες στον διακομιστή ώστε να τις βλέπει κάθε συσκευή που ανοίγει αυτή τη διεύθυνση. Τίποτα δεν διαγράφεται από αυτό το πρόγραμμα περιήγησης.",
|
||||
"MIGRATION_LATER_HINT": "Μπορείτε να το κάνετε αργότερα από Ρυθμίσεις → Διακομιστής.",
|
||||
"MIGRATION_NOT_NOW": "Όχι τώρα",
|
||||
"MIGRATION_MOVE": "Μεταφορά στον διακομιστή",
|
||||
"MIGRATION_DONE": "Μεταφέρθηκαν {{imported}}, συγχωνεύτηκαν {{merged}}, απέτυχαν {{failed}}.",
|
||||
"MIGRATION_FAILED": "Η μεταφορά απέτυχε. Οι λίστες σας παραμένουν σε αυτό το πρόγραμμα περιήγησης· δοκιμάστε ξανά από Ρυθμίσεις → Διακομιστής.",
|
||||
"MIGRATION_RELOAD": "Επαναφόρτωση",
|
||||
"SIGN_IN_TITLE": "Απαιτείται διακριτικό πρόσβασης",
|
||||
"SIGN_IN_DESCRIPTION": "Αυτός ο διακομιστής IPTVnator ζητά διακριτικό πρόσβασης πριν εμφανίσει τις λίστες αναπαραγωγής σας.",
|
||||
"SIGN_IN_TOKEN_LABEL": "Διακριτικό πρόσβασης",
|
||||
"SIGN_IN_HINT": "Θα το βρείτε στη ρύθμιση IPTVNATOR_ACCESS_TOKEN του διακομιστή.",
|
||||
"SIGN_IN_REJECTED": "Το διακριτικό απορρίφθηκε. Ελέγξτε το και δοκιμάστε ξανά.",
|
||||
"SIGN_IN_UNREACHABLE": "Ο διακομιστής δεν είναι προσβάσιμος. Ελέγξτε τη σύνδεση και δοκιμάστε ξανά.",
|
||||
"SIGN_IN_STORAGE": "Αυτό το πρόγραμμα περιήγησης δεν επιτρέπει στο IPTVnator να αποθηκεύσει το διακριτικό. Επιτρέψτε τα δεδομένα ιστότοπου για αυτή τη σελίδα και δοκιμάστε ξανά.",
|
||||
"SIGN_IN_CONTINUE": "Συνέχεια",
|
||||
"STATUS_ONLINE": "Ο διακομιστής είναι συνδεδεμένος",
|
||||
"STATUS_OFFLINE": "Ο διακομιστής δεν είναι διαθέσιμος",
|
||||
"STATUS_UNAUTHORIZED": "Ο διακομιστής χρειάζεται διακριτικό πρόσβασης",
|
||||
"OFFLINE_BANNER": "Ο διακομιστής δεν είναι διαθέσιμος. Οι αλλαγές δεν μπορούν να αποθηκευτούν μέχρι να επανέλθει.",
|
||||
"RETRY": "Επανάληψη",
|
||||
"DELETE_ALL_WARNING": "Αυτό αφαιρεί τις λίστες αναπαραγωγής από τον διακομιστή για κάθε συσκευή."
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "Σκούρο",
|
||||
"LIGHT_THEME": "Ανοιχτό",
|
||||
|
||||
@@ -316,6 +316,24 @@
|
||||
"NAV_REMOTE": "Remote",
|
||||
"NAV_DATA": "Data",
|
||||
"NAV_BACKUP": "Backup & data",
|
||||
"NAV_SERVER": "Server",
|
||||
"SERVER_DESCRIPTION": "Playlists, favorites and watch progress are stored on this server and shared by every device that opens it.",
|
||||
"SERVER_CONNECTION": "Connection",
|
||||
"SERVER_CONNECTION_DESCRIPTION": "The IPTVnator server this app saves to.",
|
||||
"SERVER_CONNECTED": "Connected",
|
||||
"SERVER_OFFLINE": "Unavailable",
|
||||
"SERVER_STORAGE": "Storage",
|
||||
"SERVER_STORAGE_DESCRIPTION": "Everything is kept in one database file on the server. Export a backup to keep a copy elsewhere.",
|
||||
"SERVER_STORAGE_SUMMARY": "{{playlists}} playlists · {{size}}",
|
||||
"SERVER_EXPORT_BACKUP": "Export backup",
|
||||
"SERVER_IMPORT_BROWSER": "Playlists in this browser",
|
||||
"SERVER_IMPORT_BROWSER_DESCRIPTION": "Playlists saved in this browser before it used the server. Move them so every device sees them; playlists the server already has are merged.",
|
||||
"SERVER_IMPORT_BROWSER_COUNT": "{{count}} playlists are stored only in this browser.",
|
||||
"SERVER_ACCESS": "Access",
|
||||
"SERVER_ACCESS_DESCRIPTION": "Who can open this server's library.",
|
||||
"SERVER_ACCESS_OPEN": "Anyone who can reach this address can use it. Set IPTVNATOR_ACCESS_TOKEN on the server to require a token.",
|
||||
"SERVER_ACCESS_TOKEN": "This server requires an access token.",
|
||||
"SERVER_SIGN_OUT": "Sign out",
|
||||
"NAV_ABOUT": "About",
|
||||
"NAV_GROUP_APP": "App",
|
||||
"NAV_GROUP_LIBRARY": "Library",
|
||||
@@ -643,6 +661,32 @@
|
||||
"LOCKED_TOAST": "Locked"
|
||||
}
|
||||
},
|
||||
"SERVER_STORAGE": {
|
||||
"WRITE_FAILED": "Could not save to the server. Check the connection and try again.",
|
||||
"TOO_LARGE": "This playlist is larger than the server accepts.",
|
||||
"MIGRATION_TITLE": "Found {{count}} playlists in this browser",
|
||||
"MIGRATION_DESCRIPTION": "They are stored only in this browser. Move them to the server so every device that opens this address sees them. Nothing is deleted from this browser.",
|
||||
"MIGRATION_LATER_HINT": "You can do this later in Settings → Server.",
|
||||
"MIGRATION_NOT_NOW": "Not now",
|
||||
"MIGRATION_MOVE": "Move to server",
|
||||
"MIGRATION_DONE": "Moved {{imported}}, merged {{merged}}, failed {{failed}}.",
|
||||
"MIGRATION_FAILED": "Moving failed. Your playlists are still in this browser; try again from Settings → Server.",
|
||||
"MIGRATION_RELOAD": "Reload",
|
||||
"SIGN_IN_TITLE": "Access token required",
|
||||
"SIGN_IN_DESCRIPTION": "This IPTVnator server asks for an access token before it shows your playlists.",
|
||||
"SIGN_IN_TOKEN_LABEL": "Access token",
|
||||
"SIGN_IN_HINT": "Find it in the server's IPTVNATOR_ACCESS_TOKEN setting.",
|
||||
"SIGN_IN_REJECTED": "Token rejected. Check it and try again.",
|
||||
"SIGN_IN_UNREACHABLE": "Cannot reach the server. Check the connection and try again.",
|
||||
"SIGN_IN_STORAGE": "This browser does not let IPTVnator keep the token. Allow site data for this page and try again.",
|
||||
"SIGN_IN_CONTINUE": "Continue",
|
||||
"STATUS_ONLINE": "Server connected",
|
||||
"STATUS_OFFLINE": "Server unavailable",
|
||||
"STATUS_UNAUTHORIZED": "Server needs an access token",
|
||||
"OFFLINE_BANNER": "Server unavailable. Changes cannot be saved until it is back.",
|
||||
"RETRY": "Retry",
|
||||
"DELETE_ALL_WARNING": "This removes the playlists from the server for every device."
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "Dark",
|
||||
"LIGHT_THEME": "Light",
|
||||
|
||||
@@ -316,6 +316,24 @@
|
||||
"NAV_REMOTE": "Remoto",
|
||||
"NAV_DATA": "Datos",
|
||||
"NAV_BACKUP": "Copia de seguridad y datos",
|
||||
"NAV_SERVER": "Servidor",
|
||||
"SERVER_DESCRIPTION": "Las listas de reproducción, los favoritos y el progreso de visualización se guardan en este servidor y los comparten todos los dispositivos que lo abren.",
|
||||
"SERVER_CONNECTION": "Conexión",
|
||||
"SERVER_CONNECTION_DESCRIPTION": "El servidor de IPTVnator en el que guarda esta aplicación.",
|
||||
"SERVER_CONNECTED": "Conectado",
|
||||
"SERVER_OFFLINE": "No disponible",
|
||||
"SERVER_STORAGE": "Almacenamiento",
|
||||
"SERVER_STORAGE_DESCRIPTION": "Todo se guarda en un único archivo de base de datos en el servidor. Exporte una copia de seguridad para conservar una copia en otro lugar.",
|
||||
"SERVER_STORAGE_SUMMARY": "{{playlists}} listas de reproducción · {{size}}",
|
||||
"SERVER_EXPORT_BACKUP": "Exportar copia",
|
||||
"SERVER_IMPORT_BROWSER": "Listas en este navegador",
|
||||
"SERVER_IMPORT_BROWSER_DESCRIPTION": "Listas de reproducción guardadas en este navegador antes de que usara el servidor. Muévalas para que todos los dispositivos las vean; las que el servidor ya tiene se combinan.",
|
||||
"SERVER_IMPORT_BROWSER_COUNT": "{{count}} listas de reproducción solo están guardadas en este navegador.",
|
||||
"SERVER_ACCESS": "Acceso",
|
||||
"SERVER_ACCESS_DESCRIPTION": "Quién puede abrir la biblioteca de este servidor.",
|
||||
"SERVER_ACCESS_OPEN": "Cualquiera que llegue a esta dirección puede usarlo. Defina IPTVNATOR_ACCESS_TOKEN en el servidor para exigir un token.",
|
||||
"SERVER_ACCESS_TOKEN": "Este servidor requiere un token de acceso.",
|
||||
"SERVER_SIGN_OUT": "Cerrar sesión",
|
||||
"NAV_ABOUT": "Acerca de",
|
||||
"NAV_GROUP_APP": "Aplicación",
|
||||
"NAV_GROUP_LIBRARY": "Biblioteca",
|
||||
@@ -643,6 +661,32 @@
|
||||
"LOCKED_TOAST": "Bloqueado"
|
||||
}
|
||||
},
|
||||
"SERVER_STORAGE": {
|
||||
"WRITE_FAILED": "No se pudo guardar en el servidor. Compruebe la conexión e inténtelo de nuevo.",
|
||||
"TOO_LARGE": "Esta lista de reproducción supera el tamaño que acepta el servidor.",
|
||||
"MIGRATION_TITLE": "Se encontraron {{count}} listas de reproducción en este navegador",
|
||||
"MIGRATION_DESCRIPTION": "Solo están guardadas en este navegador. Muévalas al servidor para que las vean todos los dispositivos que abren esta dirección. No se borra nada de este navegador.",
|
||||
"MIGRATION_LATER_HINT": "Puede hacerlo más tarde en Ajustes → Servidor.",
|
||||
"MIGRATION_NOT_NOW": "Ahora no",
|
||||
"MIGRATION_MOVE": "Mover al servidor",
|
||||
"MIGRATION_DONE": "Movidas: {{imported}}, combinadas: {{merged}}, fallidas: {{failed}}.",
|
||||
"MIGRATION_FAILED": "No se pudieron mover. Sus listas siguen en este navegador; inténtelo de nuevo en Ajustes → Servidor.",
|
||||
"MIGRATION_RELOAD": "Recargar",
|
||||
"SIGN_IN_TITLE": "Se necesita un token de acceso",
|
||||
"SIGN_IN_DESCRIPTION": "Este servidor de IPTVnator pide un token de acceso antes de mostrar sus listas de reproducción.",
|
||||
"SIGN_IN_TOKEN_LABEL": "Token de acceso",
|
||||
"SIGN_IN_HINT": "Lo encontrará en el ajuste IPTVNATOR_ACCESS_TOKEN del servidor.",
|
||||
"SIGN_IN_REJECTED": "Token rechazado. Compruébelo e inténtelo de nuevo.",
|
||||
"SIGN_IN_UNREACHABLE": "No se puede contactar con el servidor. Compruebe la conexión e inténtelo de nuevo.",
|
||||
"SIGN_IN_STORAGE": "Este navegador no permite que IPTVnator guarde el token. Permita los datos del sitio para esta página e inténtelo de nuevo.",
|
||||
"SIGN_IN_CONTINUE": "Continuar",
|
||||
"STATUS_ONLINE": "Servidor conectado",
|
||||
"STATUS_OFFLINE": "Servidor no disponible",
|
||||
"STATUS_UNAUTHORIZED": "El servidor necesita un token de acceso",
|
||||
"OFFLINE_BANNER": "Servidor no disponible. Los cambios no se pueden guardar hasta que vuelva.",
|
||||
"RETRY": "Reintentar",
|
||||
"DELETE_ALL_WARNING": "Esto elimina las listas de reproducción del servidor para todos los dispositivos."
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "Oscuro",
|
||||
"LIGHT_THEME": "Claro",
|
||||
|
||||
@@ -316,6 +316,24 @@
|
||||
"NAV_REMOTE": "Télécommande",
|
||||
"NAV_DATA": "Données",
|
||||
"NAV_BACKUP": "Sauvegarde et données",
|
||||
"NAV_SERVER": "Serveur",
|
||||
"SERVER_DESCRIPTION": "Les playlists, les favoris et la progression de lecture sont stockés sur ce serveur et partagés par tous les appareils qui l’ouvrent.",
|
||||
"SERVER_CONNECTION": "Connexion",
|
||||
"SERVER_CONNECTION_DESCRIPTION": "Le serveur IPTVnator sur lequel cette application enregistre.",
|
||||
"SERVER_CONNECTED": "Connecté",
|
||||
"SERVER_OFFLINE": "Indisponible",
|
||||
"SERVER_STORAGE": "Stockage",
|
||||
"SERVER_STORAGE_DESCRIPTION": "Tout est conservé dans un seul fichier de base de données sur le serveur. Exportez une sauvegarde pour en garder une copie ailleurs.",
|
||||
"SERVER_STORAGE_SUMMARY": "Playlists : {{playlists}} · {{size}}",
|
||||
"SERVER_EXPORT_BACKUP": "Exporter une sauvegarde",
|
||||
"SERVER_IMPORT_BROWSER": "Playlists de ce navigateur",
|
||||
"SERVER_IMPORT_BROWSER_DESCRIPTION": "Playlists enregistrées dans ce navigateur avant qu’il utilise le serveur. Déplacez-les pour que tous les appareils les voient ; celles que le serveur possède déjà sont fusionnées.",
|
||||
"SERVER_IMPORT_BROWSER_COUNT": "{{count}} playlists sont stockées uniquement dans ce navigateur.",
|
||||
"SERVER_ACCESS": "Accès",
|
||||
"SERVER_ACCESS_DESCRIPTION": "Qui peut ouvrir la bibliothèque de ce serveur.",
|
||||
"SERVER_ACCESS_OPEN": "Toute personne qui atteint cette adresse peut l’utiliser. Définissez IPTVNATOR_ACCESS_TOKEN sur le serveur pour exiger un jeton.",
|
||||
"SERVER_ACCESS_TOKEN": "Ce serveur exige un jeton d’accès.",
|
||||
"SERVER_SIGN_OUT": "Se déconnecter",
|
||||
"NAV_ABOUT": "À propos",
|
||||
"NAV_GROUP_APP": "Application",
|
||||
"NAV_GROUP_LIBRARY": "Bibliothèque",
|
||||
@@ -643,6 +661,32 @@
|
||||
"LOCKED_TOAST": "Verrouillé"
|
||||
}
|
||||
},
|
||||
"SERVER_STORAGE": {
|
||||
"WRITE_FAILED": "Impossible d’enregistrer sur le serveur. Vérifiez la connexion et réessayez.",
|
||||
"TOO_LARGE": "Cette playlist dépasse la taille acceptée par le serveur.",
|
||||
"MIGRATION_TITLE": "{{count}} playlists trouvées dans ce navigateur",
|
||||
"MIGRATION_DESCRIPTION": "Elles ne sont stockées que dans ce navigateur. Déplacez-les vers le serveur pour que tous les appareils qui ouvrent cette adresse les voient. Rien n’est supprimé de ce navigateur.",
|
||||
"MIGRATION_LATER_HINT": "Vous pourrez le faire plus tard dans Paramètres → Serveur.",
|
||||
"MIGRATION_NOT_NOW": "Plus tard",
|
||||
"MIGRATION_MOVE": "Déplacer vers le serveur",
|
||||
"MIGRATION_DONE": "Déplacées : {{imported}}, fusionnées : {{merged}}, en échec : {{failed}}.",
|
||||
"MIGRATION_FAILED": "Le déplacement a échoué. Vos playlists sont toujours dans ce navigateur ; réessayez depuis Paramètres → Serveur.",
|
||||
"MIGRATION_RELOAD": "Recharger",
|
||||
"SIGN_IN_TITLE": "Jeton d’accès requis",
|
||||
"SIGN_IN_DESCRIPTION": "Ce serveur IPTVnator demande un jeton d’accès avant d’afficher vos playlists.",
|
||||
"SIGN_IN_TOKEN_LABEL": "Jeton d’accès",
|
||||
"SIGN_IN_HINT": "Il figure dans le paramètre IPTVNATOR_ACCESS_TOKEN du serveur.",
|
||||
"SIGN_IN_REJECTED": "Jeton refusé. Vérifiez-le et réessayez.",
|
||||
"SIGN_IN_UNREACHABLE": "Serveur injoignable. Vérifiez la connexion et réessayez.",
|
||||
"SIGN_IN_STORAGE": "Ce navigateur ne permet pas à IPTVnator d’enregistrer le jeton. Autorisez les données de site pour cette page et réessayez.",
|
||||
"SIGN_IN_CONTINUE": "Continuer",
|
||||
"STATUS_ONLINE": "Serveur connecté",
|
||||
"STATUS_OFFLINE": "Serveur indisponible",
|
||||
"STATUS_UNAUTHORIZED": "Le serveur demande un jeton d’accès",
|
||||
"OFFLINE_BANNER": "Serveur indisponible. Les modifications ne peuvent pas être enregistrées avant son retour.",
|
||||
"RETRY": "Réessayer",
|
||||
"DELETE_ALL_WARNING": "Cela supprime les playlists du serveur pour tous les appareils."
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "Sombre",
|
||||
"LIGHT_THEME": "Clair",
|
||||
|
||||
@@ -316,6 +316,24 @@
|
||||
"NAV_REMOTE": "Távvezérlés",
|
||||
"NAV_DATA": "Adatok",
|
||||
"NAV_BACKUP": "Biztonsági mentés és adatok",
|
||||
"NAV_SERVER": "Szerver",
|
||||
"SERVER_DESCRIPTION": "A lejátszási listák, a kedvencek és a megtekintési előrehaladás ezen a szerveren tárolódik, és minden eszköz ugyanazt látja, amely megnyitja.",
|
||||
"SERVER_CONNECTION": "Kapcsolat",
|
||||
"SERVER_CONNECTION_DESCRIPTION": "Az IPTVnator szerver, amelyre ez az alkalmazás ment.",
|
||||
"SERVER_CONNECTED": "Csatlakozva",
|
||||
"SERVER_OFFLINE": "Nem érhető el",
|
||||
"SERVER_STORAGE": "Tárhely",
|
||||
"SERVER_STORAGE_DESCRIPTION": "Minden egyetlen adatbázisfájlban van a szerveren. Exportáljon biztonsági mentést, hogy máshol is legyen másolata.",
|
||||
"SERVER_STORAGE_SUMMARY": "{{playlists}} lejátszási lista · {{size}}",
|
||||
"SERVER_EXPORT_BACKUP": "Mentés exportálása",
|
||||
"SERVER_IMPORT_BROWSER": "Lejátszási listák ebben a böngészőben",
|
||||
"SERVER_IMPORT_BROWSER_DESCRIPTION": "Lejátszási listák, amelyeket ebben a böngészőben mentett, mielőtt az a szervert használta volna. Helyezze át őket, hogy minden eszköz lássa; a szerveren már meglévők összevonásra kerülnek.",
|
||||
"SERVER_IMPORT_BROWSER_COUNT": "{{count}} lejátszási lista csak ebben a böngészőben van tárolva.",
|
||||
"SERVER_ACCESS": "Hozzáférés",
|
||||
"SERVER_ACCESS_DESCRIPTION": "Ki nyithatja meg a szerver könyvtárát.",
|
||||
"SERVER_ACCESS_OPEN": "Bárki használhatja, aki eléri ezt a címet. Token megköveteléséhez állítsa be az IPTVNATOR_ACCESS_TOKEN értéket a szerveren.",
|
||||
"SERVER_ACCESS_TOKEN": "Ez a szerver hozzáférési tokent kér.",
|
||||
"SERVER_SIGN_OUT": "Kijelentkezés",
|
||||
"NAV_ABOUT": "Névjegy",
|
||||
"NAV_GROUP_APP": "Alkalmazás",
|
||||
"NAV_GROUP_LIBRARY": "Médiatár",
|
||||
@@ -643,6 +661,32 @@
|
||||
"LOCKED_TOAST": "Zárolva"
|
||||
}
|
||||
},
|
||||
"SERVER_STORAGE": {
|
||||
"WRITE_FAILED": "Nem sikerült menteni a szerverre. Ellenőrizze a kapcsolatot, és próbálja újra.",
|
||||
"TOO_LARGE": "Ez a lejátszási lista nagyobb, mint amit a szerver elfogad.",
|
||||
"MIGRATION_TITLE": "{{count}} lejátszási lista található ebben a böngészőben",
|
||||
"MIGRATION_DESCRIPTION": "Ezek csak ebben a böngészőben vannak tárolva. Helyezze át őket a szerverre, hogy minden eszköz lássa őket, amely megnyitja ezt a címet. Ebből a böngészőből semmi sem törlődik.",
|
||||
"MIGRATION_LATER_HINT": "Ezt később is megteheti a Beállítások → Szerver menüben.",
|
||||
"MIGRATION_NOT_NOW": "Most nem",
|
||||
"MIGRATION_MOVE": "Áthelyezés a szerverre",
|
||||
"MIGRATION_DONE": "Áthelyezve: {{imported}}, összevonva: {{merged}}, sikertelen: {{failed}}.",
|
||||
"MIGRATION_FAILED": "Az áthelyezés nem sikerült. A lejátszási listák továbbra is ebben a böngészőben vannak; próbálja újra a Beállítások → Szerver menüben.",
|
||||
"MIGRATION_RELOAD": "Újratöltés",
|
||||
"SIGN_IN_TITLE": "Hozzáférési token szükséges",
|
||||
"SIGN_IN_DESCRIPTION": "Ez az IPTVnator szerver hozzáférési tokent kér, mielőtt megjeleníti a lejátszási listáit.",
|
||||
"SIGN_IN_TOKEN_LABEL": "Hozzáférési token",
|
||||
"SIGN_IN_HINT": "A szerver IPTVNATOR_ACCESS_TOKEN beállításában találja.",
|
||||
"SIGN_IN_REJECTED": "A tokent elutasította a szerver. Ellenőrizze, és próbálja újra.",
|
||||
"SIGN_IN_UNREACHABLE": "A szerver nem érhető el. Ellenőrizze a kapcsolatot, és próbálja újra.",
|
||||
"SIGN_IN_STORAGE": "Ez a böngésző nem engedi, hogy az IPTVnator megőrizze a tokent. Engedélyezze a webhelyadatokat ehhez az oldalhoz, és próbálja újra.",
|
||||
"SIGN_IN_CONTINUE": "Tovább",
|
||||
"STATUS_ONLINE": "Szerver csatlakoztatva",
|
||||
"STATUS_OFFLINE": "A szerver nem érhető el",
|
||||
"STATUS_UNAUTHORIZED": "A szerver hozzáférési tokent kér",
|
||||
"OFFLINE_BANNER": "A szerver nem érhető el. A módosítások nem menthetők, amíg újra elérhető nem lesz.",
|
||||
"RETRY": "Újra",
|
||||
"DELETE_ALL_WARNING": "Ez minden eszközön eltávolítja a lejátszási listákat a szerverről."
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "Sötét",
|
||||
"LIGHT_THEME": "Világos",
|
||||
|
||||
@@ -316,6 +316,24 @@
|
||||
"NAV_REMOTE": "Remote",
|
||||
"NAV_DATA": "Dati",
|
||||
"NAV_BACKUP": "Copia di sicurezza e dati",
|
||||
"NAV_SERVER": "Server",
|
||||
"SERVER_DESCRIPTION": "Playlist, preferiti e avanzamento della visione sono salvati su questo server e condivisi da ogni dispositivo che lo apre.",
|
||||
"SERVER_CONNECTION": "Connessione",
|
||||
"SERVER_CONNECTION_DESCRIPTION": "Il server IPTVnator su cui salva questa app.",
|
||||
"SERVER_CONNECTED": "Connesso",
|
||||
"SERVER_OFFLINE": "Non disponibile",
|
||||
"SERVER_STORAGE": "Archiviazione",
|
||||
"SERVER_STORAGE_DESCRIPTION": "Tutto è conservato in un unico file di database sul server. Esporta una copia di sicurezza per tenerne una copia altrove.",
|
||||
"SERVER_STORAGE_SUMMARY": "{{playlists}} playlist · {{size}}",
|
||||
"SERVER_EXPORT_BACKUP": "Esporta copia",
|
||||
"SERVER_IMPORT_BROWSER": "Playlist in questo browser",
|
||||
"SERVER_IMPORT_BROWSER_DESCRIPTION": "Playlist salvate in questo browser prima che usasse il server. Spostale perché ogni dispositivo le veda; quelle già presenti sul server vengono unite.",
|
||||
"SERVER_IMPORT_BROWSER_COUNT": "{{count}} playlist sono salvate solo in questo browser.",
|
||||
"SERVER_ACCESS": "Accesso",
|
||||
"SERVER_ACCESS_DESCRIPTION": "Chi può aprire la libreria di questo server.",
|
||||
"SERVER_ACCESS_OPEN": "Chiunque raggiunga questo indirizzo può usarlo. Imposta IPTVNATOR_ACCESS_TOKEN sul server per richiedere un token.",
|
||||
"SERVER_ACCESS_TOKEN": "Questo server richiede un token di accesso.",
|
||||
"SERVER_SIGN_OUT": "Esci",
|
||||
"NAV_ABOUT": "Info",
|
||||
"NAV_GROUP_APP": "App",
|
||||
"NAV_GROUP_LIBRARY": "Libreria",
|
||||
@@ -643,6 +661,32 @@
|
||||
"LOCKED_TOAST": "Bloccato"
|
||||
}
|
||||
},
|
||||
"SERVER_STORAGE": {
|
||||
"WRITE_FAILED": "Impossibile salvare sul server. Controlla la connessione e riprova.",
|
||||
"TOO_LARGE": "Questa playlist è più grande di quanto il server accetti.",
|
||||
"MIGRATION_TITLE": "Trovate {{count}} playlist in questo browser",
|
||||
"MIGRATION_DESCRIPTION": "Sono salvate solo in questo browser. Spostale sul server perché le veda ogni dispositivo che apre questo indirizzo. Da questo browser non viene eliminato nulla.",
|
||||
"MIGRATION_LATER_HINT": "Puoi farlo più tardi in Impostazioni → Server.",
|
||||
"MIGRATION_NOT_NOW": "Non ora",
|
||||
"MIGRATION_MOVE": "Sposta sul server",
|
||||
"MIGRATION_DONE": "Spostate: {{imported}}, unite: {{merged}}, non riuscite: {{failed}}.",
|
||||
"MIGRATION_FAILED": "Spostamento non riuscito. Le tue playlist sono ancora in questo browser; riprova da Impostazioni → Server.",
|
||||
"MIGRATION_RELOAD": "Ricarica",
|
||||
"SIGN_IN_TITLE": "Token di accesso richiesto",
|
||||
"SIGN_IN_DESCRIPTION": "Questo server IPTVnator chiede un token di accesso prima di mostrare le tue playlist.",
|
||||
"SIGN_IN_TOKEN_LABEL": "Token di accesso",
|
||||
"SIGN_IN_HINT": "Lo trovi nell’impostazione IPTVNATOR_ACCESS_TOKEN del server.",
|
||||
"SIGN_IN_REJECTED": "Token rifiutato. Controllalo e riprova.",
|
||||
"SIGN_IN_UNREACHABLE": "Impossibile raggiungere il server. Controlla la connessione e riprova.",
|
||||
"SIGN_IN_STORAGE": "Questo browser non consente a IPTVnator di salvare il token. Consenti i dati del sito per questa pagina e riprova.",
|
||||
"SIGN_IN_CONTINUE": "Continua",
|
||||
"STATUS_ONLINE": "Server connesso",
|
||||
"STATUS_OFFLINE": "Server non disponibile",
|
||||
"STATUS_UNAUTHORIZED": "Il server richiede un token di accesso",
|
||||
"OFFLINE_BANNER": "Server non disponibile. Le modifiche non possono essere salvate finché non torna disponibile.",
|
||||
"RETRY": "Riprova",
|
||||
"DELETE_ALL_WARNING": "Questo rimuove le playlist dal server per tutti i dispositivi."
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "Scuro",
|
||||
"LIGHT_THEME": "Chiaro",
|
||||
|
||||
@@ -316,6 +316,24 @@
|
||||
"NAV_REMOTE": "リモート",
|
||||
"NAV_DATA": "データ",
|
||||
"NAV_BACKUP": "バックアップとデータ",
|
||||
"NAV_SERVER": "サーバー",
|
||||
"SERVER_DESCRIPTION": "プレイリスト、お気に入り、視聴の進行状況はこのサーバーに保存され、このサーバーを開くすべてのデバイスで共有されます。",
|
||||
"SERVER_CONNECTION": "接続",
|
||||
"SERVER_CONNECTION_DESCRIPTION": "このアプリの保存先の IPTVnator サーバーです。",
|
||||
"SERVER_CONNECTED": "接続済み",
|
||||
"SERVER_OFFLINE": "利用不可",
|
||||
"SERVER_STORAGE": "ストレージ",
|
||||
"SERVER_STORAGE_DESCRIPTION": "すべてサーバー上の 1 つのデータベースファイルに保存されます。別の場所にコピーを残すには、バックアップをエクスポートしてください。",
|
||||
"SERVER_STORAGE_SUMMARY": "プレイリスト {{playlists}} 件 · {{size}}",
|
||||
"SERVER_EXPORT_BACKUP": "バックアップをエクスポート",
|
||||
"SERVER_IMPORT_BROWSER": "このブラウザーのプレイリスト",
|
||||
"SERVER_IMPORT_BROWSER_DESCRIPTION": "サーバーを使う前にこのブラウザーに保存されたプレイリストです。移動するとすべてのデバイスで表示されます。サーバーに既にあるプレイリストは統合されます。",
|
||||
"SERVER_IMPORT_BROWSER_COUNT": "{{count}} 件のプレイリストがこのブラウザーにのみ保存されています。",
|
||||
"SERVER_ACCESS": "アクセス",
|
||||
"SERVER_ACCESS_DESCRIPTION": "このサーバーのライブラリを開けるユーザー。",
|
||||
"SERVER_ACCESS_OPEN": "このアドレスにアクセスできる人は誰でも利用できます。トークンを必須にするには、サーバーで IPTVNATOR_ACCESS_TOKEN を設定してください。",
|
||||
"SERVER_ACCESS_TOKEN": "このサーバーにはアクセストークンが必要です。",
|
||||
"SERVER_SIGN_OUT": "サインアウト",
|
||||
"NAV_ABOUT": "アプリについて",
|
||||
"NAV_GROUP_APP": "アプリ",
|
||||
"NAV_GROUP_LIBRARY": "ライブラリ",
|
||||
@@ -643,6 +661,32 @@
|
||||
"LOCKED_TOAST": "ロックしました"
|
||||
}
|
||||
},
|
||||
"SERVER_STORAGE": {
|
||||
"WRITE_FAILED": "サーバーに保存できませんでした。接続を確認して、もう一度お試しください。",
|
||||
"TOO_LARGE": "このプレイリストはサーバーが受け付けるサイズを超えています。",
|
||||
"MIGRATION_TITLE": "このブラウザーに {{count}} 件のプレイリストが見つかりました",
|
||||
"MIGRATION_DESCRIPTION": "これらはこのブラウザーにのみ保存されています。サーバーに移動すると、このアドレスを開くすべてのデバイスで表示されます。このブラウザーからは何も削除されません。",
|
||||
"MIGRATION_LATER_HINT": "後で「設定 → サーバー」から実行できます。",
|
||||
"MIGRATION_NOT_NOW": "後で",
|
||||
"MIGRATION_MOVE": "サーバーに移動",
|
||||
"MIGRATION_DONE": "移動 {{imported}} 件、統合 {{merged}} 件、失敗 {{failed}} 件。",
|
||||
"MIGRATION_FAILED": "移動に失敗しました。プレイリストはこのブラウザーに残っています。「設定 → サーバー」からもう一度お試しください。",
|
||||
"MIGRATION_RELOAD": "再読み込み",
|
||||
"SIGN_IN_TITLE": "アクセストークンが必要です",
|
||||
"SIGN_IN_DESCRIPTION": "この IPTVnator サーバーは、プレイリストを表示する前にアクセストークンを求めます。",
|
||||
"SIGN_IN_TOKEN_LABEL": "アクセストークン",
|
||||
"SIGN_IN_HINT": "サーバーの IPTVNATOR_ACCESS_TOKEN 設定で確認できます。",
|
||||
"SIGN_IN_REJECTED": "トークンが拒否されました。確認して、もう一度お試しください。",
|
||||
"SIGN_IN_UNREACHABLE": "サーバーに接続できません。接続を確認して、もう一度お試しください。",
|
||||
"SIGN_IN_STORAGE": "このブラウザーでは IPTVnator がトークンを保存できません。このページのサイトデータを許可して、もう一度お試しください。",
|
||||
"SIGN_IN_CONTINUE": "続行",
|
||||
"STATUS_ONLINE": "サーバーに接続済み",
|
||||
"STATUS_OFFLINE": "サーバーを利用できません",
|
||||
"STATUS_UNAUTHORIZED": "サーバーにアクセストークンが必要です",
|
||||
"OFFLINE_BANNER": "サーバーを利用できません。復旧するまで変更を保存できません。",
|
||||
"RETRY": "再試行",
|
||||
"DELETE_ALL_WARNING": "すべてのデバイスで、サーバーからプレイリストが削除されます。"
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "ダーク",
|
||||
"LIGHT_THEME": "ライト",
|
||||
|
||||
@@ -316,6 +316,24 @@
|
||||
"NAV_REMOTE": "원격",
|
||||
"NAV_DATA": "데이터",
|
||||
"NAV_BACKUP": "백업 및 데이터",
|
||||
"NAV_SERVER": "서버",
|
||||
"SERVER_DESCRIPTION": "플레이리스트, 즐겨찾기, 시청 진행 상황이 이 서버에 저장되며 이 서버를 여는 모든 기기에서 공유됩니다.",
|
||||
"SERVER_CONNECTION": "연결",
|
||||
"SERVER_CONNECTION_DESCRIPTION": "이 앱이 데이터를 저장하는 IPTVnator 서버입니다.",
|
||||
"SERVER_CONNECTED": "연결됨",
|
||||
"SERVER_OFFLINE": "사용할 수 없음",
|
||||
"SERVER_STORAGE": "저장소",
|
||||
"SERVER_STORAGE_DESCRIPTION": "모든 데이터는 서버의 데이터베이스 파일 하나에 보관됩니다. 다른 곳에 사본을 두려면 백업을 내보내세요.",
|
||||
"SERVER_STORAGE_SUMMARY": "플레이리스트 {{playlists}}개 · {{size}}",
|
||||
"SERVER_EXPORT_BACKUP": "백업 내보내기",
|
||||
"SERVER_IMPORT_BROWSER": "이 브라우저의 플레이리스트",
|
||||
"SERVER_IMPORT_BROWSER_DESCRIPTION": "서버를 사용하기 전에 이 브라우저에 저장된 플레이리스트입니다. 옮기면 모든 기기에서 볼 수 있으며, 서버에 이미 있는 플레이리스트는 병합됩니다.",
|
||||
"SERVER_IMPORT_BROWSER_COUNT": "플레이리스트 {{count}}개가 이 브라우저에만 저장되어 있습니다.",
|
||||
"SERVER_ACCESS": "접근",
|
||||
"SERVER_ACCESS_DESCRIPTION": "이 서버의 라이브러리를 열 수 있는 사람.",
|
||||
"SERVER_ACCESS_OPEN": "이 주소에 접속할 수 있는 누구나 사용할 수 있습니다. 토큰을 요구하려면 서버에 IPTVNATOR_ACCESS_TOKEN을 설정하세요.",
|
||||
"SERVER_ACCESS_TOKEN": "이 서버는 액세스 토큰이 필요합니다.",
|
||||
"SERVER_SIGN_OUT": "로그아웃",
|
||||
"NAV_ABOUT": "정보",
|
||||
"NAV_GROUP_APP": "앱",
|
||||
"NAV_GROUP_LIBRARY": "라이브러리",
|
||||
@@ -643,6 +661,32 @@
|
||||
"LOCKED_TOAST": "잠겼습니다"
|
||||
}
|
||||
},
|
||||
"SERVER_STORAGE": {
|
||||
"WRITE_FAILED": "서버에 저장하지 못했습니다. 연결을 확인하고 다시 시도하세요.",
|
||||
"TOO_LARGE": "이 플레이리스트는 서버가 허용하는 크기보다 큽니다.",
|
||||
"MIGRATION_TITLE": "이 브라우저에서 플레이리스트 {{count}}개를 찾았습니다",
|
||||
"MIGRATION_DESCRIPTION": "이 브라우저에만 저장되어 있습니다. 서버로 옮기면 이 주소를 여는 모든 기기에서 볼 수 있습니다. 이 브라우저에서는 아무것도 삭제되지 않습니다.",
|
||||
"MIGRATION_LATER_HINT": "나중에 설정 → 서버에서 할 수 있습니다.",
|
||||
"MIGRATION_NOT_NOW": "나중에",
|
||||
"MIGRATION_MOVE": "서버로 옮기기",
|
||||
"MIGRATION_DONE": "이동 {{imported}}개, 병합 {{merged}}개, 실패 {{failed}}개.",
|
||||
"MIGRATION_FAILED": "옮기지 못했습니다. 플레이리스트는 이 브라우저에 그대로 있습니다. 설정 → 서버에서 다시 시도하세요.",
|
||||
"MIGRATION_RELOAD": "새로고침",
|
||||
"SIGN_IN_TITLE": "액세스 토큰 필요",
|
||||
"SIGN_IN_DESCRIPTION": "이 IPTVnator 서버는 플레이리스트를 보여 주기 전에 액세스 토큰을 요청합니다.",
|
||||
"SIGN_IN_TOKEN_LABEL": "액세스 토큰",
|
||||
"SIGN_IN_HINT": "서버의 IPTVNATOR_ACCESS_TOKEN 설정에서 확인할 수 있습니다.",
|
||||
"SIGN_IN_REJECTED": "토큰이 거부되었습니다. 확인하고 다시 시도하세요.",
|
||||
"SIGN_IN_UNREACHABLE": "서버에 연결할 수 없습니다. 연결을 확인하고 다시 시도하세요.",
|
||||
"SIGN_IN_STORAGE": "이 브라우저에서는 IPTVnator가 토큰을 저장할 수 없습니다. 이 페이지의 사이트 데이터를 허용하고 다시 시도하세요.",
|
||||
"SIGN_IN_CONTINUE": "계속",
|
||||
"STATUS_ONLINE": "서버 연결됨",
|
||||
"STATUS_OFFLINE": "서버를 사용할 수 없음",
|
||||
"STATUS_UNAUTHORIZED": "서버에 액세스 토큰이 필요함",
|
||||
"OFFLINE_BANNER": "서버를 사용할 수 없습니다. 서버가 다시 연결될 때까지 변경 사항을 저장할 수 없습니다.",
|
||||
"RETRY": "다시 시도",
|
||||
"DELETE_ALL_WARNING": "모든 기기에서 서버의 플레이리스트가 삭제됩니다."
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "어둡게",
|
||||
"LIGHT_THEME": "밝게",
|
||||
|
||||
@@ -316,6 +316,24 @@
|
||||
"NAV_REMOTE": "Afstandsbediening",
|
||||
"NAV_DATA": "Gegevens",
|
||||
"NAV_BACKUP": "Back-up en gegevens",
|
||||
"NAV_SERVER": "Server",
|
||||
"SERVER_DESCRIPTION": "Afspeellijsten, favorieten en kijkvoortgang worden op deze server bewaard en gedeeld met elk apparaat dat hem opent.",
|
||||
"SERVER_CONNECTION": "Verbinding",
|
||||
"SERVER_CONNECTION_DESCRIPTION": "De IPTVnator-server waarop deze app opslaat.",
|
||||
"SERVER_CONNECTED": "Verbonden",
|
||||
"SERVER_OFFLINE": "Niet bereikbaar",
|
||||
"SERVER_STORAGE": "Opslag",
|
||||
"SERVER_STORAGE_DESCRIPTION": "Alles staat in één databasebestand op de server. Exporteer een back-up om ergens anders een kopie te bewaren.",
|
||||
"SERVER_STORAGE_SUMMARY": "{{playlists}} afspeellijsten · {{size}}",
|
||||
"SERVER_EXPORT_BACKUP": "Back-up exporteren",
|
||||
"SERVER_IMPORT_BROWSER": "Afspeellijsten in deze browser",
|
||||
"SERVER_IMPORT_BROWSER_DESCRIPTION": "Afspeellijsten die in deze browser zijn opgeslagen voordat hij de server gebruikte. Verplaats ze zodat elk apparaat ze ziet; afspeellijsten die de server al heeft, worden samengevoegd.",
|
||||
"SERVER_IMPORT_BROWSER_COUNT": "{{count}} afspeellijsten staan alleen in deze browser.",
|
||||
"SERVER_ACCESS": "Toegang",
|
||||
"SERVER_ACCESS_DESCRIPTION": "Wie de bibliotheek van deze server kan openen.",
|
||||
"SERVER_ACCESS_OPEN": "Iedereen die dit adres kan bereiken, kan hem gebruiken. Stel IPTVNATOR_ACCESS_TOKEN in op de server om een token te vereisen.",
|
||||
"SERVER_ACCESS_TOKEN": "Deze server vereist een toegangstoken.",
|
||||
"SERVER_SIGN_OUT": "Afmelden",
|
||||
"NAV_ABOUT": "Over",
|
||||
"NAV_GROUP_APP": "App",
|
||||
"NAV_GROUP_LIBRARY": "Bibliotheek",
|
||||
@@ -643,6 +661,32 @@
|
||||
"LOCKED_TOAST": "Vergrendeld"
|
||||
}
|
||||
},
|
||||
"SERVER_STORAGE": {
|
||||
"WRITE_FAILED": "Opslaan op de server is mislukt. Controleer de verbinding en probeer het opnieuw.",
|
||||
"TOO_LARGE": "Deze afspeellijst is groter dan de server accepteert.",
|
||||
"MIGRATION_TITLE": "{{count}} afspeellijsten gevonden in deze browser",
|
||||
"MIGRATION_DESCRIPTION": "Ze staan alleen in deze browser. Verplaats ze naar de server zodat elk apparaat dat dit adres opent ze ziet. Er wordt niets uit deze browser verwijderd.",
|
||||
"MIGRATION_LATER_HINT": "Je kunt dit later doen via Instellingen → Server.",
|
||||
"MIGRATION_NOT_NOW": "Niet nu",
|
||||
"MIGRATION_MOVE": "Naar server verplaatsen",
|
||||
"MIGRATION_DONE": "Verplaatst: {{imported}}, samengevoegd: {{merged}}, mislukt: {{failed}}.",
|
||||
"MIGRATION_FAILED": "Verplaatsen is mislukt. Je afspeellijsten staan nog in deze browser; probeer het opnieuw via Instellingen → Server.",
|
||||
"MIGRATION_RELOAD": "Opnieuw laden",
|
||||
"SIGN_IN_TITLE": "Toegangstoken vereist",
|
||||
"SIGN_IN_DESCRIPTION": "Deze IPTVnator-server vraagt om een toegangstoken voordat hij je afspeellijsten toont.",
|
||||
"SIGN_IN_TOKEN_LABEL": "Toegangstoken",
|
||||
"SIGN_IN_HINT": "Je vindt het in de instelling IPTVNATOR_ACCESS_TOKEN van de server.",
|
||||
"SIGN_IN_REJECTED": "Token geweigerd. Controleer het en probeer het opnieuw.",
|
||||
"SIGN_IN_UNREACHABLE": "De server is niet bereikbaar. Controleer de verbinding en probeer het opnieuw.",
|
||||
"SIGN_IN_STORAGE": "Deze browser laat IPTVnator het token niet bewaren. Sta sitegegevens voor deze pagina toe en probeer het opnieuw.",
|
||||
"SIGN_IN_CONTINUE": "Doorgaan",
|
||||
"STATUS_ONLINE": "Server verbonden",
|
||||
"STATUS_OFFLINE": "Server niet bereikbaar",
|
||||
"STATUS_UNAUTHORIZED": "Server vraagt om een toegangstoken",
|
||||
"OFFLINE_BANNER": "Server niet bereikbaar. Wijzigingen kunnen pas worden opgeslagen als hij weer bereikbaar is.",
|
||||
"RETRY": "Opnieuw proberen",
|
||||
"DELETE_ALL_WARNING": "Hiermee worden de afspeellijsten voor alle apparaten van de server verwijderd."
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "Donker",
|
||||
"LIGHT_THEME": "Licht",
|
||||
|
||||
@@ -316,6 +316,24 @@
|
||||
"NAV_REMOTE": "Pilot",
|
||||
"NAV_DATA": "Dane",
|
||||
"NAV_BACKUP": "Kopia zapasowa i dane",
|
||||
"NAV_SERVER": "Serwer",
|
||||
"SERVER_DESCRIPTION": "Listy odtwarzania, ulubione i postęp oglądania są przechowywane na tym serwerze i współdzielone przez każde urządzenie, które go otwiera.",
|
||||
"SERVER_CONNECTION": "Połączenie",
|
||||
"SERVER_CONNECTION_DESCRIPTION": "Serwer IPTVnator, na którym ta aplikacja zapisuje dane.",
|
||||
"SERVER_CONNECTED": "Połączono",
|
||||
"SERVER_OFFLINE": "Niedostępny",
|
||||
"SERVER_STORAGE": "Pamięć",
|
||||
"SERVER_STORAGE_DESCRIPTION": "Wszystko jest przechowywane w jednym pliku bazy danych na serwerze. Wyeksportuj kopię zapasową, aby mieć kopię w innym miejscu.",
|
||||
"SERVER_STORAGE_SUMMARY": "Listy odtwarzania: {{playlists}} · {{size}}",
|
||||
"SERVER_EXPORT_BACKUP": "Eksportuj kopię",
|
||||
"SERVER_IMPORT_BROWSER": "Listy w tej przeglądarce",
|
||||
"SERVER_IMPORT_BROWSER_DESCRIPTION": "Listy odtwarzania zapisane w tej przeglądarce, zanim zaczęła korzystać z serwera. Przenieś je, aby każde urządzenie je widziało; listy, które serwer już ma, zostaną scalone.",
|
||||
"SERVER_IMPORT_BROWSER_COUNT": "Listy odtwarzania zapisane tylko w tej przeglądarce: {{count}}.",
|
||||
"SERVER_ACCESS": "Dostęp",
|
||||
"SERVER_ACCESS_DESCRIPTION": "Kto może otworzyć bibliotekę tego serwera.",
|
||||
"SERVER_ACCESS_OPEN": "Może z niego korzystać każdy, kto dotrze pod ten adres. Ustaw IPTVNATOR_ACCESS_TOKEN na serwerze, aby wymagać tokenu.",
|
||||
"SERVER_ACCESS_TOKEN": "Ten serwer wymaga tokenu dostępu.",
|
||||
"SERVER_SIGN_OUT": "Wyloguj",
|
||||
"NAV_ABOUT": "O aplikacji",
|
||||
"NAV_GROUP_APP": "Aplikacja",
|
||||
"NAV_GROUP_LIBRARY": "Biblioteka",
|
||||
@@ -643,6 +661,32 @@
|
||||
"LOCKED_TOAST": "Zablokowano"
|
||||
}
|
||||
},
|
||||
"SERVER_STORAGE": {
|
||||
"WRITE_FAILED": "Nie udało się zapisać na serwerze. Sprawdź połączenie i spróbuj ponownie.",
|
||||
"TOO_LARGE": "Ta lista odtwarzania jest większa, niż akceptuje serwer.",
|
||||
"MIGRATION_TITLE": "Znaleziono listy odtwarzania w tej przeglądarce: {{count}}",
|
||||
"MIGRATION_DESCRIPTION": "Są zapisane tylko w tej przeglądarce. Przenieś je na serwer, aby widziało je każde urządzenie otwierające ten adres. Nic nie zostanie usunięte z tej przeglądarki.",
|
||||
"MIGRATION_LATER_HINT": "Możesz to zrobić później w Ustawienia → Serwer.",
|
||||
"MIGRATION_NOT_NOW": "Nie teraz",
|
||||
"MIGRATION_MOVE": "Przenieś na serwer",
|
||||
"MIGRATION_DONE": "Przeniesiono: {{imported}}, scalono: {{merged}}, błędy: {{failed}}.",
|
||||
"MIGRATION_FAILED": "Przenoszenie nie powiodło się. Twoje listy odtwarzania nadal są w tej przeglądarce; spróbuj ponownie w Ustawienia → Serwer.",
|
||||
"MIGRATION_RELOAD": "Odśwież",
|
||||
"SIGN_IN_TITLE": "Wymagany token dostępu",
|
||||
"SIGN_IN_DESCRIPTION": "Ten serwer IPTVnator prosi o token dostępu, zanim pokaże Twoje listy odtwarzania.",
|
||||
"SIGN_IN_TOKEN_LABEL": "Token dostępu",
|
||||
"SIGN_IN_HINT": "Znajdziesz go w ustawieniu IPTVNATOR_ACCESS_TOKEN serwera.",
|
||||
"SIGN_IN_REJECTED": "Token odrzucony. Sprawdź go i spróbuj ponownie.",
|
||||
"SIGN_IN_UNREACHABLE": "Brak połączenia z serwerem. Sprawdź połączenie i spróbuj ponownie.",
|
||||
"SIGN_IN_STORAGE": "Ta przeglądarka nie pozwala IPTVnator zapisać tokenu. Zezwól na dane witryny dla tej strony i spróbuj ponownie.",
|
||||
"SIGN_IN_CONTINUE": "Dalej",
|
||||
"STATUS_ONLINE": "Serwer połączony",
|
||||
"STATUS_OFFLINE": "Serwer niedostępny",
|
||||
"STATUS_UNAUTHORIZED": "Serwer wymaga tokenu dostępu",
|
||||
"OFFLINE_BANNER": "Serwer niedostępny. Zmian nie można zapisać, dopóki nie będzie znowu dostępny.",
|
||||
"RETRY": "Ponów",
|
||||
"DELETE_ALL_WARNING": "Spowoduje to usunięcie list odtwarzania z serwera dla wszystkich urządzeń."
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "Ciemny",
|
||||
"LIGHT_THEME": "Jasny",
|
||||
|
||||
@@ -316,6 +316,24 @@
|
||||
"NAV_REMOTE": "Remoto",
|
||||
"NAV_DATA": "Dados",
|
||||
"NAV_BACKUP": "Cópia de segurança e dados",
|
||||
"NAV_SERVER": "Servidor",
|
||||
"SERVER_DESCRIPTION": "Playlists, favoritos e progresso de exibição ficam neste servidor e são compartilhados por todos os dispositivos que o abrem.",
|
||||
"SERVER_CONNECTION": "Conexão",
|
||||
"SERVER_CONNECTION_DESCRIPTION": "O servidor IPTVnator onde este app salva os dados.",
|
||||
"SERVER_CONNECTED": "Conectado",
|
||||
"SERVER_OFFLINE": "Indisponível",
|
||||
"SERVER_STORAGE": "Armazenamento",
|
||||
"SERVER_STORAGE_DESCRIPTION": "Tudo fica em um único arquivo de banco de dados no servidor. Exporte uma cópia de segurança para manter uma cópia em outro lugar.",
|
||||
"SERVER_STORAGE_SUMMARY": "Playlists: {{playlists}} · {{size}}",
|
||||
"SERVER_EXPORT_BACKUP": "Exportar cópia",
|
||||
"SERVER_IMPORT_BROWSER": "Playlists neste navegador",
|
||||
"SERVER_IMPORT_BROWSER_DESCRIPTION": "Playlists salvas neste navegador antes de ele usar o servidor. Mova-as para que todos os dispositivos as vejam; as que o servidor já tem são mescladas.",
|
||||
"SERVER_IMPORT_BROWSER_COUNT": "{{count}} playlists estão salvas apenas neste navegador.",
|
||||
"SERVER_ACCESS": "Acesso",
|
||||
"SERVER_ACCESS_DESCRIPTION": "Quem pode abrir a biblioteca deste servidor.",
|
||||
"SERVER_ACCESS_OPEN": "Qualquer pessoa que alcance este endereço pode usá-lo. Defina IPTVNATOR_ACCESS_TOKEN no servidor para exigir um token.",
|
||||
"SERVER_ACCESS_TOKEN": "Este servidor exige um token de acesso.",
|
||||
"SERVER_SIGN_OUT": "Sair",
|
||||
"NAV_ABOUT": "Sobre",
|
||||
"NAV_GROUP_APP": "Aplicativo",
|
||||
"NAV_GROUP_LIBRARY": "Biblioteca",
|
||||
@@ -643,6 +661,32 @@
|
||||
"LOCKED_TOAST": "Bloqueado"
|
||||
}
|
||||
},
|
||||
"SERVER_STORAGE": {
|
||||
"WRITE_FAILED": "Não foi possível salvar no servidor. Verifique a conexão e tente novamente.",
|
||||
"TOO_LARGE": "Esta playlist é maior do que o servidor aceita.",
|
||||
"MIGRATION_TITLE": "{{count}} playlists encontradas neste navegador",
|
||||
"MIGRATION_DESCRIPTION": "Elas estão salvas apenas neste navegador. Mova-as para o servidor para que todos os dispositivos que abrem este endereço as vejam. Nada é apagado deste navegador.",
|
||||
"MIGRATION_LATER_HINT": "Você pode fazer isso depois em Configurações → Servidor.",
|
||||
"MIGRATION_NOT_NOW": "Agora não",
|
||||
"MIGRATION_MOVE": "Mover para o servidor",
|
||||
"MIGRATION_DONE": "Movidas: {{imported}}, mescladas: {{merged}}, com falha: {{failed}}.",
|
||||
"MIGRATION_FAILED": "Falha ao mover. Suas playlists continuam neste navegador; tente novamente em Configurações → Servidor.",
|
||||
"MIGRATION_RELOAD": "Recarregar",
|
||||
"SIGN_IN_TITLE": "Token de acesso necessário",
|
||||
"SIGN_IN_DESCRIPTION": "Este servidor IPTVnator pede um token de acesso antes de mostrar suas playlists.",
|
||||
"SIGN_IN_TOKEN_LABEL": "Token de acesso",
|
||||
"SIGN_IN_HINT": "Ele está na configuração IPTVNATOR_ACCESS_TOKEN do servidor.",
|
||||
"SIGN_IN_REJECTED": "Token recusado. Verifique e tente novamente.",
|
||||
"SIGN_IN_UNREACHABLE": "Não foi possível acessar o servidor. Verifique a conexão e tente novamente.",
|
||||
"SIGN_IN_STORAGE": "Este navegador não permite que o IPTVnator salve o token. Permita os dados do site para esta página e tente novamente.",
|
||||
"SIGN_IN_CONTINUE": "Continuar",
|
||||
"STATUS_ONLINE": "Servidor conectado",
|
||||
"STATUS_OFFLINE": "Servidor indisponível",
|
||||
"STATUS_UNAUTHORIZED": "O servidor precisa de um token de acesso",
|
||||
"OFFLINE_BANNER": "Servidor indisponível. As alterações não podem ser salvas até que ele volte.",
|
||||
"RETRY": "Tentar novamente",
|
||||
"DELETE_ALL_WARNING": "Isso remove as playlists do servidor para todos os dispositivos."
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "Escuro",
|
||||
"LIGHT_THEME": "Claro",
|
||||
|
||||
@@ -316,6 +316,24 @@
|
||||
"NAV_REMOTE": "Пульт",
|
||||
"NAV_DATA": "Данные",
|
||||
"NAV_BACKUP": "Резервная копия и данные",
|
||||
"NAV_SERVER": "Сервер",
|
||||
"SERVER_DESCRIPTION": "Плейлисты, избранное и прогресс просмотра хранятся на этом сервере и доступны на каждом устройстве, которое его открывает.",
|
||||
"SERVER_CONNECTION": "Подключение",
|
||||
"SERVER_CONNECTION_DESCRIPTION": "Сервер IPTVnator, на котором это приложение хранит данные.",
|
||||
"SERVER_CONNECTED": "Подключено",
|
||||
"SERVER_OFFLINE": "Недоступен",
|
||||
"SERVER_STORAGE": "Хранилище",
|
||||
"SERVER_STORAGE_DESCRIPTION": "Всё хранится в одном файле базы данных на сервере. Экспортируйте резервную копию, чтобы сохранить копию в другом месте.",
|
||||
"SERVER_STORAGE_SUMMARY": "Плейлистов: {{playlists}} · {{size}}",
|
||||
"SERVER_EXPORT_BACKUP": "Экспортировать копию",
|
||||
"SERVER_IMPORT_BROWSER": "Плейлисты в этом браузере",
|
||||
"SERVER_IMPORT_BROWSER_DESCRIPTION": "Плейлисты, сохранённые в этом браузере до перехода на сервер. Перенесите их, чтобы их видели все устройства; плейлисты, которые уже есть на сервере, будут объединены.",
|
||||
"SERVER_IMPORT_BROWSER_COUNT": "Плейлистов, сохранённых только в этом браузере: {{count}}.",
|
||||
"SERVER_ACCESS": "Доступ",
|
||||
"SERVER_ACCESS_DESCRIPTION": "Кто может открыть библиотеку этого сервера.",
|
||||
"SERVER_ACCESS_OPEN": "Пользоваться может любой, кто может открыть этот адрес. Задайте IPTVNATOR_ACCESS_TOKEN на сервере, чтобы требовать токен.",
|
||||
"SERVER_ACCESS_TOKEN": "Этот сервер требует токен доступа.",
|
||||
"SERVER_SIGN_OUT": "Выйти",
|
||||
"NAV_ABOUT": "Инфо",
|
||||
"NAV_GROUP_APP": "Приложение",
|
||||
"NAV_GROUP_LIBRARY": "Библиотека",
|
||||
@@ -643,6 +661,32 @@
|
||||
"LOCKED_TOAST": "Заблокировано"
|
||||
}
|
||||
},
|
||||
"SERVER_STORAGE": {
|
||||
"WRITE_FAILED": "Не удалось сохранить на сервер. Проверьте подключение и попробуйте снова.",
|
||||
"TOO_LARGE": "Этот плейлист больше, чем принимает сервер.",
|
||||
"MIGRATION_TITLE": "В этом браузере найдено плейлистов: {{count}}",
|
||||
"MIGRATION_DESCRIPTION": "Они хранятся только в этом браузере. Перенесите их на сервер, чтобы их видели все устройства, которые открывают этот адрес. Из этого браузера ничего не удаляется.",
|
||||
"MIGRATION_LATER_HINT": "Это можно сделать позже в Настройки → Сервер.",
|
||||
"MIGRATION_NOT_NOW": "Не сейчас",
|
||||
"MIGRATION_MOVE": "Перенести на сервер",
|
||||
"MIGRATION_DONE": "Перенесено: {{imported}}, объединено: {{merged}}, с ошибкой: {{failed}}.",
|
||||
"MIGRATION_FAILED": "Не удалось перенести. Плейлисты остались в этом браузере; попробуйте снова в Настройки → Сервер.",
|
||||
"MIGRATION_RELOAD": "Перезагрузить",
|
||||
"SIGN_IN_TITLE": "Нужен токен доступа",
|
||||
"SIGN_IN_DESCRIPTION": "Этот сервер IPTVnator запрашивает токен доступа, прежде чем показать ваши плейлисты.",
|
||||
"SIGN_IN_TOKEN_LABEL": "Токен доступа",
|
||||
"SIGN_IN_HINT": "Он задан в настройке IPTVNATOR_ACCESS_TOKEN на сервере.",
|
||||
"SIGN_IN_REJECTED": "Токен отклонён. Проверьте его и попробуйте снова.",
|
||||
"SIGN_IN_UNREACHABLE": "Сервер недоступен. Проверьте подключение и попробуйте снова.",
|
||||
"SIGN_IN_STORAGE": "Браузер не даёт IPTVnator сохранить токен. Разрешите данные сайта для этой страницы и попробуйте снова.",
|
||||
"SIGN_IN_CONTINUE": "Продолжить",
|
||||
"STATUS_ONLINE": "Сервер подключён",
|
||||
"STATUS_OFFLINE": "Сервер недоступен",
|
||||
"STATUS_UNAUTHORIZED": "Серверу нужен токен доступа",
|
||||
"OFFLINE_BANNER": "Сервер недоступен. Изменения нельзя сохранить, пока он не вернётся.",
|
||||
"RETRY": "Повторить",
|
||||
"DELETE_ALL_WARNING": "Плейлисты будут удалены с сервера для всех устройств."
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "Тёмная",
|
||||
"LIGHT_THEME": "Светлая",
|
||||
|
||||
@@ -316,6 +316,24 @@
|
||||
"NAV_REMOTE": "Uzaktan",
|
||||
"NAV_DATA": "Veri",
|
||||
"NAV_BACKUP": "Yedekleme ve veriler",
|
||||
"NAV_SERVER": "Sunucu",
|
||||
"SERVER_DESCRIPTION": "Oynatma listeleri, favoriler ve izleme ilerlemesi bu sunucuda saklanır ve onu açan her cihazla paylaşılır.",
|
||||
"SERVER_CONNECTION": "Bağlantı",
|
||||
"SERVER_CONNECTION_DESCRIPTION": "Bu uygulamanın verileri kaydettiği IPTVnator sunucusu.",
|
||||
"SERVER_CONNECTED": "Bağlı",
|
||||
"SERVER_OFFLINE": "Kullanılamıyor",
|
||||
"SERVER_STORAGE": "Depolama",
|
||||
"SERVER_STORAGE_DESCRIPTION": "Her şey sunucudaki tek bir veritabanı dosyasında tutulur. Başka bir yerde kopya saklamak için yedeği dışa aktarın.",
|
||||
"SERVER_STORAGE_SUMMARY": "{{playlists}} oynatma listesi · {{size}}",
|
||||
"SERVER_EXPORT_BACKUP": "Yedeği dışa aktar",
|
||||
"SERVER_IMPORT_BROWSER": "Bu tarayıcıdaki oynatma listeleri",
|
||||
"SERVER_IMPORT_BROWSER_DESCRIPTION": "Bu tarayıcı sunucuyu kullanmadan önce burada kaydedilmiş oynatma listeleri. Her cihazın görmesi için bunları taşıyın; sunucuda zaten olan listeler birleştirilir.",
|
||||
"SERVER_IMPORT_BROWSER_COUNT": "{{count}} oynatma listesi yalnızca bu tarayıcıda saklanıyor.",
|
||||
"SERVER_ACCESS": "Erişim",
|
||||
"SERVER_ACCESS_DESCRIPTION": "Bu sunucunun kitaplığını kimlerin açabileceği.",
|
||||
"SERVER_ACCESS_OPEN": "Bu adrese ulaşabilen herkes kullanabilir. Belirteç istemek için sunucuda IPTVNATOR_ACCESS_TOKEN ayarlayın.",
|
||||
"SERVER_ACCESS_TOKEN": "Bu sunucu bir erişim belirteci gerektiriyor.",
|
||||
"SERVER_SIGN_OUT": "Oturumu kapat",
|
||||
"NAV_ABOUT": "Hakkında",
|
||||
"NAV_GROUP_APP": "Uygulama",
|
||||
"NAV_GROUP_LIBRARY": "Kitaplık",
|
||||
@@ -643,6 +661,32 @@
|
||||
"LOCKED_TOAST": "Kilitlendi"
|
||||
}
|
||||
},
|
||||
"SERVER_STORAGE": {
|
||||
"WRITE_FAILED": "Sunucuya kaydedilemedi. Bağlantıyı kontrol edip tekrar deneyin.",
|
||||
"TOO_LARGE": "Bu oynatma listesi sunucunun kabul ettiğinden büyük.",
|
||||
"MIGRATION_TITLE": "Bu tarayıcıda {{count}} oynatma listesi bulundu",
|
||||
"MIGRATION_DESCRIPTION": "Bunlar yalnızca bu tarayıcıda saklanıyor. Bu adresi açan her cihazın görmesi için sunucuya taşıyın. Bu tarayıcıdan hiçbir şey silinmez.",
|
||||
"MIGRATION_LATER_HINT": "Bunu daha sonra Ayarlar → Sunucu bölümünden yapabilirsiniz.",
|
||||
"MIGRATION_NOT_NOW": "Şimdi değil",
|
||||
"MIGRATION_MOVE": "Sunucuya taşı",
|
||||
"MIGRATION_DONE": "Taşınan: {{imported}}, birleştirilen: {{merged}}, başarısız: {{failed}}.",
|
||||
"MIGRATION_FAILED": "Taşıma başarısız oldu. Oynatma listeleriniz hâlâ bu tarayıcıda; Ayarlar → Sunucu bölümünden tekrar deneyin.",
|
||||
"MIGRATION_RELOAD": "Yeniden yükle",
|
||||
"SIGN_IN_TITLE": "Erişim belirteci gerekli",
|
||||
"SIGN_IN_DESCRIPTION": "Bu IPTVnator sunucusu oynatma listelerinizi göstermeden önce bir erişim belirteci istiyor.",
|
||||
"SIGN_IN_TOKEN_LABEL": "Erişim belirteci",
|
||||
"SIGN_IN_HINT": "Sunucunun IPTVNATOR_ACCESS_TOKEN ayarında bulabilirsiniz.",
|
||||
"SIGN_IN_REJECTED": "Belirteç reddedildi. Kontrol edip tekrar deneyin.",
|
||||
"SIGN_IN_UNREACHABLE": "Sunucuya ulaşılamıyor. Bağlantıyı kontrol edip tekrar deneyin.",
|
||||
"SIGN_IN_STORAGE": "Bu tarayıcı IPTVnator'ın belirteci saklamasına izin vermiyor. Bu sayfa için site verilerine izin verip tekrar deneyin.",
|
||||
"SIGN_IN_CONTINUE": "Devam",
|
||||
"STATUS_ONLINE": "Sunucu bağlı",
|
||||
"STATUS_OFFLINE": "Sunucu kullanılamıyor",
|
||||
"STATUS_UNAUTHORIZED": "Sunucu erişim belirteci istiyor",
|
||||
"OFFLINE_BANNER": "Sunucu kullanılamıyor. Sunucu geri gelene kadar değişiklikler kaydedilemez.",
|
||||
"RETRY": "Tekrar dene",
|
||||
"DELETE_ALL_WARNING": "Bu işlem oynatma listelerini tüm cihazlar için sunucudan kaldırır."
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "Koyu",
|
||||
"LIGHT_THEME": "Açık",
|
||||
|
||||
@@ -316,6 +316,24 @@
|
||||
"NAV_REMOTE": "远程",
|
||||
"NAV_DATA": "数据",
|
||||
"NAV_BACKUP": "备份与数据",
|
||||
"NAV_SERVER": "服务器",
|
||||
"SERVER_DESCRIPTION": "播放列表、收藏和观看进度保存在此服务器上,所有打开它的设备共享这些数据。",
|
||||
"SERVER_CONNECTION": "连接",
|
||||
"SERVER_CONNECTION_DESCRIPTION": "此应用保存数据的 IPTVnator 服务器。",
|
||||
"SERVER_CONNECTED": "已连接",
|
||||
"SERVER_OFFLINE": "不可用",
|
||||
"SERVER_STORAGE": "存储",
|
||||
"SERVER_STORAGE_DESCRIPTION": "所有数据都保存在服务器上的一个数据库文件中。导出备份可在其他位置保留一份副本。",
|
||||
"SERVER_STORAGE_SUMMARY": "{{playlists}} 个播放列表 · {{size}}",
|
||||
"SERVER_EXPORT_BACKUP": "导出备份",
|
||||
"SERVER_IMPORT_BROWSER": "此浏览器中的播放列表",
|
||||
"SERVER_IMPORT_BROWSER_DESCRIPTION": "此浏览器在使用服务器之前保存的播放列表。移到服务器后,所有设备都能看到;服务器上已有的播放列表会被合并。",
|
||||
"SERVER_IMPORT_BROWSER_COUNT": "有 {{count}} 个播放列表仅保存在此浏览器中。",
|
||||
"SERVER_ACCESS": "访问",
|
||||
"SERVER_ACCESS_DESCRIPTION": "谁可以打开此服务器的媒体库。",
|
||||
"SERVER_ACCESS_OPEN": "任何能访问此地址的人都可以使用。在服务器上设置 IPTVNATOR_ACCESS_TOKEN 即可要求令牌。",
|
||||
"SERVER_ACCESS_TOKEN": "此服务器需要访问令牌。",
|
||||
"SERVER_SIGN_OUT": "退出登录",
|
||||
"NAV_ABOUT": "关于",
|
||||
"NAV_GROUP_APP": "应用",
|
||||
"NAV_GROUP_LIBRARY": "媒体库",
|
||||
@@ -643,6 +661,32 @@
|
||||
"LOCKED_TOAST": "已锁定"
|
||||
}
|
||||
},
|
||||
"SERVER_STORAGE": {
|
||||
"WRITE_FAILED": "无法保存到服务器。请检查连接后重试。",
|
||||
"TOO_LARGE": "此播放列表超出服务器可接受的大小。",
|
||||
"MIGRATION_TITLE": "在此浏览器中找到 {{count}} 个播放列表",
|
||||
"MIGRATION_DESCRIPTION": "它们仅保存在此浏览器中。移到服务器后,所有打开此地址的设备都能看到。此浏览器中的内容不会被删除。",
|
||||
"MIGRATION_LATER_HINT": "您也可以稍后在“设置 → 服务器”中完成。",
|
||||
"MIGRATION_NOT_NOW": "暂不",
|
||||
"MIGRATION_MOVE": "移到服务器",
|
||||
"MIGRATION_DONE": "已移动 {{imported}} 个,已合并 {{merged}} 个,失败 {{failed}} 个。",
|
||||
"MIGRATION_FAILED": "移动失败。您的播放列表仍在此浏览器中;请在“设置 → 服务器”中重试。",
|
||||
"MIGRATION_RELOAD": "重新加载",
|
||||
"SIGN_IN_TITLE": "需要访问令牌",
|
||||
"SIGN_IN_DESCRIPTION": "此 IPTVnator 服务器在显示您的播放列表前需要访问令牌。",
|
||||
"SIGN_IN_TOKEN_LABEL": "访问令牌",
|
||||
"SIGN_IN_HINT": "可在服务器的 IPTVNATOR_ACCESS_TOKEN 设置中找到。",
|
||||
"SIGN_IN_REJECTED": "令牌被拒绝。请检查后重试。",
|
||||
"SIGN_IN_UNREACHABLE": "无法连接到服务器。请检查连接后重试。",
|
||||
"SIGN_IN_STORAGE": "此浏览器不允许 IPTVnator 保存令牌。请允许此页面的网站数据后重试。",
|
||||
"SIGN_IN_CONTINUE": "继续",
|
||||
"STATUS_ONLINE": "服务器已连接",
|
||||
"STATUS_OFFLINE": "服务器不可用",
|
||||
"STATUS_UNAUTHORIZED": "服务器需要访问令牌",
|
||||
"OFFLINE_BANNER": "服务器不可用。服务器恢复之前无法保存更改。",
|
||||
"RETRY": "重试",
|
||||
"DELETE_ALL_WARNING": "这会从服务器上删除播放列表,所有设备都会受影响。"
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "深色",
|
||||
"LIGHT_THEME": "浅色",
|
||||
|
||||
@@ -316,6 +316,24 @@
|
||||
"NAV_REMOTE": "遠端",
|
||||
"NAV_DATA": "資料",
|
||||
"NAV_BACKUP": "備份與資料",
|
||||
"NAV_SERVER": "伺服器",
|
||||
"SERVER_DESCRIPTION": "播放清單、我的最愛和觀看進度會儲存在此伺服器上,並由所有開啟它的裝置共用。",
|
||||
"SERVER_CONNECTION": "連線",
|
||||
"SERVER_CONNECTION_DESCRIPTION": "此應用程式儲存資料的 IPTVnator 伺服器。",
|
||||
"SERVER_CONNECTED": "已連線",
|
||||
"SERVER_OFFLINE": "無法使用",
|
||||
"SERVER_STORAGE": "儲存空間",
|
||||
"SERVER_STORAGE_DESCRIPTION": "所有資料都保存在伺服器上的一個資料庫檔案中。匯出備份即可在其他地方保留一份副本。",
|
||||
"SERVER_STORAGE_SUMMARY": "{{playlists}} 個播放清單 · {{size}}",
|
||||
"SERVER_EXPORT_BACKUP": "匯出備份",
|
||||
"SERVER_IMPORT_BROWSER": "此瀏覽器中的播放清單",
|
||||
"SERVER_IMPORT_BROWSER_DESCRIPTION": "此瀏覽器在使用伺服器之前儲存的播放清單。移到伺服器後,所有裝置都能看到;伺服器上已有的播放清單會合併。",
|
||||
"SERVER_IMPORT_BROWSER_COUNT": "有 {{count}} 個播放清單只儲存在此瀏覽器中。",
|
||||
"SERVER_ACCESS": "存取",
|
||||
"SERVER_ACCESS_DESCRIPTION": "誰可以開啟此伺服器的媒體庫。",
|
||||
"SERVER_ACCESS_OPEN": "任何能連到此位址的人都可以使用。在伺服器上設定 IPTVNATOR_ACCESS_TOKEN 即可要求權杖。",
|
||||
"SERVER_ACCESS_TOKEN": "此伺服器需要存取權杖。",
|
||||
"SERVER_SIGN_OUT": "登出",
|
||||
"NAV_ABOUT": "關於",
|
||||
"NAV_GROUP_APP": "應用程式",
|
||||
"NAV_GROUP_LIBRARY": "媒體庫",
|
||||
@@ -643,6 +661,32 @@
|
||||
"LOCKED_TOAST": "已鎖定"
|
||||
}
|
||||
},
|
||||
"SERVER_STORAGE": {
|
||||
"WRITE_FAILED": "無法儲存到伺服器。請檢查連線後再試一次。",
|
||||
"TOO_LARGE": "此播放清單超出伺服器可接受的大小。",
|
||||
"MIGRATION_TITLE": "在此瀏覽器中找到 {{count}} 個播放清單",
|
||||
"MIGRATION_DESCRIPTION": "它們只儲存在此瀏覽器中。移到伺服器後,所有開啟此位址的裝置都能看到。此瀏覽器中的內容不會被刪除。",
|
||||
"MIGRATION_LATER_HINT": "您也可以稍後在「設定 → 伺服器」中完成。",
|
||||
"MIGRATION_NOT_NOW": "暫時不要",
|
||||
"MIGRATION_MOVE": "移到伺服器",
|
||||
"MIGRATION_DONE": "已移動 {{imported}} 個,已合併 {{merged}} 個,失敗 {{failed}} 個。",
|
||||
"MIGRATION_FAILED": "移動失敗。您的播放清單仍在此瀏覽器中;請在「設定 → 伺服器」中再試一次。",
|
||||
"MIGRATION_RELOAD": "重新載入",
|
||||
"SIGN_IN_TITLE": "需要存取權杖",
|
||||
"SIGN_IN_DESCRIPTION": "此 IPTVnator 伺服器在顯示您的播放清單前需要存取權杖。",
|
||||
"SIGN_IN_TOKEN_LABEL": "存取權杖",
|
||||
"SIGN_IN_HINT": "可在伺服器的 IPTVNATOR_ACCESS_TOKEN 設定中找到。",
|
||||
"SIGN_IN_REJECTED": "權杖遭拒。請檢查後再試一次。",
|
||||
"SIGN_IN_UNREACHABLE": "無法連線到伺服器。請檢查連線後再試一次。",
|
||||
"SIGN_IN_STORAGE": "此瀏覽器不允許 IPTVnator 儲存權杖。請允許此頁面的網站資料後再試一次。",
|
||||
"SIGN_IN_CONTINUE": "繼續",
|
||||
"STATUS_ONLINE": "伺服器已連線",
|
||||
"STATUS_OFFLINE": "伺服器無法使用",
|
||||
"STATUS_UNAUTHORIZED": "伺服器需要存取權杖",
|
||||
"OFFLINE_BANNER": "伺服器無法使用。伺服器恢復之前無法儲存變更。",
|
||||
"RETRY": "重試",
|
||||
"DELETE_ALL_WARNING": "這會從伺服器上刪除播放清單,所有裝置都會受影響。"
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "深色",
|
||||
"LIGHT_THEME": "淺色",
|
||||
|
||||
+28
-3
@@ -1,8 +1,12 @@
|
||||
import { ApplicationConfig, mergeApplicationConfig } from '@angular/core';
|
||||
import { bootstrapApplication } from '@angular/platform-browser';
|
||||
import { resolveRestoredRendererRoute } from '@iptvnator/shared/interfaces';
|
||||
import { registerAppDateLocale } from './app/app-date-locales';
|
||||
import { AppComponent } from './app/app.component';
|
||||
import { appConfig, getInitialLanguage } from './app/app.config';
|
||||
import { probeServerInstance } from './app/server-storage/server-instance-probe';
|
||||
import { resolveServerStorageStart } from './app/server-storage/server-storage-start';
|
||||
import { getRuntimeBackendUrl } from './app/services/runtime-config';
|
||||
|
||||
// A reloaded packaged renderer arrives on index.html with the route it was
|
||||
// on carried in the query string (the Electron main process recovers the
|
||||
@@ -16,12 +20,33 @@ if (restoredHref !== null) {
|
||||
window.history.replaceState(window.history.state, '', restoredHref);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds server storage when the self-hosted backend offers it, or when this
|
||||
* browser has used it before and it does not answer right now.
|
||||
*/
|
||||
async function resolveAppConfig(): Promise<ApplicationConfig> {
|
||||
const baseUrl = getRuntimeBackendUrl();
|
||||
const start = resolveServerStorageStart(
|
||||
await probeServerInstance(),
|
||||
baseUrl
|
||||
);
|
||||
if (!start) {
|
||||
return appConfig;
|
||||
}
|
||||
const { provideServerStorage } =
|
||||
await import('./app/server-storage/server-storage.providers');
|
||||
return mergeApplicationConfig(appConfig, {
|
||||
providers: provideServerStorage(start, baseUrl),
|
||||
});
|
||||
}
|
||||
|
||||
// Angular ships only English locale data; the preferred language's data is a
|
||||
// lazy chunk. It is awaited before bootstrapping because the first route
|
||||
// renders dates with that locale, and a locale without data throws. English
|
||||
// (the default) resolves immediately.
|
||||
registerAppDateLocale(getInitialLanguage())
|
||||
.then(() => bootstrapApplication(AppComponent, appConfig))
|
||||
// (the default) resolves immediately. The server-storage probe runs in
|
||||
// parallel and only when the deployment provides a runtime backend URL.
|
||||
Promise.all([registerAppDateLocale(getInitialLanguage()), resolveAppConfig()])
|
||||
.then(([, config]) => bootstrapApplication(AppComponent, config))
|
||||
.then(() => {
|
||||
// Splash is rendered eagerly by index.html so the user sees something
|
||||
// immediately instead of a blank Material-grey background. Once Angular
|
||||
|
||||
Reference in new issue
Block a user