diff --git a/README.md b/README.md index 12df3ec..bb15f84 100644 --- a/README.md +++ b/README.md @@ -14,9 +14,12 @@ A Flask web app for browsing and playing TeslaCam (Tesla Dashcam/Sentry) recordi ## Project Structure ```txt -tesla-cam-view-1/ +tesla-cam-view/ ├── videoplayer.py # Flask app: scanning, routing, video streaming -├── requirements.txt # Python dependencies (Flask) +├── requirements.txt # Python dependencies +├── .env # Local configuration (git-ignored) +├── .env.example # Configuration template (committed) +├── .gitignore ├── templates/ │ ├── index.html # Main page: camera tabs, player, clip list │ └── camera_view.html # Single-camera view page @@ -26,7 +29,7 @@ tesla-cam-view-1/ ## Requirements - Python 3.x -- Flask, python-dotenv, gunicorn (see `requirements.txt`) +- See `requirements.txt`: Flask, python-dotenv, gunicorn ## Setup @@ -46,24 +49,31 @@ tesla-cam-view-1/ pip install -r requirements.txt ``` -3. Configure the TeslaCam directory via environment variables. Copy the example - file and edit it: +3. Configure the app via environment variables. Copy the example file and edit it: ```bash cp .env.example .env ``` - ```ini - TESLACAM_DIRECTORY=/mnt/TeslaCam - FLASK_RUN_HOST=0.0.0.0 - FLASK_RUN_PORT=5000 - FLASK_DEBUG=0 - ``` - `TESLACAM_DIRECTORY` should point to the root folder containing your `RecentClips`, `SavedClips`, and `SentryClips` subfolders. Variables set in the real environment take precedence over `.env`. +## Configuration + +All settings are read from environment variables, with sensible defaults. You +can set them in a `.env` file, export them in the shell, or pass them to the +process manager. + +| Variable | Default | Description | +| -------------------- | --------------- | ----------------------------------------------- | +| `TESLACAM_DIRECTORY` | `/mnt/TeslaCam` | Root directory of the TeslaCam USB drive | +| `FLASK_RUN_HOST` | `0.0.0.0` | Bind address (local dev server only) | +| `FLASK_RUN_PORT` | `5000` | Port (local dev server only) | +| `FLASK_DEBUG` | `0` | Enable Flask debug mode (local dev server only) | +| `SCAN_CACHE_TTL` | `3600` | Seconds to cache the directory scan. Hit | +| | | `/refresh` to force a re-scan. | + ## Usage ### Local development server @@ -80,15 +90,15 @@ For production, run the app behind a WSGI server such as **gunicorn**. The `app` object in `videoplayer.py` is the WSGI entry point: ```bash -gunicorn -w 4 -b 0.0.0.0:5000 'videoplayer:app' +# Two workers are sufficient for this app +gunicorn -w 2 -b 0.0.0.0:5000 'videoplayer:app' ``` -`TESLACAM_DIRECTORY` is read from the environment (or `.env`), so make sure it -is set for the gunicorn process, e.g.: +Make sure `TESLACAM_DIRECTORY` is set for the gunicorn process, e.g.: ```bash export TESLACAM_DIRECTORY=/mnt/TeslaCam -gunicorn -w 4 -b 0.0.0.0:5000 'videoplayer:app' +gunicorn -w 2 -b 0.0.0.0:5000 'videoplayer:app' ``` For a systemd service or other process manager, set the variable in the unit @@ -121,9 +131,13 @@ file / environment rather than relying on `.env`. ## Notes - The directory scan is cached in memory for `SCAN_CACHE_TTL` seconds - (default 300) to avoid re-walking the tree on every request. Hit + (default 3600) to avoid re-walking the tree on every request. Hit `/refresh` to force a re-scan after copying new clips onto the drive. +- Under gunicorn with multiple workers, each worker keeps its own cache; a + `/refresh` only refreshes the worker that handles the request. - Debug mode is off by default (`FLASK_DEBUG=0`) and is only used by the local dev server. It has no effect when running under a WSGI server like gunicorn. -- The TeslaCam directory must be accessible from the machine running the app (e.g., a mounted USB drive or network share). -- Filename parsing assumes the standard TeslaCam naming format; files not matching the regex are ignored. +- The TeslaCam directory must be accessible from the machine running the app + (e.g., a mounted USB drive or network share). +- Filename parsing assumes the standard TeslaCam naming format; files not + matching the regex are ignored. diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000..fa7722e --- /dev/null +++ b/static/css/style.css @@ -0,0 +1,505 @@ +:root { + --bg-primary: #0a0a0c; + --bg-secondary: #17171a; + --bg-tertiary: #202024; + --bg-elevated: #2a2a2f; + --accent: #e31937; /* Tesla Red */ + --accent-hover: #ff2b4d; + --text-primary: #f4f4f5; + --text-secondary: #a1a1aa; + --text-muted: #6b6b73; + --border: #303036; + --radius: 12px; + --radius-sm: 8px; + --shadow: 0 8px 30px rgba(0, 0, 0, 0.5); + --font: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, + Helvetica, Arial, sans-serif; +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html, +body { + height: 100%; + background: var(--bg-primary); + color: var(--text-primary); + font-family: var(--font); + -webkit-font-smoothing: antialiased; +} + +body { + display: flex; + flex-direction: column; + min-height: 100vh; +} + +/* Header */ +header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 24px; + background: linear-gradient(180deg, #131316 0%, var(--bg-secondary) 100%); + border-bottom: 1px solid var(--border); + position: sticky; + top: 0; + z-index: 50; + gap: 16px; + flex-wrap: wrap; +} + +.brand { + display: flex; + align-items: center; + gap: 12px; + font-weight: 700; + font-size: 18px; + letter-spacing: 0.5px; +} +.brand .logo { + width: 30px; + height: 30px; + display: grid; + place-items: center; + color: var(--accent); +} +.brand .logo svg { + width: 100%; + height: 100%; + fill: var(--accent); +} +.brand small { + display: block; + font-size: 11px; + font-weight: 500; + color: var(--text-muted); + letter-spacing: 1.5px; + text-transform: uppercase; +} + +.search-wrap { + flex: 1 1 320px; + max-width: 480px; + position: relative; +} +.search-wrap input { + width: 100%; + padding: 11px 40px 11px 40px; + background: var(--bg-tertiary); + border: 1px solid var(--border); + border-radius: 30px; + color: var(--text-primary); + font-size: 14px; + outline: none; + transition: border-color 0.2s, box-shadow 0.2s; +} +.search-wrap input:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(227, 25, 55, 0.15); +} +.search-wrap .search-icon { + position: absolute; + left: 14px; + top: 50%; + transform: translateY(-50%); + color: var(--text-muted); + pointer-events: none; +} +.search-wrap .clear-btn { + position: absolute; + right: 10px; + top: 50%; + transform: translateY(-50%); + background: var(--bg-elevated); + border: none; + color: var(--text-secondary); + width: 22px; + height: 22px; + border-radius: 50%; + cursor: pointer; + display: none; + align-items: center; + justify-content: center; + font-size: 14px; + line-height: 1; +} +.search-wrap .clear-btn.visible { + display: flex; +} + +/* Layout */ +.layout { + display: grid; + grid-template-columns: minmax(0, 1.9fr) minmax(320px, 1fr); + gap: 20px; + padding: 20px 24px; + flex: 1; + align-items: start; +} + +/* Video Player Section */ +.player-panel { + position: sticky; + top: 84px; + display: flex; + flex-direction: column; + gap: 14px; +} + +.video-stage { + position: relative; + background: #000; + border-radius: var(--radius); + overflow: hidden; + box-shadow: var(--shadow); + border: 1px solid var(--border); + aspect-ratio: 16 / 9; +} +.video-stage video { + width: 100%; + height: 100%; + display: block; + object-fit: contain; + background: #000; +} +.video-placeholder { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + color: var(--text-muted); + text-align: center; + padding: 20px; +} +.video-placeholder svg { + width: 54px; + height: 54px; + opacity: 0.4; +} +.video-badge { + position: absolute; + top: 12px; + left: 12px; + background: rgba(0, 0, 0, 0.65); + backdrop-filter: blur(6px); + padding: 6px 12px; + border-radius: 20px; + font-size: 12px; + font-weight: 600; + display: none; + align-items: center; + gap: 8px; + border: 1px solid rgba(255, 255, 255, 0.08); +} +.video-badge.visible { + display: flex; +} +.video-badge .dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--accent); + box-shadow: 0 0 8px var(--accent); +} + +/* Camera Synchronized Switch Bar */ +.cam-switch { + display: flex; + flex-wrap: wrap; + gap: 8px; + padding: 12px; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius); +} +.cam-switch .cs-label { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 1px; + color: var(--text-muted); + width: 100%; + margin-bottom: 2px; +} +.cam-switch button { + flex: 1 1 auto; + min-width: 88px; + padding: 9px 10px; + background: var(--bg-tertiary); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text-secondary); + font-size: 12.5px; + font-weight: 600; + cursor: pointer; + transition: all 0.15s; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; +} +.cam-switch button:hover:not(:disabled) { + background: var(--bg-elevated); + color: var(--text-primary); + border-color: #444; +} +.cam-switch button.active { + background: var(--accent); + border-color: var(--accent); + color: #fff; +} +.cam-switch button:disabled { + opacity: 0.3; + cursor: not-allowed; +} + +.now-playing { + font-size: 13px; + color: var(--text-secondary); + padding: 0 4px; + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} +.now-playing strong { + color: var(--text-primary); +} +.now-playing .ts { + color: var(--accent); + font-variant-numeric: tabular-nums; +} + +/* Sidebar & Clip List */ +.sidebar { + display: flex; + flex-direction: column; + gap: 14px; + min-width: 0; +} + +.cam-tabs { + display: flex; + flex-wrap: wrap; + gap: 8px; +} +.cam-tabs .tab { + padding: 8px 14px; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: 30px; + color: var(--text-secondary); + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: all 0.15s; + display: flex; + align-items: center; + gap: 7px; +} +.cam-tabs .tab:hover { + background: var(--bg-tertiary); + color: var(--text-primary); +} +.cam-tabs .tab.active { + background: var(--accent); + border-color: var(--accent); + color: #fff; +} +.cam-tabs .tab .count { + background: rgba(255, 255, 255, 0.12); + padding: 1px 7px; + border-radius: 20px; + font-size: 11px; + font-weight: 700; +} +.cam-tabs .tab.active .count { + background: rgba(0, 0, 0, 0.25); +} + +.list-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + font-size: 12px; + color: var(--text-muted); +} +.list-toolbar select { + background: var(--bg-tertiary); + border: 1px solid var(--border); + color: var(--text-secondary); + border-radius: var(--radius-sm); + padding: 6px 10px; + font-size: 12px; + cursor: pointer; + outline: none; +} + +.clip-list { + display: flex; + flex-direction: column; + gap: 8px; + max-height: calc(100vh - 260px); + overflow-y: auto; + padding-right: 4px; +} +.clip-list::-webkit-scrollbar { + width: 8px; +} +.clip-list::-webkit-scrollbar-thumb { + background: var(--bg-elevated); + border-radius: 10px; +} +.clip-list::-webkit-scrollbar-track { + background: transparent; +} + +.cam-group-header { + font-size: 12px; + text-transform: uppercase; + letter-spacing: 1px; + color: var(--text-muted); + padding: 10px 4px 4px; + font-weight: 700; + display: flex; + align-items: center; + justify-content: space-between; + position: sticky; + top: 0; + background: var(--bg-primary); + z-index: 2; +} +.cam-group-header a { + color: var(--accent); + text-decoration: none; + font-size: 11px; + font-weight: 600; + text-transform: none; + letter-spacing: 0; + display: inline-flex; + align-items: center; + gap: 4px; +} +.cam-group-header a:hover { + text-decoration: underline; +} + +.clip { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 12px; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + cursor: pointer; + transition: all 0.15s; +} +.clip:hover { + background: var(--bg-tertiary); + border-color: #3d3d44; + transform: translateX(2px); +} +.clip.playing { + border-color: var(--accent); + background: linear-gradient( + 90deg, + rgba(227, 25, 55, 0.12), + var(--bg-tertiary) + ); +} +.clip .thumb { + flex-shrink: 0; + width: 44px; + height: 44px; + border-radius: 8px; + background: var(--bg-tertiary); + display: grid; + place-items: center; + color: var(--accent); +} +.clip.playing .thumb { + background: rgba(227, 25, 55, 0.15); +} +.clip .meta { + min-width: 0; + flex: 1; +} +.clip .meta .fn { + font-size: 13px; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: var(--text-primary); +} +.clip .meta .sub { + font-size: 11.5px; + color: var(--text-muted); + margin-top: 2px; + display: flex; + gap: 8px; + flex-wrap: wrap; +} +.clip .meta .sub .cam-pill { + color: var(--text-secondary); + background: var(--bg-tertiary); + padding: 0 7px; + border-radius: 20px; + font-weight: 600; +} +.clip .play-ico { + color: var(--text-muted); + flex-shrink: 0; +} +.clip.playing .play-ico { + color: var(--accent); +} + +.empty { + text-align: center; + padding: 40px 20px; + color: var(--text-muted); + font-size: 14px; +} +.empty svg { + width: 40px; + height: 40px; + opacity: 0.3; + margin-bottom: 10px; +} + +@media (max-width: 960px) { + .layout { + grid-template-columns: 1fr; + } + .player-panel { + position: static; + } + .clip-list { + max-height: none; + } +} +@media (max-width: 560px) { + header { + padding: 12px 16px; + } + .layout { + padding: 14px 16px; + } + .brand small { + display: none; + } +} + +[hidden] { + display: none !important; +} \ No newline at end of file diff --git a/static/js/app.js b/static/js/app.js new file mode 100644 index 0000000..aee332d --- /dev/null +++ b/static/js/app.js @@ -0,0 +1,394 @@ +(function () { + "use strict"; + + const RAW = JSON.parse( + document.getElementById("videoData").textContent || "{}", + ); + + const cameras = {}; + const clips = []; + Object.keys(RAW).forEach(function (key) { + const cam = RAW[key]; + cameras[key] = cam.display; + (cam.videos || []).forEach(function (v) { + clips.push({ + cam: key, + camDisplay: cam.display, + filename: v.filename, + timestamp: v.timestamp, + rel_path: v.rel_path, + url: v.url, + }); + }); + }); + + // Index clips by timestamp for angle synchronization + const byTimestamp = {}; + clips.forEach(function (c) { + if (!byTimestamp[c.timestamp]) byTimestamp[c.timestamp] = {}; + byTimestamp[c.timestamp][c.cam] = c; + }); + + const player = document.getElementById("player"); + const placeholder = document.getElementById("videoPlaceholder"); + const badge = document.getElementById("videoBadge"); + const badgeText = document.getElementById("badgeText"); + const clipListEl = document.getElementById("clipList"); + const searchBox = document.getElementById("searchBox"); + const clearSearch = document.getElementById("clearSearch"); + const camTabsEl = document.getElementById("camTabs"); + const sortSelect = document.getElementById("sortSelect"); + const resultCount = document.getElementById("resultCount"); + const camSwitchBtns = document.getElementById("camSwitchButtons"); + const nowPlaying = document.getElementById("nowPlaying"); + const npName = document.getElementById("npName"); + const npTime = document.getElementById("npTime"); + document.getElementById("countAll").textContent = clips.length; + + let activeCam = "all"; + let query = ""; + let sortMode = "oldest"; + let current = null; + + function tsToDate(ts) { + const m = /^(\d{4})-(\d{2})-(\d{2})[_ ](\d{2})-(\d{2})-(\d{2})/.exec( + ts || "", + ); + if (!m) return null; + return new Date(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], +m[6]); + } + + function prettyTs(ts) { + const d = tsToDate(ts); + if (!d) return ts || "Unknown"; + return d.toLocaleString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); + } + + function esc(s) { + return String(s).replace(/[&<>"']/g, function (c) { + return { + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", + }[c]; + }); + } + + function getFiltered() { + let out = clips.filter(function (c) { + if (activeCam !== "all" && c.cam !== activeCam) return false; + if (query) { + const hay = ( + c.filename + + " " + + c.timestamp + + " " + + c.camDisplay + + " " + + prettyTs(c.timestamp) + ).toLowerCase(); + if (hay.indexOf(query) === -1) return false; + } + return true; + }); + out.sort(function (a, b) { + if (sortMode === "name") return a.filename.localeCompare(b.filename); + const da = tsToDate(a.timestamp), + db = tsToDate(b.timestamp); + const ta = da ? da.getTime() : 0, + tb = db ? db.getTime() : 0; + return sortMode === "oldest" ? ta - tb : tb - ta; + }); + return out; + } + + function render() { + const list = getFiltered(); + resultCount.textContent = + list.length + (list.length === 1 ? " clip" : " clips"); + + if (!list.length) { + clipListEl.innerHTML = + '
No clips match your filters.
'; + return; + } + + let html = ""; + if (activeCam === "all") { + const groups = {}; + list.forEach(function (c) { + (groups[c.cam] = groups[c.cam] || []).push(c); + }); + Object.keys(groups).forEach(function (key) { + html += groupHeader(key, groups[key].length); + groups[key].forEach(function (c) { + html += clipRow(c); + }); + }); + } else { + html += groupHeader(activeCam, list.length); + list.forEach(function (c) { + html += clipRow(c); + }); + } + clipListEl.innerHTML = html; + highlightCurrent(); + } + + function groupHeader(key, n) { + return ( + '
' + + esc(cameras[key] || key) + + " · " + + n + + "" + + "
" + ); + } + + function clipRow(c) { + const id = c.cam + "|" + c.timestamp + "|" + c.filename; + return ( + '
' + + '
' + + '' + + "
" + + '
' + + '
' + + esc(c.filename) + + "
" + + '
' + + esc(c.camDisplay) + + "" + + "" + + esc(prettyTs(c.timestamp)) + + "
" + + "
" + + '
' + + '' + + "
" + + "
" + ); + } + + function playClip(clip) { + if (!clip) return; + current = clip; + player.src = clip.url; + player.load(); + player.play().catch(function () {}); + + placeholder.hidden = true; + badge.classList.add("visible"); + badgeText.textContent = clip.camDisplay; + + nowPlaying.hidden = false; + npName.textContent = clip.filename; + npTime.textContent = prettyTs(clip.timestamp); + + buildCamSwitch(clip); + highlightCurrent(); + } + + function buildCamSwitch(clip) { + const group = byTimestamp[clip.timestamp] || {}; + const availKeys = Object.keys(group); + + let html = ""; + Object.keys(cameras).forEach(function (key) { + const has = group[key]; + const isActive = key === clip.cam; + html += + '"; + }); + if (availKeys.length <= 1) { + html += + '' + + "Only one camera view recorded for this timestamp."; + } + camSwitchBtns.innerHTML = html; + } + + function switchCam(key) { + if (!current) return; + const group = byTimestamp[current.timestamp] || {}; + const target = group[key]; + if (!target) return; + + const savedTime = player.currentTime || 0; + const wasPlaying = !player.paused; + + current = target; + player.src = target.url; + player.load(); + + badgeText.textContent = target.camDisplay; + npName.textContent = target.filename; + + // Preserve video playback timestamp position across camera angles + player.addEventListener("loadedmetadata", function onMeta() { + player.removeEventListener("loadedmetadata", onMeta); + try { + if (savedTime && savedTime < player.duration) { + player.currentTime = savedTime; + } + } catch (e) {} + if (wasPlaying) player.play().catch(function () {}); + }); + + camSwitchBtns.querySelectorAll("button").forEach(function (b) { + b.classList.toggle("active", b.getAttribute("data-switch") === key); + }); + highlightCurrent(); + } + + function highlightCurrent() { + const rows = clipListEl.querySelectorAll(".clip"); + rows.forEach(function (r) { + const isCur = + current && + r.getAttribute("data-cam") === current.cam && + r.getAttribute("data-ts") === current.timestamp && + r.getAttribute("data-fn") === current.filename; + r.classList.toggle("playing", !!isCur); + }); + } + + clipListEl.addEventListener("click", function (e) { + const row = e.target.closest(".clip"); + if (!row) return; + playClip({ + cam: row.getAttribute("data-cam"), + camDisplay: cameras[row.getAttribute("data-cam")], + filename: row.getAttribute("data-fn"), + timestamp: row.getAttribute("data-ts"), + url: row.getAttribute("data-url"), + }); + }); + + camSwitchBtns.addEventListener("click", function (e) { + const btn = e.target.closest("button[data-switch]"); + if (!btn || btn.disabled) return; + switchCam(btn.getAttribute("data-switch")); + }); + + camTabsEl.addEventListener("click", function (e) { + const tab = e.target.closest(".tab"); + if (!tab) return; + camTabsEl.querySelectorAll(".tab").forEach(function (t) { + t.classList.remove("active"); + }); + tab.classList.add("active"); + activeCam = tab.getAttribute("data-cam"); + render(); + }); + + let searchTimer; + searchBox.addEventListener("input", function () { + clearSearch.classList.toggle("visible", !!searchBox.value); + clearTimeout(searchTimer); + searchTimer = setTimeout(function () { + query = searchBox.value.trim().toLowerCase(); + render(); + }, 120); + }); + + clearSearch.addEventListener("click", function () { + searchBox.value = ""; + query = ""; + clearSearch.classList.remove("visible"); + render(); + searchBox.focus(); + }); + + sortSelect.addEventListener("change", function () { + sortMode = sortSelect.value; + render(); + }); + + // Keyboard Shortcuts: 1-9 (Camera Switch), J/K (Next/Prev Clip) + document.addEventListener("keydown", function (e) { + if (/INPUT|TEXTAREA|SELECT/.test(document.activeElement.tagName)) return; + if (e.key >= "1" && e.key <= "9") { + const keys = Object.keys(cameras); + const idx = +e.key - 1; + if (current && keys[idx]) switchCam(keys[idx]); + } else if (e.key === "j" || e.key === "k") { + const list = getFiltered(); + if (!list.length) return; + let i = list.findIndex(function (c) { + return ( + current && + c.cam === current.cam && + c.timestamp === current.timestamp && + c.filename === current.filename + ); + }); + i = + e.key === "j" + ? Math.min(list.length - 1, i + 1) + : Math.max(0, i - 1); + if (list[i]) playClip(list[i]); + } + }); + + // Auto-play next clip of the same camera when video finishes + player.addEventListener("ended", function () { + if (!current) return; + // Build a list of clips for the SAME camera, sorted like the sidebar + const sameCam = clips + .filter(function (c) { + return c.cam === current.cam; + }) + .sort(function (a, b) { + if (sortMode === "name") return a.filename.localeCompare(b.filename); + const da = tsToDate(a.timestamp), + db = tsToDate(b.timestamp); + const ta = da ? da.getTime() : 0, + tb = db ? db.getTime() : 0; + return sortMode === "oldest" ? ta - tb : tb - ta; + }); + const i = sameCam.findIndex(function (c) { + return ( + c.cam === current.cam && + c.timestamp === current.timestamp && + c.filename === current.filename + ); + }); + if (i > -1 && sameCam[i + 1]) playClip(sameCam[i + 1]); + }); + + render(); +})(); \ No newline at end of file diff --git a/templates/camera_view.html b/templates/camera_view.html deleted file mode 100644 index f33c68d..0000000 --- a/templates/camera_view.html +++ /dev/null @@ -1,789 +0,0 @@ - - - - - - {{ camera_view_name }} Camera - TeslaCam - - - - -
-
- - - - - All Cameras - -
- - TeslaCam -
-
- -
- - - -
-
- - -
-
-

{{ camera_view_name }} Camera

- {{ videos | length }} Clips -
- - -
- - -
- -
-
- -
- - - - -
Select a video clip from the queue to play
-
-
- - -
- - - -
- - - - - - - diff --git a/templates/index.html b/templates/index.html index 77db409..5d7c6b0 100644 --- a/templates/index.html +++ b/templates/index.html @@ -4,520 +4,7 @@ TeslaCam Video Player - + @@ -655,409 +142,6 @@ } - + diff --git a/videoplayer.py b/videoplayer.py index 160bd03..6a52fc7 100644 --- a/videoplayer.py +++ b/videoplayer.py @@ -14,7 +14,7 @@ import re import time from dotenv import load_dotenv -from flask import Flask, render_template, send_from_directory, abort +from flask import Flask, render_template, send_from_directory, abort, jsonify # Load variables from a .env file if present (does not override real env vars) load_dotenv() @@ -171,6 +171,24 @@ def refresh(): return 'Cache refreshed', 200 +@app.route('/health') +def health(): + """ + Health-check endpoint for load balancers / monitoring. + Returns 200 with a JSON status when the app is running. + """ + return jsonify({'status': 'ok'}) + + +@app.route('/static/') +def serve_static(filename): + """ + Serves static assets (CSS, JS, images) from the static/ directory. + """ + static_dir = app.static_folder or os.path.join(app.root_path, 'static') + return send_from_directory(static_dir, filename) + + if __name__ == '__main__': # Local dev server. For production, run behind a WSGI server instead, e.g.: # gunicorn -w 4 -b 0.0.0.0:5000 'videoplayer:app'