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

Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-10-15 09:54:49 +02:00
parent 78c894234c
commit f580d88a14
46 changed files with 3700 additions and 8 deletions
+2
View File
@@ -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.
+25 -1
View File
@@ -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."
}
+25 -1
View File
@@ -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."
}
+86
View File
@@ -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)
}
}
+115
View File
@@ -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
}
+12
View File
@@ -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
}
+25 -1
View File
@@ -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)
}
}
+31
View File
@@ -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")
+5 -1
View File
@@ -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()
}
}
}
+422
View File
@@ -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)
}
}
}
+17 -3
View File
@@ -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 {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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'; }
@@ -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'; }
@@ -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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')+'</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'; }
@@ -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 {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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 {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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 {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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 {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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 {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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 {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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 {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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>
+5 -1
View File
@@ -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.",