Import Legacy Database

This commit is contained in:
Matthieu B
2025-05-22 18:50:03 +01:00
parent 5e1d1105ab
commit 19fa6e497a
19 changed files with 554 additions and 425 deletions

24
.idea/dataSources.xml generated
View File

@@ -1,24 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
<data-source source="LOCAL" name="database" uuid="f9392a92-add5-459a-b5a7-51c14270493b">
<driver-ref>sqlite.xerial</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>org.sqlite.JDBC</jdbc-driver>
<jdbc-url>jdbc:sqlite:$PROJECT_DIR$/database/database.db</jdbc-url>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
<data-source source="LOCAL" name="database [2]" uuid="183e054d-136b-42e0-82fb-c80fbefe183a">
<driver-ref>sqlite.xerial</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>org.sqlite.JDBC</jdbc-driver>
<jdbc-url>jdbc:sqlite:$PROJECT_DIR$/app/database/database.db</jdbc-url>
<working-dir>$ProjectFileDir$</working-dir>
<libraries>
<library>
<url>file://$APPLICATION_CONFIG_DIR$/jdbc-drivers/Xerial SQLiteJDBC/3.40.1/sqlite-jdbc-3.40.1.jar</url>
</library>
</libraries>
</data-source>
</component>
</project>

6
.idea/sqldialects.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="SqlDialectMappings">
<file url="file://$PROJECT_DIR$/app/legacy_migration/import_legacy.py" dialect="SQLite" />
</component>
</project>

View File

@@ -20,7 +20,7 @@ def dashboard():
.filter_by(key="server_verified")
.first()
)
if not server_verified or server_verified.value != "true":
if not server_verified:
return redirect("/setup/")
return render_template("admin.html")

View File

@@ -4,14 +4,14 @@ from app.extensions import db
from app.models import Notification
from app.services.notifications import _discord, _ntfy # your existing helpers
notify_bp = Blueprint("notify", __name__, url_prefix="/admin/notifications")
notify_bp = Blueprint("notify", __name__, url_prefix="/settings/notifications")
@notify_bp.route("/", methods=["GET"])
@login_required
def list_agents():
# replace peewee .select() with SQLAlchemy .query.all()
agents = Notification.query.all()
return render_template("admin/notifications.html", agents=agents)
return render_template("settings/notifications.html", agents=agents)
@notify_bp.route("/create", methods=["GET", "POST"])
@login_required

View File

