selects 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- selects/__init__.py +3 -0
- selects/__main__.py +4 -0
- selects/classical/__init__.py +0 -0
- selects/classical/aesthetic_grade.py +101 -0
- selects/classical/auto_reject.py +29 -0
- selects/classical/blur.py +27 -0
- selects/classical/exposure.py +29 -0
- selects/classical/faces.py +65 -0
- selects/classical/straighten.py +111 -0
- selects/cli.py +176 -0
- selects/config.py +74 -0
- selects/db/__init__.py +127 -0
- selects/db/migrations/env.py +67 -0
- selects/db/migrations/script.py.mako +26 -0
- selects/db/migrations/versions/8ff743c44fc7_baseline_schema.py +297 -0
- selects/db/migrations/versions/a1b2c3d4e5f6_add_source_to_photo_tags_pk.py +105 -0
- selects/db/migrations/versions/c3d4e5f6a7b8_add_face_attribute_columns.py +63 -0
- selects/db/migrations/versions/d4e5f6a7b8c9_add_video_analysis_columns.py +75 -0
- selects/db/models.py +393 -0
- selects/decode/__init__.py +20 -0
- selects/decode/heic.py +16 -0
- selects/decode/jpeg.py +41 -0
- selects/decode/raw.py +27 -0
- selects/decode/video.py +61 -0
- selects/dedup.py +273 -0
- selects/export.py +316 -0
- selects/gpu.py +78 -0
- selects/indexer/__init__.py +0 -0
- selects/indexer/exif.py +147 -0
- selects/indexer/orchestrator.py +113 -0
- selects/indexer/preview.py +39 -0
- selects/indexer/walker.py +84 -0
- selects/ml/__init__.py +0 -0
- selects/ml/caption.py +168 -0
- selects/ml/curation.py +321 -0
- selects/ml/deblur_nafnet.py +275 -0
- selects/ml/embed.py +171 -0
- selects/ml/face_attributes.py +396 -0
- selects/ml/faces.py +126 -0
- selects/ml/locations.py +460 -0
- selects/ml/lowlight.py +173 -0
- selects/ml/model_assets.py +326 -0
- selects/ml/moments.py +214 -0
- selects/ml/nl_story.py +261 -0
- selects/ml/persons.py +149 -0
- selects/ml/ram_tags.py +260 -0
- selects/ml/retouch_csrnet.py +173 -0
- selects/ml/search.py +68 -0
- selects/ml/smart_clusters.py +539 -0
- selects/ml/stories.py +594 -0
- selects/ml/tags.py +150 -0
- selects/ml/taste.py +390 -0
- selects/ml/thematic_clusters.py +270 -0
- selects/ml/trip_data.py +195 -0
- selects/ml/trip_type.py +84 -0
- selects/pipeline.py +128 -0
- selects/recap.py +588 -0
- selects/server/__init__.py +0 -0
- selects/server/app.py +155 -0
- selects/server/dedup_routes.py +91 -0
- selects/server/export_routes.py +252 -0
- selects/server/faces2_routes.py +128 -0
- selects/server/fs_routes.py +49 -0
- selects/server/libraries.py +87 -0
- selects/server/library_manager.py +295 -0
- selects/server/models_routes.py +62 -0
- selects/server/pipeline_runner.py +41 -0
- selects/server/recap_routes.py +52 -0
- selects/server/routes.py +2392 -0
- selects/server/search2_routes.py +212 -0
- selects/server/static/assets/index-8kseOkrj.js +89 -0
- selects/server/static/assets/index-DBCjYqrq.css +1 -0
- selects/server/static/index.html +15 -0
- selects/server/taste_routes.py +44 -0
- selects/server/video_routes.py +199 -0
- selects/server/watch_routes.py +60 -0
- selects/server/ws.py +48 -0
- selects/video.py +392 -0
- selects/watcher.py +314 -0
- selects-0.1.0.dist-info/METADATA +255 -0
- selects-0.1.0.dist-info/RECORD +84 -0
- selects-0.1.0.dist-info/WHEEL +4 -0
- selects-0.1.0.dist-info/entry_points.txt +2 -0
- selects-0.1.0.dist-info/licenses/LICENSE +21 -0
selects/db/__init__.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""selects database session management."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import threading
|
|
5
|
+
from contextlib import contextmanager
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Dict, Generator, Tuple
|
|
8
|
+
|
|
9
|
+
from sqlalchemy import create_engine, event, inspect
|
|
10
|
+
from sqlalchemy.engine import Engine
|
|
11
|
+
from sqlalchemy.orm import Session, sessionmaker
|
|
12
|
+
|
|
13
|
+
from selects.db.models import Base
|
|
14
|
+
|
|
15
|
+
# Directory holding the packaged Alembic environment (env.py, versions/). Ships
|
|
16
|
+
# inside the selects.db package so migrations are available wherever the
|
|
17
|
+
# package is installed, without relying on an alembic.ini in the CWD.
|
|
18
|
+
_MIGRATIONS_DIR = Path(__file__).parent / "migrations"
|
|
19
|
+
|
|
20
|
+
# Cache of (engine, sessionmaker) keyed by resolved db path, so repeated
|
|
21
|
+
# init_db() calls with the same path reuse a single Engine/pool instead of
|
|
22
|
+
# creating a new one each time (important since init_db is invoked once per
|
|
23
|
+
# ML pipeline stage, ~20 call sites, while the app also serves concurrent
|
|
24
|
+
# requests + background indexing writes against the same SQLite file).
|
|
25
|
+
_ENGINES: Dict[str, Tuple[Engine, "sessionmaker[Session]"]] = {}
|
|
26
|
+
_ENGINES_LOCK = threading.Lock()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _make_url(db_path: Path) -> str:
|
|
30
|
+
return f"sqlite:///{db_path}"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _set_sqlite_pragmas(dbapi_connection, connection_record) -> None: # noqa: ANN001
|
|
34
|
+
cursor = dbapi_connection.cursor()
|
|
35
|
+
try:
|
|
36
|
+
cursor.execute("PRAGMA journal_mode=WAL")
|
|
37
|
+
cursor.execute("PRAGMA synchronous=NORMAL")
|
|
38
|
+
cursor.execute("PRAGMA foreign_keys=ON")
|
|
39
|
+
cursor.execute("PRAGMA busy_timeout=5000")
|
|
40
|
+
finally:
|
|
41
|
+
cursor.close()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _alembic_config(connection): # noqa: ANN001, ANN202
|
|
45
|
+
"""Build an in-code Alembic Config pointing at the packaged migrations dir
|
|
46
|
+
and bound to an already-open *connection* (so migrations run on the same
|
|
47
|
+
engine + WAL pragmas as the rest of the app, no alembic.ini required)."""
|
|
48
|
+
from alembic.config import Config
|
|
49
|
+
|
|
50
|
+
cfg = Config()
|
|
51
|
+
cfg.set_main_option("script_location", str(_MIGRATIONS_DIR))
|
|
52
|
+
cfg.attributes["connection"] = connection
|
|
53
|
+
return cfg
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _ensure_schema(engine: Engine) -> None:
|
|
57
|
+
"""Bring *engine*'s database to the current schema via Alembic.
|
|
58
|
+
|
|
59
|
+
Three cases:
|
|
60
|
+
* Fresh DB (no tables): ``create_all`` then stamp head — fast path that
|
|
61
|
+
avoids replaying every migration on brand-new databases.
|
|
62
|
+
* Existing DB without an ``alembic_version`` table (every pre-Alembic
|
|
63
|
+
selects DB in the wild): stamp the baseline revision, then upgrade to
|
|
64
|
+
head. The follow-up revision that adds ``source`` to the photo_tags PK is
|
|
65
|
+
a guarded no-op when the column is already present, so this is safe for
|
|
66
|
+
DBs at any historical photo_tags shape.
|
|
67
|
+
* DB already under Alembic control: upgrade to head.
|
|
68
|
+
"""
|
|
69
|
+
from alembic import command
|
|
70
|
+
from alembic.script import ScriptDirectory
|
|
71
|
+
|
|
72
|
+
table_names = set(inspect(engine).get_table_names())
|
|
73
|
+
|
|
74
|
+
if not table_names:
|
|
75
|
+
with engine.begin() as conn:
|
|
76
|
+
Base.metadata.create_all(conn)
|
|
77
|
+
with engine.connect() as conn:
|
|
78
|
+
command.stamp(_alembic_config(conn), "head")
|
|
79
|
+
return
|
|
80
|
+
|
|
81
|
+
if "alembic_version" not in table_names:
|
|
82
|
+
with engine.connect() as conn:
|
|
83
|
+
cfg = _alembic_config(conn)
|
|
84
|
+
baseline = ScriptDirectory.from_config(cfg).get_base()
|
|
85
|
+
command.stamp(cfg, baseline)
|
|
86
|
+
with engine.connect() as conn:
|
|
87
|
+
command.upgrade(_alembic_config(conn), "head")
|
|
88
|
+
return
|
|
89
|
+
|
|
90
|
+
with engine.connect() as conn:
|
|
91
|
+
command.upgrade(_alembic_config(conn), "head")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def init_db(db_path: Path) -> sessionmaker[Session]:
|
|
95
|
+
"""Create all tables and return a sessionmaker bound to *db_path*.
|
|
96
|
+
|
|
97
|
+
Idempotent: repeated calls with the same (resolved) path reuse the same
|
|
98
|
+
cached Engine/sessionmaker rather than creating a new Engine each time,
|
|
99
|
+
and ``create_all`` only runs once per path per process.
|
|
100
|
+
"""
|
|
101
|
+
key = str(Path(db_path).resolve())
|
|
102
|
+
with _ENGINES_LOCK:
|
|
103
|
+
cached = _ENGINES.get(key)
|
|
104
|
+
if cached is not None:
|
|
105
|
+
return cached[1]
|
|
106
|
+
|
|
107
|
+
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
108
|
+
engine = create_engine(_make_url(db_path), echo=False)
|
|
109
|
+
event.listens_for(engine, "connect")(_set_sqlite_pragmas)
|
|
110
|
+
_ensure_schema(engine)
|
|
111
|
+
factory = sessionmaker(bind=engine, expire_on_commit=False)
|
|
112
|
+
_ENGINES[key] = (engine, factory)
|
|
113
|
+
return factory
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@contextmanager
|
|
117
|
+
def session_scope(Session: sessionmaker[Session]) -> Generator[Session, None, None]: # noqa: N803
|
|
118
|
+
"""Provide a transactional session scope, committing on success."""
|
|
119
|
+
session = Session()
|
|
120
|
+
try:
|
|
121
|
+
yield session
|
|
122
|
+
session.commit()
|
|
123
|
+
except Exception:
|
|
124
|
+
session.rollback()
|
|
125
|
+
raise
|
|
126
|
+
finally:
|
|
127
|
+
session.close()
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Alembic environment for selects.
|
|
2
|
+
|
|
3
|
+
Designed to be driven programmatically via the Alembic Python API (see
|
|
4
|
+
``selects.db._ensure_schema``) rather than an ``alembic.ini`` on disk. The
|
|
5
|
+
target database URL is supplied through ``config`` — either as an already-open
|
|
6
|
+
Connection in ``config.attributes['connection']`` or via the ``sqlalchemy.url``
|
|
7
|
+
main option. ``render_as_batch=True`` is enabled so future column ALTERs work on
|
|
8
|
+
SQLite (which has no native ALTER for most operations).
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from alembic import context
|
|
13
|
+
from sqlalchemy import engine_from_config, pool
|
|
14
|
+
|
|
15
|
+
from selects.db.models import Base
|
|
16
|
+
|
|
17
|
+
config = context.config
|
|
18
|
+
|
|
19
|
+
target_metadata = Base.metadata
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def run_migrations_offline() -> None:
|
|
23
|
+
url = config.get_main_option("sqlalchemy.url")
|
|
24
|
+
context.configure(
|
|
25
|
+
url=url,
|
|
26
|
+
target_metadata=target_metadata,
|
|
27
|
+
literal_binds=True,
|
|
28
|
+
dialect_opts={"paramstyle": "named"},
|
|
29
|
+
render_as_batch=True,
|
|
30
|
+
compare_type=True,
|
|
31
|
+
)
|
|
32
|
+
with context.begin_transaction():
|
|
33
|
+
context.run_migrations()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _run_with_connection(connection) -> None:
|
|
37
|
+
context.configure(
|
|
38
|
+
connection=connection,
|
|
39
|
+
target_metadata=target_metadata,
|
|
40
|
+
render_as_batch=True,
|
|
41
|
+
compare_type=True,
|
|
42
|
+
)
|
|
43
|
+
with context.begin_transaction():
|
|
44
|
+
context.run_migrations()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def run_migrations_online() -> None:
|
|
48
|
+
# Reuse a caller-provided connection when available so migrations run on the
|
|
49
|
+
# same engine (and WAL pragmas) as the rest of the app.
|
|
50
|
+
connection = config.attributes.get("connection", None)
|
|
51
|
+
if connection is not None:
|
|
52
|
+
_run_with_connection(connection)
|
|
53
|
+
return
|
|
54
|
+
|
|
55
|
+
connectable = engine_from_config(
|
|
56
|
+
config.get_section(config.config_ini_section, {}),
|
|
57
|
+
prefix="sqlalchemy.",
|
|
58
|
+
poolclass=pool.NullPool,
|
|
59
|
+
)
|
|
60
|
+
with connectable.connect() as conn:
|
|
61
|
+
_run_with_connection(conn)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
if context.is_offline_mode():
|
|
65
|
+
run_migrations_offline()
|
|
66
|
+
else:
|
|
67
|
+
run_migrations_online()
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""${message}
|
|
2
|
+
|
|
3
|
+
Revision ID: ${up_revision}
|
|
4
|
+
Revises: ${down_revision | comma,n}
|
|
5
|
+
Create Date: ${create_date}
|
|
6
|
+
|
|
7
|
+
"""
|
|
8
|
+
from typing import Sequence, Union
|
|
9
|
+
|
|
10
|
+
from alembic import op
|
|
11
|
+
import sqlalchemy as sa
|
|
12
|
+
${imports if imports else ""}
|
|
13
|
+
|
|
14
|
+
# revision identifiers, used by Alembic.
|
|
15
|
+
revision: str = ${repr(up_revision)}
|
|
16
|
+
down_revision: Union[str, None] = ${repr(down_revision)}
|
|
17
|
+
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
|
18
|
+
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def upgrade() -> None:
|
|
22
|
+
${upgrades if upgrades else "pass"}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def downgrade() -> None:
|
|
26
|
+
${downgrades if downgrades else "pass"}
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"""baseline schema
|
|
2
|
+
|
|
3
|
+
Revision ID: 8ff743c44fc7
|
|
4
|
+
Revises:
|
|
5
|
+
Create Date: 2026-07-05 22:10:30.688752
|
|
6
|
+
|
|
7
|
+
"""
|
|
8
|
+
from typing import Sequence, Union
|
|
9
|
+
|
|
10
|
+
from alembic import op
|
|
11
|
+
import sqlalchemy as sa
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# revision identifiers, used by Alembic.
|
|
15
|
+
revision: str = '8ff743c44fc7'
|
|
16
|
+
down_revision: Union[str, None] = None
|
|
17
|
+
branch_labels: Union[str, Sequence[str], None] = None
|
|
18
|
+
depends_on: Union[str, Sequence[str], None] = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def upgrade() -> None:
|
|
22
|
+
# ### commands auto generated by Alembic - please adjust! ###
|
|
23
|
+
op.create_table('geocode_cache',
|
|
24
|
+
sa.Column('lat_round', sa.Float(), nullable=False),
|
|
25
|
+
sa.Column('lon_round', sa.Float(), nullable=False),
|
|
26
|
+
sa.Column('payload', sa.Text(), nullable=True),
|
|
27
|
+
sa.Column('display_name', sa.Text(), nullable=True),
|
|
28
|
+
sa.Column('wikipedia_summary', sa.Text(), nullable=True),
|
|
29
|
+
sa.PrimaryKeyConstraint('lat_round', 'lon_round')
|
|
30
|
+
)
|
|
31
|
+
op.create_table('photos',
|
|
32
|
+
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
|
33
|
+
sa.Column('path', sa.String(length=4096), nullable=False),
|
|
34
|
+
sa.Column('sha256', sa.String(length=64), nullable=True),
|
|
35
|
+
sa.Column('mtime', sa.Float(), nullable=True),
|
|
36
|
+
sa.Column('size_bytes', sa.Integer(), nullable=True),
|
|
37
|
+
sa.Column('format', sa.String(length=16), nullable=True),
|
|
38
|
+
sa.Column('width', sa.Integer(), nullable=True),
|
|
39
|
+
sa.Column('height', sa.Integer(), nullable=True),
|
|
40
|
+
sa.Column('taken_at', sa.DateTime(), nullable=True),
|
|
41
|
+
sa.Column('gps_lat', sa.Float(), nullable=True),
|
|
42
|
+
sa.Column('gps_lon', sa.Float(), nullable=True),
|
|
43
|
+
sa.Column('camera', sa.String(length=256), nullable=True),
|
|
44
|
+
sa.Column('thumb_path', sa.String(length=4096), nullable=True),
|
|
45
|
+
sa.Column('preview_path', sa.String(length=4096), nullable=True),
|
|
46
|
+
sa.Column('indexed_at', sa.DateTime(), nullable=True),
|
|
47
|
+
sa.PrimaryKeyConstraint('id')
|
|
48
|
+
)
|
|
49
|
+
with op.batch_alter_table('photos', schema=None) as batch_op:
|
|
50
|
+
batch_op.create_index(batch_op.f('ix_photos_path'), ['path'], unique=True)
|
|
51
|
+
|
|
52
|
+
op.create_table('stories',
|
|
53
|
+
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
|
54
|
+
sa.Column('day', sa.String(length=10), nullable=False),
|
|
55
|
+
sa.Column('title', sa.Text(), nullable=False),
|
|
56
|
+
sa.Column('photo_count', sa.Integer(), nullable=False),
|
|
57
|
+
sa.Column('created_at', sa.DateTime(), nullable=False),
|
|
58
|
+
sa.PrimaryKeyConstraint('id')
|
|
59
|
+
)
|
|
60
|
+
with op.batch_alter_table('stories', schema=None) as batch_op:
|
|
61
|
+
batch_op.create_index(batch_op.f('ix_stories_day'), ['day'], unique=True)
|
|
62
|
+
|
|
63
|
+
op.create_table('videos',
|
|
64
|
+
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
|
65
|
+
sa.Column('path', sa.String(length=4096), nullable=False),
|
|
66
|
+
sa.Column('sha256', sa.String(length=64), nullable=True),
|
|
67
|
+
sa.Column('mtime', sa.Float(), nullable=True),
|
|
68
|
+
sa.Column('size_bytes', sa.Integer(), nullable=True),
|
|
69
|
+
sa.Column('format', sa.String(length=16), nullable=True),
|
|
70
|
+
sa.Column('width', sa.Integer(), nullable=True),
|
|
71
|
+
sa.Column('height', sa.Integer(), nullable=True),
|
|
72
|
+
sa.Column('duration_sec', sa.Float(), nullable=True),
|
|
73
|
+
sa.Column('taken_at', sa.DateTime(), nullable=True),
|
|
74
|
+
sa.Column('thumb_path', sa.String(length=4096), nullable=True),
|
|
75
|
+
sa.Column('preview_path', sa.String(length=4096), nullable=True),
|
|
76
|
+
sa.Column('indexed_at', sa.DateTime(), nullable=True),
|
|
77
|
+
sa.PrimaryKeyConstraint('id')
|
|
78
|
+
)
|
|
79
|
+
with op.batch_alter_table('videos', schema=None) as batch_op:
|
|
80
|
+
batch_op.create_index(batch_op.f('ix_videos_path'), ['path'], unique=True)
|
|
81
|
+
|
|
82
|
+
op.create_table('aesthetic_scores',
|
|
83
|
+
sa.Column('photo_id', sa.Integer(), nullable=False),
|
|
84
|
+
sa.Column('nima_score', sa.Float(), nullable=True),
|
|
85
|
+
sa.Column('ap25_score', sa.Float(), nullable=True),
|
|
86
|
+
sa.Column('personal_score', sa.Float(), nullable=True),
|
|
87
|
+
sa.ForeignKeyConstraint(['photo_id'], ['photos.id'], ondelete='CASCADE'),
|
|
88
|
+
sa.PrimaryKeyConstraint('photo_id')
|
|
89
|
+
)
|
|
90
|
+
op.create_table('classical_scores',
|
|
91
|
+
sa.Column('photo_id', sa.Integer(), nullable=False),
|
|
92
|
+
sa.Column('blur', sa.Float(), nullable=True),
|
|
93
|
+
sa.Column('exposure', sa.Float(), nullable=True),
|
|
94
|
+
sa.Column('faces_count', sa.Integer(), nullable=True),
|
|
95
|
+
sa.Column('eyes_open_ratio', sa.Float(), nullable=True),
|
|
96
|
+
sa.Column('auto_reject', sa.Boolean(), nullable=False),
|
|
97
|
+
sa.Column('reject_reason', sa.Text(), nullable=True),
|
|
98
|
+
sa.ForeignKeyConstraint(['photo_id'], ['photos.id'], ondelete='CASCADE'),
|
|
99
|
+
sa.PrimaryKeyConstraint('photo_id')
|
|
100
|
+
)
|
|
101
|
+
op.create_table('embeddings',
|
|
102
|
+
sa.Column('photo_id', sa.Integer(), nullable=False),
|
|
103
|
+
sa.Column('siglip', sa.LargeBinary(), nullable=False),
|
|
104
|
+
sa.Column('aesthetic_iqa', sa.Float(), nullable=True),
|
|
105
|
+
sa.ForeignKeyConstraint(['photo_id'], ['photos.id'], ondelete='CASCADE'),
|
|
106
|
+
sa.PrimaryKeyConstraint('photo_id')
|
|
107
|
+
)
|
|
108
|
+
op.create_table('face_embeddings',
|
|
109
|
+
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
|
110
|
+
sa.Column('photo_id', sa.Integer(), nullable=False),
|
|
111
|
+
sa.Column('face_index', sa.Integer(), nullable=False),
|
|
112
|
+
sa.Column('embedding', sa.LargeBinary(), nullable=False),
|
|
113
|
+
sa.Column('bbox_x', sa.Integer(), nullable=False),
|
|
114
|
+
sa.Column('bbox_y', sa.Integer(), nullable=False),
|
|
115
|
+
sa.Column('bbox_w', sa.Integer(), nullable=False),
|
|
116
|
+
sa.Column('bbox_h', sa.Integer(), nullable=False),
|
|
117
|
+
sa.Column('confidence', sa.Float(), nullable=False),
|
|
118
|
+
sa.ForeignKeyConstraint(['photo_id'], ['photos.id'], ondelete='CASCADE'),
|
|
119
|
+
sa.PrimaryKeyConstraint('id')
|
|
120
|
+
)
|
|
121
|
+
with op.batch_alter_table('face_embeddings', schema=None) as batch_op:
|
|
122
|
+
batch_op.create_index(batch_op.f('ix_face_embeddings_photo_id'), ['photo_id'], unique=False)
|
|
123
|
+
|
|
124
|
+
op.create_table('moments',
|
|
125
|
+
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
|
126
|
+
sa.Column('primary_photo_id', sa.Integer(), nullable=False),
|
|
127
|
+
sa.Column('started_at', sa.DateTime(), nullable=False),
|
|
128
|
+
sa.Column('ended_at', sa.DateTime(), nullable=False),
|
|
129
|
+
sa.Column('size', sa.Integer(), nullable=False),
|
|
130
|
+
sa.ForeignKeyConstraint(['primary_photo_id'], ['photos.id'], ),
|
|
131
|
+
sa.PrimaryKeyConstraint('id')
|
|
132
|
+
)
|
|
133
|
+
with op.batch_alter_table('moments', schema=None) as batch_op:
|
|
134
|
+
batch_op.create_index(batch_op.f('ix_moments_primary_photo_id'), ['primary_photo_id'], unique=False)
|
|
135
|
+
|
|
136
|
+
op.create_table('photo_categories',
|
|
137
|
+
sa.Column('photo_id', sa.Integer(), nullable=False),
|
|
138
|
+
sa.Column('landscape_sim', sa.Float(), nullable=True),
|
|
139
|
+
sa.Column('portrait_sim', sa.Float(), nullable=True),
|
|
140
|
+
sa.Column('object_sim', sa.Float(), nullable=True),
|
|
141
|
+
sa.Column('primary_category', sa.String(length=16), nullable=True),
|
|
142
|
+
sa.ForeignKeyConstraint(['photo_id'], ['photos.id'], ondelete='CASCADE'),
|
|
143
|
+
sa.PrimaryKeyConstraint('photo_id')
|
|
144
|
+
)
|
|
145
|
+
op.create_table('photo_ratings',
|
|
146
|
+
sa.Column('photo_id', sa.Integer(), nullable=False),
|
|
147
|
+
sa.Column('rating', sa.Integer(), nullable=False),
|
|
148
|
+
sa.Column('rated_at', sa.DateTime(), nullable=False),
|
|
149
|
+
sa.ForeignKeyConstraint(['photo_id'], ['photos.id'], ondelete='CASCADE'),
|
|
150
|
+
sa.PrimaryKeyConstraint('photo_id')
|
|
151
|
+
)
|
|
152
|
+
# NOTE: photo_tags is intentionally created here in its PRE-source shape:
|
|
153
|
+
# PK (photo_id, tag), no ``source`` column. The follow-up revision
|
|
154
|
+
# ('add source to photo_tags PK') ports the historical hand-rolled migration
|
|
155
|
+
# (_migrate_add_source_column) that adds the column and rebuilds the PK. This
|
|
156
|
+
# lets pre-source databases in the wild stamp this baseline honestly and then
|
|
157
|
+
# upgrade to head, while the follow-up revision is a guarded no-op for DBs
|
|
158
|
+
# that already have the source column.
|
|
159
|
+
op.create_table('photo_tags',
|
|
160
|
+
sa.Column('photo_id', sa.Integer(), nullable=False),
|
|
161
|
+
sa.Column('tag', sa.String(length=128), nullable=False),
|
|
162
|
+
sa.Column('score', sa.Float(), nullable=False),
|
|
163
|
+
sa.ForeignKeyConstraint(['photo_id'], ['photos.id'], ondelete='CASCADE'),
|
|
164
|
+
sa.PrimaryKeyConstraint('photo_id', 'tag')
|
|
165
|
+
)
|
|
166
|
+
with op.batch_alter_table('photo_tags', schema=None) as batch_op:
|
|
167
|
+
batch_op.create_index('ix_photo_tags_tag', ['tag'], unique=False)
|
|
168
|
+
|
|
169
|
+
op.create_table('pipeline_states',
|
|
170
|
+
sa.Column('photo_id', sa.Integer(), nullable=False),
|
|
171
|
+
sa.Column('classical_done', sa.Boolean(), nullable=False),
|
|
172
|
+
sa.Column('embedding_done', sa.Boolean(), nullable=False),
|
|
173
|
+
sa.Column('vl_done', sa.Boolean(), nullable=False),
|
|
174
|
+
sa.Column('ordering_done', sa.Boolean(), nullable=False),
|
|
175
|
+
sa.Column('error', sa.Text(), nullable=True),
|
|
176
|
+
sa.ForeignKeyConstraint(['photo_id'], ['photos.id'], ondelete='CASCADE'),
|
|
177
|
+
sa.PrimaryKeyConstraint('photo_id')
|
|
178
|
+
)
|
|
179
|
+
with op.batch_alter_table('pipeline_states', schema=None) as batch_op:
|
|
180
|
+
batch_op.create_index('ix_pipeline_states_work', ['classical_done', 'embedding_done'], unique=False)
|
|
181
|
+
|
|
182
|
+
op.create_table('story_items',
|
|
183
|
+
sa.Column('story_id', sa.Integer(), nullable=False),
|
|
184
|
+
sa.Column('rank', sa.Integer(), nullable=False),
|
|
185
|
+
sa.Column('photo_id', sa.Integer(), nullable=False),
|
|
186
|
+
sa.Column('scene_label', sa.String(length=64), nullable=True),
|
|
187
|
+
sa.Column('scene_rank', sa.Integer(), nullable=True),
|
|
188
|
+
sa.ForeignKeyConstraint(['photo_id'], ['photos.id'], ondelete='CASCADE'),
|
|
189
|
+
sa.ForeignKeyConstraint(['story_id'], ['stories.id'], ondelete='CASCADE'),
|
|
190
|
+
sa.PrimaryKeyConstraint('story_id', 'rank')
|
|
191
|
+
)
|
|
192
|
+
op.create_table('swipes',
|
|
193
|
+
sa.Column('photo_id', sa.Integer(), nullable=False),
|
|
194
|
+
sa.Column('decision', sa.Text(), nullable=False),
|
|
195
|
+
sa.Column('swiped_at', sa.DateTime(), nullable=False),
|
|
196
|
+
sa.ForeignKeyConstraint(['photo_id'], ['photos.id'], ),
|
|
197
|
+
sa.PrimaryKeyConstraint('photo_id')
|
|
198
|
+
)
|
|
199
|
+
op.create_table('visits',
|
|
200
|
+
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
|
201
|
+
sa.Column('story_id', sa.Integer(), nullable=False),
|
|
202
|
+
sa.Column('rank', sa.Integer(), nullable=False),
|
|
203
|
+
sa.Column('name', sa.Text(), nullable=False),
|
|
204
|
+
sa.Column('summary', sa.Text(), nullable=True),
|
|
205
|
+
sa.Column('lat', sa.Float(), nullable=False),
|
|
206
|
+
sa.Column('lon', sa.Float(), nullable=False),
|
|
207
|
+
sa.Column('elevation_m', sa.Integer(), nullable=True),
|
|
208
|
+
sa.Column('arrived_at', sa.DateTime(), nullable=False),
|
|
209
|
+
sa.Column('departed_at', sa.DateTime(), nullable=False),
|
|
210
|
+
sa.Column('photo_count', sa.Integer(), nullable=False),
|
|
211
|
+
sa.Column('cover_photo_id', sa.Integer(), nullable=True),
|
|
212
|
+
sa.ForeignKeyConstraint(['cover_photo_id'], ['photos.id'], ),
|
|
213
|
+
sa.ForeignKeyConstraint(['story_id'], ['stories.id'], ondelete='CASCADE'),
|
|
214
|
+
sa.PrimaryKeyConstraint('id')
|
|
215
|
+
)
|
|
216
|
+
with op.batch_alter_table('visits', schema=None) as batch_op:
|
|
217
|
+
batch_op.create_index(batch_op.f('ix_visits_story_id'), ['story_id'], unique=False)
|
|
218
|
+
batch_op.create_index('ix_visits_story_rank', ['story_id', 'rank'], unique=False)
|
|
219
|
+
|
|
220
|
+
op.create_table('moment_members',
|
|
221
|
+
sa.Column('moment_id', sa.Integer(), nullable=False),
|
|
222
|
+
sa.Column('photo_id', sa.Integer(), nullable=False),
|
|
223
|
+
sa.Column('rank', sa.Integer(), nullable=False),
|
|
224
|
+
sa.ForeignKeyConstraint(['moment_id'], ['moments.id'], ondelete='CASCADE'),
|
|
225
|
+
sa.ForeignKeyConstraint(['photo_id'], ['photos.id'], ondelete='CASCADE'),
|
|
226
|
+
sa.PrimaryKeyConstraint('moment_id', 'photo_id')
|
|
227
|
+
)
|
|
228
|
+
op.create_table('persons',
|
|
229
|
+
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
|
230
|
+
sa.Column('label', sa.Text(), nullable=True),
|
|
231
|
+
sa.Column('cover_face_embedding_id', sa.Integer(), nullable=True),
|
|
232
|
+
sa.Column('photo_count', sa.Integer(), nullable=False),
|
|
233
|
+
sa.Column('centroid', sa.LargeBinary(), nullable=True),
|
|
234
|
+
sa.Column('created_at', sa.DateTime(), nullable=False),
|
|
235
|
+
sa.ForeignKeyConstraint(['cover_face_embedding_id'], ['face_embeddings.id'], ),
|
|
236
|
+
sa.PrimaryKeyConstraint('id')
|
|
237
|
+
)
|
|
238
|
+
op.create_table('photo_persons',
|
|
239
|
+
sa.Column('photo_id', sa.Integer(), nullable=False),
|
|
240
|
+
sa.Column('person_id', sa.Integer(), nullable=False),
|
|
241
|
+
sa.Column('face_embedding_id', sa.Integer(), nullable=False),
|
|
242
|
+
sa.Column('confidence', sa.Float(), nullable=False),
|
|
243
|
+
sa.ForeignKeyConstraint(['face_embedding_id'], ['face_embeddings.id'], ),
|
|
244
|
+
sa.ForeignKeyConstraint(['person_id'], ['persons.id'], ),
|
|
245
|
+
sa.ForeignKeyConstraint(['photo_id'], ['photos.id'], ),
|
|
246
|
+
sa.PrimaryKeyConstraint('photo_id', 'person_id')
|
|
247
|
+
)
|
|
248
|
+
# ### end Alembic commands ###
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def downgrade() -> None:
|
|
252
|
+
# ### commands auto generated by Alembic - please adjust! ###
|
|
253
|
+
op.drop_table('photo_persons')
|
|
254
|
+
op.drop_table('persons')
|
|
255
|
+
op.drop_table('moment_members')
|
|
256
|
+
with op.batch_alter_table('visits', schema=None) as batch_op:
|
|
257
|
+
batch_op.drop_index('ix_visits_story_rank')
|
|
258
|
+
batch_op.drop_index(batch_op.f('ix_visits_story_id'))
|
|
259
|
+
|
|
260
|
+
op.drop_table('visits')
|
|
261
|
+
op.drop_table('swipes')
|
|
262
|
+
op.drop_table('story_items')
|
|
263
|
+
with op.batch_alter_table('pipeline_states', schema=None) as batch_op:
|
|
264
|
+
batch_op.drop_index('ix_pipeline_states_work')
|
|
265
|
+
|
|
266
|
+
op.drop_table('pipeline_states')
|
|
267
|
+
with op.batch_alter_table('photo_tags', schema=None) as batch_op:
|
|
268
|
+
batch_op.drop_index('ix_photo_tags_tag')
|
|
269
|
+
|
|
270
|
+
op.drop_table('photo_tags')
|
|
271
|
+
op.drop_table('photo_ratings')
|
|
272
|
+
op.drop_table('photo_categories')
|
|
273
|
+
with op.batch_alter_table('moments', schema=None) as batch_op:
|
|
274
|
+
batch_op.drop_index(batch_op.f('ix_moments_primary_photo_id'))
|
|
275
|
+
|
|
276
|
+
op.drop_table('moments')
|
|
277
|
+
with op.batch_alter_table('face_embeddings', schema=None) as batch_op:
|
|
278
|
+
batch_op.drop_index(batch_op.f('ix_face_embeddings_photo_id'))
|
|
279
|
+
|
|
280
|
+
op.drop_table('face_embeddings')
|
|
281
|
+
op.drop_table('embeddings')
|
|
282
|
+
op.drop_table('classical_scores')
|
|
283
|
+
op.drop_table('aesthetic_scores')
|
|
284
|
+
with op.batch_alter_table('videos', schema=None) as batch_op:
|
|
285
|
+
batch_op.drop_index(batch_op.f('ix_videos_path'))
|
|
286
|
+
|
|
287
|
+
op.drop_table('videos')
|
|
288
|
+
with op.batch_alter_table('stories', schema=None) as batch_op:
|
|
289
|
+
batch_op.drop_index(batch_op.f('ix_stories_day'))
|
|
290
|
+
|
|
291
|
+
op.drop_table('stories')
|
|
292
|
+
with op.batch_alter_table('photos', schema=None) as batch_op:
|
|
293
|
+
batch_op.drop_index(batch_op.f('ix_photos_path'))
|
|
294
|
+
|
|
295
|
+
op.drop_table('photos')
|
|
296
|
+
op.drop_table('geocode_cache')
|
|
297
|
+
# ### end Alembic commands ###
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""add source column to photo_tags and rebuild PK
|
|
2
|
+
|
|
3
|
+
Ports the historical hand-rolled ``_migrate_add_source_column`` (formerly in
|
|
4
|
+
selects/ml/ram_tags.py). Adds the ``source`` column to ``photo_tags`` and
|
|
5
|
+
rebuilds the table so the primary key is ``(photo_id, tag, source)`` — allowing
|
|
6
|
+
the same tag name from different sources (RAM++, posting/lookback clusters,
|
|
7
|
+
legacy NULL-source SigLIP tags).
|
|
8
|
+
|
|
9
|
+
This revision is a **guarded no-op** when ``source`` is already part of the
|
|
10
|
+
primary key, so it is safe to run against databases that were either created by
|
|
11
|
+
``create_all`` at a schema point that already had the source-in-PK shape, or
|
|
12
|
+
already upgraded by the old hand-rolled migration.
|
|
13
|
+
|
|
14
|
+
Revision ID: a1b2c3d4e5f6
|
|
15
|
+
Revises: 8ff743c44fc7
|
|
16
|
+
Create Date: 2026-07-05 22:15:00.000000
|
|
17
|
+
|
|
18
|
+
"""
|
|
19
|
+
from typing import Sequence, Union
|
|
20
|
+
|
|
21
|
+
import sqlalchemy as sa
|
|
22
|
+
from alembic import op
|
|
23
|
+
|
|
24
|
+
# revision identifiers, used by Alembic.
|
|
25
|
+
revision: str = "a1b2c3d4e5f6"
|
|
26
|
+
down_revision: Union[str, None] = "8ff743c44fc7"
|
|
27
|
+
branch_labels: Union[str, Sequence[str], None] = None
|
|
28
|
+
depends_on: Union[str, Sequence[str], None] = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def upgrade() -> None:
|
|
32
|
+
bind = op.get_bind()
|
|
33
|
+
insp = sa.inspect(bind)
|
|
34
|
+
|
|
35
|
+
if "photo_tags" not in insp.get_table_names():
|
|
36
|
+
return
|
|
37
|
+
|
|
38
|
+
pk_cols = insp.get_pk_constraint("photo_tags").get("constrained_columns") or []
|
|
39
|
+
if "source" in pk_cols:
|
|
40
|
+
# Already migrated (source is part of the PK) — nothing to do.
|
|
41
|
+
return
|
|
42
|
+
|
|
43
|
+
existing_cols = {c["name"] for c in insp.get_columns("photo_tags")}
|
|
44
|
+
|
|
45
|
+
# Raw-SQL rebuild (mirrors the historical hand-rolled migration). We do not
|
|
46
|
+
# toggle PRAGMA foreign_keys here because Alembic runs inside a transaction
|
|
47
|
+
# (where the pragma is a no-op) and photo_tags is not referenced by any other
|
|
48
|
+
# table's foreign key, so recreating it is safe.
|
|
49
|
+
op.execute(
|
|
50
|
+
"""
|
|
51
|
+
CREATE TABLE photo_tags_new (
|
|
52
|
+
photo_id INTEGER NOT NULL,
|
|
53
|
+
tag VARCHAR(128) NOT NULL,
|
|
54
|
+
score FLOAT NOT NULL,
|
|
55
|
+
source VARCHAR(16),
|
|
56
|
+
PRIMARY KEY (photo_id, tag, source),
|
|
57
|
+
FOREIGN KEY(photo_id) REFERENCES photos (id) ON DELETE CASCADE
|
|
58
|
+
)
|
|
59
|
+
"""
|
|
60
|
+
)
|
|
61
|
+
if "source" in existing_cols:
|
|
62
|
+
op.execute(
|
|
63
|
+
"INSERT OR IGNORE INTO photo_tags_new (photo_id, tag, score, source) "
|
|
64
|
+
"SELECT photo_id, tag, score, source FROM photo_tags"
|
|
65
|
+
)
|
|
66
|
+
else:
|
|
67
|
+
op.execute(
|
|
68
|
+
"INSERT OR IGNORE INTO photo_tags_new (photo_id, tag, score, source) "
|
|
69
|
+
"SELECT photo_id, tag, score, NULL FROM photo_tags"
|
|
70
|
+
)
|
|
71
|
+
op.execute("DROP TABLE photo_tags")
|
|
72
|
+
op.execute("ALTER TABLE photo_tags_new RENAME TO photo_tags")
|
|
73
|
+
op.execute("CREATE INDEX IF NOT EXISTS ix_photo_tags_tag ON photo_tags (tag)")
|
|
74
|
+
op.execute("CREATE INDEX IF NOT EXISTS ix_photo_tags_source ON photo_tags (source)")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def downgrade() -> None:
|
|
78
|
+
bind = op.get_bind()
|
|
79
|
+
insp = sa.inspect(bind)
|
|
80
|
+
|
|
81
|
+
if "photo_tags" not in insp.get_table_names():
|
|
82
|
+
return
|
|
83
|
+
|
|
84
|
+
pk_cols = insp.get_pk_constraint("photo_tags").get("constrained_columns") or []
|
|
85
|
+
if "source" not in pk_cols:
|
|
86
|
+
return
|
|
87
|
+
|
|
88
|
+
op.execute(
|
|
89
|
+
"""
|
|
90
|
+
CREATE TABLE photo_tags_new (
|
|
91
|
+
photo_id INTEGER NOT NULL,
|
|
92
|
+
tag VARCHAR(128) NOT NULL,
|
|
93
|
+
score FLOAT NOT NULL,
|
|
94
|
+
PRIMARY KEY (photo_id, tag),
|
|
95
|
+
FOREIGN KEY(photo_id) REFERENCES photos (id) ON DELETE CASCADE
|
|
96
|
+
)
|
|
97
|
+
"""
|
|
98
|
+
)
|
|
99
|
+
op.execute(
|
|
100
|
+
"INSERT OR IGNORE INTO photo_tags_new (photo_id, tag, score) "
|
|
101
|
+
"SELECT photo_id, tag, score FROM photo_tags"
|
|
102
|
+
)
|
|
103
|
+
op.execute("DROP TABLE photo_tags")
|
|
104
|
+
op.execute("ALTER TABLE photo_tags_new RENAME TO photo_tags")
|
|
105
|
+
op.execute("CREATE INDEX IF NOT EXISTS ix_photo_tags_tag ON photo_tags (tag)")
|