.
This commit is contained in:
14
.env
Normal file
14
.env
Normal 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
14
.env.example
Normal 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
9
.idea/.gitignore
generated
vendored
Normal 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
10
.idea/immich_drop.iml
generated
Normal 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>
|
||||
23
.idea/inspectionProfiles/Project_Default.xml
generated
Normal file
23
.idea/inspectionProfiles/Project_Default.xml
generated
Normal 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>
|
||||
6
.idea/inspectionProfiles/profiles_settings.xml
generated
Normal file
6
.idea/inspectionProfiles/profiles_settings.xml
generated
Normal 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
7
.idea/misc.xml
generated
Normal 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
8
.idea/modules.xml
generated
Normal 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
141
.idea/sonarlint/issuestore/index.pb
generated
Normal 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
|
||||
141
.idea/sonarlint/securityhotspotstore/index.pb
generated
Normal file
141
.idea/sonarlint/securityhotspotstore/index.pb
generated
Normal 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
6
.idea/vcs.xml
generated
Normal 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
29
Dockerfile
Normal 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
15
README.md
Normal 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
0
app/__init__.py
Normal file
352
app/app.py
Normal file
352
app/app.py
Normal 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
31
app/config.py
Normal 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
BIN
data/state.db
Normal file
Binary file not shown.
22
docker-compose.yml
Normal file
22
docker-compose.yml
Normal 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
189
frontend/app.js
Normal 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
75
frontend/index.html
Normal 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
13
main.py
Normal 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
26
requirements.txt
Normal 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
|
||||
Reference in New Issue
Block a user