Skip to content

Commit 6b66695

Browse files
committed
feat!: add file date metadata to db
1 parent 6aa0cf7 commit 6b66695

6 files changed

Lines changed: 269 additions & 25 deletions

File tree

src/tagstudio/core/library/alchemy/library.py

Lines changed: 86 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
# pyright: reportDeprecated=false
88

99

10+
import platform
1011
import re
1112
import shutil
1213
import sys
@@ -93,6 +94,7 @@
9394
TextFieldTemplate,
9495
)
9596
from tagstudio.core.library.alchemy.joins import TagEntry, TagParent
97+
from tagstudio.core.library.alchemy.metadata import FileMetadata
9698
from tagstudio.core.library.alchemy.models import (
9799
Entry,
98100
Folder,
@@ -105,6 +107,7 @@
105107
from tagstudio.core.library.alchemy.visitors import SQLBoolExpressionBuilder
106108
from tagstudio.core.library.ignore import migrate_ext_list
107109
from tagstudio.core.library.json.library import Library as JsonLibrary
110+
from tagstudio.core.utils.stat import get_date_created, get_date_modified
108111
from tagstudio.core.utils.types import unwrap
109112
from tagstudio.qt.translations import Translations
110113

@@ -924,7 +927,11 @@ def get_entry(self, entry_id: int) -> Entry | None:
924927
return entry
925928

926929
def get_entry_full(
927-
self, entry_id: int, with_fields: bool = True, with_tags: bool = True
930+
self,
931+
entry_id: int,
932+
with_fields: bool = True,
933+
with_tags: bool = True,
934+
with_metadata: bool = True,
928935
) -> Entry | None:
929936
"""Load entry and join with all joins and all tags."""
930937
# NOTE: TODO: Currently this method makes multiple separate queries to the db and combines
@@ -954,6 +961,11 @@ def get_entry_full(
954961
)
955962
)
956963

964+
if with_metadata:
965+
entry_stmt = entry_stmt.outerjoin(Entry.file_metadata).options(
966+
selectinload(Entry.file_metadata),
967+
)
968+
957969
start_time = time.time()
958970
entry = session.scalar(entry_stmt)
959971
if with_tags:
@@ -1147,10 +1159,79 @@ def remove_entries(self, entry_ids: list[int]) -> None:
11471159
session.query(Entry).where(Entry.id.in_(sub_list)).delete()
11481160
session.commit()
11491161

1150-
def has_entry_with_path(self, path: Path) -> bool:
1151-
"""Check if an entry with this path is in the library."""
1162+
def get_entry_id_from_path(self, path: Path) -> int:
1163+
"""Attempt to return an Entry ID given a filepath, else return -1."""
11521164
with Session(self.engine) as session:
1153-
return session.query(exists().where(Entry.path == path)).scalar()
1165+
return session.scalar(select(Entry.id).where(Entry.path == path).limit(1)) or -1
1166+
1167+
# def update_entry_file_metadata(
1168+
# self, entry_id: int, date_created: datetime | None, date_modified: datetime | None
1169+
# ):
1170+
# with Session(self.engine) as session:
1171+
# stmt = update(FileMetadata).where(
1172+
# and_(
1173+
# FileMetadata.entry_id == entry_id,
1174+
# )
1175+
# )
1176+
# if date_created:
1177+
# stmt = stmt.values(date_created=date_created)
1178+
# if date_modified:
1179+
# stmt = stmt.values(date_modified=date_modified)
1180+
1181+
# session.execute(stmt)
1182+
# session.commit()
1183+
1184+
def refresh_file_entry_stats(self, entry_id: int, path: Path | None):
1185+
"""Updates a file entry's associated stat() data."""
1186+
needs_update = False
1187+
1188+
entry = self.get_entry_full(
1189+
entry_id, with_fields=False, with_tags=False, with_metadata=True
1190+
)
1191+
if not entry:
1192+
return
1193+
1194+
if not path:
1195+
full_path = unwrap(self.library_dir) / entry.path
1196+
else:
1197+
full_path = unwrap(self.library_dir) / path
1198+
1199+
logger.info(full_path)
1200+
1201+
file_date_created = get_date_created(full_path)
1202+
file_date_modified = get_date_modified(full_path)
1203+
1204+
# Log info
1205+
if entry.date_created != file_date_created:
1206+
logger.info(f"Difference in date_created!: {entry.date_created}/{file_date_created}")
1207+
needs_update = True
1208+
else:
1209+
logger.info("No difference in date_created.")
1210+
1211+
if entry.date_modified != file_date_modified:
1212+
logger.info(f"Difference in date_modified!: {entry.date_modified}/{file_date_modified}")
1213+
needs_update = True
1214+
else:
1215+
logger.info("No difference in date_modified")
1216+
1217+
if needs_update:
1218+
return
1219+
else:
1220+
logger.info(f"Updating entry file_metadata for {full_path}")
1221+
1222+
with Session(self.engine) as session:
1223+
stmt = update(FileMetadata).where(
1224+
and_(
1225+
FileMetadata.entry_id == entry_id,
1226+
)
1227+
)
1228+
if file_date_created:
1229+
stmt = stmt.values(date_created=file_date_created)
1230+
if file_date_modified:
1231+
stmt = stmt.values(date_modified=file_date_modified)
1232+
1233+
session.execute(stmt)
1234+
session.commit()
11541235

