Media build part 2 (unreleased): drive wiring in every app (R-1024/R-1037), old-install repair (R-1047), drive events notice + real restart now/later + stream check, file browser lets go of dead/removed drives and recreates on return (R-1038), safe removal keeps the drive (R-1044), Plex claim on the running server + probe accept (R-1028/R-1045)
gates / gates (push) Successful in 45s
gates / gates (push) Successful in 45s
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -23,6 +23,29 @@ Not released yet (bake → vouch → floor, `09` §3 decision 247). Design: felh
|
||||
in it, `media/torrents`, `media/transmission`, `import`. `TestProtectedHDDPaths_R1032_UserdataLayout` (red-proved).
|
||||
- **The wizard does not ask a media app for a drive (R-1031)** — HDD_PATH (the app's own config) goes on the default
|
||||
drive, else the first present one. `TestR1031_MediaAppIsNotAskedForADrive` (red-proved).
|
||||
- **Part 2 — a new drive made ready in every app (R-1024, R-1037), package `internal/mediawire`.** After a media app
|
||||
comes up (debounced 60 s) and after the media restart: per present drive the download folders; qBittorrent automatic
|
||||
management + categories; Transmission's incomplete folder off; Sonarr/Radarr tag, root folder, the two download
|
||||
clients, minimum free space; Prowlarr → Sonarr/Radarr once; Plex: the drive's folders added to the existing libraries,
|
||||
a scan, „scan on change" on — with the server's own `PlexOnlineToken` read at use (`09` §3 decision 248); Jellyfin
|
||||
with the API key named „felhom" read at use from Jellyfin's own database (no key → one household step, said on the
|
||||
page). Keys read at use, never stored or logged; links never undone when an app stops. Idempotent:
|
||||
`TestRun_SecondRunWritesNothing` (red-proved: the measured repeated qBittorrent writes).
|
||||
- **Old Sonarr/Radarr installs repaired by themselves (R-1047)** — only when /media is no longer bound: same-path root
|
||||
folder, `moveFiles:false` editor, rescan, old root removed, Radarr's collections too. `TestRepairOldRoot_K7`
|
||||
(red-proved: moveFiles=true is caught).
|
||||
- **Drive events (R-1023, R-1038, R-1044; decisions 238/239).** A drive added, removed or returned is recorded on the
|
||||
drive; the storage page and the start page say which media apps must restart, with „Újraindítás most" (after a
|
||||
Plex/Jellyfin stream check — a restart cuts a stream) or „Később" (03:30), and per drive „restarted since: yes/no".
|
||||
The file browser skips a dead or safely removed drive and is RECREATED when a drive comes back (it held the dead mount).
|
||||
A container still holding a dead drive (`shutdown` in its own mountinfo) is listed. **Safe removal** with media apps:
|
||||
pause only that drive's torrents, recreate the media apps and the file browser without it, unmount — and keep the
|
||||
drive registered; its return needs no household step and its torrents resume after the restart.
|
||||
`TestMediaEvents_*`, `TestNextMediaLater` (red-proved: the paused list kept through the return).
|
||||
- **Plex without the 4-minute race (R-1028, R-1045)** — the app page of a closed Plex gate asks for the claim code; the
|
||||
box claims the RUNNING server (`POST /myplex/claim?token=`), never logs the code, and the gate opens only when Plex says
|
||||
claimed. The setup probe gains `accept:` (Plex answers XML without it). `TestSetupDoneProbe_AcceptJSON_Plex`,
|
||||
`TestPlexClaimBox_RendersPerBranch` (both red-proved).
|
||||
|
||||
## Unreleased (2026-10-15) — the shared rule file; no code change
|
||||
|
||||
|
||||
@@ -1968,6 +1968,8 @@ func main() {
|
||||
// Migration done-hook: a decommission-initiated migration finalizes the source decommission on
|
||||
// success (soft-mark + agent). Wire it before RecoverMigration so a resumed one still finalizes.
|
||||
stackMgr.SetMigrationDoneHook(webServer.OnMigrationDone)
|
||||
// R-1024 (v0.313.0): a media app that comes up gets every present drive wired in (debounced, idempotent).
|
||||
stackMgr.SetOnMediaUp(webServer.RunMediaWiringSoon)
|
||||
// R-536: „Alkalmazás telepítve" is sent when the async deploy ACTUALLY finishes, and a deploy
|
||||
// that ends badly now says so instead of being silence. The accept-time event is
|
||||
// `app_deploy_started`, emitted by the API router beside its 202.
|
||||
|
||||
@@ -2693,5 +2693,29 @@
|
||||
"app_info.lost_files_taken": "taken:",
|
||||
"app_info.lost_files_hint": "If your phone still has them, upload them again. Check now, before your phone frees their space.",
|
||||
"app_info.lost_files_dismiss_btn": "OK, I have read this",
|
||||
"err.lost_files.nothing_to_dismiss": "There is no note about lost files for this app."
|
||||
"err.lost_files.nothing_to_dismiss": "There is no note about lost files for this app.",
|
||||
"media.notice_title": "The media apps must restart",
|
||||
"media.notice_body": "A drive changed. The media apps see the change only after a restart.",
|
||||
"media.change_added": "added",
|
||||
"media.change_removed": "removed",
|
||||
"media.change_returned": "came back",
|
||||
"media.restarted_yes": "apps restarted since then: yes",
|
||||
"media.restarted_no": "apps restarted since then: no",
|
||||
"media.apps": "Apps:",
|
||||
"media.stale": "Still holds a dead drive:",
|
||||
"media.restart_now": "Restart now",
|
||||
"media.restart_later": "Later (tonight 3:30)",
|
||||
"media.later_at": "Scheduled:",
|
||||
"media.streams_playing": "Something is playing now. A restart cuts it. Restart anyway?",
|
||||
"media.restart_done": "Restarted. The drives are connected within a minute or two.",
|
||||
"media.wiring_title": "Drives connected to the apps",
|
||||
"media.wire_now": "Connect now",
|
||||
"media.state_household": "needs one step from you",
|
||||
"media.jellyfin_key_hint": "Jellyfin: Dashboard → API Keys → +, key name: felhom. Then press „Connect now”.",
|
||||
"media.error": "Error:",
|
||||
"app_info.plex_claim_intro": "Open https://plex.tv/claim with your Plex account, copy the code and paste it here. The code lives 4 minutes, but Plex already runs, so there is no rush with the install.",
|
||||
"app_info.plex_claim_btn": "Connect Plex",
|
||||
"err.plex_claim.bad_code": "This is not a Plex code. A code starts with: claim-",
|
||||
"err.plex_claim.refused": "Plex did not accept the code. It may have run out: get a new one at plex.tv/claim.",
|
||||
"err.plex_claim.not_claimed": "Plex does not report the connection yet. Try again in a minute."
|
||||
}
|
||||
|
||||
@@ -2681,5 +2681,29 @@
|
||||
"app_info.lost_files_taken": "készült:",
|
||||
"app_info.lost_files_hint": "Ha a telefonodon még megvannak, töltsd fel őket újra. Nézd meg most, mielőtt a telefon felszabadítja a helyüket.",
|
||||
"app_info.lost_files_dismiss_btn": "Rendben, elolvastam",
|
||||
"err.lost_files.nothing_to_dismiss": "Ehhez az alkalmazáshoz nincs elveszett fájlokról szóló értesítés."
|
||||
"err.lost_files.nothing_to_dismiss": "Ehhez az alkalmazáshoz nincs elveszett fájlokról szóló értesítés.",
|
||||
"media.notice_title": "A médiaalkalmazásoknak újra kell indulniuk",
|
||||
"media.notice_body": "Egy meghajtó megváltozott. A médiaalkalmazások csak újraindítás után látják a változást.",
|
||||
"media.change_added": "hozzáadva",
|
||||
"media.change_removed": "eltávolítva",
|
||||
"media.change_returned": "visszatért",
|
||||
"media.restarted_yes": "az alkalmazások azóta újraindultak: igen",
|
||||
"media.restarted_no": "az alkalmazások azóta újraindultak: nem",
|
||||
"media.apps": "Érintett alkalmazások:",
|
||||
"media.stale": "Még egy halott meghajtót tart:",
|
||||
"media.restart_now": "Újraindítás most",
|
||||
"media.restart_later": "Később (ma éjjel 3:30)",
|
||||
"media.later_at": "Ütemezve:",
|
||||
"media.streams_playing": "Most is megy egy lejátszás. Az újraindítás megszakítja. Biztosan újraindítod?",
|
||||
"media.restart_done": "Újraindítva. A meghajtók bekötése egy-két perc múlva kész.",
|
||||
"media.wiring_title": "Meghajtók bekötése az alkalmazásokba",
|
||||
"media.wire_now": "Bekötés most",
|
||||
"media.state_household": "egy lépés kell tőled",
|
||||
"media.jellyfin_key_hint": "Jellyfin: Vezérlőpult → API-kulcsok → +, a kulcs neve: felhom. Utána nyomd meg a „Bekötés most” gombot.",
|
||||
"media.error": "Hiba:",
|
||||
"app_info.plex_claim_intro": "Nyisd meg a https://plex.tv/claim oldalt a Plex-fiókoddal, másold ki a kódot, és illeszd be ide. A kód 4 percig érvényes, de a Plex már fut, így nem kell sietned a telepítéssel.",
|
||||
"app_info.plex_claim_btn": "Plex összekapcsolása",
|
||||
"err.plex_claim.bad_code": "Ez nem Plex-kód. A kód így kezdődik: claim-",
|
||||
"err.plex_claim.refused": "A Plex nem fogadta el a kódot. Lehet, hogy lejárt: kérj újat a plex.tv/claim oldalon.",
|
||||
"err.plex_claim.not_claimed": "A Plex még nem jelzi, hogy össze van kapcsolva. Próbáld újra egy perc múlva."
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package mediawire
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec"
|
||||
)
|
||||
|
||||
// DockerBox is the production Box: the docker CLI on this guest.
|
||||
type DockerBox struct{}
|
||||
|
||||
func (DockerBox) Running(c string) bool {
|
||||
out, err := dockerexec.Command("docker", "inspect", "-f", "{{.State.Running}}", c).Output()
|
||||
return err == nil && strings.TrimSpace(string(out)) == "true"
|
||||
}
|
||||
|
||||
func (DockerBox) Env(c, name string) (string, error) {
|
||||
out, err := dockerexec.Command("docker", "exec", c, "printenv", name).Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%s: reading %s: %w", c, name, err)
|
||||
}
|
||||
return strings.TrimRight(string(out), "\n"), nil
|
||||
}
|
||||
|
||||
func (DockerBox) ReadFile(c, path string) ([]byte, error) {
|
||||
out, err := dockerexec.Command("docker", "exec", c, "cat", path).Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: reading %s: %w", c, path, err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (DockerBox) EnsureDir(p string) error { return appbackup.EnsureUserdataDir(p) }
|
||||
|
||||
func (DockerBox) Mounts(c string) map[string]string {
|
||||
out, err := dockerexec.Command("docker", "inspect", "-f", "{{json .Mounts}}", c).Output()
|
||||
res := map[string]string{}
|
||||
if err != nil {
|
||||
return res
|
||||
}
|
||||
var ms []struct {
|
||||
Source string `json:"Source"`
|
||||
Destination string `json:"Destination"`
|
||||
}
|
||||
if json.Unmarshal(out, &ms) == nil {
|
||||
for _, m := range ms {
|
||||
res[filepath.Clean(m.Destination)] = m.Source
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// JellyfinKey copies Jellyfin's database (with its -wal/-shm, R-286's lesson) into a 0700 temp dir, reads the API key
|
||||
// with this name read-only, and removes the copy at once. The key is returned to the caller only.
|
||||
func (DockerBox) JellyfinKey(name string) (string, error) {
|
||||
dir, err := os.MkdirTemp("", "felhom-jf-")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
for _, f := range []string{"jellyfin.db", "jellyfin.db-wal", "jellyfin.db-shm"} {
|
||||
err := dockerexec.Command("docker", "cp", "jellyfin:/config/data/"+f, filepath.Join(dir, f)).Run()
|
||||
if err != nil && f == "jellyfin.db" {
|
||||
return "", fmt.Errorf("copying Jellyfin's database: %w", err)
|
||||
}
|
||||
}
|
||||
db, err := sql.Open("sqlite", "file:"+filepath.Join(dir, "jellyfin.db"))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer db.Close()
|
||||
var key string
|
||||
err = db.QueryRow(`SELECT AccessToken FROM ApiKeys WHERE Name = ? ORDER BY DateCreated DESC LIMIT 1`, name).Scan(&key)
|
||||
if err == sql.ErrNoRows {
|
||||
return "", nil
|
||||
}
|
||||
return key, err
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,281 @@
|
||||
package mediawire
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The fakes answer the calls wire.py measured, with the answer SHAPES it recorded, and keep state — so the second run
|
||||
// sees what the first one wrote. Every write is counted.
|
||||
|
||||
const (
|
||||
qbPW = "qb-secret-pw-1"
|
||||
trPW = "tr-secret-pw-2"
|
||||
sonKey = "sonarr-key-3"
|
||||
plexT = "plex-token-4"
|
||||
jfKey = "jf-key-5"
|
||||
)
|
||||
|
||||
type fakeBox struct {
|
||||
running map[string]bool
|
||||
dirs []string
|
||||
}
|
||||
|
||||
func (b *fakeBox) Running(c string) bool { return b.running[c] }
|
||||
func (b *fakeBox) Env(c, n string) (string, error) {
|
||||
return map[string]string{"qbittorrent/QBT_ADMIN_PASSWORD": qbPW, "transmission/PASS": trPW}[c+"/"+n], nil
|
||||
}
|
||||
func (b *fakeBox) ReadFile(c, p string) ([]byte, error) {
|
||||
if c == "plex" {
|
||||
return []byte(`<Preferences PlexOnlineToken="` + plexT + `" />`), nil
|
||||
}
|
||||
return []byte("<Config><ApiKey>" + sonKey + "</ApiKey></Config>"), nil
|
||||
}
|
||||
func (b *fakeBox) JellyfinKey(string) (string, error) { return jfKey, nil }
|
||||
func (b *fakeBox) EnsureDir(p string) error { b.dirs = append(b.dirs, p); return nil }
|
||||
func (b *fakeBox) Mounts(c string) map[string]string {
|
||||
return map[string]string{"/media": "/mnt/felhom-drives/media_a/userdata/media"}
|
||||
}
|
||||
|
||||
type world struct {
|
||||
mu sync.Mutex
|
||||
writes []string
|
||||
// qBittorrent
|
||||
autoTMM bool
|
||||
cats map[string]map[string]interface{}
|
||||
// Sonarr
|
||||
tags []map[string]interface{}
|
||||
roots []map[string]interface{}
|
||||
clients []map[string]interface{}
|
||||
minFree int
|
||||
// Plex
|
||||
plexLocs []string
|
||||
fsEvent map[string]bool
|
||||
// Jellyfin
|
||||
jfLocs []string
|
||||
}
|
||||
|
||||
func (w *world) wrote(s string) { w.mu.Lock(); w.writes = append(w.writes, s); w.mu.Unlock() }
|
||||
|
||||
func newWorld() *world {
|
||||
return &world{cats: map[string]map[string]interface{}{}, plexLocs: []string{"/media/tv"}, fsEvent: map[string]bool{},
|
||||
jfLocs: []string{"/media/tv"}}
|
||||
}
|
||||
|
||||
func (w *world) server(t *testing.T) *httptest.Server {
|
||||
return httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
j := func(v interface{}) { _ = json.NewEncoder(rw).Encode(v) }
|
||||
p := r.URL.Path
|
||||
switch {
|
||||
// qBittorrent
|
||||
case p == "/api/v2/auth/login":
|
||||
_ = r.ParseForm()
|
||||
if r.Form.Get("password") != qbPW {
|
||||
rw.WriteHeader(403)
|
||||
return
|
||||
}
|
||||
http.SetCookie(rw, &http.Cookie{Name: "QBT_SID_8080", Value: "sid"})
|
||||
case p == "/api/v2/app/preferences":
|
||||
j(map[string]interface{}{"auto_tmm_enabled": w.autoTMM})
|
||||
case p == "/api/v2/app/setPreferences":
|
||||
w.autoTMM = true
|
||||
w.writes = append(w.writes, "qb setPreferences")
|
||||
case p == "/api/v2/torrents/categories":
|
||||
j(w.cats)
|
||||
case p == "/api/v2/torrents/createCategory" || p == "/api/v2/torrents/editCategory":
|
||||
_ = r.ParseForm()
|
||||
w.cats[r.Form.Get("category")] = map[string]interface{}{"savePath": r.Form.Get("savePath"), "download_path": r.Form.Get("downloadPath")}
|
||||
w.writes = append(w.writes, "qb "+p)
|
||||
// Transmission
|
||||
case p == "/transmission/rpc":
|
||||
if r.Header.Get("X-Transmission-Session-Id") == "" {
|
||||
rw.Header().Set("X-Transmission-Session-Id", "s1")
|
||||
rw.WriteHeader(409)
|
||||
return
|
||||
}
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
if strings.Contains(string(b), "session-set") {
|
||||
w.writes = append(w.writes, "tr session-set")
|
||||
}
|
||||
j(map[string]interface{}{"arguments": map[string]interface{}{"incomplete-dir-enabled": false}})
|
||||
// Sonarr
|
||||
case p == "/api/v3/tag" && r.Method == "GET":
|
||||
j(w.tags)
|
||||
case p == "/api/v3/tag":
|
||||
var in map[string]interface{}
|
||||
_ = json.NewDecoder(r.Body).Decode(&in)
|
||||
in["id"] = len(w.tags) + 1
|
||||
w.tags = append(w.tags, in)
|
||||
w.writes = append(w.writes, "sonarr tag")
|
||||
j(in)
|
||||
case p == "/api/v3/rootfolder" && r.Method == "GET":
|
||||
j(w.roots)
|
||||
case p == "/api/v3/rootfolder":
|
||||
var in map[string]interface{}
|
||||
_ = json.NewDecoder(r.Body).Decode(&in)
|
||||
w.roots = append(w.roots, in)
|
||||
w.writes = append(w.writes, "sonarr rootfolder")
|
||||
j(in)
|
||||
case p == "/api/v3/downloadclient" && r.Method == "GET":
|
||||
j(w.clients)
|
||||
case p == "/api/v3/downloadclient/schema":
|
||||
j([]map[string]interface{}{
|
||||
{"implementation": "QBittorrent", "fields": []map[string]interface{}{{"name": "host"}, {"name": "password"}, {"name": "tvCategory", "value": "tv-sonarr"}}},
|
||||
{"implementation": "Transmission", "fields": []map[string]interface{}{{"name": "host"}, {"name": "password"}, {"name": "tvCategory", "value": "tv-sonarr"}, {"name": "tvDirectory"}}},
|
||||
})
|
||||
case p == "/api/v3/downloadclient":
|
||||
var in map[string]interface{}
|
||||
_ = json.NewDecoder(r.Body).Decode(&in)
|
||||
for _, f := range in["fields"].([]interface{}) {
|
||||
fm := f.(map[string]interface{})
|
||||
if fm["name"] == "tvCategory" && fm["value"] != "" && strings.HasPrefix(in["name"].(string), "Transmission") {
|
||||
rw.WriteHeader(400) // „Cannot use Category and Directory" (measured)
|
||||
return
|
||||
}
|
||||
}
|
||||
w.clients = append(w.clients, in)
|
||||
w.writes = append(w.writes, "sonarr downloadclient "+in["name"].(string))
|
||||
case p == "/api/v3/config/mediamanagement" && r.Method == "GET":
|
||||
j(map[string]interface{}{"id": 1, "minimumFreeSpaceWhenImporting": w.minFree})
|
||||
case strings.HasPrefix(p, "/api/v3/config/mediamanagement/"):
|
||||
var in map[string]interface{}
|
||||
_ = json.NewDecoder(r.Body).Decode(&in)
|
||||
w.minFree = int(in["minimumFreeSpaceWhenImporting"].(float64))
|
||||
w.writes = append(w.writes, "sonarr mediamanagement")
|
||||
// Plex
|
||||
case p == "/library/sections" && r.Method == "GET":
|
||||
if r.Header.Get("X-Plex-Token") != plexT {
|
||||
rw.WriteHeader(401)
|
||||
return
|
||||
}
|
||||
var locs []map[string]string
|
||||
for _, l := range w.plexLocs {
|
||||
locs = append(locs, map[string]string{"path": l})
|
||||
}
|
||||
j(map[string]interface{}{"MediaContainer": map[string]interface{}{"Directory": []map[string]interface{}{
|
||||
{"key": "1", "type": "show", "title": "TV", "agent": "a", "scanner": "s", "language": "hu", "Location": locs}}}})
|
||||
case p == "/library/sections/1" && r.Method == "PUT":
|
||||
q, _ := url.ParseQuery(r.URL.RawQuery)
|
||||
w.plexLocs = q["location"]
|
||||
w.writes = append(w.writes, "plex section")
|
||||
case p == "/library/sections/1/refresh":
|
||||
case p == "/:/prefs" && r.Method == "GET":
|
||||
var st []map[string]interface{}
|
||||
for _, id := range []string{"FSEventLibraryUpdatesEnabled", "FSEventLibraryPartialScanEnabled"} {
|
||||
st = append(st, map[string]interface{}{"id": id, "value": w.fsEvent[id]})
|
||||
}
|
||||
j(map[string]interface{}{"MediaContainer": map[string]interface{}{"Setting": st}})
|
||||
case p == "/:/prefs" && r.Method == "PUT":
|
||||
for k := range r.URL.Query() {
|
||||
w.fsEvent[k] = true
|
||||
}
|
||||
w.writes = append(w.writes, "plex prefs")
|
||||
// Jellyfin
|
||||
case p == "/Library/VirtualFolders":
|
||||
j([]map[string]interface{}{{"Name": "Sorozatok", "CollectionType": "tvshows", "Locations": w.jfLocs}})
|
||||
case p == "/Library/VirtualFolders/Paths":
|
||||
var in map[string]interface{}
|
||||
_ = json.NewDecoder(r.Body).Decode(&in)
|
||||
w.jfLocs = append(w.jfLocs, in["PathInfo"].(map[string]interface{})["Path"].(string))
|
||||
w.writes = append(w.writes, "jellyfin path")
|
||||
case p == "/Library/Refresh":
|
||||
default:
|
||||
t.Errorf("unexpected call %s %s", r.Method, p)
|
||||
rw.WriteHeader(404)
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
// TestRun_SecondRunWritesNothing pins `15` §10 „added": one run makes drive B ready in every app; a second run of the
|
||||
// same state writes NOTHING (K2: the measured second run re-sent equal qBittorrent edits — fixed here by reading first).
|
||||
// The drive the players' template binds at /media is not added a second time under its same-path name.
|
||||
//
|
||||
// COMPANION RED-PROOF (2026-10-15): drop the „already equal" check in qbittorrent() → the second run writes 4
|
||||
// category edits and this test fails. Restored.
|
||||
func TestRun_SecondRunWritesNothing(t *testing.T) {
|
||||
wd := newWorld()
|
||||
srv := wd.server(t)
|
||||
defer srv.Close()
|
||||
var logs strings.Builder
|
||||
box := &fakeBox{running: map[string]bool{"qbittorrent": true, "transmission": true, "sonarr": true, "plex": true, "jellyfin": true}}
|
||||
w := &Wirer{Box: box, Logger: log.New(&logs, "", 0), HTTP: srv.Client(), URL: func(string, int) string { return srv.URL }}
|
||||
drives := []Drive{{Path: "/mnt/felhom-drives/media_a"}, {Path: "/mnt/felhom-drives/media_b"}}
|
||||
|
||||
first := w.Run(drives)
|
||||
for _, r := range first {
|
||||
if r.State == "error" || r.State == "household" {
|
||||
t.Fatalf("first run: %s %s: %s", r.App, r.State, r.Detail)
|
||||
}
|
||||
}
|
||||
n := len(wd.writes)
|
||||
if n == 0 {
|
||||
t.Fatal("the first run wrote nothing")
|
||||
}
|
||||
want := []string{
|
||||
"qb setPreferences", "sonarr tag", "sonarr rootfolder", "sonarr downloadclient qBittorrent media-b",
|
||||
"sonarr downloadclient Transmission media-b", "sonarr mediamanagement", "plex section", "plex prefs", "jellyfin path",
|
||||
}
|
||||
all := strings.Join(wd.writes, "|")
|
||||
for _, x := range want {
|
||||
if !strings.Contains(all, x) {
|
||||
t.Errorf("first run did not do %q (writes: %v)", x, wd.writes)
|
||||
}
|
||||
}
|
||||
for _, l := range wd.plexLocs {
|
||||
if l == "/mnt/felhom-drives/media_a/userdata/media/tv" {
|
||||
t.Error("drive A is bound at /media for Plex and already in the library as /media/tv — added twice")
|
||||
}
|
||||
}
|
||||
if !containsStr(wd.plexLocs, "/mnt/felhom-drives/media_b/userdata/media/tv") {
|
||||
t.Errorf("drive B is not in the Plex library: %v", wd.plexLocs)
|
||||
}
|
||||
|
||||
w.Run(drives)
|
||||
if extra := wd.writes[n:]; len(extra) != 0 {
|
||||
t.Fatalf("the second run of the same state wrote %d time(s): %v", len(extra), extra)
|
||||
}
|
||||
for _, secret := range []string{qbPW, trPW, sonKey, plexT, jfKey} {
|
||||
if strings.Contains(logs.String(), secret) {
|
||||
t.Fatalf("a secret reached the log")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Plex not claimed → a household state, nothing written.
|
||||
func TestRun_UnclaimedPlexIsAHouseholdStep(t *testing.T) {
|
||||
wd := newWorld()
|
||||
srv := wd.server(t)
|
||||
defer srv.Close()
|
||||
box := &unclaimedBox{fakeBox{running: map[string]bool{"plex": true}}}
|
||||
w := &Wirer{Box: box, Logger: log.New(io.Discard, "", 0), HTTP: srv.Client(), URL: func(string, int) string { return srv.URL }}
|
||||
for _, r := range w.Run([]Drive{{Path: "/mnt/felhom-drives/media_b"}}) {
|
||||
if r.App == "plex" && r.State != "household" {
|
||||
t.Fatalf("an unclaimed Plex must be a household step, got %s", r.State)
|
||||
}
|
||||
}
|
||||
if len(wd.writes) != 0 {
|
||||
t.Fatalf("nothing may be written to an unclaimed Plex: %v", wd.writes)
|
||||
}
|
||||
}
|
||||
|
||||
type unclaimedBox struct{ fakeBox }
|
||||
|
||||
func (b *unclaimedBox) ReadFile(c, p string) ([]byte, error) { return []byte(`<Preferences />`), nil }
|
||||
|
||||
func containsStr(xs []string, s string) bool {
|
||||
for _, x := range xs {
|
||||
if x == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package mediawire
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// newLayoutBox: Sonarr runs the NEW template — no /media bind, the drive's media at the same path.
|
||||
type newLayoutBox struct{ fakeBox }
|
||||
|
||||
func (b *newLayoutBox) Mounts(string) map[string]string {
|
||||
return map[string]string{"/mnt/felhom-drives/hdd_1/userdata/media": "/mnt/felhom-drives/hdd_1/userdata/media"}
|
||||
}
|
||||
|
||||
// TestRepairOldRoot_K7 pins R-1047 with the bench's measured sequence (K7): an install from before bc54c01 stored its
|
||||
// library at /media/tv; after the update the repair adds the same-path root, moves the series' root with
|
||||
// moveFiles=false, rescans, and deletes the old root — and a second run does nothing.
|
||||
//
|
||||
// COMPANION RED-PROOF (2026-10-15): send moveFiles=true → this test fails (a file move is never allowed). Restored.
|
||||
func TestRepairOldRoot_K7(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
roots := []map[string]interface{}{{"id": 1, "path": "/media/tv"}}
|
||||
series := []map[string]interface{}{{"id": 7, "path": "/media/tv/Problem Child"}}
|
||||
var calls []string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
j := func(v interface{}) { _ = json.NewEncoder(rw).Encode(v) }
|
||||
switch p := r.URL.Path; {
|
||||
case p == "/api/v3/series":
|
||||
j(series)
|
||||
case p == "/api/v3/rootfolder" && r.Method == "GET":
|
||||
j(roots)
|
||||
case p == "/api/v3/rootfolder" && r.Method == "POST":
|
||||
var in map[string]interface{}
|
||||
_ = json.NewDecoder(r.Body).Decode(&in)
|
||||
in["id"] = 2
|
||||
roots = append(roots, in)
|
||||
calls = append(calls, "add root "+in["path"].(string))
|
||||
case p == "/api/v3/series/editor":
|
||||
var in map[string]interface{}
|
||||
_ = json.NewDecoder(r.Body).Decode(&in)
|
||||
if in["moveFiles"] != false {
|
||||
t.Errorf("the repair asked the app to MOVE FILES: %v", in)
|
||||
}
|
||||
for _, s := range series {
|
||||
s["path"] = strings.Replace(s["path"].(string), "/media/tv", in["rootFolderPath"].(string), 1)
|
||||
}
|
||||
calls = append(calls, "editor "+in["rootFolderPath"].(string))
|
||||
case p == "/api/v3/command":
|
||||
calls = append(calls, "rescan")
|
||||
case p == "/api/v3/rootfolder/1" && r.Method == "DELETE":
|
||||
roots = roots[1:]
|
||||
calls = append(calls, "delete old root")
|
||||
default:
|
||||
rw.WriteHeader(404)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
w := &Wirer{Box: &newLayoutBox{}, Logger: log.New(io.Discard, "", 0), HTTP: srv.Client(), URL: func(string, int) string { return srv.URL }}
|
||||
var r Result
|
||||
if err := w.repairOldRoot("sonarr", srv.URL+"/api/v3", nil, "tv", &r); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := "add root /mnt/felhom-drives/hdd_1/userdata/media/tv|editor /mnt/felhom-drives/hdd_1/userdata/media/tv|rescan|delete old root"
|
||||
if got := strings.Join(calls, "|"); got != want {
|
||||
t.Fatalf("repair calls:\n got %s\n want %s", got, want)
|
||||
}
|
||||
if series[0]["path"] != "/mnt/felhom-drives/hdd_1/userdata/media/tv/Problem Child" {
|
||||
t.Fatalf("the series' stored path did not move: %v", series[0]["path"])
|
||||
}
|
||||
n := len(calls)
|
||||
if err := w.repairOldRoot("sonarr", srv.URL+"/api/v3", nil, "tv", &r); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(calls) != n {
|
||||
t.Fatalf("a second repair run wrote again: %v", calls[n:])
|
||||
}
|
||||
}
|
||||
|
||||
// The OLD template still running (/media bound): nothing is touched.
|
||||
func TestRepairOldRoot_OldTemplateUntouched(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
||||
t.Errorf("no call may reach an app still on the old template: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer srv.Close()
|
||||
w := &Wirer{Box: &fakeBox{}, Logger: log.New(io.Discard, "", 0), HTTP: srv.Client(), URL: func(string, int) string { return srv.URL }}
|
||||
var r Result
|
||||
if err := w.repairOldRoot("sonarr", srv.URL+"/api/v3", nil, "tv", &r); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The media drive events (v0.313.0): a drive change makes the restart pending until the apps restart; a safe removal
|
||||
// keeps the drive registered and its paused torrents until the media apps restart after its return (R-1044).
|
||||
func TestMediaEvents_PendingUntilRestarted(t *testing.T) {
|
||||
s := newTestSettings(t, []StoragePath{{Path: "/mnt/felhom-drives/media_b", Label: "Media B"}})
|
||||
if err := s.MarkMediaChanged("/mnt/felhom-drives/media_b", "added"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !s.GetStoragePaths()[0].MediaRestartPending() {
|
||||
t.Fatal("a drive just added must make the media restart pending")
|
||||
}
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
if err := s.MarkMediaRestarted(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.GetStoragePaths()[0].MediaRestartPending() {
|
||||
t.Fatal("after the restart nothing is pending")
|
||||
}
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
_ = s.MarkMediaChanged("/mnt/felhom-drives/media_b", "returned")
|
||||
if !s.GetStoragePaths()[0].MediaRestartPending() {
|
||||
t.Fatal("a later change makes it pending again — the timestamps must compare correctly as text")
|
||||
}
|
||||
}
|
||||
|
||||
// COMPANION RED-PROOF (2026-10-15): make ClearEjected also clear EjectPaused → this test fails (nothing to resume after
|
||||
// the restart: the household's torrents stay stopped). Restored.
|
||||
func TestMediaEvents_SafeRemovalKeepsDriveAndTorrents(t *testing.T) {
|
||||
s := newTestSettings(t, []StoragePath{{Path: "/mnt/felhom-drives/media_c", Label: "Media C"}})
|
||||
if err := s.SetEjected("/mnt/felhom-drives/media_c", "uuid:abc", []string{"qbit:h1", "trans:h2"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sp := s.GetStoragePaths()
|
||||
if len(sp) != 1 || !sp[0].Disconnected || sp[0].EjectedAt == "" {
|
||||
t.Fatalf("a safe removal must keep the drive registered and mark it removed: %+v", sp)
|
||||
}
|
||||
if got := s.TakeResumable(); len(got) != 0 {
|
||||
t.Fatalf("while the drive is away nothing may resume: %v", got)
|
||||
}
|
||||
if err := s.ClearEjected("/mnt/felhom-drives/media_c"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := s.TakeResumable()
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("after the return the two paused torrents must be handed to the restart: %v", got)
|
||||
}
|
||||
if again := s.TakeResumable(); len(again) != 0 {
|
||||
t.Fatalf("taken once, never twice: %v", again)
|
||||
}
|
||||
}
|
||||
@@ -579,6 +579,20 @@ type StoragePath struct {
|
||||
DecommissionedAt string `json:"decommissioned_at,omitempty"` // RFC3339 timestamp
|
||||
MigratedTo string `json:"migrated_to,omitempty"` // path of target drive
|
||||
|
||||
// The media drive events (v0.313.0, R-1023/R-1038/R-1044, `09` §3 decisions 238/239/245). MediaChangedAt: the drive
|
||||
// was added, removed, or came back — the media apps must restart to see it; MediaRestartedAt: they were restarted
|
||||
// after that (the drive card's „apps restarted since this drive changed"). Ejected*: a SAFE removal keeps the drive
|
||||
// registered (R-1044): EjectedAt marks it, EjectDurableID finds it again, EjectSeenGone says the disk left after the
|
||||
// eject (so its return is a return, not the still-mounted raw disk), EjectPaused lists the torrents the removal
|
||||
// paused ("qbit:<hash>", "trans:<hash>") so its return resumes exactly those.
|
||||
MediaChangedAt string `json:"media_changed_at,omitempty"`
|
||||
MediaChange string `json:"media_change,omitempty"` // added | removed | returned
|
||||
MediaRestartedAt string `json:"media_restarted_at,omitempty"`
|
||||
EjectedAt string `json:"ejected_at,omitempty"`
|
||||
EjectDurableID string `json:"eject_durable_id,omitempty"`
|
||||
EjectSeenGone bool `json:"eject_seen_gone,omitempty"`
|
||||
EjectPaused []string `json:"eject_paused,omitempty"`
|
||||
|
||||
// Kind discriminates a physical drive ("" / "drive") from a NAS network share ("network"). A network
|
||||
// share is bulk-media only and carries the fields below; the drive lifecycle does NOT apply to it.
|
||||
Kind string `json:"kind,omitempty"`
|
||||
@@ -2815,3 +2829,104 @@ func (s *Settings) ClearUnhealthyStopHold(stack string) (bool, error) {
|
||||
}
|
||||
return true, s.save()
|
||||
}
|
||||
|
||||
// MarkMediaChanged records a media drive event (added | removed | returned) — the media apps must restart to see it.
|
||||
func (s *Settings) MarkMediaChanged(path, change string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for i := range s.StoragePaths {
|
||||
if s.StoragePaths[i].Path == path {
|
||||
s.StoragePaths[i].MediaChangedAt = time.Now().UTC().Format(mediaTS)
|
||||
s.StoragePaths[i].MediaChange = change
|
||||
if s.log != nil {
|
||||
s.log.Printf("[INFO] [settings] media drive event: %s %s — the media apps must restart to see it", path, change)
|
||||
}
|
||||
return s.save()
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("storage path %q not found", path)
|
||||
}
|
||||
|
||||
// MarkMediaRestarted records that the media apps were restarted now, after every pending drive event.
|
||||
func (s *Settings) MarkMediaRestarted() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
now := time.Now().UTC().Format(mediaTS)
|
||||
for i := range s.StoragePaths {
|
||||
if s.StoragePaths[i].MediaChangedAt != "" {
|
||||
s.StoragePaths[i].MediaRestartedAt = now
|
||||
}
|
||||
}
|
||||
return s.save()
|
||||
}
|
||||
|
||||
// MediaRestartPending reports whether a drive changed after the media apps last restarted.
|
||||
func (sp StoragePath) MediaRestartPending() bool {
|
||||
return sp.MediaChangedAt != "" && sp.MediaRestartedAt < sp.MediaChangedAt
|
||||
}
|
||||
|
||||
// SetEjected records a SAFE removal: the drive stays registered and disconnected, with the torrents the removal paused.
|
||||
func (s *Settings) SetEjected(path, durableID string, paused []string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for i := range s.StoragePaths {
|
||||
if s.StoragePaths[i].Path == path {
|
||||
sp := &s.StoragePaths[i]
|
||||
sp.Disconnected, sp.DisconnectedAt = true, time.Now().UTC().Format(time.RFC3339)
|
||||
sp.EjectedAt, sp.EjectDurableID, sp.EjectSeenGone, sp.EjectPaused = sp.DisconnectedAt, durableID, false, paused
|
||||
return s.save()
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("storage path %q not found", path)
|
||||
}
|
||||
|
||||
// SetEjectSeenGone records that a safely removed drive's disk left the host after the eject.
|
||||
func (s *Settings) SetEjectSeenGone(path string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for i := range s.StoragePaths {
|
||||
if s.StoragePaths[i].Path == path {
|
||||
s.StoragePaths[i].EjectSeenGone = true
|
||||
return s.save()
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("storage path %q not found", path)
|
||||
}
|
||||
|
||||
// ClearEjected ends a safe removal (the drive came back): connected, the eject fields cleared.
|
||||
func (s *Settings) ClearEjected(path string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for i := range s.StoragePaths {
|
||||
if s.StoragePaths[i].Path == path {
|
||||
sp := &s.StoragePaths[i]
|
||||
sp.Disconnected, sp.DisconnectedAt, sp.StoppedStacks = false, "", nil
|
||||
// EjectPaused is KEPT: the torrents resume after the media apps' restart (TakeResumable), when the download
|
||||
// apps see the drive again — resumed before, they would report their files missing.
|
||||
sp.EjectedAt, sp.EjectDurableID, sp.EjectSeenGone = "", "", false
|
||||
return s.save()
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("storage path %q not found", path)
|
||||
}
|
||||
|
||||
// mediaTS is a FIXED-width timestamp, so two of them compare correctly as strings (RFC3339Nano trims trailing zeros).
|
||||
const mediaTS = "2006-01-02T15:04:05.000000000Z"
|
||||
|
||||
// TakeResumable returns, and clears, the torrents paused by a safe removal of drives that are back.
|
||||
func (s *Settings) TakeResumable() []string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
var out []string
|
||||
for i := range s.StoragePaths {
|
||||
sp := &s.StoragePaths[i]
|
||||
if sp.EjectedAt == "" && len(sp.EjectPaused) > 0 {
|
||||
out = append(out, sp.EjectPaused...)
|
||||
sp.EjectPaused = nil
|
||||
}
|
||||
}
|
||||
if len(out) > 0 {
|
||||
_ = s.save()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -333,6 +333,8 @@ type Manager struct {
|
||||
isMountPoint func(string) bool
|
||||
// mediaDrivesFn replaces the enrolled-drive read of media_library.go (tests); nil in production.
|
||||
mediaDrivesFn func() []MediaDrive
|
||||
// onMediaUp runs after a media app's successful `compose up` (the drive wiring, R-1024); nil = nothing.
|
||||
onMediaUp func(name string)
|
||||
// mediaEnsureDirFn replaces appbackup.EnsureUserdataDir for the per-drive media folder (tests); nil in production.
|
||||
mediaEnsureDirFn func(string) error
|
||||
|
||||
@@ -1685,9 +1687,19 @@ func (m *Manager) composeExecCustomEnv(dir string, env []string, args ...string)
|
||||
}
|
||||
|
||||
m.logger.Printf("[DEBUG] Command completed: %s %s (took %.1fs)", m.composeCmd, strings.Join(args, " "), time.Since(start).Seconds())
|
||||
// R-1024: a media app that came up (installed, started, restarted) gets the drives wired in (idempotent; the web
|
||||
// side debounces). Never for a stack without the marker.
|
||||
if len(args) > 0 && args[0] == "up" && m.onMediaUp != nil {
|
||||
if meta := LoadMetadata(dir); meta.MediaLibrary != nil {
|
||||
m.onMediaUp(filepath.Base(dir))
|
||||
}
|
||||
}
|
||||
return stdout.String(), nil
|
||||
}
|
||||
|
||||
// SetOnMediaUp registers the callback run after a media app's successful `compose up` (main.go: the wiring).
|
||||
func (m *Manager) SetOnMediaUp(f func(name string)) { m.onMediaUp = f }
|
||||
|
||||
func (m *Manager) execCommand(name string, args ...string) (string, error) {
|
||||
// execFn is the process-boundary seam (nil in production). It exists so R-51 can be proven
|
||||
// through the REAL refreshStatusLocked path — docker ps → aggregateState → docker inspect —
|
||||
|
||||
@@ -86,6 +86,8 @@ func (m *Manager) mediaDrives() []MediaDrive {
|
||||
seen[p] = true
|
||||
d := MediaDrive{Path: p, Present: true}
|
||||
switch {
|
||||
case sp.Disconnected || sp.EjectedAt != "":
|
||||
d.Present, d.Why = false, "removed"
|
||||
case !isMount(p):
|
||||
d.Present, d.Why = false, "not mounted"
|
||||
case system.MountIsShutdown(p):
|
||||
@@ -324,3 +326,14 @@ func (m *Manager) defaultMediaDrive() string {
|
||||
}
|
||||
return first
|
||||
}
|
||||
|
||||
// PresentMediaDrives is the drives the media apps bind now (present, enrolled, not network, not removed).
|
||||
func (m *Manager) PresentMediaDrives() []string {
|
||||
var out []string
|
||||
for _, d := range m.mediaDrives() {
|
||||
if d.Present {
|
||||
out = append(out, d.Path)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -50,6 +50,9 @@ type SetupDoneProbe struct {
|
||||
Done string `yaml:"done" json:"done"`
|
||||
// DoneStatus (v0.282.0, R-715): a non-200 HTTP status that itself means "set up" (gramps-web answers 405).
|
||||
DoneStatus int `yaml:"done_status,omitempty" json:"done_status,omitempty"`
|
||||
// Accept (v0.313.0, R-1028/R-1045): the Accept header the probe sends — Plex answers /identity in XML unless asked
|
||||
// for JSON (measured 2026-10-15). Empty = no header (unchanged for every other app).
|
||||
Accept string `yaml:"accept,omitempty" json:"accept,omitempty"`
|
||||
}
|
||||
|
||||
// Setup gate states.
|
||||
@@ -380,12 +383,33 @@ var setupGateProbeFetch = func(url string) (int, []byte, error) {
|
||||
return resp.StatusCode, b, err
|
||||
}
|
||||
|
||||
// setupGateProbeFetchAccept is setupGateProbeFetch with an Accept header (a seam too).
|
||||
var setupGateProbeFetchAccept = func(url, accept string) (int, []byte, error) {
|
||||
c := &http.Client{Timeout: 5 * time.Second}
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
req.Header.Set("Accept", accept)
|
||||
resp, err := c.Do(req)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
return resp.StatusCode, b, err
|
||||
}
|
||||
|
||||
// probeOnce asks the app's own status once. v0.282.0 (R-715): `done_status:` — an app that answers a fixed non-200
|
||||
// status once it is set up (gramps-web: 405 "Users already exist") — counts as done on that status alone; any other
|
||||
// non-200 is "cannot read" (fail closed). A 200 is read as JSON: `field` is a dotted path whose parts may be list
|
||||
// indexes (`setup.0.status` — ghost; `0.done` — home-assistant), compared as text with `done`.
|
||||
func probeOnce(p *SetupDoneProbe) (done bool, got string, err error) {
|
||||
status, body, err := setupGateProbeFetch(p.URL)
|
||||
fetch := setupGateProbeFetch
|
||||
if p.Accept != "" {
|
||||
fetch = func(u string) (int, []byte, error) { return setupGateProbeFetchAccept(u, p.Accept) }
|
||||
}
|
||||
status, body, err := fetch(p.URL)
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// R-1028/R-1045: Plex's „claimed" can be probed only with Accept: application/json (it answers XML otherwise —
|
||||
// measured 2026-10-15 on Tester 1). The probe with `accept:` reads the JSON field; without it the same server reads
|
||||
// „not JSON" and the gate stays closed. COMPANION RED-PROOF: ignore p.Accept in probeOnce → the first case fails.
|
||||
func TestSetupDoneProbe_AcceptJSON_Plex(t *testing.T) {
|
||||
claimed := false
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
c := "0"
|
||||
if claimed {
|
||||
c = "1"
|
||||
}
|
||||
if r.Header.Get("Accept") == "application/json" {
|
||||
v := "false"
|
||||
if claimed {
|
||||
v = "true"
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"MediaContainer":{"size":0,"claimed":` + v + `}}`))
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`<?xml version="1.0"?><MediaContainer size="0" claimed="` + c + `"></MediaContainer>`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
p := &SetupDoneProbe{URL: srv.URL + "/identity", Field: "MediaContainer.claimed", Done: "true", Accept: "application/json"}
|
||||
if done, got, err := probeOnce(p); err != nil || done {
|
||||
t.Fatalf("an unclaimed Plex must read NOT done: done=%v got=%q err=%v", done, got, err)
|
||||
}
|
||||
claimed = true
|
||||
if done, got, err := probeOnce(p); err != nil || !done {
|
||||
t.Fatalf("a claimed Plex must read done: done=%v got=%q err=%v", done, got, err)
|
||||
}
|
||||
p.Accept = ""
|
||||
if done, got, _ := probeOnce(p); done || got != "not JSON" {
|
||||
t.Fatalf("without the Accept header Plex answers XML: done=%v got=%q", done, got)
|
||||
}
|
||||
}
|
||||
@@ -782,6 +782,8 @@ func (s *Server) appDetailHandler(w http.ResponseWriter, r *http.Request, slug s
|
||||
// v0.280.0 (decision 46): the setup gate's card, while the gate stands.
|
||||
data["SetupGateClosed"] = found.Deployed && found.AppConfig != nil && found.AppConfig.SetupGate.Closed()
|
||||
data["SetupGateHasProbe"] = found.Meta.SetupDoneProbe != nil && found.Meta.SetupDoneProbe.URL != ""
|
||||
// R-1028 (v0.313.0): Plex is claimed on its running server — the household pastes the code here, after the install.
|
||||
data["PlexClaim"] = data["SetupGateClosed"] == true && found.Meta.Slug == "plex"
|
||||
// R-741: the install hold's card, while the app is held for its first-login change.
|
||||
data["InstallHoldClosed"] = found.Deployed && found.AppConfig != nil && found.AppConfig.InstallHold.Closed()
|
||||
// v0.282.0 (decision 49): "close sign-up now" on an app installed before the rule; the app's own switch.
|
||||
@@ -3478,6 +3480,7 @@ func (s *Server) settingsStorageAddHandler(w http.ResponseWriter, r *http.Reques
|
||||
|
||||
s.logger.Printf("[INFO] [web] Storage path added: %s (%s)", path, label)
|
||||
go s.SyncFileBrowserMounts()
|
||||
s.mediaDriveEvent(path, "added")
|
||||
http.Redirect(w, r, "/storage?storage_msg=success&storage_detail="+url.QueryEscape(s.msg(r, "storage.msg_added", path)), http.StatusFound)
|
||||
}
|
||||
|
||||
@@ -3596,6 +3599,14 @@ func (s *Server) SyncFileBrowserMounts() {
|
||||
s.syncFileBrowserMounts(false)
|
||||
}
|
||||
|
||||
// SyncFileBrowserMountsForce syncs AND recreates the file browser even when nothing changed (R-1038: a returned drive).
|
||||
func (s *Server) SyncFileBrowserMountsForce() {
|
||||
s.fbForceMu.Lock()
|
||||
s.fbForce = true
|
||||
s.fbForceMu.Unlock()
|
||||
s.syncFileBrowserMounts(false)
|
||||
}
|
||||
|
||||
// SyncFileBrowserMountsReset is like SyncFileBrowserMounts but resets the FileBrowser
|
||||
// database when sources change. Use only after restore — normal operations should use
|
||||
// SyncFileBrowserMounts to preserve user accounts, permissions, and share links.
|
||||
@@ -3673,6 +3684,7 @@ func (s *Server) syncFileBrowserMounts(resetDBOnChange bool) {
|
||||
// no mount behind it renders a broken sidebar entry).
|
||||
storageMounts, configPaths := buildFileBrowserPaths(paths, fbPathDeps{
|
||||
isMount: system.IsMountPoint,
|
||||
isDead: system.MountIsShutdown,
|
||||
classify: s.classifyFSPath,
|
||||
ensureSkeleton: s.ensureUserdataSkeleton,
|
||||
logger: s.logger,
|
||||
@@ -3748,6 +3760,16 @@ func (s *Server) syncFileBrowserMounts(resetDBOnChange bool) {
|
||||
finalConfig, _ := os.ReadFile(configPath)
|
||||
finalCompose, _ := os.ReadFile(composePath)
|
||||
changed := fbNeedsRecreate(oldConfig, finalConfig, oldCompose, finalCompose)
|
||||
// R-1038 (v0.313.0): a drive that CAME BACK at the same path changes neither file, yet the running file browser
|
||||
// still holds the dead mount (I/O error 4 minutes after the return, measured). A forced sync recreates it.
|
||||
s.fbForceMu.Lock()
|
||||
if s.fbForce {
|
||||
if !changed {
|
||||
s.logger.Printf("[INFO] [web] FileBrowser: recreating although nothing changed — a drive came back (R-1038)")
|
||||
}
|
||||
changed, s.fbForce = true, false
|
||||
}
|
||||
s.fbForceMu.Unlock()
|
||||
|
||||
// If sources changed and caller requested a DB reset (restore flow),
|
||||
// nuke the data volume so FileBrowser re-reads config.yaml from scratch.
|
||||
@@ -3791,6 +3813,7 @@ func (s *Server) syncFileBrowserMounts(resetDBOnChange bool) {
|
||||
// without a drive, a NAS or docker.
|
||||
type fbPathDeps struct {
|
||||
isMount func(string) bool // drive-absent gate probe (production: system.IsMountPoint)
|
||||
isDead func(string) bool // a dead filesystem at the path (production: system.MountIsShutdown); nil = never
|
||||
classify func(string) string // network stub gate (production: Server.classifyFSPath; nil → include, fail open)
|
||||
ensureSkeleton func(string) error // userdata skeleton (production: appbackup.EnsureUserdataSkeleton) — DRIVES ONLY
|
||||
// nsRootFor resolves a bare DRIVE path to its felhom-data namespace root (R-203). Injected rather
|
||||
@@ -3846,6 +3869,14 @@ func buildFileBrowserPaths(paths []settings.StoragePath, d fbPathDeps) (storageM
|
||||
}
|
||||
continue
|
||||
}
|
||||
// R-1038/R-1044 (v0.313.0): a DEAD drive (pulled: `shutdown` in mountinfo — no disk I/O) and a SAFELY REMOVED
|
||||
// one are not bound either: the file browser lets go of them, and binds the drive again when it is back.
|
||||
if sp.EjectedAt != "" || (d.isDead != nil && d.isDead(sp.Path)) {
|
||||
if d.logger != nil {
|
||||
d.logger.Printf("[INFO] [web] FileBrowser: drive %s is removed or dead — not bound this pass", sp.Path)
|
||||
}
|
||||
continue
|
||||
}
|
||||
// SCOPE to the drive's `userdata/` subtree (v0.66.0): the customer browses ONLY userdata —
|
||||
// app internals (appdata/) and the recovery units + Tier 2 copies (backups/) are NOT mounted
|
||||
// into FileBrowser. userdata is owned group 1000 mode 2775 (setgid), and FileBrowser runs as
|
||||
|
||||
@@ -371,6 +371,19 @@ func i18nCases() []i18nCase {
|
||||
}}
|
||||
}
|
||||
base = append(base, gateCase("app_info_setup_gate_button", false), gateCase("app_info_setup_gate_probe", true))
|
||||
// v0.313.0 (R-1028): the Plex claim box inside a closed Plex gate — captured when born.
|
||||
base = append(base, i18nCase{"app_info_plex_claim", "app_info", func() map[string]interface{} {
|
||||
d := i18nLayoutData("stacks", "Plex")
|
||||
st := stacks.Stack{Name: "plex", Deployed: true, State: stacks.StateRunning}
|
||||
st.Meta = stacks.Metadata{DisplayName: "Plex", Slug: "plex"}
|
||||
d["Stack"] = st
|
||||
d["Meta"] = st.Meta
|
||||
d["AppInfo"] = st.Meta.AppInfo
|
||||
d["SetupGateClosed"] = true
|
||||
d["SetupGateHasProbe"] = true
|
||||
d["PlexClaim"] = true
|
||||
return d
|
||||
}})
|
||||
// R-741: the install hold's card, while an after_install app waits for its first-login change.
|
||||
base = append(base, i18nCase{"app_info_install_hold", "app_info", func() map[string]interface{} {
|
||||
d := i18nLayoutData("stacks", "Calibre")
|
||||
|
||||
@@ -308,6 +308,7 @@ func (s *Server) ReconcileDriveGates() {
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
s.reconcileEjectedDrives(ctx, agent, resp.Disks)
|
||||
for _, a := range planDriveGates(s.settings.GetStoragePaths(), resp.Disks) {
|
||||
switch {
|
||||
case a.Stop:
|
||||
@@ -325,6 +326,7 @@ func (s *Server) ReconcileDriveGates() {
|
||||
// E-2 Part 5: when the absent drive is the BACKUP TARGET, that is the more specific and more
|
||||
// urgent fact, so it gets its own event rather than being folded into the generic one.
|
||||
s.notifyDriveAbsent(a.Path, stopped, driveTargetByPath(resp.Disks))
|
||||
s.mediaDriveEvent(a.Path, "removed")
|
||||
go s.SyncFileBrowserMounts()
|
||||
case a.Return:
|
||||
if a.Raw != "" {
|
||||
@@ -346,7 +348,9 @@ func (s *Server) ReconcileDriveGates() {
|
||||
// E-2b: the recovery half. An operator told a drive vanished must be told it came back —
|
||||
// otherwise the alarm is a dead end and the next one is trusted less.
|
||||
s.notifyDriveReturned(a.Path, driveTargetByPath(resp.Disks))
|
||||
go s.SyncFileBrowserMounts()
|
||||
s.mediaDriveEvent(a.Path, "returned")
|
||||
// R-1038: same path, same files → nothing „changed", yet the file browser holds the DEAD mount. Recreate.
|
||||
go s.SyncFileBrowserMountsForce()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,422 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/mediawire"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||
)
|
||||
|
||||
// ── Media drive events (v0.313.0; `09` §3 decisions 238/239/245; felhom.eu `architecture/15-media-library.md` §10) ──
|
||||
//
|
||||
// On a drive ADDED, REMOVED or RETURNED the media apps must restart to see it (Docker resolves binds at container
|
||||
// start). The box records the event on the drive (settings: MediaChangedAt), and the storage page and the start page say
|
||||
// which apps must restart, with „restart now" and „later" (tonight 03:30). Each drive card says whether the apps were
|
||||
// restarted since the drive changed. „Restart now" first counts what Plex and Jellyfin are playing (a restart cuts a
|
||||
// stream — K5-4) and asks once more when something plays. After the restart the box wires every present drive into the
|
||||
// apps (mediawire, R-1024). A RETURNED drive also recreates the file browser at once (R-1038 — it holds no stream).
|
||||
//
|
||||
// A SAFE REMOVAL (R-1044) pauses only that drive's torrents, recreates the media apps and the file browser WITHOUT the
|
||||
// drive, then unmounts — and keeps the drive registered, so its return needs no household step; the paused torrents
|
||||
// resume when it is back. The dead-mount rule holds throughout: no path here ever makes an empty folder under an app.
|
||||
|
||||
const mediaLaterHour, mediaLaterMinute = 3, 30
|
||||
|
||||
func (s *Server) wirer() *mediawire.Wirer {
|
||||
s.mediaMu.Lock()
|
||||
defer s.mediaMu.Unlock()
|
||||
if s.mediaWirer == nil {
|
||||
s.mediaWirer = mediawire.New(mediawire.DockerBox{}, s.logger)
|
||||
}
|
||||
return s.mediaWirer
|
||||
}
|
||||
|
||||
func (s *Server) mediaApps() []string {
|
||||
if s.stackMgr == nil {
|
||||
return nil
|
||||
}
|
||||
return s.stackMgr.MediaApps()
|
||||
}
|
||||
|
||||
func (s *Server) presentMediaDrives() []mediawire.Drive {
|
||||
if s.stackMgr == nil {
|
||||
return nil
|
||||
}
|
||||
var out []mediawire.Drive
|
||||
for _, p := range s.stackMgr.PresentMediaDrives() {
|
||||
out = append(out, mediawire.Drive{Path: p})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// RunMediaWiring wires every present drive into the media apps and keeps the result for the page.
|
||||
func (s *Server) RunMediaWiring(reason string) []mediawire.Result {
|
||||
if len(s.mediaApps()) == 0 {
|
||||
return nil
|
||||
}
|
||||
s.logger.Printf("[INFO] [media] wiring the drives into the media apps (%s)", reason)
|
||||
res := s.wirer().Run(s.presentMediaDrives())
|
||||
s.mediaMu.Lock()
|
||||
s.mediaWiring, s.mediaWiredAt = res, time.Now()
|
||||
s.mediaMu.Unlock()
|
||||
return res
|
||||
}
|
||||
|
||||
// RunMediaWiringSoon debounces the wiring after media apps come up: one run 60 s after the LAST call (an install and
|
||||
// a restart of six apps make one run, not six), once more 2 min later if an app answered with an error.
|
||||
func (s *Server) RunMediaWiringSoon(app string) {
|
||||
s.mediaMu.Lock()
|
||||
if s.mediaSoonTmr != nil {
|
||||
s.mediaSoonTmr.Stop()
|
||||
}
|
||||
s.mediaSoonTmr = time.AfterFunc(60*time.Second, func() {
|
||||
for _, r := range s.RunMediaWiring("a media app came up: " + app) {
|
||||
if r.State == "error" {
|
||||
time.Sleep(2 * time.Minute)
|
||||
s.RunMediaWiring("retry after an app error")
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
s.mediaMu.Unlock()
|
||||
}
|
||||
|
||||
// mediaDriveEvent records a drive added / removed / returned — only when a media app exists to care.
|
||||
func (s *Server) mediaDriveEvent(path, change string) {
|
||||
if s.settings == nil || len(s.mediaApps()) == 0 {
|
||||
return
|
||||
}
|
||||
if err := s.settings.MarkMediaChanged(path, change); err != nil {
|
||||
s.logger.Printf("[WARN] [media] recording the drive event %s %s: %v", change, path, err)
|
||||
return
|
||||
}
|
||||
if s.stackMgr != nil {
|
||||
// Write the new per-drive binds now (no restart): the page can say exactly which apps changed.
|
||||
if changed := s.stackMgr.MediaAppsNeedingRestart(); len(changed) > 0 {
|
||||
s.logger.Printf("[INFO] [media] drive %s %s: the binds of %v changed — they see it after a restart", path, change, changed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// staleMediaApps lists running media apps (and the file browser) whose own mount table still holds a DEAD drive
|
||||
// (`shutdown` — K4: each container's mountinfo shows the old device after a return). No disk I/O.
|
||||
func (s *Server) staleMediaApps() []string {
|
||||
var out []string
|
||||
for _, c := range append(s.mediaApps(), "filebrowser") {
|
||||
b, err := dockerexec.Command("docker", "exec", c, "cat", "/proc/self/mountinfo").Output()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, line := range strings.Split(string(b), "\n") {
|
||||
f := strings.Fields(line)
|
||||
if len(f) < 5 || !strings.HasPrefix(f[4], StableParentDir+"/") {
|
||||
continue
|
||||
}
|
||||
if i := strings.Index(line, " - "); i >= 0 {
|
||||
if t := strings.Fields(line[i+3:]); len(t) >= 3 && strings.Contains(","+t[2]+",", ",shutdown,") {
|
||||
out = append(out, c)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// MediaDriveNotice is what the storage page and the start page show.
|
||||
type MediaDriveNotice struct {
|
||||
Pending bool `json:"pending"`
|
||||
Drives []MediaNoticeDrive `json:"drives"`
|
||||
Apps []string `json:"apps"`
|
||||
Stale []string `json:"stale,omitempty"`
|
||||
LaterAt string `json:"later_at,omitempty"`
|
||||
Wiring []mediawire.Result `json:"wiring,omitempty"`
|
||||
WiredAt string `json:"wired_at,omitempty"`
|
||||
}
|
||||
|
||||
// MediaNoticeDrive is one drive's line.
|
||||
type MediaNoticeDrive struct {
|
||||
Path string `json:"path"`
|
||||
Label string `json:"label"`
|
||||
Change string `json:"change"`
|
||||
ChangedAt string `json:"changed_at"`
|
||||
Restarted bool `json:"restarted"` // „apps restarted since this drive changed"
|
||||
Removed bool `json:"removed,omitempty"`
|
||||
}
|
||||
|
||||
// MediaNotice computes the notice. Cheap enough for a page render: settings + one `docker exec cat` per media app.
|
||||
func (s *Server) MediaNotice(checkStale bool) MediaDriveNotice {
|
||||
n := MediaDriveNotice{Apps: s.mediaApps()}
|
||||
if s.settings == nil || len(n.Apps) == 0 {
|
||||
return n
|
||||
}
|
||||
for _, sp := range s.settings.GetStoragePaths() {
|
||||
if sp.MediaChangedAt == "" {
|
||||
continue
|
||||
}
|
||||
d := MediaNoticeDrive{Path: sp.Path, Label: sp.Label, Change: sp.MediaChange, ChangedAt: sp.MediaChangedAt,
|
||||
Restarted: !sp.MediaRestartPending(), Removed: sp.EjectedAt != ""}
|
||||
if d.Label == "" {
|
||||
d.Label = filepath.Base(sp.Path)
|
||||
}
|
||||
n.Pending = n.Pending || !d.Restarted
|
||||
n.Drives = append(n.Drives, d)
|
||||
}
|
||||
sort.Slice(n.Drives, func(i, j int) bool { return n.Drives[i].Path < n.Drives[j].Path })
|
||||
if checkStale {
|
||||
n.Stale = s.staleMediaApps()
|
||||
n.Pending = n.Pending || len(n.Stale) > 0
|
||||
}
|
||||
s.mediaMu.Lock()
|
||||
if !s.mediaLaterAt.IsZero() {
|
||||
n.LaterAt = s.mediaLaterAt.Format("2006-01-02 15:04")
|
||||
}
|
||||
n.Wiring = s.mediaWiring
|
||||
if !s.mediaWiredAt.IsZero() {
|
||||
n.WiredAt = s.mediaWiredAt.Format(time.RFC3339)
|
||||
}
|
||||
s.mediaMu.Unlock()
|
||||
return n
|
||||
}
|
||||
|
||||
// restartMediaApps restarts every media app for real (RestartStack: force-recreate with the stack's env — R-1026),
|
||||
// recreates the file browser, records the restart on every drive, then wires the present drives in.
|
||||
func (s *Server) restartMediaApps(reason string) []string {
|
||||
var failed []string
|
||||
for _, name := range s.mediaApps() {
|
||||
if s.appHeld(name) {
|
||||
continue
|
||||
}
|
||||
if err := s.stackMgr.RestartStack(name); err != nil {
|
||||
s.logger.Printf("[WARN] [media] restart %s (%s): %v", name, reason, err)
|
||||
failed = append(failed, name)
|
||||
}
|
||||
}
|
||||
s.SyncFileBrowserMountsForce()
|
||||
if len(failed) == 0 {
|
||||
if err := s.settings.MarkMediaRestarted(); err != nil {
|
||||
s.logger.Printf("[WARN] [media] recording the restart: %v", err)
|
||||
}
|
||||
}
|
||||
// R-1044: the torrents a safe removal paused resume now that the download apps see their drive again (K3-6: no
|
||||
// re-check after a restart).
|
||||
if ids := s.settings.TakeResumable(); len(ids) > 0 {
|
||||
go func() {
|
||||
time.Sleep(30 * time.Second)
|
||||
if err := s.wirer().Resume(ids); err != nil {
|
||||
s.logger.Printf("[WARN] [media] resuming %d torrent(s) paused by a safe removal: %v", len(ids), err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
s.logger.Printf("[INFO] [media] media apps restarted (%s); failed: %v", reason, failed)
|
||||
return failed
|
||||
}
|
||||
|
||||
func nextMediaLater(now time.Time) time.Time {
|
||||
loc, err := time.LoadLocation("Europe/Budapest")
|
||||
if err != nil {
|
||||
loc = time.Local
|
||||
}
|
||||
n := now.In(loc)
|
||||
t := time.Date(n.Year(), n.Month(), n.Day(), mediaLaterHour, mediaLaterMinute, 0, 0, loc)
|
||||
if !t.After(n) {
|
||||
t = t.Add(24 * time.Hour)
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// handleMediaRestart: POST /api/storage/media/restart {"when":"now"|"later","force":bool}.
|
||||
func (s *Server) handleMediaRestart(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
When string `json:"when"`
|
||||
Force bool `json:"force"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeDiskJSON(w, http.StatusBadRequest, false, s.msg(r, "disk.err.bad_request"), nil)
|
||||
return
|
||||
}
|
||||
if req.When == "later" {
|
||||
at := nextMediaLater(time.Now())
|
||||
s.mediaMu.Lock()
|
||||
if s.mediaLaterTmr != nil {
|
||||
s.mediaLaterTmr.Stop()
|
||||
}
|
||||
s.mediaLaterAt = at
|
||||
s.mediaLaterTmr = time.AfterFunc(time.Until(at), func() {
|
||||
s.restartMediaApps("the household's „later\"")
|
||||
s.mediaMu.Lock()
|
||||
s.mediaLaterAt = time.Time{}
|
||||
s.mediaMu.Unlock()
|
||||
})
|
||||
s.mediaMu.Unlock()
|
||||
s.logger.Printf("[INFO] [media] media restart scheduled for %s (the household's „later\"; a controller restart forgets it, the notice stays)", at.Format(time.RFC3339))
|
||||
writeDiskJSON(w, http.StatusOK, true, "", map[string]string{"later_at": at.Format("2006-01-02 15:04")})
|
||||
return
|
||||
}
|
||||
if !req.Force {
|
||||
if n, notes := s.wirer().PlayingStreams(); n > 0 {
|
||||
writeDiskJSON(w, http.StatusConflict, false, s.msg(r, "media.streams_playing"), map[string]interface{}{"streams": n, "where": notes})
|
||||
return
|
||||
}
|
||||
}
|
||||
failed := s.restartMediaApps("the household's „restart now\"")
|
||||
writeDiskJSON(w, http.StatusOK, len(failed) == 0, "", map[string]interface{}{"failed": failed})
|
||||
}
|
||||
|
||||
// ServeMediaAPI answers /api/storage/media/* (dispatched by ServeStorageAPI).
|
||||
func (s *Server) ServeMediaAPI(w http.ResponseWriter, r *http.Request) bool {
|
||||
switch {
|
||||
case r.URL.Path == "/api/storage/media/status" && r.Method == http.MethodGet:
|
||||
writeDiskJSON(w, http.StatusOK, true, "", s.MediaNotice(true))
|
||||
case r.URL.Path == "/api/storage/media/restart" && r.Method == http.MethodPost:
|
||||
s.handleMediaRestart(w, r)
|
||||
case r.URL.Path == "/api/storage/media/wire" && r.Method == http.MethodPost:
|
||||
writeDiskJSON(w, http.StatusOK, true, "", s.RunMediaWiring("the household's „connect now\""))
|
||||
default:
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ── Safe removal and return (R-1044) ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// mediaSafeRemoval runs the media half of a safe removal BEFORE the agent unmounts: pause the drive's torrents, mark the
|
||||
// drive removed (it stays registered), recreate the media apps and the file browser without it. Returns the paused
|
||||
// torrents for the record.
|
||||
func (s *Server) mediaSafeRemoval(path, durableID string) []string {
|
||||
d := mediawire.Drive{Path: path}
|
||||
paused, err := s.wirer().PauseOnDrive(d)
|
||||
if err != nil {
|
||||
s.logger.Printf("[WARN] [media] safe removal %s: pausing its torrents: %v (continuing — a paused torrent is a courtesy, the release below is the safety)", path, err)
|
||||
}
|
||||
if err := s.settings.SetEjected(path, durableID, paused); err != nil {
|
||||
s.logger.Printf("[WARN] [media] safe removal %s: recording it: %v", path, err)
|
||||
}
|
||||
for _, name := range s.mediaApps() {
|
||||
if s.appHeld(name) {
|
||||
continue
|
||||
}
|
||||
// The override no longer lists the removed drive, so the recreate lets go of it.
|
||||
if err := s.stackMgr.RestartStack(name); err != nil {
|
||||
s.logger.Printf("[WARN] [media] safe removal %s: recreating %s without it: %v", path, name, err)
|
||||
}
|
||||
}
|
||||
s.SyncFileBrowserMountsForce()
|
||||
s.logger.Printf("[INFO] [media] safe removal %s: %d torrent(s) paused, the media apps and the file browser let go of it", path, len(paused))
|
||||
return paused
|
||||
}
|
||||
|
||||
// reconcileEjectedDrives handles a safely removed drive: once its disk has LEFT (not attached any more) and is BACK
|
||||
// (attached again), re-bind it under the parent, clear the removal, resume its torrents, and raise the notice.
|
||||
func (s *Server) reconcileEjectedDrives(ctx context.Context, agent *agentapi.Client, disks []agentapi.DiskInfo) {
|
||||
for _, sp := range s.settings.GetStoragePaths() {
|
||||
if sp.EjectedAt == "" {
|
||||
continue
|
||||
}
|
||||
var disk *agentapi.DiskInfo
|
||||
for i := range disks {
|
||||
if (sp.EjectDurableID != "" && disks[i].DurableID == sp.EjectDurableID) || (sp.EjectDurableID == "" && disks[i].GuestPath == sp.Path) {
|
||||
disk = &disks[i]
|
||||
}
|
||||
}
|
||||
attached := disk != nil && disk.State == "attached"
|
||||
if !sp.EjectSeenGone {
|
||||
if !attached {
|
||||
_ = s.settings.SetEjectSeenGone(sp.Path)
|
||||
s.logger.Printf("[INFO] [media] safely removed drive %s: its disk has left the box", sp.Path)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !attached {
|
||||
continue
|
||||
}
|
||||
if !disk.BoundUnderParent && disk.MountPath != "" {
|
||||
if err := agent.GuestAttach(ctx, disk.MountPath); err != nil {
|
||||
s.logger.Printf("[WARN] [media] safely removed drive %s is back: re-attach (raw %s): %v", sp.Path, disk.MountPath, err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
if err := s.settings.ClearEjected(sp.Path); err != nil {
|
||||
s.logger.Printf("[WARN] [media] safely removed drive %s is back: clearing the removal: %v", sp.Path, err)
|
||||
continue
|
||||
}
|
||||
s.logger.Printf("[INFO] [media] safely removed drive %s is BACK — registered as before, no household step", sp.Path)
|
||||
s.mediaDriveEvent(sp.Path, "returned")
|
||||
// The file browser holds no stream: it gets the drive back at once. The torrents resume after the media apps'
|
||||
// restart (restartMediaApps → TakeResumable).
|
||||
go s.SyncFileBrowserMountsForce()
|
||||
}
|
||||
}
|
||||
|
||||
// ejectDurableID finds the durable id of the disk behind a registered path (for finding it again after the return).
|
||||
func ejectDurableID(disks []agentapi.DiskInfo, path string) string {
|
||||
for _, d := range disks {
|
||||
if d.GuestPath == path {
|
||||
return d.DurableID
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
var _ = settings.StoragePath{}
|
||||
|
||||
// plexClaimCode is the shape of a plex.tv claim code (claim-<20-ish>); anything else is refused before a call.
|
||||
var plexClaimCode = regexp.MustCompile(`^claim-[A-Za-z0-9_-]{8,64}$`)
|
||||
|
||||
// appPlexClaimHandler (R-1028, `15` §10 K6): the household's code claims the RUNNING Plex — POST
|
||||
// http://plex:32400/myplex/claim?token=<code> (no Plex token needed: the server is unclaimed). The code is never logged
|
||||
// and never stored. The setup gate opens only when Plex itself then says claimed (the probe), never on the press alone.
|
||||
func (s *Server) appPlexClaimHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || !plexClaimCode.MatchString(strings.TrimSpace(req.Code)) {
|
||||
escrowJSON(w, http.StatusBadRequest, nil, s.msg(r, "err.plex_claim.bad_code"))
|
||||
return
|
||||
}
|
||||
code, err := plexClaim(strings.TrimSpace(req.Code))
|
||||
s.logger.Printf("[INFO] [web] plex: the household's claim code given to the running server -> HTTP %d (err %v; the code is not logged)", code, err)
|
||||
if err != nil || code != http.StatusOK {
|
||||
escrowJSON(w, http.StatusConflict, nil, s.msg(r, "err.plex_claim.refused"))
|
||||
return
|
||||
}
|
||||
for i := 0; i < 10; i++ {
|
||||
if has, done, _, perr := s.stackMgr.SetupGateProbe("plex"); has && perr == nil && done {
|
||||
if oerr := s.stackMgr.OpenSetupGate("plex", stacks.SetupGateByProbe); oerr != nil && !errors.Is(oerr, stacks.ErrSetupGateNotClosed) {
|
||||
s.logger.Printf("[WARN] [web] plex: claimed, but opening the setup gate failed: %v", oerr)
|
||||
}
|
||||
s.logger.Printf("[INFO] [web] plex: claimed — the setup gate is open (R-1028)")
|
||||
escrowJSON(w, http.StatusOK, map[string]any{"claimed": true}, "")
|
||||
return
|
||||
}
|
||||
time.Sleep(time.Second)
|
||||
}
|
||||
escrowJSON(w, http.StatusConflict, nil, s.msg(r, "err.plex_claim.not_claimed"))
|
||||
}
|
||||
|
||||
// plexClaim is the call (a seam for tests).
|
||||
var plexClaim = func(code string) (int, error) {
|
||||
req, err := http.NewRequest(http.MethodPost, "http://plex:32400/myplex/claim?token="+url.QueryEscape(code), nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
resp.Body.Close()
|
||||
return resp.StatusCode, nil
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// „Later" is tonight 03:30 Budapest time — or tomorrow's when it is already past 03:30.
|
||||
func TestNextMediaLater(t *testing.T) {
|
||||
loc, _ := time.LoadLocation("Europe/Budapest")
|
||||
for now, want := range map[time.Time]string{
|
||||
time.Date(2026, 10, 15, 21, 0, 0, 0, loc): "2026-10-16 03:30",
|
||||
time.Date(2026, 10, 15, 2, 0, 0, 0, loc): "2026-10-15 03:30",
|
||||
time.Date(2026, 10, 15, 3, 30, 0, 0, loc): "2026-10-16 03:30",
|
||||
} {
|
||||
if got := nextMediaLater(now).Format("2006-01-02 15:04"); got != want {
|
||||
t.Errorf("now %s: later = %s, want %s", now.Format(time.RFC3339), got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||
)
|
||||
|
||||
// R-1028: the Plex claim box renders only for a closed Plex gate — render test per branch (the seam-wiring rule).
|
||||
// COMPANION RED-PROOF: remove the {{if .PlexClaim}} block → the first case fails. Restored.
|
||||
func TestPlexClaimBox_RendersPerBranch(t *testing.T) {
|
||||
st := lcStack("plex", "", true)
|
||||
st.State = stacks.StateRunning
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
plexClaim bool
|
||||
gateClosed bool
|
||||
want bool
|
||||
}{{"closed plex", true, true, true}, {"gate open", false, false, false}, {"other app closed", false, true, false}} {
|
||||
d := appInfoData(st)
|
||||
d["SetupGateClosed"] = c.gateClosed
|
||||
d["PlexClaim"] = c.plexClaim
|
||||
h := renderBackupPage(t, "app_info", d)
|
||||
if got := strings.Contains(h, `data-plex-claim="true"`) && strings.Contains(h, `id="plex-claim-code"`); got != c.want {
|
||||
t.Errorf("%s: claim box shown=%v, want %v", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The code is checked for its shape before any call: no call for something that is not a claim code.
|
||||
func TestPlexClaimCodeShape(t *testing.T) {
|
||||
for code, ok := range map[string]bool{"claim-AbCdEf123456789_-xyz": true, "claim-short": false, "claim-abcdefgh": true, "claim-": false,
|
||||
"https://plex.tv/claim": false, "claim-abc def": false, "": false} {
|
||||
if got := plexClaimCode.MatchString(code); got != ok {
|
||||
t.Errorf("%q: accepted=%v, want %v", code, got, ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import (
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/integrations"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/mediawire"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/notify"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/scheduler"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/selfupdate"
|
||||
@@ -91,9 +92,20 @@ type Server struct {
|
||||
fileBrowserMu sync.Mutex
|
||||
// fbReqMu guards the file-browser sync's single-flight counters (R-695, v0.275.0): fbReqGen counts
|
||||
// requests, fbDoneGen is the newest request a finished sync is known to have covered.
|
||||
fbReqMu sync.Mutex
|
||||
fbReqGen uint64
|
||||
fbDoneGen uint64
|
||||
fbReqMu sync.Mutex
|
||||
// fbForce (R-1038): the next FileBrowser sync recreates even when nothing changed (a drive came back).
|
||||
fbForceMu sync.Mutex
|
||||
fbForce bool
|
||||
// media drive events (v0.313.0): the wiring (R-1024) and the „restart later" timer.
|
||||
mediaMu sync.Mutex
|
||||
mediaWiring []mediawire.Result
|
||||
mediaWiredAt time.Time
|
||||
mediaLaterAt time.Time
|
||||
mediaLaterTmr *time.Timer
|
||||
mediaWirer *mediawire.Wirer
|
||||
mediaSoonTmr *time.Timer
|
||||
fbReqGen uint64
|
||||
fbDoneGen uint64
|
||||
// syncFileBrowserHook replaces the sync's body in tests (the single-flight is the unit under test).
|
||||
syncFileBrowserHook func()
|
||||
// langSwitchSync replaces the language switch's file-browser sync in tests (R-691).
|
||||
@@ -850,6 +862,8 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
// path wins. POST (not GET) so CsrfProtect covers it and it is not cacheable — see the handler.
|
||||
// v0.280.0: the household's two presses on the app page — "Done, I set it up" (decision 46) and "I changed
|
||||
// it" under a known default login (R-710). POST, so CsrfProtect covers them.
|
||||
case path == "/apps/plex/plex-claim" && r.Method == http.MethodPost:
|
||||
s.appPlexClaimHandler(w, r)
|
||||
case strings.HasPrefix(path, "/apps/") && strings.HasSuffix(path, "/setup-gate/open") && r.Method == http.MethodPost:
|
||||
s.appSetupGateOpenHandler(w, r, strings.TrimSuffix(strings.TrimPrefix(path, "/apps/"), "/setup-gate/open"))
|
||||
// R-1007: the household dismisses the lost-files note. POST, so CsrfProtect covers it.
|
||||
|
||||
@@ -315,6 +315,7 @@ func (s *Server) registerStoragePath(where, label string, setDefault bool) error
|
||||
return util.MsgError("err.web.regisztracio_sikertelen", err)
|
||||
}
|
||||
go s.SyncFileBrowserMounts()
|
||||
s.mediaDriveEvent(where, "added") // R-1023: the media apps see it after a restart
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -370,6 +371,9 @@ func (s *Server) storageWizardPageHandler(w http.ResponseWriter, r *http.Request
|
||||
|
||||
// ServeStorageAPI dispatches /api/storage/* (guided init/attach/eject orchestration).
|
||||
func (s *Server) ServeStorageAPI(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/api/storage/media/") && s.ServeMediaAPI(w, r) {
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case r.URL.Path == "/api/storage/init" && r.Method == http.MethodPost:
|
||||
s.handleStorageInit(w, r)
|
||||
@@ -983,12 +987,35 @@ func (s *Server) handleStorageEject(w http.ResponseWriter, r *http.Request) {
|
||||
writeDiskJSON(w, http.StatusServiceUnavailable, false, s.errText(r, err), nil)
|
||||
return
|
||||
}
|
||||
// R-1044 (v0.313.0): with media apps on the box, a safe removal FIRST lets go of the drive — pause only its torrents,
|
||||
// recreate the media apps and the file browser without it — and KEEPS it registered (removed), so its return needs no
|
||||
// household step. Measured 2026-10-14 (K3-3): the old eject left running apps holding the filesystem and forgot the
|
||||
// drive („Meglévő meghajtó csatolása" after every return).
|
||||
mediaRemoval := len(s.mediaApps()) > 0 && strings.HasPrefix(req.Where, StableParentDir+"/")
|
||||
if mediaRemoval {
|
||||
durable := ""
|
||||
if dr, derr := agent.Disks(r.Context()); derr == nil {
|
||||
durable = ejectDurableID(dr.Disks, req.Where)
|
||||
}
|
||||
s.mediaSafeRemoval(req.Where, durable)
|
||||
s.mediaDriveEvent(req.Where, "removed")
|
||||
}
|
||||
// The registered path is the STABLE /mnt/felhom-drives/<name>; the agent operates on the raw mount.
|
||||
res, err := agent.EjectDisk(r.Context(), agentWhere(req.Where))
|
||||
if err != nil {
|
||||
if mediaRemoval {
|
||||
// The unmount did not happen: the drive is still here — undo the removal mark so the apps get it back.
|
||||
_ = s.settings.ClearEjected(req.Where)
|
||||
s.mediaDriveEvent(req.Where, "returned")
|
||||
}
|
||||
writeDiskJSON(w, http.StatusBadGateway, false, s.errText(r, err), nil)
|
||||
return
|
||||
}
|
||||
if mediaRemoval {
|
||||
s.logger.Printf("[INFO] [web] eject: %s safely removed and KEPT registered (media apps on the box, R-1044)", req.Where)
|
||||
writeDiskJSON(w, http.StatusOK, true, "", res)
|
||||
return
|
||||
}
|
||||
// Deregister the path (best-effort — the unmount already succeeded).
|
||||
if rerr := s.settings.RemoveStoragePath(req.Where); rerr != nil {
|
||||
s.logger.Printf("[WARN] [web] eject: unmounted %s but deregister failed: %v", req.Where, rerr)
|
||||
|
||||
@@ -112,6 +112,16 @@
|
||||
<h3>{{T "app_info.setup_gate_title"}}</h3>
|
||||
<p>{{T "app_info.setup_gate_closed"}}</p>{{- if .SignupNative}}
|
||||
<p class="form-hint" data-restarts-once="true">{{T "app_info.restarts_once"}}</p>{{- end}}
|
||||
{{- if .PlexClaim}}
|
||||
<div class="plex-claim" data-plex-claim="true">
|
||||
<p>{{T "app_info.plex_claim_intro"}}</p>
|
||||
<div class="input-with-button">
|
||||
<input type="text" id="plex-claim-code" class="form-control" placeholder="claim-xxxxxxxxxxxxxxxxxxxx" autocomplete="off" spellcheck="false">
|
||||
<button type="button" class="btn btn-sm btn-primary" id="plex-claim-btn" onclick="plexClaim(this)">{{T "app_info.plex_claim_btn"}}</button>
|
||||
</div>
|
||||
<span class="form-hint app-press-err" style="display:none;color:var(--red)"></span>
|
||||
</div>
|
||||
{{- end}}
|
||||
{{- if .SetupGateHasProbe}}
|
||||
<p class="form-hint">{{T "app_info.setup_gate_probe"}}</p>
|
||||
<button type="button" class="btn btn-sm btn-outline" onclick="appPress(this, '/apps/{{.Meta.Slug}}/setup-gate/open')">{{T "app_info.setup_gate_done_btn"}}</button>
|
||||
@@ -368,6 +378,16 @@ function icCopyPw(btn) {
|
||||
{{- if or .SetupGateClosed .SignupClosed .CloseSignupOffered (and .Stack.Deployed .KnownLoginLine) .LostFiles}}
|
||||
<script>
|
||||
// v0.280.0: the household's presses on this page — "Done, I set it up" (decision 46), "I changed it" (R-710).
|
||||
// R-1028: the household's Plex code, given to the RUNNING server; the gate opens when Plex says it is claimed.
|
||||
function plexClaim(btn) {
|
||||
var box = btn.closest('.plex-claim'), err = box.querySelector('.app-press-err');
|
||||
var code = (document.getElementById('plex-claim-code') || {}).value || '';
|
||||
err.style.display = 'none'; btn.disabled = true;
|
||||
fetch('/apps/plex/plex-claim', {method: 'POST', headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()), credentials: 'same-origin', body: JSON.stringify({code: code.trim()})})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (j) { if (!j.ok) { throw new Error(j.error || ''); } window.location.reload(); })
|
||||
.catch(function (e) { btn.disabled = false; err.textContent = e.message; err.style.display = 'inline'; });
|
||||
}
|
||||
function appPress(btn, url) {
|
||||
var err = btn.parentNode.querySelector('.app-press-err');
|
||||
if (err) { err.style.display = 'none'; }
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
<h2>{{T "dashboard.vezerlopult"}}</h2>
|
||||
<span class="domain-badge">{{.Domain}}</span>
|
||||
</div>
|
||||
{{template "media_notice" .}}
|
||||
|
||||
<div class="stats-grid">
|
||||
<a href="/stacks?filter=running" class="stat-card">
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
{{/*
|
||||
media_notice (v0.313.0, `09` §3 decisions 238/239/245) — the media drive notice: a drive was added, removed or came back,
|
||||
so the media apps must restart to see it; „restart now" (after a stream check) or „later" (tonight 03:30); per drive
|
||||
„apps restarted since this change: yes/no"; and the result of the drive wiring. Rendered empty and filled from
|
||||
GET /api/storage/media/status, so every page that includes it shows the same truth. Hidden when there is nothing to say.
|
||||
*/}}
|
||||
{{define "media_notice"}}
|
||||
<div id="media-notice" class="settings-card media-notice" hidden>
|
||||
<h3>{{T "media.notice_title"}}</h3>
|
||||
<p class="settings-card-desc">{{T "media.notice_body"}}</p>
|
||||
<ul id="media-notice-drives" class="media-notice-list"></ul>
|
||||
<p id="media-notice-apps" class="form-hint"></p>
|
||||
<p id="media-notice-stale" class="form-hint" hidden></p>
|
||||
<p id="media-notice-later" class="form-hint" hidden></p>
|
||||
<div class="media-notice-actions">
|
||||
<button type="button" class="btn btn-sm btn-primary" id="media-restart-now">{{T "media.restart_now"}}</button>
|
||||
<button type="button" class="btn btn-sm btn-outline" id="media-restart-later">{{T "media.restart_later"}}</button>
|
||||
</div>
|
||||
<div id="media-notice-out"></div>
|
||||
<div id="media-wiring" hidden>
|
||||
<h4>{{T "media.wiring_title"}}</h4>
|
||||
<ul id="media-wiring-list" class="media-notice-list"></ul>
|
||||
<p id="media-wiring-hint" class="form-hint" hidden>{{T "media.jellyfin_key_hint"}}</p>
|
||||
<button type="button" class="btn btn-sm btn-outline" id="media-wire-now">{{T "media.wire_now"}}</button>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function(){
|
||||
var box=document.getElementById('media-notice'); if(!box) return;
|
||||
var csrf=(document.querySelector('meta[name="csrf-token"]')||{}).content||'';
|
||||
var L={added:"{{T "media.change_added"}}",removed:"{{T "media.change_removed"}}",returned:"{{T "media.change_returned"}}",
|
||||
yes:"{{T "media.restarted_yes"}}",no:"{{T "media.restarted_no"}}",apps:"{{T "media.apps"}}",stale:"{{T "media.stale"}}",
|
||||
later:"{{T "media.later_at"}}",streams:"{{T "media.streams_playing"}}",done:"{{T "media.restart_done"}}",
|
||||
err:"{{T "media.error"}}",household:"{{T "media.state_household"}}"};
|
||||
function esc(s){return String(s==null?'':s).replace(/[&<>"]/g,function(c){return {'&':'&','<':'<','>':'>','"':'"'}[c];});}
|
||||
function li(t){var e=document.createElement('li'); e.textContent=t; return e;}
|
||||
async function load(){
|
||||
try{
|
||||
var r=await fetch('/api/storage/media/status',{credentials:'same-origin'}); var j=await r.json(); if(!j.ok||!j.data) return;
|
||||
var d=j.data; if(!d.apps||!d.apps.length) return;
|
||||
var ul=document.getElementById('media-notice-drives'); ul.innerHTML='';
|
||||
(d.drives||[]).forEach(function(x){ ul.appendChild(li(x.label+' — '+(L[x.change]||x.change)+' — '+(x.restarted?L.yes:L.no))); });
|
||||
document.getElementById('media-notice-apps').textContent=L.apps+' '+d.apps.join(', ');
|
||||
var st=document.getElementById('media-notice-stale'); if(d.stale&&d.stale.length){st.textContent=L.stale+' '+d.stale.join(', '); st.hidden=false;}
|
||||
var la=document.getElementById('media-notice-later'); if(d.later_at){la.textContent=L.later+' '+d.later_at; la.hidden=false;}
|
||||
if(d.wiring&&d.wiring.length){
|
||||
var wl=document.getElementById('media-wiring-list'); wl.innerHTML=''; var hh=false;
|
||||
d.wiring.forEach(function(x){ if(x.app==='folders') return; var t=x.app+': '+(x.state==='household'?L.household:x.state)+(x.detail&&x.state==='error'?' ('+x.detail+')':''); wl.appendChild(li(t)); if(x.state==='household'&&x.app==='jellyfin') hh=true; });
|
||||
document.getElementById('media-wiring-hint').hidden=!hh; document.getElementById('media-wiring').hidden=false;
|
||||
}
|
||||
var show=d.pending||!!(d.wiring&&d.wiring.some(function(x){return x.state==='household'||x.state==='error';}));
|
||||
document.querySelector('.media-notice-actions').hidden=!d.pending;
|
||||
box.hidden=!show;
|
||||
}catch(e){}
|
||||
}
|
||||
async function post(url,body){
|
||||
var r=await fetch(url,{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf},body:JSON.stringify(body||{})});
|
||||
var j={}; try{j=await r.json();}catch(e){} return {status:r.status,j:j};
|
||||
}
|
||||
var out=document.getElementById('media-notice-out');
|
||||
async function restart(force){
|
||||
out.innerHTML='';
|
||||
var res=await post('/api/storage/media/restart',{when:'now',force:!!force});
|
||||
if(res.status===409){ felhomConfirm(document.getElementById('media-restart-now'), L.streams, function(){ restart(true); }); return; }
|
||||
out.innerHTML=res.j.ok?'<div class="alert alert-info">'+esc(L.done)+'</div>':'<div class="alert alert-error">'+esc(L.err)+' '+esc(res.j.error||((res.j.data||{}).failed||[]).join(', '))+'</div>';
|
||||
load();
|
||||
}
|
||||
document.getElementById('media-restart-now').addEventListener('click',function(){restart(false);});
|
||||
document.getElementById('media-restart-later').addEventListener('click',async function(){ await post('/api/storage/media/restart',{when:'later'}); load(); });
|
||||
document.getElementById('media-wire-now').addEventListener('click',async function(){ await post('/api/storage/media/wire',{}); load(); });
|
||||
load();
|
||||
})();
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -4,6 +4,7 @@
|
||||
<div class="page-header">
|
||||
<h2>{{T "storage.tarhely_meghajtok"}}</h2>
|
||||
</div>
|
||||
{{template "media_notice" .}}
|
||||
|
||||
<!-- Section: Storage Paths -->
|
||||
<div class="settings-card">
|
||||
|
||||
@@ -3756,3 +3756,8 @@ html::-webkit-scrollbar-track { background: var(--bg-0); }
|
||||
.kept-delete-form { display: flex; flex-direction: column; gap: .4rem; margin-top: .5rem; max-width: 32rem; }
|
||||
.kept-delete-text { color: var(--warn); margin: 0; }
|
||||
.storage-kept-link { margin-top: .75rem; }
|
||||
|
||||
/* media drive notice (v0.313.0) */
|
||||
.media-notice-list { margin: .4rem 0 .6rem 1.1rem; padding: 0; }
|
||||
.media-notice-list li { margin: .15rem 0; }
|
||||
.media-notice-actions { display: flex; gap: .5rem; flex-wrap: wrap; margin: .6rem 0; }
|
||||
|
||||
@@ -595,6 +595,16 @@
|
||||
|
||||
<script>
|
||||
|
||||
|
||||
function plexClaim(btn) {
|
||||
var box = btn.closest('.plex-claim'), err = box.querySelector('.app-press-err');
|
||||
var code = (document.getElementById('plex-claim-code') || {}).value || '';
|
||||
err.style.display = 'none'; btn.disabled = true;
|
||||
fetch('/apps/plex/plex-claim', {method: 'POST', headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()), credentials: 'same-origin', body: JSON.stringify({code: code.trim()})})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (j) { if (!j.ok) { throw new Error(j.error || ''); } window.location.reload(); })
|
||||
.catch(function (e) { btn.disabled = false; err.textContent = e.message; err.style.display = 'inline'; });
|
||||
}
|
||||
function appPress(btn, url) {
|
||||
var err = btn.parentNode.querySelector('.app-press-err');
|
||||
if (err) { err.style.display = 'none'; }
|
||||
|
||||
+10
@@ -596,6 +596,16 @@
|
||||
|
||||
<script>
|
||||
|
||||
|
||||
function plexClaim(btn) {
|
||||
var box = btn.closest('.plex-claim'), err = box.querySelector('.app-press-err');
|
||||
var code = (document.getElementById('plex-claim-code') || {}).value || '';
|
||||
err.style.display = 'none'; btn.disabled = true;
|
||||
fetch('/apps/plex/plex-claim', {method: 'POST', headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()), credentials: 'same-origin', body: JSON.stringify({code: code.trim()})})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (j) { if (!j.ok) { throw new Error(j.error || ''); } window.location.reload(); })
|
||||
.catch(function (e) { btn.disabled = false; err.textContent = e.message; err.style.display = 'inline'; });
|
||||
}
|
||||
function appPress(btn, url) {
|
||||
var err = btn.parentNode.querySelector('.app-press-err');
|
||||
if (err) { err.style.display = 'none'; }
|
||||
|
||||
+10
@@ -611,6 +611,16 @@
|
||||
|
||||
<script>
|
||||
|
||||
|
||||
function plexClaim(btn) {
|
||||
var box = btn.closest('.plex-claim'), err = box.querySelector('.app-press-err');
|
||||
var code = (document.getElementById('plex-claim-code') || {}).value || '';
|
||||
err.style.display = 'none'; btn.disabled = true;
|
||||
fetch('/apps/plex/plex-claim', {method: 'POST', headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()), credentials: 'same-origin', body: JSON.stringify({code: code.trim()})})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (j) { if (!j.ok) { throw new Error(j.error || ''); } window.location.reload(); })
|
||||
.catch(function (e) { btn.disabled = false; err.textContent = e.message; err.style.display = 'inline'; });
|
||||
}
|
||||
function appPress(btn, url) {
|
||||
var err = btn.parentNode.querySelector('.app-press-err');
|
||||
if (err) { err.style.display = 'none'; }
|
||||
|
||||
@@ -599,6 +599,16 @@
|
||||
|
||||
<script>
|
||||
|
||||
|
||||
function plexClaim(btn) {
|
||||
var box = btn.closest('.plex-claim'), err = box.querySelector('.app-press-err');
|
||||
var code = (document.getElementById('plex-claim-code') || {}).value || '';
|
||||
err.style.display = 'none'; btn.disabled = true;
|
||||
fetch('/apps/plex/plex-claim', {method: 'POST', headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()), credentials: 'same-origin', body: JSON.stringify({code: code.trim()})})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (j) { if (!j.ok) { throw new Error(j.error || ''); } window.location.reload(); })
|
||||
.catch(function (e) { btn.disabled = false; err.textContent = e.message; err.style.display = 'inline'; });
|
||||
}
|
||||
function appPress(btn, url) {
|
||||
var err = btn.parentNode.querySelector('.app-press-err');
|
||||
if (err) { err.style.display = 'none'; }
|
||||
|
||||
@@ -0,0 +1,629 @@
|
||||
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="hu" class="no-js">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Plex — Felhom.eu</title>
|
||||
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=0.247.0">
|
||||
<link rel="stylesheet" href="/static/style.css?v=0.247.0">
|
||||
<meta name="csrf-token" content="tok">
|
||||
<script>
|
||||
|
||||
|
||||
document.documentElement.className=document.documentElement.className.replace('no-js','js');
|
||||
function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};}
|
||||
var fhEN=document.documentElement.lang==='en';
|
||||
function fhNum(v,prec){var s=Number(v).toFixed(prec);return fhEN?s:s.replace('.',',');}
|
||||
function fhIsoDate(iso){return fhEN?iso:iso.replace(/^(\d{4})-(\d{2})-(\d{2})/,'$1. $2. $3.');}
|
||||
function fhDateTime(d){return fhIsoDate(d.toLocaleString('sv-SE',{timeZone:'Europe/Budapest',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}));}
|
||||
function fhMonthDay(d){var s=d.toLocaleDateString('sv-SE',{timeZone:'Europe/Budapest',month:'2-digit',day:'2-digit'});return fhEN?s:s.replace(/^(\d{2})-(\d{2})$/,'$1. $2.');}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
function felhomConfirm(el,question,onYes){
|
||||
if(!el||el.dataset.fcOpen)return;
|
||||
el.dataset.fcOpen='1';
|
||||
var wrap=document.createElement('span');wrap.className='inline-confirm';
|
||||
var q=document.createElement('span');q.className='inline-confirm-q';q.textContent=question;
|
||||
var yes=document.createElement('button');yes.type='button';yes.className='btn btn-xs btn-danger';yes.textContent='Igen';
|
||||
var no=document.createElement('button');no.type='button';no.className='btn btn-xs btn-outline';no.textContent='Mégse';
|
||||
wrap.appendChild(q);wrap.appendChild(yes);wrap.appendChild(no);
|
||||
el.style.display='none';el.parentNode.insertBefore(wrap,el.nextSibling);
|
||||
function close(){wrap.remove();el.style.display='';delete el.dataset.fcOpen;}
|
||||
no.addEventListener('click',close);
|
||||
yes.addEventListener('click',function(){close();onYes();});
|
||||
}
|
||||
document.addEventListener('click',function(e){
|
||||
var btn=e.target.closest?e.target.closest('[data-confirm]'):null;
|
||||
if(!btn)return;
|
||||
e.preventDefault();
|
||||
felhomConfirm(btn,btn.getAttribute('data-confirm'),function(){
|
||||
var form=btn.closest('form');
|
||||
if(form){if(form.requestSubmit)form.requestSubmit(btn);else form.submit();}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
document.addEventListener('click',function(e){
|
||||
var btn=e.target.closest?e.target.closest('.nav-group-toggle'):null;
|
||||
if(!btn)return;
|
||||
var group=btn.closest('.nav-group');
|
||||
if(!group)return;
|
||||
var willOpen=!group.classList.contains('is-open');
|
||||
document.querySelectorAll('.nav-group.is-open').forEach(function(g){
|
||||
g.classList.remove('is-open');
|
||||
var t=g.querySelector('.nav-group-toggle');
|
||||
if(t)t.setAttribute('aria-expanded','false');
|
||||
});
|
||||
if(willOpen){group.classList.add('is-open');btn.setAttribute('aria-expanded','true');}
|
||||
});
|
||||
function showAlert(msg){var o=document.createElement('div');o.className='modal-overlay';o.id='alert-modal';o.addEventListener('click',function(e){if(e.target===o)o.remove();});var c=document.createElement('div');c.className='modal-card';c.innerHTML='<h3>Üzenet</h3><pre style="white-space:pre-wrap;word-break:break-word;background:var(--bg-2);padding:.75rem;border-radius:.375rem;font-size:.85rem;max-height:60vh;overflow-y:auto;user-select:text;cursor:text">'+msg.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')+'</pre><div class="modal-actions"><button class="btn btn-primary" onclick="document.getElementById(\'alert-modal\').remove()">OK</button></div>';o.appendChild(c);document.body.appendChild(o);}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" style="display:none" aria-hidden="true">
|
||||
<symbol id="i-hard-drive" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 16h.01" /> <path d="M2.212 11.577a2 2 0 0 0-.212.896V18a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-5.527a2 2 0 0 0-.212-.896L18.55 5.11A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" /> <path d="M21.946 12.013H2.054" /> <path d="M6 16h.01" /></symbol>
|
||||
<symbol id="i-cpu" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20v2" /> <path d="M12 2v2" /> <path d="M17 20v2" /> <path d="M17 2v2" /> <path d="M2 12h2" /> <path d="M2 17h2" /> <path d="M2 7h2" /> <path d="M20 12h2" /> <path d="M20 17h2" /> <path d="M20 7h2" /> <path d="M7 20v2" /> <path d="M7 2v2" /> <rect x="4" y="4" width="16" height="16" rx="2" /> <rect x="8" y="8" width="8" height="8" rx="1" /></symbol>
|
||||
<symbol id="i-memory-stick" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 12v-2" /> <path d="M12 18v-2" /> <path d="M16 12v-2" /> <path d="M16 18v-2" /> <path d="M2 11h1.5" /> <path d="M20 18v-2" /> <path d="M20.5 11H22" /> <path d="M4 18v-2" /> <path d="M8 12v-2" /> <path d="M8 18v-2" /> <rect x="2" y="6" width="20" height="10" rx="2" /></symbol>
|
||||
<symbol id="i-thermometer" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 4v10.54a4 4 0 1 1-4 0V4a2 2 0 0 1 4 0Z" /></symbol>
|
||||
<symbol id="i-triangle-alert" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3" /> <path d="M12 9v4" /> <path d="M12 17h.01" /></symbol>
|
||||
<symbol id="i-lock" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="11" x="3" y="11" rx="2" ry="2" /> <path d="M7 11V7a5 5 0 0 1 10 0v4" /></symbol>
|
||||
<symbol id="i-cloud" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z" /></symbol>
|
||||
<symbol id="i-square" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2" /></symbol>
|
||||
<symbol id="i-rotate-cw" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8" /> <path d="M21 3v5h-5" /></symbol>
|
||||
<symbol id="i-file-text" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z" /> <path d="M14 2v5a1 1 0 0 0 1 1h5" /> <path d="M10 9H8" /> <path d="M16 13H8" /> <path d="M16 17H8" /></symbol>
|
||||
<symbol id="i-external-link" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h6v6" /> <path d="M10 14 21 3" /> <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" /></symbol>
|
||||
<symbol id="i-shield" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" /></symbol>
|
||||
<symbol id="i-server" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="20" height="8" x="2" y="2" rx="2" ry="2" /> <rect width="20" height="8" x="2" y="14" rx="2" ry="2" /> <line x1="6" x2="6.01" y1="6" y2="6" /> <line x1="6" x2="6.01" y1="18" y2="18" /></symbol>
|
||||
<symbol id="i-share" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5" r="3" /> <circle cx="6" cy="12" r="3" /> <circle cx="18" cy="19" r="3" /> <line x1="8.59" x2="15.42" y1="13.51" y2="17.49" /> <line x1="15.41" x2="8.59" y1="6.51" y2="10.49" /></symbol>
|
||||
<symbol id="i-layout-grid" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="7" height="7" x="3" y="3" rx="1" /> <rect width="7" height="7" x="14" y="3" rx="1" /> <rect width="7" height="7" x="14" y="14" rx="1" /> <rect width="7" height="7" x="3" y="14" rx="1" /></symbol>
|
||||
<symbol id="i-rocket" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91 0z" /> <path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z" /> <path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0" /> <path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5" /></symbol>
|
||||
<symbol id="i-settings" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915" /> <circle cx="12" cy="12" r="3" /></symbol>
|
||||
<symbol id="i-bell" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.268 21a2 2 0 0 0 3.464 0" /> <path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326" /></symbol>
|
||||
<symbol id="i-x" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18" /> <path d="m6 6 12 12" /></symbol>
|
||||
<symbol id="i-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5" /></symbol>
|
||||
<symbol id="i-pencil" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" /> <path d="m15 5 4 4" /></symbol>
|
||||
<symbol id="i-info" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10" /> <path d="M12 16v-4" /> <path d="M12 8h.01" /></symbol>
|
||||
<symbol id="i-download" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 15V3" /> <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /> <path d="m7 10 5 5 5-5" /></symbol>
|
||||
<symbol id="i-upload" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12" /> <path d="m17 8-5-5-5 5" /> <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /></symbol>
|
||||
<symbol id="i-trash-2" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 11v6" /> <path d="M14 11v6" /> <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" /> <path d="M3 6h18" /> <path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /></symbol>
|
||||
<symbol id="i-wrench" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.106-3.105c.32-.322.863-.22.983.218a6 6 0 0 1-8.259 7.057l-7.91 7.91a1 1 0 0 1-2.999-3l7.91-7.91a6 6 0 0 1 7.057-8.259c.438.12.54.662.219.984z" /></symbol>
|
||||
<symbol id="i-link" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" /> <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" /></symbol>
|
||||
<symbol id="i-search" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21 21-4.34-4.34" /> <circle cx="11" cy="11" r="8" /></symbol>
|
||||
<symbol id="i-plus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14" /> <path d="M12 5v14" /></symbol>
|
||||
<symbol id="i-chevron-down" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6" /></symbol>
|
||||
<symbol id="i-play" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z" /></symbol>
|
||||
<symbol id="i-pause" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="14" y="3" width="5" height="18" rx="1" /> <rect x="5" y="3" width="5" height="18" rx="1" /></symbol>
|
||||
<symbol id="i-menu" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12h16" /> <path d="M4 6h16" /> <path d="M4 18h16" /></symbol>
|
||||
</svg>
|
||||
<header class="mobile-topbar">
|
||||
<a href="/" class="mobile-topbar-logo"><img src="/static/felhom-logo.svg?v=0.247.0" alt="Felhom.eu"></a>
|
||||
<button type="button" class="nav-burger" aria-expanded="false" aria-controls="sidebar" aria-label="Menü">
|
||||
<svg class="ico"><use href="#i-menu"/></svg>
|
||||
</button>
|
||||
</header>
|
||||
<div class="nav-backdrop" hidden></div>
|
||||
<nav class="sidebar" id="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<img src="/static/felhom-logo.svg?v=0.247.0" alt="Felhom.eu" class="sidebar-logo">
|
||||
</div>
|
||||
<ul class="nav-links">
|
||||
<li><a href="/launcher" class=""><svg class="ico"><use href="#i-rocket"/></svg>Indítópult</a></li>
|
||||
<li><a href="/dashboard" class=""><svg class="ico"><use href="#i-layout-grid"/></svg>Vezérlőpult</a></li>
|
||||
<li><a href="/stacks" class="active"><svg class="ico"><use href="#i-cloud"/></svg>Alkalmazások</a></li>
|
||||
|
||||
<li class="nav-group">
|
||||
<button type="button" class="nav-group-toggle" aria-expanded="false" aria-controls="nav-group-storage"><svg class="ico"><use href="#i-hard-drive"/></svg>Tárhely<svg class="ico nav-chevron"><use href="#i-chevron-down"/></svg></button>
|
||||
<ul id="nav-group-storage" class="nav-links nav-links-sub nav-links-nested">
|
||||
<li><a href="/storage" class="">Meghajtók</a></li>
|
||||
<li><a href="/storage/network" class="">Hálózati tárhely</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<li class="nav-group">
|
||||
<button type="button" class="nav-group-toggle" aria-expanded="false" aria-controls="nav-group-backups"><svg class="ico"><use href="#i-shield"/></svg>Biztonsági mentés<svg class="ico nav-chevron"><use href="#i-chevron-down"/></svg></button>
|
||||
<ul id="nav-group-backups" class="nav-links nav-links-sub nav-links-nested">
|
||||
<li><a href="/backups" class="">Áttekintés</a></li>
|
||||
<li><a href="/backups/remote" class="">Távoli mentés</a></li>
|
||||
<li><a href="/backups/apps" class="">Alkalmazások</a></li>
|
||||
<li><a href="/backups/restore" class="">Visszaállítás</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<li class="nav-group">
|
||||
<button type="button" class="nav-group-toggle" aria-expanded="false" aria-controls="nav-group-sharing"><svg class="ico"><use href="#i-share"/></svg>Megosztás<svg class="ico nav-chevron"><use href="#i-chevron-down"/></svg></button>
|
||||
<ul id="nav-group-sharing" class="nav-links nav-links-sub nav-links-nested">
|
||||
<li><a href="/sharing" class="">Hálózati megosztás</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><a href="/monitoring" class=""><svg class="ico"><use href="#i-cpu"/></svg>Rendszermonitor</a></li>
|
||||
<li><a href="/debug" class=""><svg class="ico"><use href="#i-wrench"/></svg>Hibakeresés</a></li>
|
||||
</ul>
|
||||
<div class="sidebar-bottom">
|
||||
<div class="nav-group-label">Beállítások</div>
|
||||
<ul class="nav-links nav-links-sub">
|
||||
<li><a href="/settings" class=""><svg class="ico"><use href="#i-settings"/></svg>Rendszer</a></li>
|
||||
<li><a href="/settings/notifications" class=""><svg class="ico"><use href="#i-bell"/></svg>Értesítések</a></li>
|
||||
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
|
||||
</ul>
|
||||
<div class="sidebar-footer">
|
||||
<span class="version">0.247.0</span><details class="lang-globe">
|
||||
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
|
||||
<ul class="lang-globe-menu">
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
|
||||
</ul>
|
||||
</details>
|
||||
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<main class="content">
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="page-header">
|
||||
<div style="display:flex;align-items:center;gap:1rem">
|
||||
<a href="/stacks" class="btn btn-sm btn-outline">← Alkalmazások</a>
|
||||
<h2>Plex</h2>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:.5rem">
|
||||
|
||||
<span class="stack-state-badge state-run">Fut</span>
|
||||
|
||||
|
||||
|
||||
|
||||
<a href="/stacks/plex/logs" class="btn btn-sm btn-outline">Napló</a>
|
||||
|
||||
<a href="/stacks/plex/export" class="btn btn-sm btn-outline">Exportálás</a>
|
||||
<a href="/stacks/plex/deploy" class="btn btn-sm btn-outline">Beállítások</a>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="app-info-hero">
|
||||
<img class="app-info-logo" src="/static/assets/plex-logo.svg"
|
||||
alt="Plex" data-fallback="/static/app-placeholder.svg"
|
||||
onerror="if(!this.dataset.step){this.dataset.step='1';this.src='\/static\/assets\/plex-logo.png';}else if(this.dataset.fallback&&this.dataset.step==='1'){this.dataset.step='2';this.src=this.dataset.fallback;}else{this.onerror=null;this.style.visibility='hidden';}">
|
||||
<div class="app-info-hero-text">
|
||||
|
||||
<p class="app-info-tagline"></p>
|
||||
|
||||
<div class="stack-meta-badges">
|
||||
<span class="meta-badge">~ RAM</span>
|
||||
<span class="meta-badge"></span>
|
||||
|
||||
<span class="meta-badge meta-badge-warn">Csak x86</span>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="app-screenshots" id="screenshots">
|
||||
<img src="/static/assets/plex-screenshot-1.webp" alt="" class="app-screenshot"
|
||||
onerror="this.style.display='none'">
|
||||
<img src="/static/assets/plex-screenshot-2.webp" alt="" class="app-screenshot"
|
||||
onerror="this.style.display='none'">
|
||||
<img src="/static/assets/plex-screenshot-3.webp" alt="" class="app-screenshot"
|
||||
onerror="this.style.display='none'">
|
||||
</div>
|
||||
<div class="app-info-card" style="margin-top:1rem" id="setup-gate-card">
|
||||
<h3>Első beállítás</h3>
|
||||
<p>Most csak te éred el ezt az alkalmazást, amíg be vagy jelentkezve a vezérlőpultba. Így más nem hozhatja létre az első admin fiókot. Nyisd meg, és végezd el az első beállítást.</p>
|
||||
<div class="plex-claim" data-plex-claim="true">
|
||||
<p>Nyisd meg a https://plex.tv/claim oldalt a Plex-fiókoddal, másold ki a kódot, és illeszd be ide. A kód 4 percig érvényes, de a Plex már fut, így nem kell sietned a telepítéssel.</p>
|
||||
<div class="input-with-button">
|
||||
<input type="text" id="plex-claim-code" class="form-control" placeholder="claim-xxxxxxxxxxxxxxxxxxxx" autocomplete="off" spellcheck="false">
|
||||
<button type="button" class="btn btn-sm btn-primary" id="plex-claim-btn" onclick="plexClaim(this)">Plex összekapcsolása</button>
|
||||
</div>
|
||||
<span class="form-hint app-press-err" style="display:none;color:var(--red)"></span>
|
||||
</div>
|
||||
<p class="form-hint">Ha kész, a doboz magától észreveszi, és mindenkinek megnyitja az alkalmazást.</p>
|
||||
<button type="button" class="btn btn-sm btn-outline" onclick="appPress(this, '/apps/plex/setup-gate/open')">Kész, beállítottam</button>
|
||||
<span class="form-hint app-press-err" style="display:none;color:var(--red)"></span>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</main>
|
||||
<script>
|
||||
|
||||
|
||||
|
||||
(function(){
|
||||
var burger=document.querySelector('.nav-burger');
|
||||
var sidebar=document.getElementById('sidebar');
|
||||
var backdrop=document.querySelector('.nav-backdrop');
|
||||
if(!burger||!sidebar||!backdrop)return;
|
||||
function setOpen(open){
|
||||
sidebar.classList.toggle('is-open',open);
|
||||
document.body.classList.toggle('nav-open',open);
|
||||
backdrop.hidden=!open;
|
||||
burger.setAttribute('aria-expanded',open?'true':'false');
|
||||
}
|
||||
burger.addEventListener('click',function(){setOpen(!sidebar.classList.contains('is-open'));});
|
||||
backdrop.addEventListener('click',function(){setOpen(false);});
|
||||
document.addEventListener('keydown',function(e){
|
||||
if(e.key==='Escape'&&sidebar.classList.contains('is-open'))setOpen(false);
|
||||
});
|
||||
})();
|
||||
document.addEventListener('click', function(e) {
|
||||
if (e.target.closest('a, button, .btn, input, select, textarea, .app-row-actions, .stack-detail-actions')) return;
|
||||
var card = e.target.closest('[data-href]');
|
||||
if (card) window.location.href = card.dataset.href;
|
||||
});
|
||||
async function checkBeforeDeploy(e, name) {
|
||||
try {
|
||||
var resp = await fetch('/api/stacks/' + name);
|
||||
var data = await resp.json();
|
||||
if (data.ok && data.data && data.data.deployed) {
|
||||
e.preventDefault();
|
||||
showAlert('Ez az alkalmazás már telepítve van.');
|
||||
window.location.reload();
|
||||
return false;
|
||||
}
|
||||
} catch(err) {}
|
||||
return true;
|
||||
}
|
||||
async function syncTemplates() {
|
||||
const btn = document.getElementById('sync-btn');
|
||||
const toast = document.getElementById('sync-toast');
|
||||
if (!btn) return;
|
||||
const origText = btn.innerHTML;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '↻ Frissítés...';
|
||||
btn.classList.add('loading');
|
||||
try {
|
||||
const resp = await fetch('/api/sync', {
|
||||
method: 'POST',
|
||||
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders())
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (toast) {
|
||||
toast.textContent = data.ok ? (data.message || 'Sablonok frissítve') : ('Hiba: ' + (data.error || 'Ismeretlen hiba'));
|
||||
toast.className = 'sync-toast ' + (data.ok ? 'sync-toast-ok' : 'sync-toast-err');
|
||||
toast.style.display = 'block';
|
||||
setTimeout(function() { toast.style.display = 'none'; }, 5000);
|
||||
}
|
||||
if (data.ok && data.data && (data.data.new_apps && data.data.new_apps.length > 0 || data.data.updated && data.data.updated.length > 0)) {
|
||||
setTimeout(function() { window.location.reload(); }, 1500);
|
||||
}
|
||||
} catch (err) {
|
||||
if (toast) {
|
||||
toast.textContent = 'Hálózati hiba: ' + err.message;
|
||||
toast.className = 'sync-toast sync-toast-err';
|
||||
toast.style.display = 'block';
|
||||
setTimeout(function() { toast.style.display = 'none'; }, 5000);
|
||||
}
|
||||
}
|
||||
btn.innerHTML = origText;
|
||||
btn.disabled = false;
|
||||
btn.classList.remove('loading');
|
||||
}
|
||||
async function stackAction(event, name, action) {
|
||||
const btn = event.currentTarget;
|
||||
const origText = btn.textContent;
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Folyamatban...';
|
||||
btn.classList.add('loading');
|
||||
try {
|
||||
const resp = await fetch('/api/stacks/' + name + '/' + action, {
|
||||
method: 'POST',
|
||||
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders())
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (!data.ok) {
|
||||
showAlert('Hiba: ' + (data.error || 'Ismeretlen hiba'));
|
||||
btn.textContent = origText;
|
||||
btn.disabled = false;
|
||||
btn.classList.remove('loading');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
|
||||
if (action === 'update') {
|
||||
followUpdate(name, btn);
|
||||
return;
|
||||
}
|
||||
window.location.reload();
|
||||
} catch (err) {
|
||||
showAlert('Hálózati hiba: ' + err.message);
|
||||
btn.textContent = origText;
|
||||
btn.disabled = false;
|
||||
btn.classList.remove('loading');
|
||||
}
|
||||
}
|
||||
function followUpdate(name, btn) {
|
||||
var started = Date.now();
|
||||
var timer = setInterval(async function() {
|
||||
if (Date.now() - started > 30 * 60 * 1000) { clearInterval(timer); window.location.reload(); return; }
|
||||
try {
|
||||
var r = await fetch('/api/stacks/' + name);
|
||||
var d = await r.json();
|
||||
if (!d.ok || !d.data) return;
|
||||
if (d.data.update_phase_label) btn.textContent = d.data.update_phase_label;
|
||||
if (!d.data.updating) { clearInterval(timer); window.location.reload(); }
|
||||
} catch (e) {}
|
||||
}, 3000);
|
||||
}
|
||||
async function deleteOrphanStack(name) {
|
||||
var modal = document.createElement('div');
|
||||
modal.className = 'modal-overlay';
|
||||
modal.id = 'delete-modal';
|
||||
modal.innerHTML = '<div class="modal-card"><h3>Betöltés...</h3></div>';
|
||||
modal.addEventListener('click', function(e) { if (e.target === modal) closeDeleteModal(); });
|
||||
document.body.appendChild(modal);
|
||||
try {
|
||||
var resp = await fetch('/api/stacks/' + name + '/hdd-data');
|
||||
var data = await resp.json();
|
||||
var hddInfo = '';
|
||||
var checkboxHTML = '';
|
||||
if (data.ok && data.data && data.data.has_hdd_data) {
|
||||
hddInfo = '<div class="modal-hdd-info"><strong>Felhasználói adatok a merevlemezen:</strong>';
|
||||
data.data.hdd_paths.forEach(function(p) {
|
||||
hddInfo += '<div class="modal-hdd-path">' + p.path + ' (' + (p.exists ? p.size_human : 'nem létezik') + ')</div>';
|
||||
});
|
||||
hddInfo += '</div>';
|
||||
checkboxHTML = '<label class="modal-checkbox"><input type="checkbox" id="delete-hdd-check"> Felhasználói adatok törlése a merevlemezről</label>';
|
||||
}
|
||||
|
||||
if (data.ok && data.data && data.data.userdata_kept && data.data.userdata_kept.length > 0) {
|
||||
var udPaths = '';
|
||||
data.data.userdata_kept.forEach(function(p) { udPaths += '<div class="modal-hdd-path">' + p.path + ' (' + p.size_human + ')</div>'; });
|
||||
hddInfo += '<div class="modal-section" data-userdata-kept="true"><strong>A fájljaid ezekben a mappákban megmaradnak — a fájlböngészőben látod őket:</strong>' + udPaths + '</div>';
|
||||
}
|
||||
modal.querySelector('.modal-card').innerHTML =
|
||||
'<h3>Alkalmazás törlése: ' + name + '</h3>' +
|
||||
'<p style="color:var(--text-2);font-size:.9rem;margin-bottom:.75rem">Ez a művelet eltávolítja a konténereket, a köteteket és a konfigurációs fájlokat.</p>' +
|
||||
'<div class="alert alert-warning" style="margin-bottom:.75rem">Ez a művelet nem visszavonható!</div>' +
|
||||
hddInfo + checkboxHTML +
|
||||
'<div class="modal-actions">' +
|
||||
'<button class="btn btn-outline" onclick="closeDeleteModal()">Mégsem</button>' +
|
||||
'<button class="btn btn-danger" id="confirm-delete-btn" onclick="confirmDelete(\'' + name + '\')">Törlés</button>' +
|
||||
'</div>';
|
||||
} catch (err) {
|
||||
modal.querySelector('.modal-card').innerHTML =
|
||||
'<h3>Hiba</h3><p style="color:var(--text-2)">Nem sikerült lekérni az adatokat: ' + err.message + '</p>' +
|
||||
'<div class="modal-actions"><button class="btn btn-outline" onclick="closeDeleteModal()">Bezárás</button></div>';
|
||||
}
|
||||
}
|
||||
function closeDeleteModal() {
|
||||
var modal = document.getElementById('delete-modal');
|
||||
if (modal) modal.remove();
|
||||
}
|
||||
async function confirmDelete(name) {
|
||||
var btn = document.getElementById('confirm-delete-btn');
|
||||
var checkbox = document.getElementById('delete-hdd-check');
|
||||
var removeHDD = checkbox ? checkbox.checked : false;
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Törlés folyamatban...';
|
||||
try {
|
||||
var resp = await fetch('/api/stacks/' + name, {
|
||||
method: 'DELETE',
|
||||
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
|
||||
body: JSON.stringify({remove_hdd_data: removeHDD})
|
||||
});
|
||||
var data = await resp.json();
|
||||
if (data.ok) {
|
||||
var modal = document.getElementById('delete-modal');
|
||||
var removedInfo = '';
|
||||
if (data.data && data.data.hdd_paths_removed && data.data.hdd_paths_removed.length > 0) {
|
||||
removedInfo = '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">Törölt adatok: ' + data.data.hdd_paths_removed.join(', ') + '</p>';
|
||||
}
|
||||
var preservedInfo = '';
|
||||
if (data.data && data.data.hdd_paths_preserved && data.data.hdd_paths_preserved.length > 0) {
|
||||
preservedInfo = '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">Megőrzött adatok: ' + data.data.hdd_paths_preserved.join(', ') + '</p>';
|
||||
}
|
||||
if (data.data && data.data.hdd_note) {
|
||||
preservedInfo += '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">' + data.data.hdd_note + '</p>';
|
||||
}
|
||||
if (data.data && data.data.userdata_kept && data.data.userdata_kept.length > 0) {
|
||||
preservedInfo += '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem" data-userdata-kept="true">A fájljaid ezekben a mappákban megmaradnak — a fájlböngészőben látod őket: ' + data.data.userdata_kept.join(', ') + '</p>';
|
||||
}
|
||||
modal.querySelector('.modal-card').innerHTML =
|
||||
'<h3>Sikeresen törölve!</h3>' +
|
||||
'<p style="color:var(--text-2)">Az alkalmazás (' + name + ') törölve lett.</p>' +
|
||||
removedInfo + preservedInfo +
|
||||
'<div class="modal-actions"><button class="btn btn-primary" onclick="window.location.href=\'/stacks\'">Bezárás</button></div>';
|
||||
} else {
|
||||
showAlert('Hiba: ' + (data.error || 'Ismeretlen hiba'));
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Törlés';
|
||||
}
|
||||
} catch (err) {
|
||||
showAlert('Hálózati hiba: ' + err.message);
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Törlés';
|
||||
}
|
||||
}
|
||||
async function removeStack(name) {
|
||||
var modal = document.createElement('div');
|
||||
modal.className = 'modal-overlay';
|
||||
modal.id = 'remove-modal';
|
||||
modal.innerHTML = '<div class="modal-card"><h3>Betöltés...</h3></div>';
|
||||
modal.addEventListener('click', function(e) { if (e.target === modal) closeRemoveModal(); });
|
||||
document.body.appendChild(modal);
|
||||
try {
|
||||
var [hddResp, backupResp] = await Promise.all([
|
||||
fetch('/api/stacks/' + name + '/hdd-data').then(function(r) { return r.json(); }),
|
||||
fetch('/api/stacks/' + name + '/backup-data').then(function(r) { return r.json(); })
|
||||
]);
|
||||
var sections = '';
|
||||
|
||||
var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only;
|
||||
if (keepOnly) {
|
||||
sections += '<div class="alert alert-info" style="margin-bottom:.75rem" data-remove-keep-only="true">Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.</div>';
|
||||
}
|
||||
|
||||
sections += '<div class="modal-section">' +
|
||||
'<strong>Mindig törlődik:</strong>' +
|
||||
'<ul style="margin:.25rem 0;padding-left:1.2rem;color:var(--text-2);font-size:.85rem">' +
|
||||
'<li>Docker kötetek (adatbázis, alkalmazás konfiguráció)</li>' +
|
||||
'<li>Telepítési konfiguráció (app.yaml)</li>' +
|
||||
'<li>Másodlagos mentés ütemezése</li>' +
|
||||
'</ul></div>';
|
||||
|
||||
var hddCheckbox = '';
|
||||
if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) {
|
||||
var hddPaths = '';
|
||||
hddResp.data.hdd_paths.forEach(function(p) {
|
||||
hddPaths += '<div class="modal-hdd-path">' + p.path + ' (' + (p.exists ? p.size_human : 'nem létezik') + ')</div>';
|
||||
});
|
||||
sections += '<div class="modal-section">' +
|
||||
'<strong>Felhasználói adatok a merevlemezen:</strong>' + hddPaths +
|
||||
'<label class="modal-checkbox"><input type="checkbox" id="remove-hdd-check"> Felhasználói adatok törlése</label>' +
|
||||
'<div class="alert alert-info" style="margin-top:.5rem;font-size:.8rem" id="remove-hdd-keep-warning">' +
|
||||
'Ha újratelepíted az alkalmazást, az adatokat újra importálnod kell, mivel az adatbázis törlődik. A megtartott adatok a továbbiakban NEM lesznek automatikusan mentve.' +
|
||||
'</div></div>';
|
||||
hddCheckbox = '\x3Cscript>document.getElementById("remove-hdd-check").addEventListener("change",function(){document.getElementById("remove-hdd-keep-warning").style.display=this.checked?"none":"";});<\/script>';
|
||||
}
|
||||
|
||||
var backupCheckbox = '';
|
||||
if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) {
|
||||
var bkPaths = '';
|
||||
backupResp.data.backup_paths.forEach(function(p) {
|
||||
if (p.exists) bkPaths += '<div class="modal-hdd-path">' + p.path + ' (' + p.size_human + ')</div>';
|
||||
});
|
||||
if (bkPaths) {
|
||||
sections += '<div class="modal-section">' +
|
||||
'<strong>Mentési adatok:</strong>' + bkPaths +
|
||||
'<label class="modal-checkbox"><input type="checkbox" id="remove-backup-check"> Mentési adatok törlése</label>' +
|
||||
'<div class="alert alert-info" style="margin-top:.5rem;font-size:.8rem">' +
|
||||
'Az éjszakai restic pillanatképek nem törölhetők egyenként — a megőrzési szabályok szerint automatikusan elavulnak.' +
|
||||
'</div></div>';
|
||||
}
|
||||
}
|
||||
|
||||
if (hddResp.ok && hddResp.data && hddResp.data.userdata_kept && hddResp.data.userdata_kept.length > 0) {
|
||||
var udPaths = '';
|
||||
hddResp.data.userdata_kept.forEach(function(p) { udPaths += '<div class="modal-hdd-path">' + p.path + ' (' + p.size_human + ')</div>'; });
|
||||
sections += '<div class="modal-section" data-userdata-kept="true"><strong>A fájljaid ezekben a mappákban megmaradnak — a fájlböngészőben látod őket:</strong>' + udPaths + '</div>';
|
||||
}
|
||||
modal.querySelector('.modal-card').innerHTML =
|
||||
'<h3>Alkalmazás eltávolítása: ' + name + '</h3>' +
|
||||
'<p style="color:var(--text-2);font-size:.9rem;margin-bottom:.75rem">Az alkalmazás visszaáll "Nincs telepítve" állapotba. A sablon megmarad, újratelepíthető.</p>' +
|
||||
'<div class="alert alert-warning" style="margin-bottom:.75rem">Ez a művelet nem visszavonható!</div>' +
|
||||
sections +
|
||||
'<div class="modal-actions">' +
|
||||
'<button class="btn btn-outline" onclick="closeRemoveModal()">Mégsem</button>' +
|
||||
'<button class="btn btn-danger" id="confirm-remove-btn" onclick="confirmRemoveStack(\'' + name + '\')">Eltávolítás</button>' +
|
||||
'</div>' + hddCheckbox;
|
||||
} catch (err) {
|
||||
modal.querySelector('.modal-card').innerHTML =
|
||||
'<h3>Hiba</h3><p style="color:var(--text-2)">Nem sikerült lekérni az adatokat: ' + err.message + '</p>' +
|
||||
'<div class="modal-actions"><button class="btn btn-outline" onclick="closeRemoveModal()">Bezárás</button></div>';
|
||||
}
|
||||
}
|
||||
function closeRemoveModal() {
|
||||
var modal = document.getElementById('remove-modal');
|
||||
if (modal) modal.remove();
|
||||
}
|
||||
async function confirmRemoveStack(name) {
|
||||
var btn = document.getElementById('confirm-remove-btn');
|
||||
var hddCheck = document.getElementById('remove-hdd-check');
|
||||
var backupCheck = document.getElementById('remove-backup-check');
|
||||
var removeHDD = hddCheck ? hddCheck.checked : false;
|
||||
var removeBackups = backupCheck ? backupCheck.checked : false;
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Eltávolítás folyamatban...';
|
||||
try {
|
||||
var resp = await fetch('/api/stacks/' + name + '/remove', {
|
||||
method: 'POST',
|
||||
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
|
||||
body: JSON.stringify({remove_hdd_data: removeHDD, remove_backups: removeBackups})
|
||||
});
|
||||
var data = await resp.json();
|
||||
if (data.ok) {
|
||||
var modal = document.getElementById('remove-modal');
|
||||
var removedInfo = '';
|
||||
if (data.data && data.data.hdd_paths_removed && data.data.hdd_paths_removed.length > 0) {
|
||||
removedInfo += '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">Törölt adatok: ' + data.data.hdd_paths_removed.join(', ') + '</p>';
|
||||
}
|
||||
if (data.data && data.data.backup_paths_removed && data.data.backup_paths_removed.length > 0) {
|
||||
removedInfo += '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">Törölt mentések: ' + data.data.backup_paths_removed.join(', ') + '</p>';
|
||||
}
|
||||
var preservedInfo = '';
|
||||
if (data.data && data.data.hdd_paths_preserved && data.data.hdd_paths_preserved.length > 0) {
|
||||
preservedInfo = '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">Megőrzött adatok: ' + data.data.hdd_paths_preserved.join(', ') + '</p>';
|
||||
}
|
||||
if (data.data && data.data.hdd_note) {
|
||||
preservedInfo += '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">' + data.data.hdd_note + '</p>';
|
||||
}
|
||||
if (data.data && data.data.userdata_kept && data.data.userdata_kept.length > 0) {
|
||||
preservedInfo += '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem" data-userdata-kept="true">A fájljaid ezekben a mappákban megmaradnak — a fájlböngészőben látod őket: ' + data.data.userdata_kept.join(', ') + '</p>';
|
||||
}
|
||||
if (data.data && data.data.backup_paths_refused && data.data.backup_paths_refused.length > 0) {
|
||||
preservedInfo += '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">Nem törölt mentések: ' + data.data.backup_paths_refused.join('; ') + '</p>';
|
||||
}
|
||||
modal.querySelector('.modal-card').innerHTML =
|
||||
'<h3>Sikeresen eltávolítva!</h3>' +
|
||||
'<p style="color:var(--text-2)">Az alkalmazás (' + name + ') eltávolítva. Újratelepíthető a Telepítés gombbal.</p>' +
|
||||
removedInfo + preservedInfo +
|
||||
'<div class="modal-actions"><button class="btn btn-primary" onclick="window.location.href=\'/stacks\'">Bezárás</button></div>';
|
||||
} else {
|
||||
showAlert('Hiba: ' + (data.error || 'Ismeretlen hiba'));
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Eltávolítás';
|
||||
}
|
||||
} catch (err) {
|
||||
showAlert('Hálózati hiba: ' + err.message);
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Eltávolítás';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
<script>
|
||||
|
||||
|
||||
function plexClaim(btn) {
|
||||
var box = btn.closest('.plex-claim'), err = box.querySelector('.app-press-err');
|
||||
var code = (document.getElementById('plex-claim-code') || {}).value || '';
|
||||
err.style.display = 'none'; btn.disabled = true;
|
||||
fetch('/apps/plex/plex-claim', {method: 'POST', headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()), credentials: 'same-origin', body: JSON.stringify({code: code.trim()})})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (j) { if (!j.ok) { throw new Error(j.error || ''); } window.location.reload(); })
|
||||
.catch(function (e) { btn.disabled = false; err.textContent = e.message; err.style.display = 'inline'; });
|
||||
}
|
||||
function appPress(btn, url) {
|
||||
var err = btn.parentNode.querySelector('.app-press-err');
|
||||
if (err) { err.style.display = 'none'; }
|
||||
btn.disabled = true;
|
||||
fetch(url, {method: 'POST', headers: csrfHeaders(), credentials: 'same-origin'})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (j) {
|
||||
if (!j.ok) { throw new Error(j.error || ''); }
|
||||
window.location.reload();
|
||||
})
|
||||
.catch(function (e) { btn.disabled = false; if (err) { err.textContent = e.message; err.style.display = 'inline'; } });
|
||||
}
|
||||
</script>
|
||||
@@ -596,6 +596,16 @@
|
||||
|
||||
<script>
|
||||
|
||||
|
||||
function plexClaim(btn) {
|
||||
var box = btn.closest('.plex-claim'), err = box.querySelector('.app-press-err');
|
||||
var code = (document.getElementById('plex-claim-code') || {}).value || '';
|
||||
err.style.display = 'none'; btn.disabled = true;
|
||||
fetch('/apps/plex/plex-claim', {method: 'POST', headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()), credentials: 'same-origin', body: JSON.stringify({code: code.trim()})})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (j) { if (!j.ok) { throw new Error(j.error || ''); } window.location.reload(); })
|
||||
.catch(function (e) { btn.disabled = false; err.textContent = e.message; err.style.display = 'inline'; });
|
||||
}
|
||||
function appPress(btn, url) {
|
||||
var err = btn.parentNode.querySelector('.app-press-err');
|
||||
if (err) { err.style.display = 'none'; }
|
||||
|
||||
@@ -597,6 +597,16 @@
|
||||
|
||||
<script>
|
||||
|
||||
|
||||
function plexClaim(btn) {
|
||||
var box = btn.closest('.plex-claim'), err = box.querySelector('.app-press-err');
|
||||
var code = (document.getElementById('plex-claim-code') || {}).value || '';
|
||||
err.style.display = 'none'; btn.disabled = true;
|
||||
fetch('/apps/plex/plex-claim', {method: 'POST', headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()), credentials: 'same-origin', body: JSON.stringify({code: code.trim()})})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (j) { if (!j.ok) { throw new Error(j.error || ''); } window.location.reload(); })
|
||||
.catch(function (e) { btn.disabled = false; err.textContent = e.message; err.style.display = 'inline'; });
|
||||
}
|
||||
function appPress(btn, url) {
|
||||
var err = btn.parentNode.querySelector('.app-press-err');
|
||||
if (err) { err.style.display = 'none'; }
|
||||
|
||||
@@ -596,6 +596,16 @@
|
||||
|
||||
<script>
|
||||
|
||||
|
||||
function plexClaim(btn) {
|
||||
var box = btn.closest('.plex-claim'), err = box.querySelector('.app-press-err');
|
||||
var code = (document.getElementById('plex-claim-code') || {}).value || '';
|
||||
err.style.display = 'none'; btn.disabled = true;
|
||||
fetch('/apps/plex/plex-claim', {method: 'POST', headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()), credentials: 'same-origin', body: JSON.stringify({code: code.trim()})})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (j) { if (!j.ok) { throw new Error(j.error || ''); } window.location.reload(); })
|
||||
.catch(function (e) { btn.disabled = false; err.textContent = e.message; err.style.display = 'inline'; });
|
||||
}
|
||||
function appPress(btn, url) {
|
||||
var err = btn.parentNode.querySelector('.app-press-err');
|
||||
if (err) { err.style.display = 'none'; }
|
||||
|
||||
@@ -596,6 +596,16 @@
|
||||
|
||||
<script>
|
||||
|
||||
|
||||
function plexClaim(btn) {
|
||||
var box = btn.closest('.plex-claim'), err = box.querySelector('.app-press-err');
|
||||
var code = (document.getElementById('plex-claim-code') || {}).value || '';
|
||||
err.style.display = 'none'; btn.disabled = true;
|
||||
fetch('/apps/plex/plex-claim', {method: 'POST', headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()), credentials: 'same-origin', body: JSON.stringify({code: code.trim()})})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (j) { if (!j.ok) { throw new Error(j.error || ''); } window.location.reload(); })
|
||||
.catch(function (e) { btn.disabled = false; err.textContent = e.message; err.style.display = 'inline'; });
|
||||
}
|
||||
function appPress(btn, url) {
|
||||
var err = btn.parentNode.querySelector('.app-press-err');
|
||||
if (err) { err.style.display = 'none'; }
|
||||
|
||||
@@ -598,6 +598,16 @@
|
||||
|
||||
<script>
|
||||
|
||||
|
||||
function plexClaim(btn) {
|
||||
var box = btn.closest('.plex-claim'), err = box.querySelector('.app-press-err');
|
||||
var code = (document.getElementById('plex-claim-code') || {}).value || '';
|
||||
err.style.display = 'none'; btn.disabled = true;
|
||||
fetch('/apps/plex/plex-claim', {method: 'POST', headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()), credentials: 'same-origin', body: JSON.stringify({code: code.trim()})})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (j) { if (!j.ok) { throw new Error(j.error || ''); } window.location.reload(); })
|
||||
.catch(function (e) { btn.disabled = false; err.textContent = e.message; err.style.display = 'inline'; });
|
||||
}
|
||||
function appPress(btn, url) {
|
||||
var err = btn.parentNode.querySelector('.app-press-err');
|
||||
if (err) { err.style.display = 'none'; }
|
||||
|
||||
+10
@@ -597,6 +597,16 @@
|
||||
|
||||
<script>
|
||||
|
||||
|
||||
function plexClaim(btn) {
|
||||
var box = btn.closest('.plex-claim'), err = box.querySelector('.app-press-err');
|
||||
var code = (document.getElementById('plex-claim-code') || {}).value || '';
|
||||
err.style.display = 'none'; btn.disabled = true;
|
||||
fetch('/apps/plex/plex-claim', {method: 'POST', headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()), credentials: 'same-origin', body: JSON.stringify({code: code.trim()})})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (j) { if (!j.ok) { throw new Error(j.error || ''); } window.location.reload(); })
|
||||
.catch(function (e) { btn.disabled = false; err.textContent = e.message; err.style.display = 'inline'; });
|
||||
}
|
||||
function appPress(btn, url) {
|
||||
var err = btn.parentNode.querySelector('.app-press-err');
|
||||
if (err) { err.style.display = 'none'; }
|
||||
|
||||
@@ -177,6 +177,74 @@
|
||||
<span class="domain-badge">example.hu</span>
|
||||
</div>
|
||||
|
||||
<div id="media-notice" class="settings-card media-notice" hidden>
|
||||
<h3>A médiaalkalmazásoknak újra kell indulniuk</h3>
|
||||
<p class="settings-card-desc">Egy meghajtó megváltozott. A médiaalkalmazások csak újraindítás után látják a változást.</p>
|
||||
<ul id="media-notice-drives" class="media-notice-list"></ul>
|
||||
<p id="media-notice-apps" class="form-hint"></p>
|
||||
<p id="media-notice-stale" class="form-hint" hidden></p>
|
||||
<p id="media-notice-later" class="form-hint" hidden></p>
|
||||
<div class="media-notice-actions">
|
||||
<button type="button" class="btn btn-sm btn-primary" id="media-restart-now">Újraindítás most</button>
|
||||
<button type="button" class="btn btn-sm btn-outline" id="media-restart-later">Később (ma éjjel 3:30)</button>
|
||||
</div>
|
||||
<div id="media-notice-out"></div>
|
||||
<div id="media-wiring" hidden>
|
||||
<h4>Meghajtók bekötése az alkalmazásokba</h4>
|
||||
<ul id="media-wiring-list" class="media-notice-list"></ul>
|
||||
<p id="media-wiring-hint" class="form-hint" hidden>Jellyfin: Vezérlőpult → API-kulcsok → +, a kulcs neve: felhom. Utána nyomd meg a „Bekötés most” gombot.</p>
|
||||
<button type="button" class="btn btn-sm btn-outline" id="media-wire-now">Bekötés most</button>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function(){
|
||||
var box=document.getElementById('media-notice'); if(!box) return;
|
||||
var csrf=(document.querySelector('meta[name="csrf-token"]')||{}).content||'';
|
||||
var L={added:"hozzáadva",removed:"eltávolítva",returned:"visszatért",
|
||||
yes:"az alkalmazások azóta újraindultak: igen",no:"az alkalmazások azóta újraindultak: nem",apps:"Érintett alkalmazások:",stale:"Még egy halott meghajtót tart:",
|
||||
later:"Ütemezve:",streams:"Most is megy egy lejátszás. Az újraindítás megszakítja. Biztosan újraindítod?",done:"Újraindítva. A meghajtók bekötése egy-két perc múlva kész.",
|
||||
err:"Hiba:",household:"egy lépés kell tőled"};
|
||||
function esc(s){return String(s==null?'':s).replace(/[&<>"]/g,function(c){return {'&':'&','<':'<','>':'>','"':'"'}[c];});}
|
||||
function li(t){var e=document.createElement('li'); e.textContent=t; return e;}
|
||||
async function load(){
|
||||
try{
|
||||
var r=await fetch('/api/storage/media/status',{credentials:'same-origin'}); var j=await r.json(); if(!j.ok||!j.data) return;
|
||||
var d=j.data; if(!d.apps||!d.apps.length) return;
|
||||
var ul=document.getElementById('media-notice-drives'); ul.innerHTML='';
|
||||
(d.drives||[]).forEach(function(x){ ul.appendChild(li(x.label+' — '+(L[x.change]||x.change)+' — '+(x.restarted?L.yes:L.no))); });
|
||||
document.getElementById('media-notice-apps').textContent=L.apps+' '+d.apps.join(', ');
|
||||
var st=document.getElementById('media-notice-stale'); if(d.stale&&d.stale.length){st.textContent=L.stale+' '+d.stale.join(', '); st.hidden=false;}
|
||||
var la=document.getElementById('media-notice-later'); if(d.later_at){la.textContent=L.later+' '+d.later_at; la.hidden=false;}
|
||||
if(d.wiring&&d.wiring.length){
|
||||
var wl=document.getElementById('media-wiring-list'); wl.innerHTML=''; var hh=false;
|
||||
d.wiring.forEach(function(x){ if(x.app==='folders') return; var t=x.app+': '+(x.state==='household'?L.household:x.state)+(x.detail&&x.state==='error'?' ('+x.detail+')':''); wl.appendChild(li(t)); if(x.state==='household'&&x.app==='jellyfin') hh=true; });
|
||||
document.getElementById('media-wiring-hint').hidden=!hh; document.getElementById('media-wiring').hidden=false;
|
||||
}
|
||||
var show=d.pending||!!(d.wiring&&d.wiring.some(function(x){return x.state==='household'||x.state==='error';}));
|
||||
document.querySelector('.media-notice-actions').hidden=!d.pending;
|
||||
box.hidden=!show;
|
||||
}catch(e){}
|
||||
}
|
||||
async function post(url,body){
|
||||
var r=await fetch(url,{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf},body:JSON.stringify(body||{})});
|
||||
var j={}; try{j=await r.json();}catch(e){} return {status:r.status,j:j};
|
||||
}
|
||||
var out=document.getElementById('media-notice-out');
|
||||
async function restart(force){
|
||||
out.innerHTML='';
|
||||
var res=await post('/api/storage/media/restart',{when:'now',force:!!force});
|
||||
if(res.status===409){ felhomConfirm(document.getElementById('media-restart-now'), L.streams, function(){ restart(true); }); return; }
|
||||
out.innerHTML=res.j.ok?'<div class="alert alert-info">'+esc(L.done)+'</div>':'<div class="alert alert-error">'+esc(L.err)+' '+esc(res.j.error||((res.j.data||{}).failed||[]).join(', '))+'</div>';
|
||||
load();
|
||||
}
|
||||
document.getElementById('media-restart-now').addEventListener('click',function(){restart(false);});
|
||||
document.getElementById('media-restart-later').addEventListener('click',async function(){ await post('/api/storage/media/restart',{when:'later'}); load(); });
|
||||
document.getElementById('media-wire-now').addEventListener('click',async function(){ await post('/api/storage/media/wire',{}); load(); });
|
||||
load();
|
||||
})();
|
||||
</script>
|
||||
|
||||
|
||||
<div class="stats-grid">
|
||||
<a href="/stacks?filter=running" class="stat-card">
|
||||
<div class="stat-value">1</div>
|
||||
|
||||
@@ -177,6 +177,74 @@
|
||||
<span class="domain-badge">example.hu</span>
|
||||
</div>
|
||||
|
||||
<div id="media-notice" class="settings-card media-notice" hidden>
|
||||
<h3>A médiaalkalmazásoknak újra kell indulniuk</h3>
|
||||
<p class="settings-card-desc">Egy meghajtó megváltozott. A médiaalkalmazások csak újraindítás után látják a változást.</p>
|
||||
<ul id="media-notice-drives" class="media-notice-list"></ul>
|
||||
<p id="media-notice-apps" class="form-hint"></p>
|
||||
<p id="media-notice-stale" class="form-hint" hidden></p>
|
||||
<p id="media-notice-later" class="form-hint" hidden></p>
|
||||
<div class="media-notice-actions">
|
||||
<button type="button" class="btn btn-sm btn-primary" id="media-restart-now">Újraindítás most</button>
|
||||
<button type="button" class="btn btn-sm btn-outline" id="media-restart-later">Később (ma éjjel 3:30)</button>
|
||||
</div>
|
||||
<div id="media-notice-out"></div>
|
||||
<div id="media-wiring" hidden>
|
||||
<h4>Meghajtók bekötése az alkalmazásokba</h4>
|
||||
<ul id="media-wiring-list" class="media-notice-list"></ul>
|
||||
<p id="media-wiring-hint" class="form-hint" hidden>Jellyfin: Vezérlőpult → API-kulcsok → +, a kulcs neve: felhom. Utána nyomd meg a „Bekötés most” gombot.</p>
|
||||
<button type="button" class="btn btn-sm btn-outline" id="media-wire-now">Bekötés most</button>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function(){
|
||||
var box=document.getElementById('media-notice'); if(!box) return;
|
||||
var csrf=(document.querySelector('meta[name="csrf-token"]')||{}).content||'';
|
||||
var L={added:"hozzáadva",removed:"eltávolítva",returned:"visszatért",
|
||||
yes:"az alkalmazások azóta újraindultak: igen",no:"az alkalmazások azóta újraindultak: nem",apps:"Érintett alkalmazások:",stale:"Még egy halott meghajtót tart:",
|
||||
later:"Ütemezve:",streams:"Most is megy egy lejátszás. Az újraindítás megszakítja. Biztosan újraindítod?",done:"Újraindítva. A meghajtók bekötése egy-két perc múlva kész.",
|
||||
err:"Hiba:",household:"egy lépés kell tőled"};
|
||||
function esc(s){return String(s==null?'':s).replace(/[&<>"]/g,function(c){return {'&':'&','<':'<','>':'>','"':'"'}[c];});}
|
||||
function li(t){var e=document.createElement('li'); e.textContent=t; return e;}
|
||||
async function load(){
|
||||
try{
|
||||
var r=await fetch('/api/storage/media/status',{credentials:'same-origin'}); var j=await r.json(); if(!j.ok||!j.data) return;
|
||||
var d=j.data; if(!d.apps||!d.apps.length) return;
|
||||
var ul=document.getElementById('media-notice-drives'); ul.innerHTML='';
|
||||
(d.drives||[]).forEach(function(x){ ul.appendChild(li(x.label+' — '+(L[x.change]||x.change)+' — '+(x.restarted?L.yes:L.no))); });
|
||||
document.getElementById('media-notice-apps').textContent=L.apps+' '+d.apps.join(', ');
|
||||
var st=document.getElementById('media-notice-stale'); if(d.stale&&d.stale.length){st.textContent=L.stale+' '+d.stale.join(', '); st.hidden=false;}
|
||||
var la=document.getElementById('media-notice-later'); if(d.later_at){la.textContent=L.later+' '+d.later_at; la.hidden=false;}
|
||||
if(d.wiring&&d.wiring.length){
|
||||
var wl=document.getElementById('media-wiring-list'); wl.innerHTML=''; var hh=false;
|
||||
d.wiring.forEach(function(x){ if(x.app==='folders') return; var t=x.app+': '+(x.state==='household'?L.household:x.state)+(x.detail&&x.state==='error'?' ('+x.detail+')':''); wl.appendChild(li(t)); if(x.state==='household'&&x.app==='jellyfin') hh=true; });
|
||||
document.getElementById('media-wiring-hint').hidden=!hh; document.getElementById('media-wiring').hidden=false;
|
||||
}
|
||||
var show=d.pending||!!(d.wiring&&d.wiring.some(function(x){return x.state==='household'||x.state==='error';}));
|
||||
document.querySelector('.media-notice-actions').hidden=!d.pending;
|
||||
box.hidden=!show;
|
||||
}catch(e){}
|
||||
}
|
||||
async function post(url,body){
|
||||
var r=await fetch(url,{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf},body:JSON.stringify(body||{})});
|
||||
var j={}; try{j=await r.json();}catch(e){} return {status:r.status,j:j};
|
||||
}
|
||||
var out=document.getElementById('media-notice-out');
|
||||
async function restart(force){
|
||||
out.innerHTML='';
|
||||
var res=await post('/api/storage/media/restart',{when:'now',force:!!force});
|
||||
if(res.status===409){ felhomConfirm(document.getElementById('media-restart-now'), L.streams, function(){ restart(true); }); return; }
|
||||
out.innerHTML=res.j.ok?'<div class="alert alert-info">'+esc(L.done)+'</div>':'<div class="alert alert-error">'+esc(L.err)+' '+esc(res.j.error||((res.j.data||{}).failed||[]).join(', '))+'</div>';
|
||||
load();
|
||||
}
|
||||
document.getElementById('media-restart-now').addEventListener('click',function(){restart(false);});
|
||||
document.getElementById('media-restart-later').addEventListener('click',async function(){ await post('/api/storage/media/restart',{when:'later'}); load(); });
|
||||
document.getElementById('media-wire-now').addEventListener('click',async function(){ await post('/api/storage/media/wire',{}); load(); });
|
||||
load();
|
||||
})();
|
||||
</script>
|
||||
|
||||
|
||||
<div class="stats-grid">
|
||||
<a href="/stacks?filter=running" class="stat-card">
|
||||
<div class="stat-value">3</div>
|
||||
|
||||
@@ -177,6 +177,74 @@
|
||||
<span class="domain-badge">example.hu</span>
|
||||
</div>
|
||||
|
||||
<div id="media-notice" class="settings-card media-notice" hidden>
|
||||
<h3>A médiaalkalmazásoknak újra kell indulniuk</h3>
|
||||
<p class="settings-card-desc">Egy meghajtó megváltozott. A médiaalkalmazások csak újraindítás után látják a változást.</p>
|
||||
<ul id="media-notice-drives" class="media-notice-list"></ul>
|
||||
<p id="media-notice-apps" class="form-hint"></p>
|
||||
<p id="media-notice-stale" class="form-hint" hidden></p>
|
||||
<p id="media-notice-later" class="form-hint" hidden></p>
|
||||
<div class="media-notice-actions">
|
||||
<button type="button" class="btn btn-sm btn-primary" id="media-restart-now">Újraindítás most</button>
|
||||
<button type="button" class="btn btn-sm btn-outline" id="media-restart-later">Később (ma éjjel 3:30)</button>
|
||||
</div>
|
||||
<div id="media-notice-out"></div>
|
||||
<div id="media-wiring" hidden>
|
||||
<h4>Meghajtók bekötése az alkalmazásokba</h4>
|
||||
<ul id="media-wiring-list" class="media-notice-list"></ul>
|
||||
<p id="media-wiring-hint" class="form-hint" hidden>Jellyfin: Vezérlőpult → API-kulcsok → +, a kulcs neve: felhom. Utána nyomd meg a „Bekötés most” gombot.</p>
|
||||
<button type="button" class="btn btn-sm btn-outline" id="media-wire-now">Bekötés most</button>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function(){
|
||||
var box=document.getElementById('media-notice'); if(!box) return;
|
||||
var csrf=(document.querySelector('meta[name="csrf-token"]')||{}).content||'';
|
||||
var L={added:"hozzáadva",removed:"eltávolítva",returned:"visszatért",
|
||||
yes:"az alkalmazások azóta újraindultak: igen",no:"az alkalmazások azóta újraindultak: nem",apps:"Érintett alkalmazások:",stale:"Még egy halott meghajtót tart:",
|
||||
later:"Ütemezve:",streams:"Most is megy egy lejátszás. Az újraindítás megszakítja. Biztosan újraindítod?",done:"Újraindítva. A meghajtók bekötése egy-két perc múlva kész.",
|
||||
err:"Hiba:",household:"egy lépés kell tőled"};
|
||||
function esc(s){return String(s==null?'':s).replace(/[&<>"]/g,function(c){return {'&':'&','<':'<','>':'>','"':'"'}[c];});}
|
||||
function li(t){var e=document.createElement('li'); e.textContent=t; return e;}
|
||||
async function load(){
|
||||
try{
|
||||
var r=await fetch('/api/storage/media/status',{credentials:'same-origin'}); var j=await r.json(); if(!j.ok||!j.data) return;
|
||||
var d=j.data; if(!d.apps||!d.apps.length) return;
|
||||
var ul=document.getElementById('media-notice-drives'); ul.innerHTML='';
|
||||
(d.drives||[]).forEach(function(x){ ul.appendChild(li(x.label+' — '+(L[x.change]||x.change)+' — '+(x.restarted?L.yes:L.no))); });
|
||||
document.getElementById('media-notice-apps').textContent=L.apps+' '+d.apps.join(', ');
|
||||
var st=document.getElementById('media-notice-stale'); if(d.stale&&d.stale.length){st.textContent=L.stale+' '+d.stale.join(', '); st.hidden=false;}
|
||||
var la=document.getElementById('media-notice-later'); if(d.later_at){la.textContent=L.later+' '+d.later_at; la.hidden=false;}
|
||||
if(d.wiring&&d.wiring.length){
|
||||
var wl=document.getElementById('media-wiring-list'); wl.innerHTML=''; var hh=false;
|
||||
d.wiring.forEach(function(x){ if(x.app==='folders') return; var t=x.app+': '+(x.state==='household'?L.household:x.state)+(x.detail&&x.state==='error'?' ('+x.detail+')':''); wl.appendChild(li(t)); if(x.state==='household'&&x.app==='jellyfin') hh=true; });
|
||||
document.getElementById('media-wiring-hint').hidden=!hh; document.getElementById('media-wiring').hidden=false;
|
||||
}
|
||||
var show=d.pending||!!(d.wiring&&d.wiring.some(function(x){return x.state==='household'||x.state==='error';}));
|
||||
document.querySelector('.media-notice-actions').hidden=!d.pending;
|
||||
box.hidden=!show;
|
||||
}catch(e){}
|
||||
}
|
||||
async function post(url,body){
|
||||
var r=await fetch(url,{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf},body:JSON.stringify(body||{})});
|
||||
var j={}; try{j=await r.json();}catch(e){} return {status:r.status,j:j};
|
||||
}
|
||||
var out=document.getElementById('media-notice-out');
|
||||
async function restart(force){
|
||||
out.innerHTML='';
|
||||
var res=await post('/api/storage/media/restart',{when:'now',force:!!force});
|
||||
if(res.status===409){ felhomConfirm(document.getElementById('media-restart-now'), L.streams, function(){ restart(true); }); return; }
|
||||
out.innerHTML=res.j.ok?'<div class="alert alert-info">'+esc(L.done)+'</div>':'<div class="alert alert-error">'+esc(L.err)+' '+esc(res.j.error||((res.j.data||{}).failed||[]).join(', '))+'</div>';
|
||||
load();
|
||||
}
|
||||
document.getElementById('media-restart-now').addEventListener('click',function(){restart(false);});
|
||||
document.getElementById('media-restart-later').addEventListener('click',async function(){ await post('/api/storage/media/restart',{when:'later'}); load(); });
|
||||
document.getElementById('media-wire-now').addEventListener('click',async function(){ await post('/api/storage/media/wire',{}); load(); });
|
||||
load();
|
||||
})();
|
||||
</script>
|
||||
|
||||
|
||||
<div class="stats-grid">
|
||||
<a href="/stacks?filter=running" class="stat-card">
|
||||
<div class="stat-value">1</div>
|
||||
|
||||
@@ -177,6 +177,74 @@
|
||||
<span class="domain-badge">example.hu</span>
|
||||
</div>
|
||||
|
||||
<div id="media-notice" class="settings-card media-notice" hidden>
|
||||
<h3>A médiaalkalmazásoknak újra kell indulniuk</h3>
|
||||
<p class="settings-card-desc">Egy meghajtó megváltozott. A médiaalkalmazások csak újraindítás után látják a változást.</p>
|
||||
<ul id="media-notice-drives" class="media-notice-list"></ul>
|
||||
<p id="media-notice-apps" class="form-hint"></p>
|
||||
<p id="media-notice-stale" class="form-hint" hidden></p>
|
||||
<p id="media-notice-later" class="form-hint" hidden></p>
|
||||
<div class="media-notice-actions">
|
||||
<button type="button" class="btn btn-sm btn-primary" id="media-restart-now">Újraindítás most</button>
|
||||
<button type="button" class="btn btn-sm btn-outline" id="media-restart-later">Később (ma éjjel 3:30)</button>
|
||||
</div>
|
||||
<div id="media-notice-out"></div>
|
||||
<div id="media-wiring" hidden>
|
||||
<h4>Meghajtók bekötése az alkalmazásokba</h4>
|
||||
<ul id="media-wiring-list" class="media-notice-list"></ul>
|
||||
<p id="media-wiring-hint" class="form-hint" hidden>Jellyfin: Vezérlőpult → API-kulcsok → +, a kulcs neve: felhom. Utána nyomd meg a „Bekötés most” gombot.</p>
|
||||
<button type="button" class="btn btn-sm btn-outline" id="media-wire-now">Bekötés most</button>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function(){
|
||||
var box=document.getElementById('media-notice'); if(!box) return;
|
||||
var csrf=(document.querySelector('meta[name="csrf-token"]')||{}).content||'';
|
||||
var L={added:"hozzáadva",removed:"eltávolítva",returned:"visszatért",
|
||||
yes:"az alkalmazások azóta újraindultak: igen",no:"az alkalmazások azóta újraindultak: nem",apps:"Érintett alkalmazások:",stale:"Még egy halott meghajtót tart:",
|
||||
later:"Ütemezve:",streams:"Most is megy egy lejátszás. Az újraindítás megszakítja. Biztosan újraindítod?",done:"Újraindítva. A meghajtók bekötése egy-két perc múlva kész.",
|
||||
err:"Hiba:",household:"egy lépés kell tőled"};
|
||||
function esc(s){return String(s==null?'':s).replace(/[&<>"]/g,function(c){return {'&':'&','<':'<','>':'>','"':'"'}[c];});}
|
||||
function li(t){var e=document.createElement('li'); e.textContent=t; return e;}
|
||||
async function load(){
|
||||
try{
|
||||
var r=await fetch('/api/storage/media/status',{credentials:'same-origin'}); var j=await r.json(); if(!j.ok||!j.data) return;
|
||||
var d=j.data; if(!d.apps||!d.apps.length) return;
|
||||
var ul=document.getElementById('media-notice-drives'); ul.innerHTML='';
|
||||
(d.drives||[]).forEach(function(x){ ul.appendChild(li(x.label+' — '+(L[x.change]||x.change)+' — '+(x.restarted?L.yes:L.no))); });
|
||||
document.getElementById('media-notice-apps').textContent=L.apps+' '+d.apps.join(', ');
|
||||
var st=document.getElementById('media-notice-stale'); if(d.stale&&d.stale.length){st.textContent=L.stale+' '+d.stale.join(', '); st.hidden=false;}
|
||||
var la=document.getElementById('media-notice-later'); if(d.later_at){la.textContent=L.later+' '+d.later_at; la.hidden=false;}
|
||||
if(d.wiring&&d.wiring.length){
|
||||
var wl=document.getElementById('media-wiring-list'); wl.innerHTML=''; var hh=false;
|
||||
d.wiring.forEach(function(x){ if(x.app==='folders') return; var t=x.app+': '+(x.state==='household'?L.household:x.state)+(x.detail&&x.state==='error'?' ('+x.detail+')':''); wl.appendChild(li(t)); if(x.state==='household'&&x.app==='jellyfin') hh=true; });
|
||||
document.getElementById('media-wiring-hint').hidden=!hh; document.getElementById('media-wiring').hidden=false;
|
||||
}
|
||||
var show=d.pending||!!(d.wiring&&d.wiring.some(function(x){return x.state==='household'||x.state==='error';}));
|
||||
document.querySelector('.media-notice-actions').hidden=!d.pending;
|
||||
box.hidden=!show;
|
||||
}catch(e){}
|
||||
}
|
||||
async function post(url,body){
|
||||
var r=await fetch(url,{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf},body:JSON.stringify(body||{})});
|
||||
var j={}; try{j=await r.json();}catch(e){} return {status:r.status,j:j};
|
||||
}
|
||||
var out=document.getElementById('media-notice-out');
|
||||
async function restart(force){
|
||||
out.innerHTML='';
|
||||
var res=await post('/api/storage/media/restart',{when:'now',force:!!force});
|
||||
if(res.status===409){ felhomConfirm(document.getElementById('media-restart-now'), L.streams, function(){ restart(true); }); return; }
|
||||
out.innerHTML=res.j.ok?'<div class="alert alert-info">'+esc(L.done)+'</div>':'<div class="alert alert-error">'+esc(L.err)+' '+esc(res.j.error||((res.j.data||{}).failed||[]).join(', '))+'</div>';
|
||||
load();
|
||||
}
|
||||
document.getElementById('media-restart-now').addEventListener('click',function(){restart(false);});
|
||||
document.getElementById('media-restart-later').addEventListener('click',async function(){ await post('/api/storage/media/restart',{when:'later'}); load(); });
|
||||
document.getElementById('media-wire-now').addEventListener('click',async function(){ await post('/api/storage/media/wire',{}); load(); });
|
||||
load();
|
||||
})();
|
||||
</script>
|
||||
|
||||
|
||||
<div class="stats-grid">
|
||||
<a href="/stacks?filter=running" class="stat-card">
|
||||
<div class="stat-value">0</div>
|
||||
|
||||
@@ -177,6 +177,74 @@
|
||||
<span class="domain-badge">example.hu</span>
|
||||
</div>
|
||||
|
||||
<div id="media-notice" class="settings-card media-notice" hidden>
|
||||
<h3>A médiaalkalmazásoknak újra kell indulniuk</h3>
|
||||
<p class="settings-card-desc">Egy meghajtó megváltozott. A médiaalkalmazások csak újraindítás után látják a változást.</p>
|
||||
<ul id="media-notice-drives" class="media-notice-list"></ul>
|
||||
<p id="media-notice-apps" class="form-hint"></p>
|
||||
<p id="media-notice-stale" class="form-hint" hidden></p>
|
||||
<p id="media-notice-later" class="form-hint" hidden></p>
|
||||
<div class="media-notice-actions">
|
||||
<button type="button" class="btn btn-sm btn-primary" id="media-restart-now">Újraindítás most</button>
|
||||
<button type="button" class="btn btn-sm btn-outline" id="media-restart-later">Később (ma éjjel 3:30)</button>
|
||||
</div>
|
||||
<div id="media-notice-out"></div>
|
||||
<div id="media-wiring" hidden>
|
||||
<h4>Meghajtók bekötése az alkalmazásokba</h4>
|
||||
<ul id="media-wiring-list" class="media-notice-list"></ul>
|
||||
<p id="media-wiring-hint" class="form-hint" hidden>Jellyfin: Vezérlőpult → API-kulcsok → +, a kulcs neve: felhom. Utána nyomd meg a „Bekötés most” gombot.</p>
|
||||
<button type="button" class="btn btn-sm btn-outline" id="media-wire-now">Bekötés most</button>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function(){
|
||||
var box=document.getElementById('media-notice'); if(!box) return;
|
||||
var csrf=(document.querySelector('meta[name="csrf-token"]')||{}).content||'';
|
||||
var L={added:"hozzáadva",removed:"eltávolítva",returned:"visszatért",
|
||||
yes:"az alkalmazások azóta újraindultak: igen",no:"az alkalmazások azóta újraindultak: nem",apps:"Érintett alkalmazások:",stale:"Még egy halott meghajtót tart:",
|
||||
later:"Ütemezve:",streams:"Most is megy egy lejátszás. Az újraindítás megszakítja. Biztosan újraindítod?",done:"Újraindítva. A meghajtók bekötése egy-két perc múlva kész.",
|
||||
err:"Hiba:",household:"egy lépés kell tőled"};
|
||||
function esc(s){return String(s==null?'':s).replace(/[&<>"]/g,function(c){return {'&':'&','<':'<','>':'>','"':'"'}[c];});}
|
||||
function li(t){var e=document.createElement('li'); e.textContent=t; return e;}
|
||||
async function load(){
|
||||
try{
|
||||
var r=await fetch('/api/storage/media/status',{credentials:'same-origin'}); var j=await r.json(); if(!j.ok||!j.data) return;
|
||||
var d=j.data; if(!d.apps||!d.apps.length) return;
|
||||
var ul=document.getElementById('media-notice-drives'); ul.innerHTML='';
|
||||
(d.drives||[]).forEach(function(x){ ul.appendChild(li(x.label+' — '+(L[x.change]||x.change)+' — '+(x.restarted?L.yes:L.no))); });
|
||||
document.getElementById('media-notice-apps').textContent=L.apps+' '+d.apps.join(', ');
|
||||
var st=document.getElementById('media-notice-stale'); if(d.stale&&d.stale.length){st.textContent=L.stale+' '+d.stale.join(', '); st.hidden=false;}
|
||||
var la=document.getElementById('media-notice-later'); if(d.later_at){la.textContent=L.later+' '+d.later_at; la.hidden=false;}
|
||||
if(d.wiring&&d.wiring.length){
|
||||
var wl=document.getElementById('media-wiring-list'); wl.innerHTML=''; var hh=false;
|
||||
d.wiring.forEach(function(x){ if(x.app==='folders') return; var t=x.app+': '+(x.state==='household'?L.household:x.state)+(x.detail&&x.state==='error'?' ('+x.detail+')':''); wl.appendChild(li(t)); if(x.state==='household'&&x.app==='jellyfin') hh=true; });
|
||||
document.getElementById('media-wiring-hint').hidden=!hh; document.getElementById('media-wiring').hidden=false;
|
||||
}
|
||||
var show=d.pending||!!(d.wiring&&d.wiring.some(function(x){return x.state==='household'||x.state==='error';}));
|
||||
document.querySelector('.media-notice-actions').hidden=!d.pending;
|
||||
box.hidden=!show;
|
||||
}catch(e){}
|
||||
}
|
||||
async function post(url,body){
|
||||
var r=await fetch(url,{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf},body:JSON.stringify(body||{})});
|
||||
var j={}; try{j=await r.json();}catch(e){} return {status:r.status,j:j};
|
||||
}
|
||||
var out=document.getElementById('media-notice-out');
|
||||
async function restart(force){
|
||||
out.innerHTML='';
|
||||
var res=await post('/api/storage/media/restart',{when:'now',force:!!force});
|
||||
if(res.status===409){ felhomConfirm(document.getElementById('media-restart-now'), L.streams, function(){ restart(true); }); return; }
|
||||
out.innerHTML=res.j.ok?'<div class="alert alert-info">'+esc(L.done)+'</div>':'<div class="alert alert-error">'+esc(L.err)+' '+esc(res.j.error||((res.j.data||{}).failed||[]).join(', '))+'</div>';
|
||||
load();
|
||||
}
|
||||
document.getElementById('media-restart-now').addEventListener('click',function(){restart(false);});
|
||||
document.getElementById('media-restart-later').addEventListener('click',async function(){ await post('/api/storage/media/restart',{when:'later'}); load(); });
|
||||
document.getElementById('media-wire-now').addEventListener('click',async function(){ await post('/api/storage/media/wire',{}); load(); });
|
||||
load();
|
||||
})();
|
||||
</script>
|
||||
|
||||
|
||||
<div class="stats-grid">
|
||||
<a href="/stacks?filter=running" class="stat-card">
|
||||
<div class="stat-value">0</div>
|
||||
|
||||
@@ -176,6 +176,74 @@
|
||||
<h2>Tárhely — Meghajtók</h2>
|
||||
</div>
|
||||
|
||||
<div id="media-notice" class="settings-card media-notice" hidden>
|
||||
<h3>A médiaalkalmazásoknak újra kell indulniuk</h3>
|
||||
<p class="settings-card-desc">Egy meghajtó megváltozott. A médiaalkalmazások csak újraindítás után látják a változást.</p>
|
||||
<ul id="media-notice-drives" class="media-notice-list"></ul>
|
||||
<p id="media-notice-apps" class="form-hint"></p>
|
||||
<p id="media-notice-stale" class="form-hint" hidden></p>
|
||||
<p id="media-notice-later" class="form-hint" hidden></p>
|
||||
<div class="media-notice-actions">
|
||||
<button type="button" class="btn btn-sm btn-primary" id="media-restart-now">Újraindítás most</button>
|
||||
<button type="button" class="btn btn-sm btn-outline" id="media-restart-later">Később (ma éjjel 3:30)</button>
|
||||
</div>
|
||||
<div id="media-notice-out"></div>
|
||||
<div id="media-wiring" hidden>
|
||||
<h4>Meghajtók bekötése az alkalmazásokba</h4>
|
||||
<ul id="media-wiring-list" class="media-notice-list"></ul>
|
||||
<p id="media-wiring-hint" class="form-hint" hidden>Jellyfin: Vezérlőpult → API-kulcsok → +, a kulcs neve: felhom. Utána nyomd meg a „Bekötés most” gombot.</p>
|
||||
<button type="button" class="btn btn-sm btn-outline" id="media-wire-now">Bekötés most</button>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function(){
|
||||
var box=document.getElementById('media-notice'); if(!box) return;
|
||||
var csrf=(document.querySelector('meta[name="csrf-token"]')||{}).content||'';
|
||||
var L={added:"hozzáadva",removed:"eltávolítva",returned:"visszatért",
|
||||
yes:"az alkalmazások azóta újraindultak: igen",no:"az alkalmazások azóta újraindultak: nem",apps:"Érintett alkalmazások:",stale:"Még egy halott meghajtót tart:",
|
||||
later:"Ütemezve:",streams:"Most is megy egy lejátszás. Az újraindítás megszakítja. Biztosan újraindítod?",done:"Újraindítva. A meghajtók bekötése egy-két perc múlva kész.",
|
||||
err:"Hiba:",household:"egy lépés kell tőled"};
|
||||
function esc(s){return String(s==null?'':s).replace(/[&<>"]/g,function(c){return {'&':'&','<':'<','>':'>','"':'"'}[c];});}
|
||||
function li(t){var e=document.createElement('li'); e.textContent=t; return e;}
|
||||
async function load(){
|
||||
try{
|
||||
var r=await fetch('/api/storage/media/status',{credentials:'same-origin'}); var j=await r.json(); if(!j.ok||!j.data) return;
|
||||
var d=j.data; if(!d.apps||!d.apps.length) return;
|
||||
var ul=document.getElementById('media-notice-drives'); ul.innerHTML='';
|
||||
(d.drives||[]).forEach(function(x){ ul.appendChild(li(x.label+' — '+(L[x.change]||x.change)+' — '+(x.restarted?L.yes:L.no))); });
|
||||
document.getElementById('media-notice-apps').textContent=L.apps+' '+d.apps.join(', ');
|
||||
var st=document.getElementById('media-notice-stale'); if(d.stale&&d.stale.length){st.textContent=L.stale+' '+d.stale.join(', '); st.hidden=false;}
|
||||
var la=document.getElementById('media-notice-later'); if(d.later_at){la.textContent=L.later+' '+d.later_at; la.hidden=false;}
|
||||
if(d.wiring&&d.wiring.length){
|
||||
var wl=document.getElementById('media-wiring-list'); wl.innerHTML=''; var hh=false;
|
||||
d.wiring.forEach(function(x){ if(x.app==='folders') return; var t=x.app+': '+(x.state==='household'?L.household:x.state)+(x.detail&&x.state==='error'?' ('+x.detail+')':''); wl.appendChild(li(t)); if(x.state==='household'&&x.app==='jellyfin') hh=true; });
|
||||
document.getElementById('media-wiring-hint').hidden=!hh; document.getElementById('media-wiring').hidden=false;
|
||||
}
|
||||
var show=d.pending||!!(d.wiring&&d.wiring.some(function(x){return x.state==='household'||x.state==='error';}));
|
||||
document.querySelector('.media-notice-actions').hidden=!d.pending;
|
||||
box.hidden=!show;
|
||||
}catch(e){}
|
||||
}
|
||||
async function post(url,body){
|
||||
var r=await fetch(url,{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf},body:JSON.stringify(body||{})});
|
||||
var j={}; try{j=await r.json();}catch(e){} return {status:r.status,j:j};
|
||||
}
|
||||
var out=document.getElementById('media-notice-out');
|
||||
async function restart(force){
|
||||
out.innerHTML='';
|
||||
var res=await post('/api/storage/media/restart',{when:'now',force:!!force});
|
||||
if(res.status===409){ felhomConfirm(document.getElementById('media-restart-now'), L.streams, function(){ restart(true); }); return; }
|
||||
out.innerHTML=res.j.ok?'<div class="alert alert-info">'+esc(L.done)+'</div>':'<div class="alert alert-error">'+esc(L.err)+' '+esc(res.j.error||((res.j.data||{}).failed||[]).join(', '))+'</div>';
|
||||
load();
|
||||
}
|
||||
document.getElementById('media-restart-now').addEventListener('click',function(){restart(false);});
|
||||
document.getElementById('media-restart-later').addEventListener('click',async function(){ await post('/api/storage/media/restart',{when:'later'}); load(); });
|
||||
document.getElementById('media-wire-now').addEventListener('click',async function(){ await post('/api/storage/media/wire',{}); load(); });
|
||||
load();
|
||||
})();
|
||||
</script>
|
||||
|
||||
|
||||
|
||||
<div class="settings-card">
|
||||
<h3>Adattárolók</h3>
|
||||
|
||||
@@ -176,6 +176,74 @@
|
||||
<h2>Tárhely — Meghajtók</h2>
|
||||
</div>
|
||||
|
||||
<div id="media-notice" class="settings-card media-notice" hidden>
|
||||
<h3>A médiaalkalmazásoknak újra kell indulniuk</h3>
|
||||
<p class="settings-card-desc">Egy meghajtó megváltozott. A médiaalkalmazások csak újraindítás után látják a változást.</p>
|
||||
<ul id="media-notice-drives" class="media-notice-list"></ul>
|
||||
<p id="media-notice-apps" class="form-hint"></p>
|
||||
<p id="media-notice-stale" class="form-hint" hidden></p>
|
||||
<p id="media-notice-later" class="form-hint" hidden></p>
|
||||
<div class="media-notice-actions">
|
||||
<button type="button" class="btn btn-sm btn-primary" id="media-restart-now">Újraindítás most</button>
|
||||
<button type="button" class="btn btn-sm btn-outline" id="media-restart-later">Később (ma éjjel 3:30)</button>
|
||||
</div>
|
||||
<div id="media-notice-out"></div>
|
||||
<div id="media-wiring" hidden>
|
||||
<h4>Meghajtók bekötése az alkalmazásokba</h4>
|
||||
<ul id="media-wiring-list" class="media-notice-list"></ul>
|
||||
<p id="media-wiring-hint" class="form-hint" hidden>Jellyfin: Vezérlőpult → API-kulcsok → +, a kulcs neve: felhom. Utána nyomd meg a „Bekötés most” gombot.</p>
|
||||
<button type="button" class="btn btn-sm btn-outline" id="media-wire-now">Bekötés most</button>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function(){
|
||||
var box=document.getElementById('media-notice'); if(!box) return;
|
||||
var csrf=(document.querySelector('meta[name="csrf-token"]')||{}).content||'';
|
||||
var L={added:"hozzáadva",removed:"eltávolítva",returned:"visszatért",
|
||||
yes:"az alkalmazások azóta újraindultak: igen",no:"az alkalmazások azóta újraindultak: nem",apps:"Érintett alkalmazások:",stale:"Még egy halott meghajtót tart:",
|
||||
later:"Ütemezve:",streams:"Most is megy egy lejátszás. Az újraindítás megszakítja. Biztosan újraindítod?",done:"Újraindítva. A meghajtók bekötése egy-két perc múlva kész.",
|
||||
err:"Hiba:",household:"egy lépés kell tőled"};
|
||||
function esc(s){return String(s==null?'':s).replace(/[&<>"]/g,function(c){return {'&':'&','<':'<','>':'>','"':'"'}[c];});}
|
||||
function li(t){var e=document.createElement('li'); e.textContent=t; return e;}
|
||||
async function load(){
|
||||
try{
|
||||
var r=await fetch('/api/storage/media/status',{credentials:'same-origin'}); var j=await r.json(); if(!j.ok||!j.data) return;
|
||||
var d=j.data; if(!d.apps||!d.apps.length) return;
|
||||
var ul=document.getElementById('media-notice-drives'); ul.innerHTML='';
|
||||
(d.drives||[]).forEach(function(x){ ul.appendChild(li(x.label+' — '+(L[x.change]||x.change)+' — '+(x.restarted?L.yes:L.no))); });
|
||||
document.getElementById('media-notice-apps').textContent=L.apps+' '+d.apps.join(', ');
|
||||
var st=document.getElementById('media-notice-stale'); if(d.stale&&d.stale.length){st.textContent=L.stale+' '+d.stale.join(', '); st.hidden=false;}
|
||||
var la=document.getElementById('media-notice-later'); if(d.later_at){la.textContent=L.later+' '+d.later_at; la.hidden=false;}
|
||||
if(d.wiring&&d.wiring.length){
|
||||
var wl=document.getElementById('media-wiring-list'); wl.innerHTML=''; var hh=false;
|
||||
d.wiring.forEach(function(x){ if(x.app==='folders') return; var t=x.app+': '+(x.state==='household'?L.household:x.state)+(x.detail&&x.state==='error'?' ('+x.detail+')':''); wl.appendChild(li(t)); if(x.state==='household'&&x.app==='jellyfin') hh=true; });
|
||||
document.getElementById('media-wiring-hint').hidden=!hh; document.getElementById('media-wiring').hidden=false;
|
||||
}
|
||||
var show=d.pending||!!(d.wiring&&d.wiring.some(function(x){return x.state==='household'||x.state==='error';}));
|
||||
document.querySelector('.media-notice-actions').hidden=!d.pending;
|
||||
box.hidden=!show;
|
||||
}catch(e){}
|
||||
}
|
||||
async function post(url,body){
|
||||
var r=await fetch(url,{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf},body:JSON.stringify(body||{})});
|
||||
var j={}; try{j=await r.json();}catch(e){} return {status:r.status,j:j};
|
||||
}
|
||||
var out=document.getElementById('media-notice-out');
|
||||
async function restart(force){
|
||||
out.innerHTML='';
|
||||
var res=await post('/api/storage/media/restart',{when:'now',force:!!force});
|
||||
if(res.status===409){ felhomConfirm(document.getElementById('media-restart-now'), L.streams, function(){ restart(true); }); return; }
|
||||
out.innerHTML=res.j.ok?'<div class="alert alert-info">'+esc(L.done)+'</div>':'<div class="alert alert-error">'+esc(L.err)+' '+esc(res.j.error||((res.j.data||{}).failed||[]).join(', '))+'</div>';
|
||||
load();
|
||||
}
|
||||
document.getElementById('media-restart-now').addEventListener('click',function(){restart(false);});
|
||||
document.getElementById('media-restart-later').addEventListener('click',async function(){ await post('/api/storage/media/restart',{when:'later'}); load(); });
|
||||
document.getElementById('media-wire-now').addEventListener('click',async function(){ await post('/api/storage/media/wire',{}); load(); });
|
||||
load();
|
||||
})();
|
||||
</script>
|
||||
|
||||
|
||||
|
||||
<div class="settings-card">
|
||||
<h3>Adattárolók</h3>
|
||||
|
||||
@@ -239,7 +239,11 @@
|
||||
"poweroff.refused.update": "BORN AS A KEY (R-998, v0.310.0) -- „A gép kikapcsolása”: the household sentence for the power-off route's answer. Pinned by TestPowerOff_EveryReasonHasASentence.",
|
||||
"poweroff.refused.drive": "BORN AS A KEY (R-998, v0.310.0) -- „A gép kikapcsolása”: the household sentence for the power-off route's answer. Pinned by TestPowerOff_EveryReasonHasASentence.",
|
||||
"poweroff.refused.other_guests": "BORN AS A KEY (R-998, v0.310.0) -- „A gép kikapcsolása”: the household sentence for the power-off route's answer. Pinned by TestPowerOff_EveryReasonHasASentence.",
|
||||
"poweroff.refused.generic": "BORN AS A KEY (R-998, v0.310.0) -- „A gép kikapcsolása”: the household sentence for the power-off route's answer. Pinned by TestPowerOff_EveryReasonHasASentence."
|
||||
"poweroff.refused.generic": "BORN AS A KEY (R-998, v0.310.0) -- „A gép kikapcsolása”: the household sentence for the power-off route's answer. Pinned by TestPowerOff_EveryReasonHasASentence.",
|
||||
"err.plex_claim.bad_code": "BORN AS A KEY (v0.313.0, 2026-10-15, R-1028/R-1023) — the Plex claim on the running server and the media restart's stream check; no Go literal preceded it.",
|
||||
"err.plex_claim.not_claimed": "BORN AS A KEY (v0.313.0, 2026-10-15, R-1028/R-1023) — the Plex claim on the running server and the media restart's stream check; no Go literal preceded it.",
|
||||
"err.plex_claim.refused": "BORN AS A KEY (v0.313.0, 2026-10-15, R-1028/R-1023) — the Plex claim on the running server and the media restart's stream check; no Go literal preceded it.",
|
||||
"media.streams_playing": "BORN AS A KEY (v0.313.0, 2026-10-15, R-1028/R-1023) — the Plex claim on the running server and the media restart's stream check; no Go literal preceded it."
|
||||
},
|
||||
"flash.share.already_on": "A megosztás már be van kapcsolva.",
|
||||
"flash.share.enable_failed": "A megosztás bekapcsolása nem sikerült.",
|
||||
|
||||
Reference in New Issue
Block a user