From e6f055ec5ba01dd8f3789a400bb8cb810c18393a Mon Sep 17 00:00:00 2001 From: "MEGASOL\\simon.adams" Date: Tue, 26 Aug 2025 13:50:28 +0200 Subject: [PATCH] . --- .env | 14 + .env.example | 14 + .idea/.gitignore | 9 + .idea/immich_drop.iml | 10 + .idea/inspectionProfiles/Project_Default.xml | 23 ++ .../inspectionProfiles/profiles_settings.xml | 6 + .idea/misc.xml | 7 + .idea/modules.xml | 8 + .idea/sonarlint/issuestore/index.pb | 141 +++++++ .idea/sonarlint/securityhotspotstore/index.pb | 141 +++++++ .idea/vcs.xml | 6 + Dockerfile | 29 ++ README.md | 15 + app/__init__.py | 0 app/app.py | 352 ++++++++++++++++++ app/config.py | 31 ++ data/state.db | Bin 0 -> 16384 bytes docker-compose.yml | 22 ++ frontend/app.js | 189 ++++++++++ frontend/index.html | 75 ++++ main.py | 13 + requirements.txt | 26 ++ 22 files changed, 1131 insertions(+) create mode 100644 .env create mode 100644 .env.example create mode 100644 .idea/.gitignore create mode 100644 .idea/immich_drop.iml create mode 100644 .idea/inspectionProfiles/Project_Default.xml create mode 100644 .idea/inspectionProfiles/profiles_settings.xml create mode 100644 .idea/misc.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/sonarlint/issuestore/index.pb create mode 100644 .idea/sonarlint/securityhotspotstore/index.pb create mode 100644 .idea/vcs.xml create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app/__init__.py create mode 100644 app/app.py create mode 100644 app/config.py create mode 100644 data/state.db create mode 100644 docker-compose.yml create mode 100644 frontend/app.js create mode 100644 frontend/index.html create mode 100644 main.py create mode 100644 requirements.txt diff --git a/.env b/.env new file mode 100644 index 0000000..f784235 --- /dev/null +++ b/.env @@ -0,0 +1,14 @@ +# Backend host/port +HOST=0.0.0.0 +PORT=8080 + +# Immich connection +IMMICH_BASE_URL=http://192.168.8.60:2283//api +IMMICH_API_KEY=n7lO2oRFVhMXqI10YL8nfelIC9lZ8ND8AxZqx1XHiA +MAX_CONCURRENT=3 + +# Optional admin token to allow UI-based config updates +CONFIG_TOKEN=change-me + +# Data path inside the container +STATE_DB=./data/state.db \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6fe9219 --- /dev/null +++ b/.env.example @@ -0,0 +1,14 @@ +# Backend host/port +HOST=0.0.0.0 +PORT=8080 + +# Immich connection +IMMICH_BASE_URL=http://192.168.8.60:2283//api +IMMICH_API_KEY=n7lO2oRFVhMXqI10YL8nfelIC9lZ8ND8AxZqx1XHiA +MAX_CONCURRENT=3 + +# Optional admin token to allow UI-based config updates +CONFIG_TOKEN=change-me + +# Data path inside the container +STATE_DB=/data/state.db \ No newline at end of file diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..596b4ab --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,9 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml +.idea/* \ No newline at end of file diff --git a/.idea/immich_drop.iml b/.idea/immich_drop.iml new file mode 100644 index 0000000..2c80e12 --- /dev/null +++ b/.idea/immich_drop.iml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..2bf4495 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,23 @@ + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..29dd319 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..ca7f2dc --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/sonarlint/issuestore/index.pb b/.idea/sonarlint/issuestore/index.pb new file mode 100644 index 0000000..17b1a16 --- /dev/null +++ b/.idea/sonarlint/issuestore/index.pb @@ -0,0 +1,141 @@ + +7 +main.py,f\1\f1bdda93d9a278e358509d498e17d97764c1fb29 +6 +app.py,1\3\13cce7fd076299c81b4986166f3d822791c9490e +4 +.env,3\c\3c84dcdc6bbe3d7817c49dcdc327b926fea1808a +8 +state.db,7\c\7c1c00bfdbb4395ef18489f85e017946b2dec599 +? +backend/main.py,c\b\cb368897cc5bf4934e15bc7a3c0b7d3b69446875 +D +backend/.env.example,7\f\7f430184f2d87067e82533dc1f285c8c4b6de4dc +< + backend/.env,f\3\f30451335edfb80e231f658d0defc83bb7e4c3a7 +A +backend/config.py,6\3\637095c9834400adf5877ab26466a185e91f2000 +: + +DOCKERFILE,0\c\0c991b229bde803c280a90acf7c90d19fca5f126 +B +docker-compose.yml,3\5\35b8c13cf2eb2a194eada000eb310d65aed53b2a +? +frontend/app.js,5\7\57aad9a23b4d57899b05f7fd23522c7e830ea8df +C +frontend/index.html,a\1\a12b0e66474192f8cb9a2db83f26413a588dec1d +V +&.venv/Lib/site-packages/dotenv/main.py,d\0\d0c38e96758e28eb1baf0a2b2e4553fe89ce7fc4 +: + +Dockerfile,6\6\6651ddff6eb82c840ced7c1dddee15c6e1913dd4 +; + app/main.py,6\0\60a964aea4a6760d2f0fe80a7725ed9d569edd6c +? +app/__init__.py,6\b\6b5a07a517e9a3845a279675e888c81b77b9d712 +@ +app/.env.example,f\9\f9bb2fae58beb497060fb30cc7ce01e0edef652d +: + +main.px.py,f\5\f55bb43db8e1393bda387dc55289fe3e690c7972 +D +app/requirements.txt,b\a\bad0e8b06e94609c24277c5f7b6b72a912d964b3 +< + .env.example,d\4\d4dae00d11854b35292c2b8a30515a9a0aa2d871 += + app/config.py,e\4\e455b71ce56a02eb2baf1c1e4fea2d900027e3d3 +: + +app/app.py,0\3\0338104998400f0c277277ec54dbc28d8388020d +9 + README.md,8\e\8ec9a00bfd09b3190ac6b22251dbb1aa95a0579d +w +G.idea/sonarlint/issuestore/5/7/57aad9a23b4d57899b05f7fd23522c7e830ea8df,7\5\75d71b53b798dbd3cdc1616018f11cb417785f53 + +Q.idea/sonarlint/securityhotspotstore/5/7/57aad9a23b4d57899b05f7fd23522c7e830ea8df,5\6\56c08efeeaf5bc850116be39a8cd6b54657ae9df + +Q.idea/sonarlint/securityhotspotstore/3/c/3c84dcdc6bbe3d7817c49dcdc327b926fea1808a,4\e\4ead0a9bb92d8636c4ad5a0ef56838d8b7bd33d6 +w +G.idea/sonarlint/issuestore/3/c/3c84dcdc6bbe3d7817c49dcdc327b926fea1808a,1\5\15ab6acc17bd4f2d048dc50beb0dfeb0086a0c18 + +Q.idea/sonarlint/securityhotspotstore/7/c/7c1c00bfdbb4395ef18489f85e017946b2dec599,6\5\65b57d718e6dc62b43eef23afa529832f8f8e8b0 +w +G.idea/sonarlint/issuestore/7/c/7c1c00bfdbb4395ef18489f85e017946b2dec599,e\d\edf9ce83e87af3013da222b9c961bcef7984b7f7 + +Q.idea/sonarlint/securityhotspotstore/a/1/a12b0e66474192f8cb9a2db83f26413a588dec1d,f\8\f8f4358ee0586ea38d84d21b3e1a494a19763229 +@ +requirements.txt,1\9\19359a61ae2446b51b549167b014da2fcf265768 + +Q.idea/sonarlint/securityhotspotstore/1/3/13cce7fd076299c81b4986166f3d822791c9490e,9\f\9f958528e4eb130c807f923fbbf706355a6bf465 +w +G.idea/sonarlint/issuestore/d/0/d0c38e96758e28eb1baf0a2b2e4553fe89ce7fc4,d\4\d448a0b5a49f133aa1d532c5230858e881b39354 + +Q.idea/sonarlint/securityhotspotstore/d/0/d0c38e96758e28eb1baf0a2b2e4553fe89ce7fc4,5\d\5db86d0983134062289cc507d225a41f51349352 +w +G.idea/sonarlint/issuestore/6/6/6651ddff6eb82c840ced7c1dddee15c6e1913dd4,d\5\d51b3fb7e90ceea661591d9cea37ed4029769a06 +w +G.idea/sonarlint/issuestore/6/b/6b5a07a517e9a3845a279675e888c81b77b9d712,0\d\0d4b49a2dd4e9e06c83f8c9e11d8468035ab577e +w +G.idea/sonarlint/issuestore/f/9/f9bb2fae58beb497060fb30cc7ce01e0edef652d,e\7\e7a198759a1681b7e16781c94fa12dd23805adc3 +w +G.idea/sonarlint/issuestore/f/5/f55bb43db8e1393bda387dc55289fe3e690c7972,9\1\91c2a51be2bc7d32087669b28c23021230c1de65 + +Q.idea/sonarlint/securityhotspotstore/f/9/f9bb2fae58beb497060fb30cc7ce01e0edef652d,1\8\18c199af4af6da1ade7a9ae3d580ecb3d5638294 + +Q.idea/sonarlint/securityhotspotstore/f/5/f55bb43db8e1393bda387dc55289fe3e690c7972,a\5\a50521aa919bcb9c97dbf9a978ecc38bbd316666 + +Q.idea/sonarlint/securityhotspotstore/6/0/60a964aea4a6760d2f0fe80a7725ed9d569edd6c,b\7\b708aad62f0501c0cda351be171cb414dbef3c1f + +Q.idea/sonarlint/securityhotspotstore/6/6/6651ddff6eb82c840ced7c1dddee15c6e1913dd4,5\e\5e4805de49b3e0799af661639276d994cb0e08ae + +Q.idea/sonarlint/securityhotspotstore/6/b/6b5a07a517e9a3845a279675e888c81b77b9d712,3\9\391ed4d9ef942c1c7ee7f895cc5196a84c92aef1 +w +G.idea/sonarlint/issuestore/b/a/bad0e8b06e94609c24277c5f7b6b72a912d964b3,e\f\ef3c2485526527fa809859e020c8c67a56854c1b + +Q.idea/sonarlint/securityhotspotstore/b/a/bad0e8b06e94609c24277c5f7b6b72a912d964b3,a\d\ad37d76e9d840d471650b43b6b7508de7dc098fa +w +G.idea/sonarlint/issuestore/d/4/d4dae00d11854b35292c2b8a30515a9a0aa2d871,6\5\653f06312c57b2eb505420827d653ecb2a3ec9f2 + +Q.idea/sonarlint/securityhotspotstore/d/4/d4dae00d11854b35292c2b8a30515a9a0aa2d871,2\d\2d7389e5765b1d99bc1d59b6d005c90457d88dd5 + +Q.idea/sonarlint/securityhotspotstore/e/4/e455b71ce56a02eb2baf1c1e4fea2d900027e3d3,c\4\c431aed043a0a790aa3c5de0ec7e7798809745fd +w +G.idea/sonarlint/issuestore/e/4/e455b71ce56a02eb2baf1c1e4fea2d900027e3d3,7\b\7b1257fe86192915bccba1ad402c40444d9c1cb6 +\ +,.idea/inspectionProfiles/Project_Default.xml,4\9\496a238a6afa168dbaf6efd37bb459331589579c + +Q.idea/sonarlint/securityhotspotstore/0/3/0338104998400f0c277277ec54dbc28d8388020d,c\9\c9362c16502bb86a494fc5274262eb8443134d30 += + data/state.db,3\9\39a1e84d3e8cac8538830af25e724aeb905ebd05 +w +G.idea/sonarlint/issuestore/8/e/8ec9a00bfd09b3190ac6b22251dbb1aa95a0579d,b\4\b4a0b63ce8560bbc5de0f9995c20cc861dc2adb3 + +Q.idea/sonarlint/securityhotspotstore/8/e/8ec9a00bfd09b3190ac6b22251dbb1aa95a0579d,1\0\10a03f31c4dce92cb371e761ce9429428bb6c176 + +Q.idea/sonarlint/securityhotspotstore/c/b/cb368897cc5bf4934e15bc7a3c0b7d3b69446875,2\3\23fe6b145fa43458771172cee75766e176007747 +w +G.idea/sonarlint/issuestore/7/f/7f430184f2d87067e82533dc1f285c8c4b6de4dc,1\a\1a770cc867c8653560c9f5596356109412c43560 + +Q.idea/sonarlint/securityhotspotstore/f/3/f30451335edfb80e231f658d0defc83bb7e4c3a7,d\a\da0fc6477c7b71fe17276da578e8d515eff6281f +w +G.idea/sonarlint/issuestore/f/3/f30451335edfb80e231f658d0defc83bb7e4c3a7,c\3\c39d98dd28d25672857407dddb5cd9a1602735a7 + +Q.idea/sonarlint/securityhotspotstore/7/f/7f430184f2d87067e82533dc1f285c8c4b6de4dc,4\4\44c62e33d35d872552fc84fa5caca1ef237afd01 +w +G.idea/sonarlint/issuestore/f/1/f1bdda93d9a278e358509d498e17d97764c1fb29,0\e\0e659b6dc067a9e8d729848985fb11903f7a88d8 +w +G.idea/sonarlint/issuestore/6/3/637095c9834400adf5877ab26466a185e91f2000,c\3\c3ea041ab2b8efce6c502998e6648b06eb4dc347 + +Q.idea/sonarlint/securityhotspotstore/f/1/f1bdda93d9a278e358509d498e17d97764c1fb29,b\5\b58a0677868503201074e965423d875803239017 + +Q.idea/sonarlint/securityhotspotstore/6/3/637095c9834400adf5877ab26466a185e91f2000,f\9\f9fca85a6b43b230e3759f6a675ecb38a8732c0f +w +G.idea/sonarlint/issuestore/0/c/0c991b229bde803c280a90acf7c90d19fca5f126,b\2\b22a2edcb4052acd57c8e48cdf7726b68e558590 + +Q.idea/sonarlint/securityhotspotstore/0/c/0c991b229bde803c280a90acf7c90d19fca5f126,c\c\cc7b15b6eb8a7fa0d7539cf59c64d8d10ff60d59 +^ +..idea/inspectionProfiles/profiles_settings.xml,1\e\1e9075f5bf079c01ef2c910709e91a497d262080 +w +G.idea/sonarlint/issuestore/3/5/35b8c13cf2eb2a194eada000eb310d65aed53b2a,f\a\fa996fb25dd8afe6bf56a281bdff677f139b23bf + +Q.idea/sonarlint/securityhotspotstore/3/5/35b8c13cf2eb2a194eada000eb310d65aed53b2a,c\d\cdb41fff691175c26b6b290b03da638b954ee337 \ No newline at end of file diff --git a/.idea/sonarlint/securityhotspotstore/index.pb b/.idea/sonarlint/securityhotspotstore/index.pb new file mode 100644 index 0000000..17b1a16 --- /dev/null +++ b/.idea/sonarlint/securityhotspotstore/index.pb @@ -0,0 +1,141 @@ + +7 +main.py,f\1\f1bdda93d9a278e358509d498e17d97764c1fb29 +6 +app.py,1\3\13cce7fd076299c81b4986166f3d822791c9490e +4 +.env,3\c\3c84dcdc6bbe3d7817c49dcdc327b926fea1808a +8 +state.db,7\c\7c1c00bfdbb4395ef18489f85e017946b2dec599 +? +backend/main.py,c\b\cb368897cc5bf4934e15bc7a3c0b7d3b69446875 +D +backend/.env.example,7\f\7f430184f2d87067e82533dc1f285c8c4b6de4dc +< + backend/.env,f\3\f30451335edfb80e231f658d0defc83bb7e4c3a7 +A +backend/config.py,6\3\637095c9834400adf5877ab26466a185e91f2000 +: + +DOCKERFILE,0\c\0c991b229bde803c280a90acf7c90d19fca5f126 +B +docker-compose.yml,3\5\35b8c13cf2eb2a194eada000eb310d65aed53b2a +? +frontend/app.js,5\7\57aad9a23b4d57899b05f7fd23522c7e830ea8df +C +frontend/index.html,a\1\a12b0e66474192f8cb9a2db83f26413a588dec1d +V +&.venv/Lib/site-packages/dotenv/main.py,d\0\d0c38e96758e28eb1baf0a2b2e4553fe89ce7fc4 +: + +Dockerfile,6\6\6651ddff6eb82c840ced7c1dddee15c6e1913dd4 +; + app/main.py,6\0\60a964aea4a6760d2f0fe80a7725ed9d569edd6c +? +app/__init__.py,6\b\6b5a07a517e9a3845a279675e888c81b77b9d712 +@ +app/.env.example,f\9\f9bb2fae58beb497060fb30cc7ce01e0edef652d +: + +main.px.py,f\5\f55bb43db8e1393bda387dc55289fe3e690c7972 +D +app/requirements.txt,b\a\bad0e8b06e94609c24277c5f7b6b72a912d964b3 +< + .env.example,d\4\d4dae00d11854b35292c2b8a30515a9a0aa2d871 += + app/config.py,e\4\e455b71ce56a02eb2baf1c1e4fea2d900027e3d3 +: + +app/app.py,0\3\0338104998400f0c277277ec54dbc28d8388020d +9 + README.md,8\e\8ec9a00bfd09b3190ac6b22251dbb1aa95a0579d +w +G.idea/sonarlint/issuestore/5/7/57aad9a23b4d57899b05f7fd23522c7e830ea8df,7\5\75d71b53b798dbd3cdc1616018f11cb417785f53 + +Q.idea/sonarlint/securityhotspotstore/5/7/57aad9a23b4d57899b05f7fd23522c7e830ea8df,5\6\56c08efeeaf5bc850116be39a8cd6b54657ae9df + +Q.idea/sonarlint/securityhotspotstore/3/c/3c84dcdc6bbe3d7817c49dcdc327b926fea1808a,4\e\4ead0a9bb92d8636c4ad5a0ef56838d8b7bd33d6 +w +G.idea/sonarlint/issuestore/3/c/3c84dcdc6bbe3d7817c49dcdc327b926fea1808a,1\5\15ab6acc17bd4f2d048dc50beb0dfeb0086a0c18 + +Q.idea/sonarlint/securityhotspotstore/7/c/7c1c00bfdbb4395ef18489f85e017946b2dec599,6\5\65b57d718e6dc62b43eef23afa529832f8f8e8b0 +w +G.idea/sonarlint/issuestore/7/c/7c1c00bfdbb4395ef18489f85e017946b2dec599,e\d\edf9ce83e87af3013da222b9c961bcef7984b7f7 + +Q.idea/sonarlint/securityhotspotstore/a/1/a12b0e66474192f8cb9a2db83f26413a588dec1d,f\8\f8f4358ee0586ea38d84d21b3e1a494a19763229 +@ +requirements.txt,1\9\19359a61ae2446b51b549167b014da2fcf265768 + +Q.idea/sonarlint/securityhotspotstore/1/3/13cce7fd076299c81b4986166f3d822791c9490e,9\f\9f958528e4eb130c807f923fbbf706355a6bf465 +w +G.idea/sonarlint/issuestore/d/0/d0c38e96758e28eb1baf0a2b2e4553fe89ce7fc4,d\4\d448a0b5a49f133aa1d532c5230858e881b39354 + +Q.idea/sonarlint/securityhotspotstore/d/0/d0c38e96758e28eb1baf0a2b2e4553fe89ce7fc4,5\d\5db86d0983134062289cc507d225a41f51349352 +w +G.idea/sonarlint/issuestore/6/6/6651ddff6eb82c840ced7c1dddee15c6e1913dd4,d\5\d51b3fb7e90ceea661591d9cea37ed4029769a06 +w +G.idea/sonarlint/issuestore/6/b/6b5a07a517e9a3845a279675e888c81b77b9d712,0\d\0d4b49a2dd4e9e06c83f8c9e11d8468035ab577e +w +G.idea/sonarlint/issuestore/f/9/f9bb2fae58beb497060fb30cc7ce01e0edef652d,e\7\e7a198759a1681b7e16781c94fa12dd23805adc3 +w +G.idea/sonarlint/issuestore/f/5/f55bb43db8e1393bda387dc55289fe3e690c7972,9\1\91c2a51be2bc7d32087669b28c23021230c1de65 + +Q.idea/sonarlint/securityhotspotstore/f/9/f9bb2fae58beb497060fb30cc7ce01e0edef652d,1\8\18c199af4af6da1ade7a9ae3d580ecb3d5638294 + +Q.idea/sonarlint/securityhotspotstore/f/5/f55bb43db8e1393bda387dc55289fe3e690c7972,a\5\a50521aa919bcb9c97dbf9a978ecc38bbd316666 + +Q.idea/sonarlint/securityhotspotstore/6/0/60a964aea4a6760d2f0fe80a7725ed9d569edd6c,b\7\b708aad62f0501c0cda351be171cb414dbef3c1f + +Q.idea/sonarlint/securityhotspotstore/6/6/6651ddff6eb82c840ced7c1dddee15c6e1913dd4,5\e\5e4805de49b3e0799af661639276d994cb0e08ae + +Q.idea/sonarlint/securityhotspotstore/6/b/6b5a07a517e9a3845a279675e888c81b77b9d712,3\9\391ed4d9ef942c1c7ee7f895cc5196a84c92aef1 +w +G.idea/sonarlint/issuestore/b/a/bad0e8b06e94609c24277c5f7b6b72a912d964b3,e\f\ef3c2485526527fa809859e020c8c67a56854c1b + +Q.idea/sonarlint/securityhotspotstore/b/a/bad0e8b06e94609c24277c5f7b6b72a912d964b3,a\d\ad37d76e9d840d471650b43b6b7508de7dc098fa +w +G.idea/sonarlint/issuestore/d/4/d4dae00d11854b35292c2b8a30515a9a0aa2d871,6\5\653f06312c57b2eb505420827d653ecb2a3ec9f2 + +Q.idea/sonarlint/securityhotspotstore/d/4/d4dae00d11854b35292c2b8a30515a9a0aa2d871,2\d\2d7389e5765b1d99bc1d59b6d005c90457d88dd5 + +Q.idea/sonarlint/securityhotspotstore/e/4/e455b71ce56a02eb2baf1c1e4fea2d900027e3d3,c\4\c431aed043a0a790aa3c5de0ec7e7798809745fd +w +G.idea/sonarlint/issuestore/e/4/e455b71ce56a02eb2baf1c1e4fea2d900027e3d3,7\b\7b1257fe86192915bccba1ad402c40444d9c1cb6 +\ +,.idea/inspectionProfiles/Project_Default.xml,4\9\496a238a6afa168dbaf6efd37bb459331589579c + +Q.idea/sonarlint/securityhotspotstore/0/3/0338104998400f0c277277ec54dbc28d8388020d,c\9\c9362c16502bb86a494fc5274262eb8443134d30 += + data/state.db,3\9\39a1e84d3e8cac8538830af25e724aeb905ebd05 +w +G.idea/sonarlint/issuestore/8/e/8ec9a00bfd09b3190ac6b22251dbb1aa95a0579d,b\4\b4a0b63ce8560bbc5de0f9995c20cc861dc2adb3 + +Q.idea/sonarlint/securityhotspotstore/8/e/8ec9a00bfd09b3190ac6b22251dbb1aa95a0579d,1\0\10a03f31c4dce92cb371e761ce9429428bb6c176 + +Q.idea/sonarlint/securityhotspotstore/c/b/cb368897cc5bf4934e15bc7a3c0b7d3b69446875,2\3\23fe6b145fa43458771172cee75766e176007747 +w +G.idea/sonarlint/issuestore/7/f/7f430184f2d87067e82533dc1f285c8c4b6de4dc,1\a\1a770cc867c8653560c9f5596356109412c43560 + +Q.idea/sonarlint/securityhotspotstore/f/3/f30451335edfb80e231f658d0defc83bb7e4c3a7,d\a\da0fc6477c7b71fe17276da578e8d515eff6281f +w +G.idea/sonarlint/issuestore/f/3/f30451335edfb80e231f658d0defc83bb7e4c3a7,c\3\c39d98dd28d25672857407dddb5cd9a1602735a7 + +Q.idea/sonarlint/securityhotspotstore/7/f/7f430184f2d87067e82533dc1f285c8c4b6de4dc,4\4\44c62e33d35d872552fc84fa5caca1ef237afd01 +w +G.idea/sonarlint/issuestore/f/1/f1bdda93d9a278e358509d498e17d97764c1fb29,0\e\0e659b6dc067a9e8d729848985fb11903f7a88d8 +w +G.idea/sonarlint/issuestore/6/3/637095c9834400adf5877ab26466a185e91f2000,c\3\c3ea041ab2b8efce6c502998e6648b06eb4dc347 + +Q.idea/sonarlint/securityhotspotstore/f/1/f1bdda93d9a278e358509d498e17d97764c1fb29,b\5\b58a0677868503201074e965423d875803239017 + +Q.idea/sonarlint/securityhotspotstore/6/3/637095c9834400adf5877ab26466a185e91f2000,f\9\f9fca85a6b43b230e3759f6a675ecb38a8732c0f +w +G.idea/sonarlint/issuestore/0/c/0c991b229bde803c280a90acf7c90d19fca5f126,b\2\b22a2edcb4052acd57c8e48cdf7726b68e558590 + +Q.idea/sonarlint/securityhotspotstore/0/c/0c991b229bde803c280a90acf7c90d19fca5f126,c\c\cc7b15b6eb8a7fa0d7539cf59c64d8d10ff60d59 +^ +..idea/inspectionProfiles/profiles_settings.xml,1\e\1e9075f5bf079c01ef2c910709e91a497d262080 +w +G.idea/sonarlint/issuestore/3/5/35b8c13cf2eb2a194eada000eb310d65aed53b2a,f\a\fa996fb25dd8afe6bf56a281bdff677f139b23bf + +Q.idea/sonarlint/securityhotspotstore/3/5/35b8c13cf2eb2a194eada000eb310d65aed53b2a,c\d\cdb41fff691175c26b6b290b03da638b954ee337 \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5134713 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,29 @@ +# syntax=docker/dockerfile:1.7 +FROM python:3.11-slim + +WORKDIR /immich_drop + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +# Install Python deps +COPY requirements.txt /app/requirements.txt +RUN pip install --no-cache-dir -r /app/requirements.txt \ + && pip install --no-cache-dir python-multipart + +# Copy app code +COPY . /immich_drop + + +# Data dir for SQLite (state.db) +RUN mkdir -p /data +VOLUME ["/data"] + +# Defaults (can be overridden via compose env) +ENV HOST=0.0.0.0 \ + PORT=8080 \ + STATE_DB=/data/state.db + +EXPOSE 8080 + +CMD ["python", "main.py"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..b675471 --- /dev/null +++ b/README.md @@ -0,0 +1,15 @@ +# Immich Drop Uploader – Clean Split (FastAPI + Static Frontend) + +- **backend/** FastAPI server (upload proxy, WebSocket progress, runtime config API) +- **frontend/** Static HTML/JS (drag & drop, queue, ephemeral banner, settings modal) + +## Run +```bash +cd backend +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +cp .env.example .env # edit base URL, API key, CONFIG_TOKEN (optional) +uvicorn backend.main:app --reload --host 0.0.0.0 --port 8080 +# open http://localhost:8080 +``` diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/app.py b/app/app.py new file mode 100644 index 0000000..b1afed0 --- /dev/null +++ b/app/app.py @@ -0,0 +1,352 @@ +""" +Immich Drop Uploader – Backend (FastAPI, simplified) +---------------------------------------------------- +- Serves static frontend (no settings UI) +- Uploads to Immich using values from .env ONLY +- Duplicate checks (local SHA-1 DB + optional Immich bulk-check) +- WebSocket progress per session +- Ephemeral "Connected" banner via /api/ping +""" + +from __future__ import annotations + +import asyncio +import io +import json +import hashlib +import os +import sqlite3 +from datetime import datetime +from typing import Dict, List, Optional + +import requests +from requests_toolbelt.multipart.encoder import MultipartEncoder, MultipartEncoderMonitor +from fastapi import FastAPI, UploadFile, WebSocket, WebSocketDisconnect, Request, Form +from fastapi.responses import HTMLResponse, JSONResponse, FileResponse +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles +from starlette.websockets import WebSocketState +from PIL import Image, ExifTags +from dotenv import load_dotenv + +from app.config import Settings, load_settings + +# ---- Load environment / defaults ---- +load_dotenv() +HOST = os.getenv("HOST", "127.0.0.1") +PORT = int(os.getenv("PORT", "8080")) +STATE_DB = os.getenv("STATE_DB", "./state.db") + +# ---- App & static ---- +app = FastAPI(title="Immich Drop Uploader (Python)") +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +FRONTEND_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "frontend") +app.mount("/static", StaticFiles(directory=FRONTEND_DIR), name="static") + +# Global settings (read-only at runtime) +SETTINGS: Settings = load_settings() + +# ---------- DB (local dedupe cache) ---------- + +def db_init() -> None: + """Create the local SQLite table used for duplicate checks (idempotent).""" + conn = sqlite3.connect(STATE_DB) + cur = conn.cursor() + cur.execute( + """ + CREATE TABLE IF NOT EXISTS uploads ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + checksum TEXT UNIQUE, + filename TEXT, + size INTEGER, + device_asset_id TEXT, + immich_asset_id TEXT, + created_at TEXT, + inserted_at TEXT DEFAULT CURRENT_TIMESTAMP + ); + """ + ) + conn.commit() + conn.close() + +def db_lookup_checksum(checksum: str) -> Optional[dict]: + """Return a record for the given checksum if seen before (None if not).""" + conn = sqlite3.connect(STATE_DB) + cur = conn.cursor() + cur.execute("SELECT checksum, immich_asset_id FROM uploads WHERE checksum = ?", (checksum,)) + row = cur.fetchone() + conn.close() + if row: + return {"checksum": row[0], "immich_asset_id": row[1]} + return None + +def db_lookup_device_asset(device_asset_id: str) -> bool: + """True if a deviceAssetId has been uploaded by this service previously.""" + conn = sqlite3.connect(STATE_DB) + cur = conn.cursor() + cur.execute("SELECT 1 FROM uploads WHERE device_asset_id = ?", (device_asset_id,)) + row = cur.fetchone() + conn.close() + return bool(row) + +def db_insert_upload(checksum: str, filename: str, size: int, device_asset_id: str, immich_asset_id: Optional[str], created_at: str) -> None: + """Insert a newly-uploaded asset into the local cache (ignore on duplicates).""" + conn = sqlite3.connect(STATE_DB) + cur = conn.cursor() + cur.execute( + "INSERT OR IGNORE INTO uploads (checksum, filename, size, device_asset_id, immich_asset_id, created_at) VALUES (?,?,?,?,?,?)", + (checksum, filename, size, device_asset_id, immich_asset_id, created_at) + ) + conn.commit() + conn.close() + +db_init() + +# ---------- WebSocket hub ---------- + +class SessionHub: + """Holds WebSocket connections per session and broadcasts progress updates.""" + def __init__(self) -> None: + self.sessions: Dict[str, List[WebSocket]] = {} + + async def connect(self, session_id: str, ws: WebSocket) -> None: + """Register a newly accepted WebSocket under the given session id.""" + self.sessions.setdefault(session_id, []).append(ws) + + def _cleanup_closed(self, session_id: str) -> None: + """Drop closed sockets and cleanup empty session buckets.""" + if session_id not in self.sessions: + return + self.sessions[session_id] = [w for w in self.sessions[session_id] if w.client_state == WebSocketState.CONNECTED] + if not self.sessions[session_id]: + del self.sessions[session_id] + + async def send(self, session_id: str, payload: dict) -> None: + """Broadcast a JSON payload to all sockets for one session.""" + conns = self.sessions.get(session_id, []) + for ws in list(conns): + try: + await ws.send_text(json.dumps(payload)) + except Exception: + try: + await ws.close() + except Exception: + pass + self._cleanup_closed(session_id) + + async def disconnect(self, session_id: str, ws: WebSocket) -> None: + """Remove a socket from the hub and close it (best-effort).""" + try: + await ws.close() + finally: + if session_id in self.sessions and ws in self.sessions[session_id]: + self.sessions[session_id].remove(ws) + self._cleanup_closed(session_id) + +hub = SessionHub() + +# ---------- Helpers ---------- + +def sha1_hex(file_bytes: bytes) -> str: + """Return SHA-1 hex digest of file_bytes.""" + h = hashlib.sha1() + h.update(file_bytes) + return h.hexdigest() + +def read_exif_datetimes(file_bytes: bytes): + """ + Extract EXIF DateTimeOriginal / ModifyDate values when possible. + Returns (created, modified) as datetime or (None, None) on failure. + """ + created = modified = None + try: + with Image.open(io.BytesIO(file_bytes)) as im: + exif = getattr(im, "_getexif", lambda: None)() or {} + if exif: + tags = {ExifTags.TAGS.get(k, k): v for k, v in exif.items()} + dt_original = tags.get("DateTimeOriginal") or tags.get("CreateDate") + dt_modified = tags.get("ModifyDate") or dt_original + def parse_dt(s: str): + try: + return datetime.strptime(s, "%Y:%m:%d %H:%M:%S") + except Exception: + return None + if isinstance(dt_original, str): + created = parse_dt(dt_original) + if isinstance(dt_modified, str): + modified = parse_dt(dt_modified) + except Exception: + pass + return created, modified + +def immich_headers() -> dict: + """Headers for Immich API calls (keeps key server-side).""" + return {"Accept": "application/json", "x-api-key": SETTINGS.immich_api_key} + +def immich_ping() -> bool: + """Best-effort reachability check against a few Immich endpoints.""" + if not SETTINGS.immich_api_key: + return False + base = SETTINGS.normalized_base_url + for path in ("/server-info", "/server/version", "/users/me"): + try: + r = requests.get(f"{base}{path}", headers=immich_headers(), timeout=4) + if 200 <= r.status_code < 400: + return True + except Exception: + continue + return False + +def immich_bulk_check(checks: List[dict]) -> Dict[str, dict]: + """Try Immich bulk upload check; return map id->result (or empty on failure).""" + try: + url = f"{SETTINGS.normalized_base_url}/assets/bulk-upload-check" + r = requests.post(url, headers=immich_headers(), json={"assets": checks}, timeout=10) + if r.status_code == 200: + results = r.json().get("results", []) + return {x["id"]: x for x in results} + except Exception: + pass + return {} + +async def send_progress(session_id: str, item_id: str, status: str, progress: int = 0, message: str = "", response_id: Optional[str] = None) -> None: + """Push a progress update over WebSocket for one queue item.""" + await hub.send(session_id, { + "item_id": item_id, + "status": status, + "progress": progress, + "message": message, + "responseId": response_id, + }) + +# ---------- Routes ---------- + +@app.get("/", response_class=HTMLResponse) +async def index(_: Request) -> HTMLResponse: + """Serve the SPA (frontend/index.html).""" + return FileResponse(os.path.join(FRONTEND_DIR, "index.html")) + +@app.post("/api/ping") +async def api_ping() -> dict: + """Connectivity test endpoint used by the UI to display a temporary banner.""" + return {"ok": immich_ping(), "base_url": SETTINGS.normalized_base_url} + +@app.websocket("/ws") +async def ws_endpoint(ws: WebSocket) -> None: + """WebSocket endpoint for pushing per-item upload progress.""" + await ws.accept() + try: + init = await ws.receive_text() + data = json.loads(init) + session_id = data.get("session_id") or "default" + except Exception: + session_id = "default" + await hub.connect(session_id, ws) + + # keepalive to avoid proxy idle timeouts + try: + while True: + msg_task = asyncio.create_task(ws.receive_text()) + keep_task = asyncio.create_task(asyncio.sleep(30)) + done, pending = await asyncio.wait({msg_task, keep_task}, return_when=asyncio.FIRST_COMPLETED) + if msg_task in done: + _ = msg_task.result() + else: + await ws.send_text('{"type":"ping"}') + for t in pending: + t.cancel() + except WebSocketDisconnect: + await hub.disconnect(session_id, ws) + +@app.post("/api/upload") +async def api_upload( + _: Request, + file: UploadFile, + item_id: str = Form(...), + session_id: str = Form(...), + last_modified: Optional[int] = Form(None), +): + """Receive a file, check duplicates, forward to Immich; stream progress via WS.""" + raw = await file.read() + size = len(raw) + checksum = sha1_hex(raw) + + exif_created, exif_modified = read_exif_datetimes(raw) + created_at = exif_created or (datetime.fromtimestamp(last_modified / 1000) if last_modified else datetime.utcnow()) + modified_at = exif_modified or created_at + created_iso = created_at.isoformat() + modified_iso = modified_at.isoformat() + + device_asset_id = f"{file.filename}-{last_modified or 0}-{size}" + + if db_lookup_checksum(checksum): + await send_progress(session_id, item_id, "duplicate", 100, "Duplicate (by checksum - local cache)") + return JSONResponse({"status": "duplicate", "id": None}, status_code=200) + if db_lookup_device_asset(device_asset_id): + await send_progress(session_id, item_id, "duplicate", 100, "Already uploaded from this device (local cache)") + return JSONResponse({"status": "duplicate", "id": None}, status_code=200) + + await send_progress(session_id, item_id, "checking", 2, "Checking duplicates…") + bulk = immich_bulk_check([{"id": item_id, "checksum": checksum}]) + if bulk.get(item_id, {}).get("action") == "reject" and bulk[item_id].get("reason") == "duplicate": + asset_id = bulk[item_id].get("assetId") + db_insert_upload(checksum, file.filename, size, device_asset_id, asset_id, created_iso) + await send_progress(session_id, item_id, "duplicate", 100, "Duplicate (server)", asset_id) + return JSONResponse({"status": "duplicate", "id": asset_id}, status_code=200) + + def gen_encoder() -> MultipartEncoder: + return MultipartEncoder(fields={ + "assetData": (file.filename, io.BytesIO(raw), file.content_type or "application/octet-stream"), + "deviceAssetId": device_asset_id, + "deviceId": f"python-{session_id}", + "fileCreatedAt": created_iso, + "fileModifiedAt": modified_iso, + "isFavorite": "false", + "filename": file.filename, + }) + + encoder = gen_encoder() + + async def do_upload(): + await send_progress(session_id, item_id, "uploading", 0, "Uploading…") + sent = {"pct": 0} + def cb(monitor: MultipartEncoderMonitor) -> None: + if monitor.len: + pct = int(monitor.bytes_read * 100 / monitor.len) + if pct != sent["pct"]: + sent["pct"] = pct + asyncio.create_task(send_progress(session_id, item_id, "uploading", pct)) + monitor = MultipartEncoderMonitor(encoder, cb) + headers = {"Accept": "application/json", "Content-Type": monitor.content_type, "x-immich-checksum": checksum, **immich_headers()} + try: + r = requests.post(f"{SETTINGS.normalized_base_url}/assets", headers=headers, data=monitor, timeout=120) + if r.status_code in (200, 201): + data = r.json() + asset_id = data.get("id") + db_insert_upload(checksum, file.filename, size, device_asset_id, asset_id, created_iso) + status = data.get("status", "created") + await send_progress(session_id, item_id, "duplicate" if status == "duplicate" else "done", 100, status, asset_id) + return JSONResponse({"id": asset_id, "status": status}, status_code=200) + else: + try: + msg = r.json().get("message", r.text) + except Exception: + msg = r.text + await send_progress(session_id, item_id, "error", 100, msg) + return JSONResponse({"error": msg}, status_code=400) + except Exception as e: + await send_progress(session_id, item_id, "error", 100, str(e)) + return JSONResponse({"error": str(e)}, status_code=500) + + return await do_upload() + +if __name__ == "__main__": + import uvicorn + uvicorn.run("backend.main:app", host=HOST, port=PORT, reload=True) diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..16e6f43 --- /dev/null +++ b/app/config.py @@ -0,0 +1,31 @@ +""" +Config loader for the Immich Drop Uploader (Python). +Reads ONLY from .env; there is NO runtime mutation from the UI. +""" + +from __future__ import annotations +import os +from dataclasses import dataclass + + +@dataclass +class Settings: + """App settings loaded from environment variables (.env).""" + immich_base_url: str + immich_api_key: str + max_concurrent: int = 3 + + @property + def normalized_base_url(self) -> str: + """Return the base URL without a trailing slash for clean joining and display.""" + return self.immich_base_url.rstrip("/") + +def load_settings() -> Settings: + """Load settings from .env, applying defaults when absent.""" + base = os.getenv("IMMICH_BASE_URL", "http://127.0.0.1:2283/api") + api_key = os.getenv("IMMICH_API_KEY", "") + try: + maxc = int(os.getenv("MAX_CONCURRENT", "3")) + except ValueError: + maxc = 3 + return Settings(immich_base_url=base, immich_api_key=api_key, max_concurrent=maxc) diff --git a/data/state.db b/data/state.db new file mode 100644 index 0000000000000000000000000000000000000000..d1089498b6d1e27947fd8b7ade29857178ab575f GIT binary patch literal 16384 zcmeI%&rZTH90%|Y2pSUp+%VzNBLp-t`T$O>W|2)8T_VwBnOg}{2Ev9h9!-2W-^imK z8HN$`Y{<9C+I9W4Yd`CyS-;ouBSDALdEi9!gj9&4kTXgNA&R_m@>->B^;O$%r9zJG zPK)I)k}VyO(qs8k7Dy0)00bZa0SG_<0uX=z1R(H_1iIOLwb@h#k@GMX;cP4qiFPPv z3o&swtGTwxRL&?@&pV86#MUQHAX>6#p?gdBNhs!Pt@MIj zs#b^7wq=@fl{VLO*5|6;{aJ3~Fq5yUHHG*SPrN1{!C6GpW!+9h?c?MoE4SKyyxod% zR*5qt2tWV=5P$##AOHafKmY;|fB*z`RiF?r4Cnt{{k&Kg1Rwwb2tWV=5P$##AOHaf hKwv3=|Nqzs5P$##AOHafKmY;|fB*y_0D;{X_y*bUkxl>r literal 0 HcmV?d00001 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d9e7516 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,22 @@ +services: + immich-drop: + build: . + container_name: immich-drop + restart: unless-stopped + ports: + - "8081:8080" + + env_file: + - ./.env + + volumes: + - immich_drop_data:/data + healthcheck: + test: ["CMD-SHELL", "python - <<'PY'\nimport urllib.request,sys\ntry:\n urllib.request.urlopen('http://localhost:8080/').read(); sys.exit(0)\nexcept Exception:\n sys.exit(1)\nPY"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + +volumes: + immich_drop_data: diff --git a/frontend/app.js b/frontend/app.js new file mode 100644 index 0000000..ba3c018 --- /dev/null +++ b/frontend/app.js @@ -0,0 +1,189 @@ +// Frontend logic (mobile-safe picker; no settings UI) +const sessionId = (crypto && crypto.randomUUID) ? crypto.randomUUID() : (Math.random().toString(36).slice(2)); +let items = []; +let socket; + +// --- helpers --- +function human(bytes){ + if (!bytes) return '0 B'; + const k = 1024, sizes = ['B','KB','MB','GB','TB']; + const i = Math.floor(Math.log(bytes)/Math.log(k)); + return (bytes/Math.pow(k,i)).toFixed(1)+' '+sizes[i]; +} + +function addItem(file){ + const id = (crypto && crypto.randomUUID) ? crypto.randomUUID() : (Math.random().toString(36).slice(2)); + const it = { id, file, name: file.name, size: file.size, status: 'queued', progress: 0 }; + items.unshift(it); + render(); +} + +function render(){ + const itemsEl = document.getElementById('items'); + itemsEl.innerHTML = items.map(it => ` +
+
+
+
${it.name} (${human(it.size)})
+
+ ${it.message ? `${it.message}` : ''} +
+
+
${it.status}
+
+
+
+
+
+ ${it.status==='uploading' ? `Uploading… ${it.progress}%` : it.status.charAt(0).toUpperCase()+it.status.slice(1)} +
+
+ `).join(''); + + const c = {queued:0,uploading:0,done:0,dup:0,err:0}; + for(const it of items){ + if(['queued','checking'].includes(it.status)) c.queued++; + if(it.status==='uploading') c.uploading++; + if(it.status==='done') c.done++; + if(it.status==='duplicate') c.dup++; + if(it.status==='error') c.err++; + } + document.getElementById('countQueued').textContent=c.queued; + document.getElementById('countUploading').textContent=c.uploading; + document.getElementById('countDone').textContent=c.done; + document.getElementById('countDup').textContent=c.dup; + document.getElementById('countErr').textContent=c.err; +} + +// --- WebSocket progress --- +function openSocket(){ + socket = new WebSocket((location.protocol==='https:'?'wss':'ws')+'://'+location.host+'/ws'); + socket.onopen = () => { socket.send(JSON.stringify({session_id: sessionId})); }; + socket.onmessage = (evt) => { + const msg = JSON.parse(evt.data); + const { item_id, status, progress, message } = msg; + const it = items.find(x => x.id===item_id); + if(!it) return; + it.status = status; + if(typeof progress==='number') it.progress = progress; + if(message) it.message = message; + render(); + }; + socket.onclose = () => setTimeout(openSocket, 2000); +} +openSocket(); + +// --- Upload queue --- +async function runQueue(){ + let inflight = 0; + async function runNext(){ + if(inflight >= 3) return; // client-side throttle; server handles uploads regardless + const next = items.find(i => i.status==='queued'); + if(!next) return; + next.status='checking'; + render(); + inflight++; + try{ + const form = new FormData(); + form.append('file', next.file); + form.append('item_id', next.id); + form.append('session_id', sessionId); + form.append('last_modified', next.file.lastModified || ''); + const res = await fetch('/api/upload', { method:'POST', body: form }); + const body = await res.json().catch(()=>({})); + if(!res.ok && next.status!=='error'){ + next.status='error'; + next.message = body.error || 'Upload failed'; + render(); + } + }catch(err){ + next.status='error'; + next.message = String(err); + render(); + }finally{ + inflight--; + setTimeout(runNext, 50); + } + } + for(let i=0;i<3;i++) runNext(); +} + +// --- DOM refs --- +const dz = document.getElementById('dropzone'); +const fi = document.getElementById('fileInput'); +const btnClearFinished = document.getElementById('btnClearFinished'); +const btnClearAll = document.getElementById('btnClearAll'); +const btnPing = document.getElementById('btnPing'); +const pingStatus = document.getElementById('pingStatus'); +const banner = document.getElementById('topBanner'); + +// --- Connection test with ephemeral banner --- +btnPing.onclick = async () => { + pingStatus.textContent = 'checking…'; + try{ + const r = await fetch('/api/ping', { method:'POST' }); + const j = await r.json(); + pingStatus.textContent = j.ok ? 'Connected' : 'No connection'; + pingStatus.className = 'ml-2 text-sm ' + (j.ok ? 'text-green-600' : 'text-red-600'); + if(j.ok){ + banner.textContent = `Connected to Immich at ${j.base_url}`; + banner.classList.remove('hidden'); + setTimeout(() => banner.classList.add('hidden'), 3000); + } + }catch{ + pingStatus.textContent = 'No connection'; + pingStatus.className='ml-2 text-sm text-red-600'; + } +}; + +// --- Drag & drop (no click-to-open on touch) --- +['dragenter','dragover'].forEach(ev => dz.addEventListener(ev, e=>{ e.preventDefault(); dz.classList.add('border-blue-500','bg-blue-50'); })); +['dragleave','drop'].forEach(ev => dz.addEventListener(ev, e=>{ e.preventDefault(); dz.classList.remove('border-blue-500','bg-blue-50'); })); +dz.addEventListener('drop', (e)=>{ + e.preventDefault(); + const files = Array.from(e.dataTransfer.files || []); + const accepted = files.filter(f => /^(image|video)\//.test(f.type) || /\.(jpe?g|png|heic|heif|webp|gif|tiff|bmp|mp4|mov|m4v|avi|mkv)$/i.test(f.name)); + accepted.forEach(addItem); + render(); + runQueue(); +}); + +// --- Mobile-safe file input change handler --- +const isTouch = ('ontouchstart' in window) || (navigator.maxTouchPoints > 0); +let suppressClicksUntil = 0; + +fi.addEventListener('click', (e) => { + // prevent bubbling to parents (extra safety) + e.stopPropagation(); +}); + +fi.onchange = () => { + // Suppress any stray clicks for a short window after the picker closes + suppressClicksUntil = Date.now() + 800; + + const files = Array.from(fi.files || []); + const accepted = files.filter(f => + /^(image|video)\//.test(f.type) || + /\.(jpe?g|png|heic|heif|webp|gif|tiff|bmp|mp4|mov|m4v|avi|mkv)$/i.test(f.name) + ); + accepted.forEach(addItem); + render(); + runQueue(); + + // Reset a bit later so selecting the same items again still triggers 'change' + setTimeout(() => { try { fi.value = ''; } catch {} }, 500); +}; + +// If you want the whole dropzone clickable on desktop only, enable this: +if (!isTouch) { + dz.addEventListener('click', () => { + // avoid accidental double-open if something weird happens + if (Date.now() < suppressClicksUntil) return; + try { fi.value = ''; } catch {} + fi.click(); + }); +} + +// --- Clear buttons --- +btnClearFinished.onclick = ()=>{ items = items.filter(i => !['done','duplicate'].includes(i.status)); render(); }; +btnClearAll.onclick = ()=>{ items = []; render(); }; diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..a295a40 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,75 @@ + + + + + + Immich Drop Uploader + + + +
+ + + +
+

Immich Drop Uploader

+
+ + +
+
+ + +
+
+ + +
+

Drop images or videos here

+

...or

+ + +
+ +
+ +
+ We never show uploaded media and keep everything session-local. No account required. +
+
+ + +
+
+
+ Queued/Processing: 0 + Uploading: 0 + Done: 0 + Duplicates: 0 + Errors: 0 +
+
+ + +
+
+
+ + +
+ +
+ Built for simple, account-less uploads to Immich. This page never lists media from the server and only shows your current session's items. +
+
+ + + + diff --git a/main.py b/main.py new file mode 100644 index 0000000..3993132 --- /dev/null +++ b/main.py @@ -0,0 +1,13 @@ +""" +Thin entrypoint so you can run `python main.py` from project root. +""" +import os +import uvicorn +from dotenv import load_dotenv +load_dotenv() + + +if __name__ == "__main__": + host = os.getenv("HOST", "127.0.0.1") + port = int(os.getenv("PORT", "8080")) + uvicorn.run("app.app:app", host=host, port=port, reload=True) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..257cbdc --- /dev/null +++ b/requirements.txt @@ -0,0 +1,26 @@ +annotated-types==0.7.0 +anyio==4.10.0 +certifi==2025.8.3 +charset-normalizer==3.4.3 +click==8.2.1 +colorama==0.4.6 +fastapi==0.116.1 +h11==0.16.0 +httptools==0.6.4 +idna==3.10 +pillow==11.3.0 +pydantic==2.11.7 +pydantic_core==2.33.2 +python-dotenv==1.1.1 +python-multipart==0.0.20 +PyYAML==6.0.2 +requests==2.32.5 +requests-toolbelt==1.0.0 +sniffio==1.3.1 +starlette==0.47.3 +typing-inspection==0.4.1 +typing_extensions==4.15.0 +urllib3==2.5.0 +uvicorn==0.35.0 +watchfiles==1.1.0 +websockets==15.0.1