11551236
def get_paths(self, limit: int = -1) -> list[str]:
11561237
path_strings: list[str] = []
@@ -1468,7 +1549,7 @@ def update_entry_path(self, entry_id: int | Entry, path: Path) -> bool:
14681549
14691550
Returns True if the action succeeded and False if the path already exists.
14701551
"""
1471-
if self.has_entry_with_path(path):
1552+
if self.get_entry_id_from_path(path) >= 0:
14721553
return False
14731554
if isinstance(entry_id, Entry):
14741555
entry_id = entry_id.id
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
# SPDX-FileCopyrightText: (c) TagStudio Contributors
2+
# SPDX-License-Identifier: MIT
3+
4+
5+
from __future__ import annotations
6+
7+
from datetime import datetime as dt
8+
from pathlib import Path
9+
from typing import TYPE_CHECKING, Any, override
10+
11+
from sqlalchemy import ForeignKey, ForeignKeyConstraint, Integer, null
12+
from sqlalchemy.orm import Mapped, declared_attr, mapped_column, relationship
13+
14+
15+
from tagstudio.core.library.alchemy.db import Base, PathType
16+
17+
from tagstudio.core.library.alchemy.joins import TagParent
18+
19+
if TYPE_CHECKING:
20+
from tagstudio.core.library.alchemy.models import Entry
21+
22+
23+
class FileMetadata(Base):
24+
"""Table that includes file data and metadata obtained from os.stat() for entries."""
25+
26+
__tablename__ = "file_metadata"
27+
28+
entry_id: Mapped[int] = mapped_column(
29+
ForeignKey("entries.id"), primary_key=True, nullable=False
30+
)
31+
32+
# NOTE: These dates are stored as floats because that's their natural form from os.stat()
33+
# and comparisons are quicker without having to convert to/from datetime objects.
34+
date_created: Mapped[float | None]
35+
date_modified: Mapped[float | None]
36+
37+
def __init__(
38+
self,
39+
entry_id: int,
40+
date_created: float | None = None,
41+
date_modified: float | None = None,
42+
) -> None:
43+
super().__init__()
44+
self.entry_id = entry_id
45+
46+
# # Path data
47+
# self.path = path
48+
# self.filename = path.name
49+
# self.suffix = path.suffix.lstrip(".").lower()
50+
51+
# File metadata
52+
self.date_created = date_created # st_birthtime on Windows and Mac, st_ctime on Linux
53+
self.date_modified = date_modified # st_mtime
54+
55+
56+
class ExifMetadata(Base):
57+
"""Contains Exif metadata for a entries."""
58+
59+
__tablename__ = "exif_metadata"
60+
61+
entry_id: Mapped[int] = mapped_column(
62+
ForeignKey("entries.id"), primary_key=True, nullable=False
63+
)
64+
date_taken: Mapped[dt | None]
65+
66+
def __init__(
67+
self,
68+
entry_id: int,
69+
date_taken: dt | None = None,
70+
) -> None:
71+
super().__init__()
72+
self.entry_id = entry_id
73+
self.date_taken = date_taken # Exif.Image.DateTime
74+
75+
76+
class DimensionMetadata(Base):
77+
"""Contains dimension metadata for entries (e.g. image and video files)."""
78+
79+
__tablename__ = "dimension_metadata"
80+
81+
entry_id: Mapped[int] = mapped_column(
82+
ForeignKey("entries.id"), primary_key=True, nullable=False
83+
)
84+
width: Mapped[int] = mapped_column(nullable=False)
85+
height: Mapped[int] = mapped_column(nullable=False)
86+
87+
def __init__(
88+
self,
89+
entry_id: int,
90+
width: int,
91+
height: int,
92+
) -> None:
93+
super().__init__()
94+
self.entry_id = entry_id
95+
self.width = width
96+
self.height = height
97+
98+
99+
class DurationMetadata(Base):
100+
"""Contains duration metadata for entries (e.g. audio and video files)."""
101+
102+
__tablename__ = "duration_metadata"
103+
104+
entry_id: Mapped[int] = mapped_column(
105+
ForeignKey("entries.id"), primary_key=True, nullable=False
106+
)
107+
duration: Mapped[float] = mapped_column(nullable=False)
108+
109+
def __init__(
110+
self,
111+
entry_id: int,
112+
duration: float,
113+
) -> None:
114+
super().__init__()
115+
self.entry_id = entry_id
116+
self.duration = duration

src/tagstudio/core/library/alchemy/models.py

Lines changed: 35 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
TextField,
1818
)
1919
from tagstudio.core.library.alchemy.joins import TagParent
20+
from tagstudio.core.library.alchemy.metadata import FileMetadata
21+
from tagstudio.core.utils.stat import get_date_created, get_date_modified
2022

2123

2224
class Namespace(Base):
@@ -182,6 +184,7 @@ def __ge__(self, other: "Tag") -> bool:
182184
return self.name >= other.name
183185

184186

187+
# TODO: Use or replace these with an actual multi-root implementation
185188
class Folder(Base):
186189
__tablename__ = "folders"
187190

@@ -196,15 +199,16 @@ class Entry(Base):
196199

197200
id: Mapped[int] = mapped_column(primary_key=True)
198201

202+
# TODO: Use or replace these with an actual multi-root implementation
199203
folder_id: Mapped[int] = mapped_column(ForeignKey("folders.id"))
200204
folder: Mapped[Folder] = relationship("Folder")
201205

206+
# TODO: Possibly move to FileMetadata table if Entry is split into Entry/FileEntry (see #588)
202207
path: Mapped[Path] = mapped_column(PathType, unique=True)
203208
filename: Mapped[str] = mapped_column()
204209
suffix: Mapped[str] = mapped_column()
205-
date_created: Mapped[dt | None]
206-
date_modified: Mapped[dt | None]
207-
date_added: Mapped[dt | None]
210+
211+
date_added: Mapped[dt | None] # The date this entry was added to the library
208212

209213
tags: Mapped[set[Tag]] = relationship(secondary="tag_entries")
210214

@@ -217,6 +221,11 @@ class Entry(Base):
217221
cascade="all, delete",
218222
)
219223

224+
file_metadata: Mapped["FileMetadata"] = relationship(
225+
uselist=False,
226+
cascade="all, delete-orphan",
227+
)
228+
220229
@property
221230
def fields(self) -> list[BaseField]:
222231
fields: list[BaseField] = []
@@ -232,30 +241,35 @@ def is_favorite(self) -> bool:
232241
def is_archived(self) -> bool:
233242
return any(tag.id == TAG_ARCHIVED for tag in self.tags)
234243

244+
@property
245+
def date_created(self) -> float | None:
246+
return self.file_metadata.date_created if self.file_metadata else None
247+
248+
@property
249+
def date_modified(self) -> float | None:
250+
return self.file_metadata.date_modified if self.file_metadata else None
251+
235252
def __init__(
236253
self,
237254
path: Path,
238255
folder: Folder,
239256
fields: list[BaseField],
240257
id: int | None = None,
241-
date_created: dt | None = None,
242-
date_modified: dt | None = None,
243258
date_added: dt | None = None,
259+
# date_created: float | None = None,
260+
# date_modified: float | None = None,
261+
path_for_file_metadata: Path | None = None,
244262
) -> None:
245263
super().__init__()
246-
self.path = path
247-
self.folder = folder
264+
248265
self.id = id # pyright: ignore[reportAttributeAccessIssue]
266+
267+
self.folder = folder # NOTE: Currently unused
268+
self.path = path
249269
self.filename = path.name
250270
self.suffix = path.suffix.lstrip(".").lower()
251271

252-
# The date the file associated with this entry was created.
253-
# st_birthtime on Windows and Mac, st_ctime on Linux.
254-
self.date_created = date_created
255-
# The date the file associated with this entry was last modified: st_mtime.
256-
self.date_modified = date_modified
257-
# The date this entry was added to the library.
258-
self.date_added = date_added
272+
self.date_added = date_added # The date this entry was added to the library
259273

260274
for field in fields:
261275
if isinstance(field, TextField):
@@ -265,6 +279,13 @@ def __init__(
265279
else:
266280
raise ValueError(f"Invalid field type: {field}")
267281

282+
if path_for_file_metadata:
283+
self.file_metadata = FileMetadata(
284+
entry_id=self.id,
285+
date_created=get_date_created(path_for_file_metadata),
286+
date_modified=get_date_modified(path_for_file_metadata),
287+
)
288+
268289
def has_tag(self, tag: Tag) -> bool:
269290
return tag in self.tags
270291

0 commit comments

Comments
 (0)