@@ -1,8 +1,9 @@
# app/blueprints/settings/routes.py
import logging
from flask import Blueprint, request, render_template, redirect, url_for, flash, jsonify
from flask import Blueprint, request, render_template, redirect, url_for, flash, jsonify, session
from flask_login import login_required
from flask_babel import _
from app.services.plex_client import scan_libraries as scan_plex
from app.services.jellyfin_client import scan_libraries as scan_jf
@@ -37,11 +38,18 @@ def _check_server_connection(data: dict) -> bool:
return check_plex(data["server_url"], data["api_key"])
return check_jellyfin(data["server_url"], data["api_key"])
@settings_bp.route("/", methods=["GET", "POST"])
@settings_bp.get("/")
@login_required
def secure_settings():
current = _load_settings()
def page():
return render_template("settings/page.html")
@settings_bp.route("/server", methods=["GET", "POST"])
@login_required
def server_settings():
setup_mode = bool(session.get("in_setup"))
current = _load_settings()
form = SettingsForm(
formdata=request.form if request.method == "POST" else None,
data=current,
@@ -50,28 +58,34 @@ def secure_settings():
if form.validate_on_submit():
data = form.data.copy()
data.pop("csrf_token", None)
# handle multi-select libraries field
# handle multi-select libraries
selected = request.form.getlist("libraries")
data["libraries"] = ", ".join(selected) if selected else current.get("libraries", "")
if not _check_server_connection(data):
return render_template("settings.html", form=form)
# re-render in either setup or normal mode
return render_template("partials/server_form.html", form=form, setup_mode=setup_mode)
# save settings to DB
data['server_verified'] = True
_save_settings(data)
flash("Settings saved successfully!", "success")
current.update(data) # so form re-renders with fresh values
# HTMX partial
if request.headers.get("HX-Request"):
return render_template("settings.html", form=form)
# if we were in setup, jump to admin dashboard
if setup_mode:
session.pop("in_setup", None)
return redirect(url_for("admin.dashboard"))
# Show errors on failed POST
if request.method == "POST" and form.errors:
return render_template("settings.html", form=form)
# otherwise stay on settings page
current.update(data)
# Normal GET → back to dashboard
return redirect(url_for("admin.dashboard"))
# If HTMX partial, or POST with errors, just re-render the form
if request.headers.get("HX-Request") or (request.method == "POST" and form.errors):
return render_template("partials/server_form.html", form=form, setup_mode=setup_mode)
# Normal GET (non-HTMX) and not in setup: show settings index
return redirect(url_for("settings.page"))
@settings_bp.route("/scan-libraries", methods=["POST"])
@login_required

View File

@@ -1,5 +1,5 @@
# app/blueprints/setup/routes.py
from flask import Blueprint, render_template, redirect, url_for, flash, request
from flask import Blueprint, render_template, redirect, url_for, flash, session
from werkzeug.security import generate_password_hash
from flask_login import login_user
@@ -12,10 +12,12 @@ from sqlalchemy.exc import IntegrityError
setup_bp = Blueprint("setup", __name__, url_prefix="/setup")
def _settings_as_dict():
"""All settings as {key: Settings instance}."""
return {s.key: s for s in Settings.query.all()}
def _ensure_keys_exist():
"""Insert missing rows so later code can rely on them."""
default_keys = [
@@ -28,6 +30,7 @@ def _ensure_keys_exist():
db.session.add(Settings(key=key, value=None))
db.session.commit()
@setup_bp.route("/", methods=["GET", "POST"])
def onboarding():
_ensure_keys_exist()
@@ -36,48 +39,22 @@ def onboarding():
# ───── Step 1: create admin account ───────────────────────────
if not s["admin_username"].value or not s["admin_password"].value:
form = AdminAccountForm()
if form.validate_on_submit():
s["admin_username"].value = form.username.data
s["admin_password"].value = generate_password_hash(form.password.data, "scrypt")
db.session.commit()
login_user(AdminUser())
flash("Admin account created lets hook up your media server.", "success")
return redirect(url_for(".onboarding"))
# → Redirect into serversettings in setup mode
# remember were still in setup
session["in_setup"] = True
return redirect(url_for("settings.server_settings"))
return render_template("setup/admin_account.html", form=form)
# ───── Step 2: verify server connection ───────────────────────
if not (s["server_verified"].value and s["server_verified"].value.lower() == "true"):
form = SettingsForm(install_mode=True)
if form.validate_on_submit():
if not _probe_server(form):
return render_template("setup/server_details.html", form=form)
# write everything in one transaction
try:
for field, row in s.items():
if field in form.data:
row.value = (form.data[field] or "").strip()
# mark as verified
s["server_verified"].value = "true"
db.session.commit()
except IntegrityError:
db.session.rollback()
flash("Database error saving settings.", "danger")
return render_template("setup/server_details.html", form=form,
setup_mode=True)
flash("Server verified setup finished!", "success")
# ← youre now authenticated
return redirect(url_for("admin.dashboard"))
return render_template("setup/server_details.html", form=form,
setup_mode=True)
# Already configured bounce to admin
return redirect(url_for("admin.dashboard"))
# If admin already exists, just forward you into server settings
return redirect(url_for("settings.server_settings", setup=1))
def _probe_server(form):
if form.server_type.data == "plex":

View File

@@ -12,10 +12,9 @@ class SettingsForm(FlaskForm):
server_url = StringField("Server URL", validators=[DataRequired()])
api_key = StringField("API Key", validators=[Optional()])
libraries = StringField("Libraries", validators=[Optional()])
overseerr_url = StringField("Overseerr URL", validators=[Optional(), URL()])
overseerr_url = StringField("Overseerr/Ombi URL", validators=[Optional(), URL()])
ombi_api_key = StringField("Ombi API Key", validators=[Optional()])
discord_id = StringField("Discord ID", validators=[Optional()])
custom_html = TextAreaField("Custom HTML", validators=[Optional()])
def __init__(self, install_mode: bool = False, *args, **kwargs):
super().__init__(*args, **kwargs)

View File

@@ -0,0 +1,114 @@
#!/usr/bin/env python3
import sqlite3
import json
import datetime
from pathlib import Path
from app import create_app, db
from app.models import Settings, Invitation, User
# Marker file written by your rename step:
marker = Path("/data/database/legacy_backup.json")
if not marker.exists():
print("[import_legacy] nothing to do")
exit(0)
# Load backup path
data = json.loads(marker.read_text())
backup_path = data["backup"]
# Open the legacy DB read-only
legacy = sqlite3.connect(f"file:{backup_path}?mode=ro", uri=True)
cur = legacy.cursor()
# Map old keys → new keys
KEY_MAP = {
"server_name": "server_name",
"server_type": "server_type",
"server_url": "server_url",
"server_api_key": "api_key",
"overseerr_url": "overseerr_url",
"ombi_api_key": "ombi_api_key",
"discord_id": "discord_id",
}
app = create_app()
with app.app_context():
# ─── 1) IMPORT SETTINGS ─────────────────────────────────
# Load all old settings into a dict so we can prefer override
old = {k: v for k, v in cur.execute("SELECT key, value FROM settings")}
for old_key, val in old.items():
if old_key == "version":
continue
new_key = KEY_MAP.get(old_key)
if not new_key:
continue
# if both override and raw URL exist, skip the raw URL
if old_key == "server_url" and old.get("server_url_override"):
continue
# parse booleans or leave strings intact
v = val
if new_key == "server_verified":
v = v.lower() == "true"
# find-or-create, then assign
row = db.session.query(Settings).filter_by(key=new_key).first()
if row:
row.value = v
else:
db.session.add(Settings(key=new_key, value=v))
db.session.commit()
# ─── 2) IMPORT INVITATIONS ───────────────────────────────
for (
code, used, used_at, created, used_by,
expires, unlimited, duration,
specific_libraries, plex_allow_sync
) in cur.execute("""
SELECT code, used, used_at, created, used_by,
expires, unlimited, duration,
specific_libraries, plex_allow_sync
FROM invitations
"""):
if db.session.query(Invitation).filter_by(code=code).first():
continue
inv = Invitation(
code = code,
used = bool(used),
used_at = datetime.datetime.fromisoformat(used_at) if used_at else None,
created = datetime.datetime.fromisoformat(created),
used_by = used_by,
expires = datetime.datetime.fromisoformat(expires) if expires else None,
unlimited = bool(unlimited),
duration = duration or "",
specific_libraries = specific_libraries or "",
plex_allow_sync = bool(plex_allow_sync),
)
db.session.add(inv)
db.session.commit()
# ─── 3) IMPORT USERS ────────────────────────────────────
for token, username, email, code, expires in cur.execute(
"SELECT token, username, email, code, expires FROM users"
):
if db.session.query(User).filter_by(token=token).first():
continue
usr = User(
token = token,
username = username,
email = email or "empty",
code = code or "empty",
expires = datetime.datetime.fromisoformat(expires) if expires else None,
)
db.session.add(usr)
db.session.commit()
# Clean up
marker.unlink()
print("[import_legacy] import complete")

View File

@@ -0,0 +1,33 @@
#!/usr/bin/env python3
import sqlite3, pathlib, datetime, json
# where your “new” DB lives, inside your container
DB = pathlib.Path("/data/database/database.db")
# append timestamp so you dont collide
BACKUP = DB.with_suffix(f".{datetime.datetime.utcnow():%Y%m%d-%H%M%S}.old")
# marker file path for the importer to notice
MARKER = pathlib.Path("/data/database/legacy_backup.json")
def is_old(db_path):
try:
con = sqlite3.connect(db_path)
row = con.execute(
"SELECT value FROM settings WHERE key='version'"
).fetchone()
return row and row[0] == "4.2.0"
except sqlite3.Error:
return False
if DB.exists() and is_old(DB):
DB.rename(BACKUP)
print(f"[rename_legacy] rotated legacy DB → {BACKUP}")
# write the JSON marker
MARKER.write_text(
json.dumps({"backup": str(BACKUP)}, indent=2),
encoding="utf-8"
)
print(f"[rename_legacy] wrote marker → {MARKER}")
else:
print("[rename_legacy] no legacy DB found")

View File

@@ -1623,12 +1623,8 @@ input:checked + .toggle-bg {
right: 0px;
}
.right-2 {
right: 0.5rem;
}
.right-2\.5 {
right: 0.625rem;
.right-3 {
right: 0.75rem;
}
.top-0 {
@@ -1639,10 +1635,6 @@ input:checked + .toggle-bg {
top: 0.75rem;
}
.right-3 {
right: 0.75rem;
}
.z-10 {
z-index: 10;
}
@@ -1677,10 +1669,6 @@ input:checked + .toggle-bg {
margin-bottom: auto;
}
.-mr-1 {
margin-right: -0.25rem;
}
.mb-1 {
margin-bottom: 0.25rem;
}
@@ -1697,10 +1685,6 @@ input:checked + .toggle-bg {
margin-bottom: 1rem;
}
.mb-5 {
margin-bottom: 1.25rem;
}
.mb-6 {
margin-bottom: 1.5rem;
}
@@ -1721,10 +1705,6 @@ input:checked + .toggle-bg {
margin-left: 0.75rem;
}
.ml-auto {
margin-left: auto;
}
.mr-1 {
margin-right: 0.25rem;
}
@@ -1877,6 +1857,10 @@ input:checked + .toggle-bg {
width: 100%;
}
.max-w-2xl {
max-width: 42rem;
}
.max-w-md {
max-width: 28rem;
}
@@ -1905,12 +1889,8 @@ input:checked + .toggle-bg {
flex-shrink: 1;
}
.flex-shrink-0 {
flex-shrink: 0;
}
.shrink-0 {
flex-shrink: 0;
.grow {
flex-grow: 1;
}
.-translate-x-full {
@@ -2035,6 +2015,14 @@ input:checked + .toggle-bg {
justify-content: space-between;
}
.gap-1 {
gap: 0.25rem;
}
.gap-2 {
gap: 0.5rem;
}
.gap-4 {
gap: 1rem;
}
@@ -2045,12 +2033,6 @@ input:checked + .toggle-bg {
margin-left: calc(0.5rem * calc(1 - var(--tw-space-x-reverse)));
}
.space-x-3 > :not([hidden]) ~ :not([hidden]) {
--tw-space-x-reverse: 0;
margin-right: calc(0.75rem * var(--tw-space-x-reverse));
margin-left: calc(0.75rem * calc(1 - var(--tw-space-x-reverse)));
}
.space-x-4 > :not([hidden]) ~ :not([hidden]) {
--tw-space-x-reverse: 0;
margin-right: calc(1rem * var(--tw-space-x-reverse));
@@ -2137,11 +2119,6 @@ input:checked + .toggle-bg {
border-bottom-right-radius: 0.5rem;
}
.rounded-t-xl {
border-top-left-radius: 0.75rem;
border-top-right-radius: 0.75rem;
}
.border {
border-width: 1px;
}
@@ -2154,8 +2131,8 @@ input:checked + .toggle-bg {
border-bottom-width: 1px;
}
.border-b-0 {
border-bottom-width: 0px;
.border-b-2 {
border-bottom-width: 2px;
}
.border-blue-600 {
@@ -2188,11 +2165,24 @@ input:checked + .toggle-bg {
border-color: rgb(14 159 110 / var(--tw-border-opacity));
}
.border-primary {
--tw-border-opacity: 1;
border-color: rgb(254 65 85 / var(--tw-border-opacity));
}
.border-red-500 {
--tw-border-opacity: 1;
border-color: rgb(240 82 82 / var(--tw-border-opacity));
}
.border-transparent {
border-color: transparent;
}
.bg-black\/50 {
background-color: rgb(0 0 0 / 0.5);
}
.bg-blue-700 {
--tw-bg-opacity: 1;
background-color: rgb(26 86 219 / var(--tw-bg-opacity));
@@ -2238,21 +2228,11 @@ input:checked + .toggle-bg {
background-color: rgb(243 250 247 / var(--tw-bg-opacity));
}
.bg-green-500 {
--tw-bg-opacity: 1;
background-color: rgb(14 159 110 / var(--tw-bg-opacity));
}
.bg-orange-100 {
--tw-bg-opacity: 1;
background-color: rgb(254 236 220 / var(--tw-bg-opacity));
}
.bg-orange-500 {
--tw-bg-opacity: 1;
background-color: rgb(255 90 31 / var(--tw-bg-opacity));
}
.bg-primary {
--tw-bg-opacity: 1;
background-color: rgb(254 65 85 / var(--tw-bg-opacity));
@@ -2268,10 +2248,6 @@ input:checked + .toggle-bg {
background-color: rgb(83 60 91 / var(--tw-bg-opacity));
}
.bg-transparent {
background-color: transparent;
}
.bg-white {
--tw-bg-opacity: 1;
background-color: rgb(255 255 255 / var(--tw-bg-opacity));
@@ -2281,10 +2257,6 @@ input:checked + .toggle-bg {
background-color: rgb(255 255 255 / 0.5);
}
.bg-black\/50 {
background-color: rgb(0 0 0 / 0.5);
}
.bg-opacity-50 {
--tw-bg-opacity: 0.5;
}
@@ -2301,10 +2273,6 @@ input:checked + .toggle-bg {
padding: 0.25rem;
}
.p-1\.5 {
padding: 0.375rem;
}
.p-2 {
padding: 0.5rem;
}
@@ -2321,10 +2289,6 @@ input:checked + .toggle-bg {
padding: 1rem;
}
.p-5 {
padding: 1.25rem;
}
.p-6 {
padding: 1.5rem;
}
@@ -2384,21 +2348,11 @@ input:checked + .toggle-bg {
padding-bottom: 0.75rem;
}
.py-4 {
padding-top: 1rem;
padding-bottom: 1rem;
}
.py-5 {
padding-top: 1.25rem;
padding-bottom: 1.25rem;
}
.py-6 {
padding-top: 1.5rem;
padding-bottom: 1.5rem;
}
.py-8 {
padding-top: 2rem;
padding-bottom: 2rem;
@@ -2424,10 +2378,6 @@ input:checked + .toggle-bg {
padding-left: 0.75rem;
}
.pl-5 {
padding-left: 1.25rem;
}
.pr-4 {
padding-right: 1rem;
}
@@ -2463,11 +2413,6 @@ input:checked + .toggle-bg {
line-height: 1;
}
.text-base {
font-size: 1rem;
line-height: 1.5rem;
}
.text-lg {
font-size: 1.125rem;
line-height: 1.75rem;
@@ -2504,18 +2449,10 @@ input:checked + .toggle-bg {
font-weight: 500;
}
.font-normal {
font-weight: 400;
}
.font-semibold {
font-weight: 600;
}
.uppercase {
text-transform: uppercase;
}
.leading-6 {
line-height: 1.5rem;
}
@@ -2670,12 +2607,6 @@ input:checked + .toggle-bg {
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
}
.shadow-2xl {
--tw-shadow: 0 25px 50px -12px rgb(0 0 0 / 0.25);
--tw-shadow-colored: 0 25px 50px -12px var(--tw-shadow-color);
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
}
.shadow-lg {
--tw-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
--tw-shadow-colored: 0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color);
@@ -2813,6 +2744,46 @@ input:checked + .toggle-bg {
color: rgb(255 255 255 / var(--tw-text-opacity));
}
.tab-btn {
border-radius: 0.5rem;
--tw-bg-opacity: 1;
background-color: rgb(243 244 246 / var(--tw-bg-opacity));
padding-top: 0.5rem;
padding-bottom: 0.5rem;
font-size: 0.875rem;
line-height: 1.25rem;
font-weight: 500;
--tw-text-opacity: 1;
color: rgb(55 65 81 / var(--tw-text-opacity));
}
.tab-btn:hover {
--tw-bg-opacity: 1;
background-color: rgb(229 231 235 / var(--tw-bg-opacity));
}
.tab-btn:focus {
outline: 2px solid transparent;
outline-offset: 2px;
--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000);
--tw-ring-opacity: 1;
--tw-ring-color: rgb(254 65 85 / var(--tw-ring-opacity));
}
.dark .tab-btn {
--tw-bg-opacity: 1;
background-color: rgb(55 65 81 / var(--tw-bg-opacity));
--tw-text-opacity: 1;
color: rgb(209 213 219 / var(--tw-text-opacity));
}
.dark .tab-btn:hover {
--tw-bg-opacity: 1;
background-color: rgb(75 85 99 / var(--tw-bg-opacity));
}
.dark .dark\:prose-invert {
--tw-prose-body: var(--tw-prose-invert-body);
--tw-prose-headings: var(--tw-prose-invert-headings);
@@ -2849,11 +2820,6 @@ input:checked + .toggle-bg {
background-color: rgb(243 244 246 / var(--tw-bg-opacity));
}
.hover\:bg-gray-200:hover {
--tw-bg-opacity: 1;
background-color: rgb(229 231 235 / var(--tw-bg-opacity));
}
.hover\:bg-gray-50:hover {
--tw-bg-opacity: 1;
background-color: rgb(249 250 251 / var(--tw-bg-opacity));
@@ -2894,11 +2860,6 @@ input:checked + .toggle-bg {
color: rgb(75 85 99 / var(--tw-text-opacity));
}
.hover\:text-gray-700:hover {
--tw-text-opacity: 1;
color: rgb(55 65 81 / var(--tw-text-opacity));
}
.hover\:text-gray-900:hover {
--tw-text-opacity: 1;
color: rgb(17 24 39 / var(--tw-text-opacity));
@@ -2909,10 +2870,6 @@ input:checked + .toggle-bg {
color: rgb(255 255 255 / var(--tw-text-opacity));
}
.hover\:underline:hover {
text-decoration-line: underline;
}
.focus\:z-10:focus {
z-index: 10;
}
@@ -2979,11 +2936,6 @@ input:checked + .toggle-bg {
--tw-ring-color: rgb(254 65 85 / var(--tw-ring-opacity));
}
.focus\:ring-red-300:focus {
--tw-ring-opacity: 1;
--tw-ring-color: rgb(248 180 180 / var(--tw-ring-opacity));
}
.focus\:ring-red-500:focus {
--tw-ring-opacity: 1;
--tw-ring-color: rgb(240 82 82 / var(--tw-ring-opacity));
@@ -2998,11 +2950,6 @@ input:checked + .toggle-bg {
border-color: rgb(63 131 248 / var(--tw-border-opacity));
}
.dark .dark\:border-gray-500 {
--tw-border-opacity: 1;
border-color: rgb(107 114 128 / var(--tw-border-opacity));
}
.dark .dark\:border-gray-600 {
--tw-border-opacity: 1;
border-color: rgb(75 85 99 / var(--tw-border-opacity));
@@ -3090,6 +3037,11 @@ input:checked + .toggle-bg {
color: rgb(63 131 248 / var(--tw-text-opacity));
}
.dark .dark\:text-gray-100 {
--tw-text-opacity: 1;
color: rgb(243 244 246 / var(--tw-text-opacity));
}
.dark .dark\:text-gray-200 {
--tw-text-opacity: 1;
color: rgb(229 231 235 / var(--tw-text-opacity));
@@ -3115,11 +3067,6 @@ input:checked + .toggle-bg {
color: rgb(222 247 236 / var(--tw-text-opacity));
}
.dark .dark\:text-green-400 {
--tw-text-opacity: 1;
color: rgb(49 196 141 / var(--tw-text-opacity));
}
.dark .dark\:text-green-500 {
--tw-text-opacity: 1;
color: rgb(14 159 110 / var(--tw-text-opacity));
@@ -3150,11 +3097,6 @@ input:checked + .toggle-bg {
color: rgb(255 255 255 / var(--tw-text-opacity));
}
.dark .dark\:text-gray-100 {
--tw-text-opacity: 1;
color: rgb(243 244 246 / var(--tw-text-opacity));
}
.dark .dark\:placeholder-gray-400::-moz-placeholder {
--tw-placeholder-opacity: 1;
color: rgb(156 163 175 / var(--tw-placeholder-opacity));
@@ -3235,11 +3177,6 @@ input:checked + .toggle-bg {
border-color: rgb(254 65 85 / var(--tw-border-opacity));
}
.dark .dark\:focus\:border-red-500:focus {
--tw-border-opacity: 1;
border-color: rgb(240 82 82 / var(--tw-border-opacity));
}
.dark .dark\:focus\:ring-blue-500:focus {
--tw-ring-opacity: 1;
--tw-ring-color: rgb(63 131 248 / var(--tw-ring-opacity));
@@ -3260,11 +3197,6 @@ input:checked + .toggle-bg {
--tw-ring-color: rgb(55 65 81 / var(--tw-ring-opacity));
}
.dark .dark\:focus\:ring-gray-800:focus {
--tw-ring-opacity: 1;
--tw-ring-color: rgb(31 41 55 / var(--tw-ring-opacity));
}
.dark .dark\:focus\:ring-primary:focus {
--tw-ring-opacity: 1;
--tw-ring-color: rgb(254 65 85 / var(--tw-ring-opacity));
@@ -3275,15 +3207,6 @@ input:checked + .toggle-bg {
--tw-ring-color: rgb(152 38 51 / var(--tw-ring-opacity));
}
.dark .dark\:focus\:ring-red-500:focus {
--tw-ring-opacity: 1;
--tw-ring-color: rgb(240 82 82 / var(--tw-ring-opacity));
}
.dark .dark\:focus\:ring-offset-gray-800:focus {
--tw-ring-offset-color: #1F2937;
}
@media (min-width: 640px) {
.sm\:my-8 {
margin-top: 2rem;
@@ -3397,11 +3320,6 @@ input:checked + .toggle-bg {
padding-bottom: 1rem;
}
.sm\:text-lg {
font-size: 1.125rem;
line-height: 1.75rem;
}
.sm\:text-sm {
font-size: 0.875rem;
line-height: 1.25rem;
@@ -3535,11 +3453,6 @@ input:checked + .toggle-bg {
padding-right: 1.5rem;
}
.lg\:px-8 {
padding-left: 2rem;
padding-right: 2rem;
}
.lg\:py-0 {
padding-top: 0px;
padding-bottom: 0px;

View File

@@ -9,3 +9,10 @@
dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400
dark:hover:bg-gray-700 dark:hover:text-white;
}
.tab-btn {
@apply text-sm font-medium py-2 bg-gray-100 dark:bg-gray-700
rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600
focus:outline-none focus:ring-2 focus:ring-primary
text-gray-700 dark:text-gray-300;
}

View File

@@ -34,7 +34,7 @@
<p class="mt-2 pb-2 text-sm text-red-600 dark:text-red-500"><span
class="font-medium">{{ error }}</span></p>
<form class="space-y-4 md:space-y-6" hx-post="{{ url_for(request.endpoint) }}"
hx-target="#content"
hx-target="#tab-body"
hx-swap="innerHTML">
<div>
<label for="name"

View File

@@ -0,0 +1,172 @@
{# templates/settings.html #}
<section class="bg-gray-50 dark:bg-gray-900 animate__animated animate__fadeIn">
<div class="flex flex-col items-center justify-center px-6 py-8 mx-auto">
<div
id="settings"
class="animate__animated w-full bg-white rounded-lg shadow dark:border md:mt-0 sm:max-w-md xl:p-0 dark:bg-gray-800 dark:border-gray-700"
>
<div class="p-6 space-y-4 md:space-y-6 sm:p-8">
<div class="flex justify-between items-center">
<h1
class="text-xl font-bold leading-tight tracking-tight text-gray-900 md:text-2xl dark:text-white"
>
{{ _("Server Settings") }}
</h1>
</div>
{# flash messages #}
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, msg in messages %}
<div class="text-{{ 'green' if category=='success' else 'red' }}-600">
{{ msg }}
</div>
{% endfor %}
{% endif %}
{% endwith %}
<form
method="post"
{% if setup_mode %}
hx-post="{{ url_for('settings.server_settings', setup=1) }}"
{% else %}
hx-post="{{ url_for('settings.server_settings') }}"
{% endif %}
hx-target="#tab-body"
hx-swap="innerHTML"
class="space-y-4"
>
{{ form.csrf_token }}
{# Server Display Name #}
<div>
{{ form.server_name.label(class="block mb-2 text-sm font-medium text-gray-900 dark:text-white") }}
{{ form.server_name(class="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary focus:border-primary block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white") }}
{% for err in form.server_name.errors %}
<p class="mt-1 text-sm text-red-600 dark:text-red-500">{{ err }}</p>
{% endfor %}
</div>
{# Server Type #}
<div>
{{ form.server_type.label(class="block mb-2 text-sm font-medium text-gray-900 dark:text-white") }}
{{ form.server_type(class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary focus:border-primary block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white") }}
{% for err in form.server_type.errors %}
<p class="mt-1 text-sm text-red-600 dark:text-red-500">{{ err }}</p>
{% endfor %}
</div>
{# Server URL #}
<div>
{{ form.server_url.label(class="block mb-2 text-sm font-medium text-gray-900 dark:text-white") }}
{{ form.server_url(class="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary focus:border-primary block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white") }}
{% for err in form.server_url.errors %}
<p class="mt-1 text-sm text-red-600 dark:text-red-500">{{ err }}</p>
{% endfor %}
</div>
{# API Key #}
<div>
{{ form.api_key.label(class="block mb-2 text-sm font-medium text-gray-900 dark:text-white") }}
{{ form.api_key(class="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary focus:border-primary block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white", type="password", autocomplete="off", placeholder="XXXXXXXXXXXXXXXXX") }}
{% for err in form.api_key.errors %}
<p class="mt-1 text-sm text-red-600 dark:text-red-500">{{ err }}</p>
{% endfor %}
</div>
<div>
{{ form.libraries.label(class="block mb-2 text-sm font-medium text-gray-900 dark:text-white") }}
<a
id="scan-btn"
class="block mb-2 text-sm text-secondary dark:text-primary cursor-pointer"
hx-post="{{ url_for('settings.scan_libraries') }}"
hx-include="[name=server_type],[name=server_url],[name=api_key]"
hx-target="#libraries"
hx-swap="innerHTML"
>
{{ _("Scan For Libraries") }}
</a>
<div id="libraries" class="space-y-2">
{# server-side pre-population still works exactly as you have it #}
</div>
{% for err in form.libraries.errors %}
<p class="mt-1 text-sm text-red-600 dark:text-red-500">{{ err }}</p>
{% endfor %}
</div>
{# Overseerr / Ombi URL & API #}
<div>
{{ form.overseerr_url.label(class="block mb-2 text-sm font-medium text-gray-900 dark:text-white") }}
{{ form.overseerr_url(class="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary focus:border-primary block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white", placeholder=_("Optional, leave empty to disable")) }}
{% for err in form.overseerr_url.errors %}
<p class="mt-1 text-sm text-red-600 dark:text-red-500">{{ err }}</p>
{% endfor %}
</div>
<div id="ombi_api" class="{% if not form.overseerr_url.data %}hidden{% endif %}">
{{ form.ombi_api_key.label(class="block mb-1 text-sm font-medium text-gray-900 dark:text-white") }}
{{ form.ombi_api_key(class="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary focus:border-primary block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white", placeholder=_("Optional, leave empty to disable")) }}
{% for err in form.ombi_api_key.errors %}
<p class="mt-1 text-sm text-red-600 dark:text-red-500">{{ err }}</p>
{% endfor %}
</div>
{# Discord ID #}
<div>
{{ form.discord_id.label(class="block mb-2 text-sm font-medium text-gray-900 dark:text-white") }}
{{ form.discord_id(class="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary focus:border-primary block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white", placeholder=_("Optional, leave empty to disable")) }}
{% for err in form.discord_id.errors %}
<p class="mt-1 text-sm text-red-600 dark:text-red-500">{{ err }}</p>
{% endfor %}
</div>
{# Submit #}
<div>
<button
type="submit"
class="w-full text-white bg-primary hover:bg-amber-700 focus:ring-4 focus:outline-none focus:ring-amber-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-primary dark:hover:bg-amber-700 dark:focus:ring-primary_hover"
>
{{ _("Save") }}
</button>
</div>
</form>
</div>
</div>
</div>
</section>
<script>
// show/hide Ombi field
document.getElementById("requests_url").addEventListener("input", function () {
const ombiDiv = document.getElementById("ombi_api");
this.value ? ombiDiv.classList.remove("hidden") : ombiDiv.classList.add("hidden");
});
</script>
<script>
// when user types in field `requests_url`, and requests url is not empty, remove hiden form `ombi_api` div, if not add the hidden class
document.getElementById("requests_url").addEventListener("input", function () {
if (document.getElementById("requests_url").value != "") {
document.getElementById("ombi_api").classList.remove("hidden");
} else {
document.getElementById("ombi_api").classList.add("hidden");
}
});
</script>
<script>
// when user types in field `requests_url`, and requests url is not empty, remove hiden form `ombi_api` div, if not add the hidden class
document.getElementById("requests_url").addEventListener("input", function () {
if (document.getElementById("requests_url").value != "") {
document.getElementById("ombi_api").classList.remove("hidden");
} else {
document.getElementById("ombi_api").classList.add("hidden");
}
});
</script>

View File

@@ -1,183 +1,48 @@
{# templates/settings.html #}
<section class="bg-gray-50 dark:bg-gray-900 animate__animated animate__fadeIn">
<div class="flex flex-col items-center justify-center px-6 py-8 mx-auto">
<div
id="settings"
class="animate__animated w-full bg-white rounded-lg shadow dark:border md:mt-0 sm:max-w-md xl:p-0 dark:bg-gray-800 dark:border-gray-700"
<section class="bg-gray-50 dark:bg-gray-900 py-8 animate__animated animate__fadeIn">
<div class="flex justify-center">
<div class="w-full max-w-2xl bg-white dark:bg-gray-800 rounded-lg shadow">
{# ───────────── tabs header ──────────────── #}
<div class="flex border-b dark:border-gray-700">
<button
class="tab-btn grow py-3 text-center font-medium text-primary border-b-2 border-primary"
hx-get="{{ url_for('settings.server_tab') }}"
hx-target="#settings-body"
hx-swap="innerHTML"
>
<div class="p-6 space-y-4 md:space-y-6 sm:p-8">
<div class="flex justify-between items-center">
<h1
class="text-xl font-bold leading-tight tracking-tight text-gray-900 md:text-2xl dark:text-white"
>
{{ _("Server Settings") }}
</h1>
{% if not setup_mode %}
<button
hx-get="{{ url_for('notify.list_agents') }}"
hx-trigger="click"
hx-target="#content"
hx-swap="innerHTML"
>
<!-- bell icon -->
<svg
class="w-6 h-6 dark:text-white"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M14.857 17.082a23.848 23.848 0 005.454-1.31A8.967 8.967 0 0118 9.75v-.7V9A6 6 0 006 9v.75a8.967 8.967 0 01-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 01-5.714 0m5.714 0a3 3 0 11-5.714 0"
></path>
</svg>
</button>
{% endif %}
</div>
{{ _("Server settings") }}
</button>
{# flash messages #}
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, msg in messages %}
<div class="text-{{ 'green' if category=='success' else 'red' }}-600">
{{ msg }}
</div>
{% endfor %}
{% endif %}
{% endwith %}
<button
class="tab-btn grow py-3 text-center font-medium text-gray-500 dark:text-gray-400"
hx-get="{{ url_for('notify.list_agents') }}"
hx-target="#settings-body"
hx-swap="innerHTML"
>
{{ _("Notification agents") }}
</button>
</div>
<form
method="post"
{% if setup_mode %}
hx-post="{{ url_for('setup.onboarding') }}"
{% else %}
hx-post="{{ url_for('settings.secure_settings') }}"
{% endif %}
hx-target="#content"
hx-swap="innerHTML"
class="space-y-4 md:space-y-6"
>
{{ form.csrf_token }}
{# Server Display Name #}
<div>
{{ form.server_name.label(class="block mb-2 text-sm font-medium text-gray-900 dark:text-white") }}
{{ form.server_name(class="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary focus:border-primary block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white") }}
{% for err in form.server_name.errors %}
<p class="mt-1 text-sm text-red-600 dark:text-red-500">{{ err }}</p>
{% endfor %}
</div>
{# Server Type #}
<div>
{{ form.server_type.label(class="block mb-2 text-sm font-medium text-gray-900 dark:text-white") }}
{{ form.server_type(class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary focus:border-primary block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white") }}
{% for err in form.server_type.errors %}
<p class="mt-1 text-sm text-red-600 dark:text-red-500">{{ err }}</p>
{% endfor %}
</div>
{# Server URL #}
<div>
{{ form.server_url.label(class="block mb-2 text-sm font-medium text-gray-900 dark:text-white") }}
{{ form.server_url(class="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary focus:border-primary block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white") }}
{% for err in form.server_url.errors %}
<p class="mt-1 text-sm text-red-600 dark:text-red-500">{{ err }}</p>
{% endfor %}
</div>
{# API Key #}
<div>
{{ form.api_key.label(class="block mb-2 text-sm font-medium text-gray-900 dark:text-white") }}
{{ form.api_key(class="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary focus:border-primary block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white", type="password", autocomplete="off", placeholder="XXXXXXXXXXXXXXXXX") }}
{% for err in form.api_key.errors %}
<p class="mt-1 text-sm text-red-600 dark:text-red-500">{{ err }}</p>
{% endfor %}
</div>
<div>
{{ form.libraries.label(class="block mb-2 text-sm font-medium text-gray-900 dark:text-white") }}
<a
id="scan-btn"
class="block mb-2 text-sm text-secondary dark:text-primary cursor-pointer"
hx-post="{{ url_for('settings.scan_libraries') }}"
hx-include="[name=server_type],[name=server_url],[name=api_key]"
hx-target="#libraries"
hx-swap="innerHTML"
>
{{ _("Scan For Libraries") }}
</a>
<div id="libraries" class="space-y-2">
{# server-side pre-population still works exactly as you have it #}
</div>
{% for err in form.libraries.errors %}
<p class="mt-1 text-sm text-red-600 dark:text-red-500">{{ err }}</p>
{% endfor %}
</div>
{# Overseerr / Ombi URL & API #}
<div>
{{ form.overseerr_url.label(class="block mb-2 text-sm font-medium text-gray-900 dark:text-white") }}
{{ form.overseerr_url(class="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary focus:border-primary block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white", placeholder=_("Optional, leave empty to disable")) }}
{% for err in form.overseerr_url.errors %}
<p class="mt-1 text-sm text-red-600 dark:text-red-500">{{ err }}</p>
{% endfor %}
</div>
<div id="ombi_api" class="{% if not form.overseerr_url.data %}hidden{% endif %}">
{{ form.ombi_api_key.label(class="block mb-1 text-sm font-medium text-gray-900 dark:text-white") }}
{{ form.ombi_api_key(class="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary focus:border-primary block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white", placeholder=_("Optional, leave empty to disable")) }}
{% for err in form.ombi_api_key.errors %}
<p class="mt-1 text-sm text-red-600 dark:text-red-500">{{ err }}</p>
{% endfor %}
</div>
{# Discord ID #}
<div>
{{ form.discord_id.label(class="block mb-2 text-sm font-medium text-gray-900 dark:text-white") }}
{{ form.discord_id(class="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary focus:border-primary block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white", placeholder=_("Optional, leave empty to disable")) }}
{% for err in form.discord_id.errors %}
<p class="mt-1 text-sm text-red-600 dark:text-red-500">{{ err }}</p>
{% endfor %}
</div>
{# Submit #}
<div>
<button
type="submit"
class="w-full text-white bg-primary hover:bg-amber-700 focus:ring-4 focus:outline-none focus:ring-amber-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-primary dark:hover:bg-amber-700 dark:focus:ring-primary_hover"
>
{{ _("Save") }}
</button>
</div>
</form>
</div>
</div>
{# ───────────── tab body - filled by HTMX ──────────────── #}
<div id="settings-body" class="p-6">
{# first load = server form #}
{% include "partials/server_form.html" %}
</div>
</div>
</div>
</section>
{# ───────────────── htmx-helper to highlight the active tab ───────────────── #}
<script>
// show/hide Ombi field
document.getElementById("requests_url").addEventListener("input", function () {
const ombiDiv = document.getElementById("ombi_api");
this.value ? ombiDiv.classList.remove("hidden") : ombiDiv.classList.add("hidden");
});
document.body.addEventListener("htmx:afterRequest", (evt) => {
const btn = evt.detail.elt;
if (btn.classList.contains("tab-btn")) {
document.querySelectorAll(".tab-btn").forEach((b) => {
b.classList.remove("text-primary", "border-primary");
b.classList.add("text-gray-500", "dark:text-gray-400", "border-transparent");
});
btn.classList.remove("text-gray-500", "dark:text-gray-400", "border-transparent");
btn.classList.add("text-primary", "border-primary");
}
});
</script>
<script>
// when user types in field `requests_url`, and requests url is not empty, remove hiden form `ombi_api` div, if not add the hidden class
document.getElementById("requests_url").addEventListener("input", function () {
if (document.getElementById("requests_url").value != "") {
document.getElementById("ombi_api").classList.remove("hidden");
} else {
document.getElementById("ombi_api").classList.add("hidden");
}
});
</script>

View File

@@ -0,0 +1,47 @@
{# templates/settings/page.html #}
{% extends "base.html" %}
{% block title %}{{ _("Settings") }}{% endblock %}
{% block main %}
<section class="flex flex-col items-center justify-center px-6 py-8">
<div class="w-full max-w-md space-y-6">
<div class="flex gap-2">
<button
class="tab-btn flex-1 flex items-center justify-center gap-1"
hx-get="{{ url_for('settings.server_settings') }}"
hx-target="#tab-body" hx-swap="innerHTML"
hx-trigger="load,click" {# load FIRST + every click #}
>
<svg class="w-6 h-6 text-gray-800 dark:text-white" aria-hidden="true"
xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor"
viewBox="0 0 24 24">
<path fill-rule="evenodd"
d="M5 5a2 2 0 0 0-2 2v3a1 1 0 0 0 1 1h16a1 1 0 0 0 1-1V7a2 2 0 0 0-2-2H5Zm9 2a1 1 0 1 0 0 2h.01a1 1 0 1 0 0-2H14Zm3 0a1 1 0 1 0 0 2h.01a1 1 0 1 0 0-2H17ZM3 17v-3a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v3a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Zm11-2a1 1 0 1 0 0 2h.01a1 1 0 1 0 0-2H14Zm3 0a1 1 0 1 0 0 2h.01a1 1 0 1 0 0-2H17Z"
clip-rule="evenodd"/>
</svg>
{{ _("Server") }}
</button>
<button
class="tab-btn flex-1 flex items-center justify-center gap-1"
hx-get="{{ url_for('notify.list_agents') }}"
hx-target="#tab-body" hx-swap="innerHTML"
>
<svg class="w-6 h-6 text-gray-800 dark:text-white" aria-hidden="true"
xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor"
viewBox="0 0 24 24">
<path
d="M17.133 12.632v-1.8a5.407 5.407 0 0 0-4.154-5.262.955.955 0 0 0 .021-.106V3.1a1 1 0 0 0-2 0v2.364a.933.933 0 0 0 .021.106 5.406 5.406 0 0 0-4.154 5.262v1.8C6.867 15.018 5 15.614 5 16.807 5 17.4 5 18 5.538 18h12.924C19 18 19 17.4 19 16.807c0-1.193-1.867-1.789-1.867-4.175Zm-13.267-.8a1 1 0 0 1-1-1 9.424 9.424 0 0 1 2.517-6.391A1.001 1.001 0 1 1 6.854 5.8a7.43 7.43 0 0 0-1.988 5.037 1 1 0 0 1-1 .995Zm16.268 0a1 1 0 0 1-1-1A7.431 7.431 0 0 0 17.146 5.8a1 1 0 0 1 1.471-1.354 9.424 9.424 0 0 1 2.517 6.391 1 1 0 0 1-1 .995ZM8.823 19a3.453 3.453 0 0 0 6.354 0H8.823Z"/>
</svg>
{{ _("Notifications") }}
</button>
</div>
<div id="tab-body" class="animate__animated animate__fadeIn"></div>
</div>
</section>
{% endblock %}

View File

@@ -6,7 +6,7 @@ Setup Wizarr
{% block main %}
<div id="content">
{% include "settings.html" %}
<div id="tab-body">
{% include "partials/server_form.html" %}
</div>
{% endblock %}

View File

@@ -7,6 +7,6 @@ Setup Wizarr
{% block main %}
<div id="content">
{% include "settings.html" %}
{% include "settings/partials/server_settings.html" %}
</div>
{% endblock %}

View File

@@ -1,8 +1,14 @@
#!/bin/sh
set -e
#!/usr/bin/env sh
set -eu
# The migrations folder is already inside the image.
# Just apply whatever's new:
# 1⃣ one-off legacy migration (fast no-op on new installs)
uv run python -m app.legacy_migration.rename_legacy
# 2⃣ Alembic migrations as usual
uv run flask db upgrade
exec "$@" # hand off to gunicorn
# 3⃣ Attempt moving legacy data to new tables
uv run python -m app.legacy_migration.import_legacy
# 4⃣ start Gunicorn / Flask
exec "$@"