This commit is contained in:
MEGASOL\simon.adams
2025-08-26 13:50:28 +02:00
commit e6f055ec5b
22 changed files with 1131 additions and 0 deletions

14
.env Normal file
View File

@@ -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

14
.env.example Normal file
View File

@@ -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

9
.idea/.gitignore generated vendored Normal file
View File

@@ -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/*

10
.idea/immich_drop.iml generated Normal file
View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.venv" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

View File

@@ -0,0 +1,23 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="PyPackageRequirementsInspection" enabled="true" level="WARNING" enabled_by_default="true">
<option name="ignoredPackages">
<value>
<list size="9">
<item index="0" class="java.lang.String" itemvalue="opencv-python" />
<item index="1" class="java.lang.String" itemvalue="pillow" />
<item index="2" class="java.lang.String" itemvalue="numpy" />
<item index="3" class="java.lang.String" itemvalue="python-snap7" />
<item index="4" class="java.lang.String" itemvalue="urllib3" />
<item index="5" class="java.lang.String" itemvalue="pypylon" />
<item index="6" class="java.lang.String" itemvalue="shapely" />
<item index="7" class="java.lang.String" itemvalue="typing_extensions" />
<item index="8" class="java.lang.String" itemvalue="python-logstash" />
</list>
</value>
</option>
</inspection_tool>
</profile>
</component>

View File

@@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<settings>
<option name="USE_PROJECT_PROFILE" value="false" />
<version value="1.0" />
</settings>
</component>

7
.idea/misc.xml generated Normal file
View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Black">
<option name="sdkName" value="Python 3.11 (immich_drop)" />
</component>
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.11 (immich_drop)" project-jdk-type="Python SDK" />
</project>

8
.idea/modules.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/immich_drop.iml" filepath="$PROJECT_DIR$/.idea/immich_drop.iml" />
</modules>
</component>
</project>

141
.idea/sonarlint/issuestore/index.pb generated Normal file
View File

@@ -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
<EFBFBD>
Q.idea/sonarlint/securityhotspotstore/5/7/57aad9a23b4d57899b05f7fd23522c7e830ea8df,5\6\56c08efeeaf5bc850116be39a8cd6b54657ae9df
<EFBFBD>
Q.idea/sonarlint/securityhotspotstore/3/c/3c84dcdc6bbe3d7817c49dcdc327b926fea1808a,4\e\4ead0a9bb92d8636c4ad5a0ef56838d8b7bd33d6
w
G.idea/sonarlint/issuestore/3/c/3c84dcdc6bbe3d7817c49dcdc327b926fea1808a,1\5\15ab6acc17bd4f2d048dc50beb0dfeb0086a0c18
<EFBFBD>
Q.idea/sonarlint/securityhotspotstore/7/c/7c1c00bfdbb4395ef18489f85e017946b2dec599,6\5\65b57d718e6dc62b43eef23afa529832f8f8e8b0
w
G.idea/sonarlint/issuestore/7/c/7c1c00bfdbb4395ef18489f85e017946b2dec599,e\d\edf9ce83e87af3013da222b9c961bcef7984b7f7
<EFBFBD>
Q.idea/sonarlint/securityhotspotstore/a/1/a12b0e66474192f8cb9a2db83f26413a588dec1d,f\8\f8f4358ee0586ea38d84d21b3e1a494a19763229
@
requirements.txt,1\9\19359a61ae2446b51b549167b014da2fcf265768
<EFBFBD>
Q.idea/sonarlint/securityhotspotstore/1/3/13cce7fd076299c81b4986166f3d822791c9490e,9\f\9f958528e4eb130c807f923fbbf706355a6bf465
w
G.idea/sonarlint/issuestore/d/0/d0c38e96758e28eb1baf0a2b2e4553fe89ce7fc4,d\4\d448a0b5a49f133aa1d532c5230858e881b39354
<EFBFBD>
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
<EFBFBD>
Q.idea/sonarlint/securityhotspotstore/f/9/f9bb2fae58beb497060fb30cc7ce01e0edef652d,1\8\18c199af4af6da1ade7a9ae3d580ecb3d5638294
<EFBFBD>
Q.idea/sonarlint/securityhotspotstore/f/5/f55bb43db8e1393bda387dc55289fe3e690c7972,a\5\a50521aa919bcb9c97dbf9a978ecc38bbd316666
<EFBFBD>
Q.idea/sonarlint/securityhotspotstore/6/0/60a964aea4a6760d2f0fe80a7725ed9d569edd6c,b\7\b708aad62f0501c0cda351be171cb414dbef3c1f
<EFBFBD>
Q.idea/sonarlint/securityhotspotstore/6/6/6651ddff6eb82c840ced7c1dddee15c6e1913dd4,5\e\5e4805de49b3e0799af661639276d994cb0e08ae
<EFBFBD>
Q.idea/sonarlint/securityhotspotstore/6/b/6b5a07a517e9a3845a279675e888c81b77b9d712,3\9\391ed4d9ef942c1c7ee7f895cc5196a84c92aef1
w
G.idea/sonarlint/issuestore/b/a/bad0e8b06e94609c24277c5f7b6b72a912d964b3,e\f\ef3c2485526527fa809859e020c8c67a56854c1b
<EFBFBD>
Q.idea/sonarlint/securityhotspotstore/b/a/bad0e8b06e94609c24277c5f7b6b72a912d964b3,a\d\ad37d76e9d840d471650b43b6b7508de7dc098fa
w
G.idea/sonarlint/issuestore/d/4/d4dae00d11854b35292c2b8a30515a9a0aa2d871,6\5\653f06312c57b2eb505420827d653ecb2a3ec9f2
<EFBFBD>
Q.idea/sonarlint/securityhotspotstore/d/4/d4dae00d11854b35292c2b8a30515a9a0aa2d871,2\d\2d7389e5765b1d99bc1d59b6d005c90457d88dd5
<EFBFBD>
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
<EFBFBD>
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
<EFBFBD>
Q.idea/sonarlint/securityhotspotstore/8/e/8ec9a00bfd09b3190ac6b22251dbb1aa95a0579d,1\0\10a03f31c4dce92cb371e761ce9429428bb6c176
<EFBFBD>
Q.idea/sonarlint/securityhotspotstore/c/b/cb368897cc5bf4934e15bc7a3c0b7d3b69446875,2\3\23fe6b145fa43458771172cee75766e176007747
w
G.idea/sonarlint/issuestore/7/f/7f430184f2d87067e82533dc1f285c8c4b6de4dc,1\a\1a770cc867c8653560c9f5596356109412c43560
<EFBFBD>
Q.idea/sonarlint/securityhotspotstore/f/3/f30451335edfb80e231f658d0defc83bb7e4c3a7,d\a\da0fc6477c7b71fe17276da578e8d515eff6281f
w
G.idea/sonarlint/issuestore/f/3/f30451335edfb80e231f658d0defc83bb7e4c3a7,c\3\c39d98dd28d25672857407dddb5cd9a1602735a7
<EFBFBD>
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
<EFBFBD>
Q.idea/sonarlint/securityhotspotstore/f/1/f1bdda93d9a278e358509d498e17d97764c1fb29,b\5\b58a0677868503201074e965423d875803239017
<EFBFBD>
Q.idea/sonarlint/securityhotspotstore/6/3/637095c9834400adf5877ab26466a185e91f2000,f\9\f9fca85a6b43b230e3759f6a675ecb38a8732c0f
w
G.idea/sonarlint/issuestore/0/c/0c991b229bde803c280a90acf7c90d19fca5f126,b\2\b22a2edcb4052acd57c8e48cdf7726b68e558590
<EFBFBD>
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

View File

@@ -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
<EFBFBD>
Q.idea/sonarlint/securityhotspotstore/5/7/57aad9a23b4d57899b05f7fd23522c7e830ea8df,5\6\56c08efeeaf5bc850116be39a8cd6b54657ae9df
<EFBFBD>
Q.idea/sonarlint/securityhotspotstore/3/c/3c84dcdc6bbe3d7817c49dcdc327b926fea1808a,4\e\4ead0a9bb92d8636c4ad5a0ef56838d8b7bd33d6
w
G.idea/sonarlint/issuestore/3/c/3c84dcdc6bbe3d7817c49dcdc327b926fea1808a,1\5\15ab6acc17bd4f2d048dc50beb0dfeb0086a0c18
<EFBFBD>
Q.idea/sonarlint/securityhotspotstore/7/c/7c1c00bfdbb4395ef18489f85e017946b2dec599,6\5\65b57d718e6dc62b43eef23afa529832f8f8e8b0
w
G.idea/sonarlint/issuestore/7/c/7c1c00bfdbb4395ef18489f85e017946b2dec599,e\d\edf9ce83e87af3013da222b9c961bcef7984b7f7
<EFBFBD>
Q.idea/sonarlint/securityhotspotstore/a/1/a12b0e66474192f8cb9a2db83f26413a588dec1d,f\8\f8f4358ee0586ea38d84d21b3e1a494a19763229
@
requirements.txt,1\9\19359a61ae2446b51b549167b014da2fcf265768
<EFBFBD>
Q.idea/sonarlint/securityhotspotstore/1/3/13cce7fd076299c81b4986166f3d822791c9490e,9\f\9f958528e4eb130c807f923fbbf706355a6bf465
w
G.idea/sonarlint/issuestore/d/0/d0c38e96758e28eb1baf0a2b2e4553fe89ce7fc4,d\4\d448a0b5a49f133aa1d532c5230858e881b39354
<EFBFBD>
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
<EFBFBD>
Q.idea/sonarlint/securityhotspotstore/f/9/f9bb2fae58beb497060fb30cc7ce01e0edef652d,1\8\18c199af4af6da1ade7a9ae3d580ecb3d5638294
<EFBFBD>
Q.idea/sonarlint/securityhotspotstore/f/5/f55bb43db8e1393bda387dc55289fe3e690c7972,a\5\a50521aa919bcb9c97dbf9a978ecc38bbd316666
<EFBFBD>
Q.idea/sonarlint/securityhotspotstore/6/0/60a964aea4a6760d2f0fe80a7725ed9d569edd6c,b\7\b708aad62f0501c0cda351be171cb414dbef3c1f
<EFBFBD>
Q.idea/sonarlint/securityhotspotstore/6/6/6651ddff6eb82c840ced7c1dddee15c6e1913dd4,5\e\5e4805de49b3e0799af661639276d994cb0e08ae
<EFBFBD>
Q.idea/sonarlint/securityhotspotstore/6/b/6b5a07a517e9a3845a279675e888c81b77b9d712,3\9\391ed4d9ef942c1c7ee7f895cc5196a84c92aef1
w
G.idea/sonarlint/issuestore/b/a/bad0e8b06e94609c24277c5f7b6b72a912d964b3,e\f\ef3c2485526527fa809859e020c8c67a56854c1b
<EFBFBD>
Q.idea/sonarlint/securityhotspotstore/b/a/bad0e8b06e94609c24277c5f7b6b72a912d964b3,a\d\ad37d76e9d840d471650b43b6b7508de7dc098fa
w
G.idea/sonarlint/issuestore/d/4/d4dae00d11854b35292c2b8a30515a9a0aa2d871,6\5\653f06312c57b2eb505420827d653ecb2a3ec9f2
<EFBFBD>
Q.idea/sonarlint/securityhotspotstore/d/4/d4dae00d11854b35292c2b8a30515a9a0aa2d871,2\d\2d7389e5765b1d99bc1d59b6d005c90457d88dd5
<EFBFBD>
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
<EFBFBD>
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
<EFBFBD>
Q.idea/sonarlint/securityhotspotstore/8/e/8ec9a00bfd09b3190ac6b22251dbb1aa95a0579d,1\0\10a03f31c4dce92cb371e761ce9429428bb6c176
<EFBFBD>
Q.idea/sonarlint/securityhotspotstore/c/b/cb368897cc5bf4934e15bc7a3c0b7d3b69446875,2\3\23fe6b145fa43458771172cee75766e176007747
w
G.idea/sonarlint/issuestore/7/f/7f430184f2d87067e82533dc1f285c8c4b6de4dc,1\a\1a770cc867c8653560c9f5596356109412c43560
<EFBFBD>
Q.idea/sonarlint/securityhotspotstore/f/3/f30451335edfb80e231f658d0defc83bb7e4c3a7,d\a\da0fc6477c7b71fe17276da578e8d515eff6281f
w
G.idea/sonarlint/issuestore/f/3/f30451335edfb80e231f658d0defc83bb7e4c3a7,c\3\c39d98dd28d25672857407dddb5cd9a1602735a7
<EFBFBD>
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
<EFBFBD>
Q.idea/sonarlint/securityhotspotstore/f/1/f1bdda93d9a278e358509d498e17d97764c1fb29,b\5\b58a0677868503201074e965423d875803239017
<EFBFBD>
Q.idea/sonarlint/securityhotspotstore/6/3/637095c9834400adf5877ab26466a185e91f2000,f\9\f9fca85a6b43b230e3759f6a675ecb38a8732c0f
w
G.idea/sonarlint/issuestore/0/c/0c991b229bde803c280a90acf7c90d19fca5f126,b\2\b22a2edcb4052acd57c8e48cdf7726b68e558590
<EFBFBD>
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

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

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

29
Dockerfile Normal file
View File

@@ -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"]

15
README.md Normal file
View File

@@ -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
```

0
app/__init__.py Normal file
View File

352
app/app.py Normal file
View File

@@ -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)

31
app/config.py Normal file
View File

@@ -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)

BIN
data/state.db Normal file

Binary file not shown.

22
docker-compose.yml Normal file
View File

@@ -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:

189
frontend/app.js Normal file
View File

@@ -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 => `
<div class="rounded-2xl border bg-white p-4 shadow-sm">
<div class="flex items-center justify-between">
<div class="min-w-0">
<div class="truncate font-medium">${it.name} <span class="text-xs text-gray-500">(${human(it.size)})</span></div>
<div class="mt-1 text-xs text-gray-600">
${it.message ? `<span>${it.message}</span>` : ''}
</div>
</div>
<div class="text-sm">${it.status}</div>
</div>
<div class="mt-3 h-2 w-full overflow-hidden rounded-full bg-gray-100">
<div class="h-full ${it.status==='done'?'bg-green-500':it.status==='duplicate'?'bg-amber-500':it.status==='error'?'bg-red-500':'bg-blue-500'}" style="width:${Math.max(it.progress, (it.status==='done'||it.status==='duplicate'||it.status==='error')?100:it.progress)}%"></div>
</div>
<div class="mt-2 text-sm text-gray-600">
${it.status==='uploading' ? `Uploading… ${it.progress}%` : it.status.charAt(0).toUpperCase()+it.status.slice(1)}
</div>
</div>
`).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(); };

75
frontend/index.html Normal file
View File

@@ -0,0 +1,75 @@
<!doctype html>
<html lang="en" xml:lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Immich Drop Uploader</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="min-h-screen bg-gray-50 text-gray-900">
<div class="mx-auto max-w-4xl p-6 space-y-6">
<!-- Ephemeral top banner -->
<div id="topBanner" class="hidden rounded-2xl border border-green-200 bg-green-50 p-3 text-green-700 text-center"></div>
<header class="flex items-center justify-between">
<h1 class="text-2xl font-semibold tracking-tight">Immich Drop Uploader</h1>
<div class="flex items-center gap-2">
<button id="btnPing" class="rounded-xl border px-3 py-1 text-sm">Test connection</button>
<span id="pingStatus" class="ml-2 text-sm text-gray-500"></span>
</div>
</header>
<!-- Dropzone -->
<section id="dropzone" class="rounded-2xl border-2 border-dashed p-10 text-center bg-white">
<div class="mx-auto h-12 w-12 opacity-70">
<!-- upload icon -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M12 16V8m0 0l-3 3m3-3 3 3M4 16a4 4 0 0 0 4 4h8a4 4 0 0 0 4-4v-1a1 1 0 1 0-2 0v1a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2v-1a1 1 0 1 0-2 0v1z"/></svg>
</div>
<p class="mt-3 font-medium">Drop images or videos here</p>
<p class="text-sm text-gray-600">...or</p>
<!-- Mobile-safe choose control: label wraps the hidden input -->
<div class="mt-3 relative inline-block">
<label class="rounded-2xl bg-black text-white px-4 py-2 hover:opacity-90 cursor-pointer select-none">
Choose files
<input id="fileInput"
type="file"
multiple
accept="image/*,video/*"
class="absolute inset-0 opacity-0 cursor-pointer" />
</label>
</div>
<div class="mt-4 text-sm text-gray-500">
We never show uploaded media and keep everything session-local. No account required.
</div>
</section>
<!-- Queue summary -->
<section id="summary" class="rounded-2xl border bg-white p-4 shadow-sm">
<div class="flex items-center justify-between text-sm">
<div class="flex gap-4">
<span>Queued/Processing: <b id="countQueued">0</b></span>
<span>Uploading: <b id="countUploading">0</b></span>
<span>Done: <b id="countDone">0</b></span>
<span>Duplicates: <b id="countDup">0</b></span>
<span>Errors: <b id="countErr">0</b></span>
</div>
<div class="flex gap-2">
<button id="btnClearFinished" class="rounded-xl border px-3 py-1 text-sm">Clear finished</button>
<button id="btnClearAll" class="rounded-xl border px-3 py-1 text-sm">Clear all</button>
</div>
</div>
</section>
<!-- Items -->
<section id="items" class="space-y-3"></section>
<footer class="pt-4 pb-10 text-center text-xs text-gray-500">
Built for simple, account-less uploads to Immich. This page never lists media from the server and only shows your current session's items.
</footer>
</div>
<script src="/static/app.js"></script>
</body>
</html>

13
main.py Normal file
View File

@@ -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)

26
requirements.txt Normal file
View File

@@ -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