mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-10-15 19:45:42 -04:00
fix(imports): keep date-only values on their calendar day
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
4 files changed
+158
-26
No files matched your search
@@ -29,21 +29,53 @@ const log = createLogger("imports");
|
||||
/** Two plays of the same item this close together are treated as the same play
|
||||
* (e.g. a Plex webhook watch and the same play scrobbled to Trakt). */
|
||||
const TIMESTAMP_DEDUPE_WINDOW_MS = 3 * 60 * 60 * 1000;
|
||||
/** Date-only entries (Letterboxd diary) match any watch within ±36h of UTC
|
||||
* midnight on that date, which covers every timezone. */
|
||||
const DATE_ONLY_DEDUPE_WINDOW_MS = 36 * 60 * 60 * 1000;
|
||||
|
||||
/** Date-only values are stored at server-local noon so stats (which bucket in server-local
|
||||
* time) put them on their calendar day. */
|
||||
function dateOnlyToLocalNoon(day: string): Date {
|
||||
return new Date(`${day}T12:00:00`);
|
||||
}
|
||||
|
||||
interface ImportedWatchTime {
|
||||
at: Date;
|
||||
/** Inclusive [from, to] ranges; an existing watch in any of them is the same play. */
|
||||
ranges: Array<[Date, Date]>;
|
||||
}
|
||||
|
||||
function importedWatchTime(item: {
|
||||
watchedAt?: string;
|
||||
watchedOn?: string;
|
||||
}): { at: Date; windowMs: number } | null {
|
||||
const candidate = item.watchedAt
|
||||
? { at: new Date(item.watchedAt), windowMs: TIMESTAMP_DEDUPE_WINDOW_MS }
|
||||
: item.watchedOn
|
||||
? { at: new Date(item.watchedOn), windowMs: DATE_ONLY_DEDUPE_WINDOW_MS }
|
||||
: null;
|
||||
if (!candidate || Number.isNaN(candidate.at.getTime())) return null;
|
||||
return candidate;
|
||||
}): ImportedWatchTime | null {
|
||||
if (item.watchedAt) {
|
||||
const at = new Date(item.watchedAt);
|
||||
if (Number.isNaN(at.getTime())) return null;
|
||||
return {
|
||||
at,
|
||||
ranges: [
|
||||
[
|
||||
new Date(at.getTime() - TIMESTAMP_DEDUPE_WINDOW_MS),
|
||||
new Date(at.getTime() + TIMESTAMP_DEDUPE_WINDOW_MS),
|
||||
],
|
||||
],
|
||||
};
|
||||
}
|
||||
if (item.watchedOn) {
|
||||
const start = new Date(`${item.watchedOn}T00:00:00`); // server-local midnight
|
||||
if (Number.isNaN(start.getTime())) return null;
|
||||
const end = new Date(start);
|
||||
end.setDate(end.getDate() + 1);
|
||||
end.setMilliseconds(end.getMilliseconds() - 1);
|
||||
// Imports before this fix stored date-only watches at UTC midnight; match those exactly.
|
||||
const legacy = new Date(`${item.watchedOn}T00:00:00Z`);
|
||||
return {
|
||||
at: dateOnlyToLocalNoon(item.watchedOn),
|
||||
ranges: [
|
||||
[start, end],
|
||||
[legacy, legacy],
|
||||
],
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function safeParseJsonArray(value: string | null): string[] {
|
||||
@@ -107,12 +139,7 @@ async function processMovie(
|
||||
|
||||
const time = importedWatchTime(movie);
|
||||
const isDuplicate = time
|
||||
? hasMovieWatchBetween(
|
||||
userId,
|
||||
title.id,
|
||||
new Date(time.at.getTime() - time.windowMs),
|
||||
new Date(time.at.getTime() + time.windowMs),
|
||||
)
|
||||
? time.ranges.some(([from, to]) => hasMovieWatchBetween(userId, title.id, from, to))
|
||||
: hasMovieWatch(userId, title.id); // undated: any existing watch counts
|
||||
if (isDuplicate) {
|
||||
result.skipped++;
|
||||
@@ -174,12 +201,7 @@ async function processEpisode(
|
||||
|
||||
const time = importedWatchTime(ep);
|
||||
const isDuplicate = time
|
||||
? hasEpisodeWatchBetween(
|
||||
userId,
|
||||
episode.id,
|
||||
new Date(time.at.getTime() - time.windowMs),
|
||||
new Date(time.at.getTime() + time.windowMs),
|
||||
)
|
||||
? time.ranges.some(([from, to]) => hasEpisodeWatchBetween(userId, episode.id, from, to))
|
||||
: hasEpisodeWatch(userId, episode.id); // undated: any existing watch counts
|
||||
if (isDuplicate) {
|
||||
result.skipped++;
|
||||
@@ -290,7 +312,7 @@ async function processRating(
|
||||
const ratedAt = item.ratedAt
|
||||
? new Date(item.ratedAt)
|
||||
: item.ratedOn
|
||||
? new Date(item.ratedOn)
|
||||
? dateOnlyToLocalNoon(item.ratedOn)
|
||||
: undefined;
|
||||
rateTitleStars(userId, title.id, item.rating, ratedAt);
|
||||
result.imported++;
|
||||
|
||||
@@ -91,7 +91,7 @@ export async function resolveMovieTmdbId(
|
||||
const results = searchResult.results ?? [];
|
||||
if (ids.year) {
|
||||
const match = results.find((r) => {
|
||||
const releaseYear = r.release_date ? new Date(r.release_date).getFullYear() : null;
|
||||
const releaseYear = r.release_date ? Number.parseInt(r.release_date.slice(0, 4), 10) : null;
|
||||
return releaseYear === ids.year;
|
||||
});
|
||||
if (match) {
|
||||
@@ -162,7 +162,7 @@ export async function resolveShowTmdbId(
|
||||
const results = searchResult.results ?? [];
|
||||
if (ids.year) {
|
||||
const match = results.find((r) => {
|
||||
const airYear = r.first_air_date ? new Date(r.first_air_date).getFullYear() : null;
|
||||
const airYear = r.first_air_date ? Number.parseInt(r.first_air_date.slice(0, 4), 10) : null;
|
||||
return airYear === ids.year;
|
||||
});
|
||||
if (match) {
|
||||
|
||||
@@ -716,6 +716,84 @@ describe("processImportJob — rewatches and added dates", () => {
|
||||
expect(movieWatches(userId)).toHaveLength(2);
|
||||
});
|
||||
|
||||
describe("date-only watches in a west-of-UTC server time zone", () => {
|
||||
async function withLosAngeles(fn: () => Promise<void>) {
|
||||
const tz = process.env.TZ;
|
||||
process.env.TZ = "America/Los_Angeles";
|
||||
try {
|
||||
await fn();
|
||||
} finally {
|
||||
if (tz === undefined) delete process.env.TZ;
|
||||
else process.env.TZ = tz;
|
||||
}
|
||||
}
|
||||
|
||||
const diary = (...days: string[]): NormalizedImport => ({
|
||||
...emptyPayload("letterboxd"),
|
||||
movies: days.map((watchedOn) => ({ tmdbId: 550, title: "Fight Club", watchedOn })),
|
||||
});
|
||||
|
||||
test("consecutive-day diary entries are both kept", () =>
|
||||
withLosAngeles(async () => {
|
||||
const userId = insertUser();
|
||||
insertMovieTitle("movie-1", 550, "Fight Club");
|
||||
const jobId = createJob(userId, diary("2024-01-15", "2024-01-16"));
|
||||
await processImportJob(jobId);
|
||||
|
||||
expect(readImportJob(jobId).importedCount).toBe(2);
|
||||
expect(movieWatches(userId)).toHaveLength(2);
|
||||
}));
|
||||
|
||||
test("stores the watch on its calendar day", () =>
|
||||
withLosAngeles(async () => {
|
||||
const userId = insertUser();
|
||||
insertMovieTitle("movie-1", 550, "Fight Club");
|
||||
await processImportJob(createJob(userId, diary("2024-01-15")));
|
||||
|
||||
const [watch] = movieWatches(userId);
|
||||
const at = watch?.watchedAt as Date;
|
||||
expect(at.getFullYear()).toBe(2024);
|
||||
expect(at.getMonth()).toBe(0);
|
||||
expect(at.getDate()).toBe(15);
|
||||
}));
|
||||
|
||||
test("re-importing the same diary is idempotent", () =>
|
||||
withLosAngeles(async () => {
|
||||
const userId = insertUser();
|
||||
insertMovieTitle("movie-1", 550, "Fight Club");
|
||||
await processImportJob(createJob(userId, diary("2024-01-15")));
|
||||
const secondId = createJob(userId, diary("2024-01-15"));
|
||||
await processImportJob(secondId);
|
||||
|
||||
expect(movieWatches(userId)).toHaveLength(1);
|
||||
expect(readImportJob(secondId).skippedCount).toBe(1);
|
||||
}));
|
||||
|
||||
test("legacy UTC-midnight rows still dedupe", () =>
|
||||
withLosAngeles(async () => {
|
||||
const userId = insertUser();
|
||||
insertMovieTitle("movie-1", 550, "Fight Club");
|
||||
insertMovieWatch(userId, "movie-1", new Date("2024-01-15T00:00:00Z"));
|
||||
const jobId = createJob(userId, diary("2024-01-15"));
|
||||
await processImportJob(jobId);
|
||||
|
||||
expect(readImportJob(jobId).skippedCount).toBe(1);
|
||||
expect(movieWatches(userId)).toHaveLength(1);
|
||||
}));
|
||||
|
||||
test("a same-day evening play dedupes the diary entry", () =>
|
||||
withLosAngeles(async () => {
|
||||
const userId = insertUser();
|
||||
insertMovieTitle("movie-1", 550, "Fight Club");
|
||||
insertMovieWatch(userId, "movie-1", new Date("2024-01-15T21:30:00"));
|
||||
const jobId = createJob(userId, diary("2024-01-15"));
|
||||
await processImportJob(jobId);
|
||||
|
||||
expect(readImportJob(jobId).skippedCount).toBe(1);
|
||||
expect(movieWatches(userId)).toHaveLength(1);
|
||||
}));
|
||||
});
|
||||
|
||||
test("library item backdates addedAt after a watch created the row", async () => {
|
||||
const userId = insertUser();
|
||||
insertMovieTitle("movie-1", 550, "Fight Club");
|
||||
|
||||
@@ -106,6 +106,22 @@ describe("resolveMovieTmdbId", () => {
|
||||
expect(result).toBe(200);
|
||||
});
|
||||
|
||||
test("matches a Jan 1 release year west of UTC", async () => {
|
||||
const tz = process.env.TZ;
|
||||
process.env.TZ = "America/Los_Angeles";
|
||||
try {
|
||||
searchMoviesSpy = vi.spyOn(tmdbClient, "searchMovies").mockResolvedValue({
|
||||
results: [{ id: 42, release_date: "2010-01-01" }],
|
||||
} as never);
|
||||
|
||||
const result = await resolveMovieTmdbId({ title: "X", year: 2010 });
|
||||
expect(result).toBe(42);
|
||||
} finally {
|
||||
if (tz === undefined) delete process.env.TZ;
|
||||
else process.env.TZ = tz;
|
||||
}
|
||||
});
|
||||
|
||||
test("title search without year match returns null", async () => {
|
||||
searchMoviesSpy = vi.spyOn(tmdbClient, "searchMovies").mockResolvedValue({
|
||||
results: [
|
||||
@@ -254,6 +270,22 @@ describe("resolveShowTmdbId", () => {
|
||||
expect(result).toBe(850);
|
||||
});
|
||||
|
||||
test("matches a Jan 1 first-air year west of UTC", async () => {
|
||||
const tz = process.env.TZ;
|
||||
process.env.TZ = "America/Los_Angeles";
|
||||
try {
|
||||
searchTvSpy = vi.spyOn(tmdbClient, "searchTv").mockResolvedValue({
|
||||
results: [{ id: 42, first_air_date: "2010-01-01" }],
|
||||
} as never);
|
||||
|
||||
const result = await resolveShowTmdbId({ title: "X", year: 2010 });
|
||||
expect(result).toBe(42);
|
||||
} finally {
|
||||
if (tz === undefined) delete process.env.TZ;
|
||||
else process.env.TZ = tz;
|
||||
}
|
||||
});
|
||||
|
||||
test("falls back to title search", async () => {
|
||||
searchTvSpy = vi.spyOn(tmdbClient, "searchTv").mockResolvedValue({
|
||||
results: [{ id: 900, name: "The Office" }],
|
||||
|
||||
Reference in new issue
Block a user