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.
Files changed (84) hide show
  1. selects/__init__.py +3 -0
  2. selects/__main__.py +4 -0
  3. selects/classical/__init__.py +0 -0
  4. selects/classical/aesthetic_grade.py +101 -0
  5. selects/classical/auto_reject.py +29 -0
  6. selects/classical/blur.py +27 -0
  7. selects/classical/exposure.py +29 -0
  8. selects/classical/faces.py +65 -0
  9. selects/classical/straighten.py +111 -0
  10. selects/cli.py +176 -0
  11. selects/config.py +74 -0
  12. selects/db/__init__.py +127 -0
  13. selects/db/migrations/env.py +67 -0
  14. selects/db/migrations/script.py.mako +26 -0
  15. selects/db/migrations/versions/8ff743c44fc7_baseline_schema.py +297 -0
  16. selects/db/migrations/versions/a1b2c3d4e5f6_add_source_to_photo_tags_pk.py +105 -0
  17. selects/db/migrations/versions/c3d4e5f6a7b8_add_face_attribute_columns.py +63 -0
  18. selects/db/migrations/versions/d4e5f6a7b8c9_add_video_analysis_columns.py +75 -0
  19. selects/db/models.py +393 -0
  20. selects/decode/__init__.py +20 -0
  21. selects/decode/heic.py +16 -0
  22. selects/decode/jpeg.py +41 -0
  23. selects/decode/raw.py +27 -0
  24. selects/decode/video.py +61 -0
  25. selects/dedup.py +273 -0
  26. selects/export.py +316 -0
  27. selects/gpu.py +78 -0
  28. selects/indexer/__init__.py +0 -0
  29. selects/indexer/exif.py +147 -0
  30. selects/indexer/orchestrator.py +113 -0
  31. selects/indexer/preview.py +39 -0
  32. selects/indexer/walker.py +84 -0
  33. selects/ml/__init__.py +0 -0
  34. selects/ml/caption.py +168 -0
  35. selects/ml/curation.py +321 -0
  36. selects/ml/deblur_nafnet.py +275 -0
  37. selects/ml/embed.py +171 -0
  38. selects/ml/face_attributes.py +396 -0
  39. selects/ml/faces.py +126 -0
  40. selects/ml/locations.py +460 -0
  41. selects/ml/lowlight.py +173 -0
  42. selects/ml/model_assets.py +326 -0
  43. selects/ml/moments.py +214 -0
  44. selects/ml/nl_story.py +261 -0
  45. selects/ml/persons.py +149 -0
  46. selects/ml/ram_tags.py +260 -0
  47. selects/ml/retouch_csrnet.py +173 -0
  48. selects/ml/search.py +68 -0
  49. selects/ml/smart_clusters.py +539 -0
  50. selects/ml/stories.py +594 -0
  51. selects/ml/tags.py +150 -0
  52. selects/ml/taste.py +390 -0
  53. selects/ml/thematic_clusters.py +270 -0
  54. selects/ml/trip_data.py +195 -0
  55. selects/ml/trip_type.py +84 -0
  56. selects/pipeline.py +128 -0
  57. selects/recap.py +588 -0
  58. selects/server/__init__.py +0 -0
  59. selects/server/app.py +155 -0
  60. selects/server/dedup_routes.py +91 -0
  61. selects/server/export_routes.py +252 -0
  62. selects/server/faces2_routes.py +128 -0
  63. selects/server/fs_routes.py +49 -0
  64. selects/server/libraries.py +87 -0
  65. selects/server/library_manager.py +295 -0
  66. selects/server/models_routes.py +62 -0
  67. selects/server/pipeline_runner.py +41 -0
  68. selects/server/recap_routes.py +52 -0
  69. selects/server/routes.py +2392 -0
  70. selects/server/search2_routes.py +212 -0
  71. selects/server/static/assets/index-8kseOkrj.js +89 -0
  72. selects/server/static/assets/index-DBCjYqrq.css +1 -0
  73. selects/server/static/index.html +15 -0
  74. selects/server/taste_routes.py +44 -0
  75. selects/server/video_routes.py +199 -0
  76. selects/server/watch_routes.py +60 -0
  77. selects/server/ws.py +48 -0
  78. selects/video.py +392 -0
  79. selects/watcher.py +314 -0
  80. selects-0.1.0.dist-info/METADATA +255 -0
  81. selects-0.1.0.dist-info/RECORD +84 -0
  82. selects-0.1.0.dist-info/WHEEL +4 -0
  83. selects-0.1.0.dist-info/entry_points.txt +2 -0
  84. 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)")