Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions client/src/Router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { ImportFromFloxPage } from './pages/import/ImportFromFloxPage';
import { ImportFromGoodreadsPage } from './pages/import/ImportFromGoodreadsPage';
import { ImportFromSimklPage } from './pages/import/ImportFromSimklPage';
import { ImportFromTraktPage } from './pages/import/ImportFromTraktPage';
import { ImportFromCsvPage } from './pages/import/ImportFromCsvPage';
import { ImportPage } from './pages/ImportPage';
import { JellyfinIntegrationPage } from './pages/integrations/JellyfinIntegrationPage';
import { KodiIntegrationPage } from './pages/integrations/KodiIntegrationPage';
Expand Down Expand Up @@ -159,6 +160,10 @@ export const router = createBrowserRouter([
path: '/import/mediatracker',
element: <ImportFormMediaTrackerPage />,
},
{
path: '/import/csv',
element: <ImportFromCsvPage />,
},
{
path: '/integrations',
element: <IntegrationsPage />,
Expand Down
3 changes: 2 additions & 1 deletion client/src/pages/ImportFromFilePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,8 @@ export const ImportFormFilePage: FC<{
/>
{importFromFile.isError && (
<div className="text-red-600">
<Trans>Unexpected file format</Trans>
<Trans>Unexpected file format</Trans><br/>
<pre>{ importFromFile.variables?.source == 'CSV' ? importFromFile.error.message : "" }</pre>
</div>
)}
{file && (
Expand Down
1 change: 1 addition & 0 deletions client/src/pages/ImportPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export const ImportPage: FC = () => {
<ImportLinkComponent path="simkl" imgSrc={simklLogo} />
<ImportLinkComponent path="ryot" imgSrc={ryotLogo} />
<ImportLinkComponent path="mediatracker" text="backup" />
<ImportLinkComponent path="csv" text="CSV" />
</div>
</>
);
Expand Down
31 changes: 25 additions & 6 deletions client/src/pages/SearchPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ const SearchPageImpl: FC<{ mediaType: MediaType }> = (props) => {
narrator: ['audiobook'],
tmdbId: ['movie', 'tv'],
imdbId: ['movie', 'tv'],
igdbId: ['video_game']
});

const [query, setQuery] = useState({
Expand All @@ -63,6 +64,7 @@ const SearchPageImpl: FC<{ mediaType: MediaType }> = (props) => {
narrator: searchParams.get('narrator'),
imdbId: searchParams.get('imdbId'),
tmdbId: parseInt(searchParams.get('tmdbId') || '') || null,
igdbId: parseInt(searchParams.get('igdbId') || '') || null,
});

const searchQuery = trpc.search.search.useQuery(
Expand Down Expand Up @@ -106,6 +108,7 @@ const SearchPageImpl: FC<{ mediaType: MediaType }> = (props) => {
narrator: string;
imdbId: string;
tmdbId: string;
igdbId: string;
}>
className="flex flex-col gap-5 md:flex-row"
validation={
Expand All @@ -120,9 +123,10 @@ const SearchPageImpl: FC<{ mediaType: MediaType }> = (props) => {
},
}
: undefined
}
}
initialValues={{
tmdbId: query.tmdbId?.toString() || undefined,
igdbId: query.igdbId?.toString() || undefined,
author: query.author || undefined,
imdbId: query.imdbId || undefined,
narrator: query.narrator || undefined,
Expand All @@ -137,6 +141,9 @@ const SearchPageImpl: FC<{ mediaType: MediaType }> = (props) => {
data.tmdbId?.length > 0
? !isNaN(parseInt(data.tmdbId || ''))
: false,
data.igdbId?.length > 0
? !isNaN(parseInt(data.igdbId || ''))
: false,
data.imdbId,
].filter(Boolean).length === 0
) {
Expand All @@ -152,6 +159,7 @@ const SearchPageImpl: FC<{ mediaType: MediaType }> = (props) => {
narrator: data.narrator || null,
imdbId: data.imdbId || null,
tmdbId: parseInt(data.tmdbId || '') || null,
igdbId: parseInt(data.igdbId || '') || null,
};

setQuery(newQuery);
Expand All @@ -171,7 +179,7 @@ const SearchPageImpl: FC<{ mediaType: MediaType }> = (props) => {
<input
type="text"
autoFocus
className="w-full md:w-80"
className="w-full md:max-w-80"
placeholder={t`Query`}
ref={ref('query')}
value={q}
Expand All @@ -183,14 +191,15 @@ const SearchPageImpl: FC<{ mediaType: MediaType }> = (props) => {
narrator: null,
query: null,
tmdbId: null,
igdbId: null,
});
}}
/>

{canUse('author') && (
<input
type="text"
className="w-full md:w-80"
className="w-full md:max-w-80"
aria-label={t`Author`}
placeholder={t`Author`}
ref={ref('author')}
Expand All @@ -199,7 +208,7 @@ const SearchPageImpl: FC<{ mediaType: MediaType }> = (props) => {
{canUse('narrator') && (
<input
type="text"
className="w-full md:w-80"
className="w-full md:max-w-80"
aria-label={t`Narrator`}
placeholder={t`Narrator`}
ref={ref('narrator')}
Expand All @@ -209,7 +218,7 @@ const SearchPageImpl: FC<{ mediaType: MediaType }> = (props) => {
{canUse('tmdbId') && (
<input
type="text"
className="w-full md:w-80"
className="w-full md:max-w-80"
aria-label="tmdb"
placeholder="tmdb"
ref={ref('tmdbId')}
Expand All @@ -219,13 +228,23 @@ const SearchPageImpl: FC<{ mediaType: MediaType }> = (props) => {
{canUse('imdbId') && (
<input
type="text"
className="w-full md:w-80"
className="w-full md:max-w-80"
aria-label="imdb"
placeholder="imdb"
ref={ref('imdbId')}
/>
)}

{canUse('igdbId') && (
<input
type="text"
className="w-full md:max-w-80"
aria-label="igdb"
placeholder="igdb"
ref={ref('igdbId')}
/>
)}

<Button
actionType="submit"
text={<Trans>Search</Trans>}
Expand Down
83 changes: 83 additions & 0 deletions client/src/pages/import/ImportFromCsvPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { FC } from 'react';
import { ImportFormFilePage } from '../ImportFromFilePage';
import { Trans } from '@lingui/macro';
import { MainTitle } from '../../components/MainTitle';

export const ImportFromCsvPage: FC = () => {
return (
<>
<MainTitle
elements={[<Trans>Import</Trans>, <Trans>from CSV</Trans>]}
/>

<ImportFormFilePage
source="CSV"
fileType="text/csv"
itemTypes={['movie', 'tv']}
instructions={<div>{
<CsvInstructions />
}</div>}
/>
</>
);
};

const CsvInstructions: FC = () => {

//workaround because react and the ligui lib process escapes differently
const newline = '\\n';

return (
<details className="instructions">
<summary className="instructions-summary">
<Trans>Click to show CSV file requirements</Trans>
</summary>
<div className="instructions-content">
<ul className="ml-8 list-decimal">
<li><Trans>Comma delimited, plain UTF-8 files only</Trans></li>
<li><Trans>Quote and escape characters are optional but must use double quotes: "</Trans></li>
<li><Trans>Windows (\r{newline}), Linux ({newline}) and old macOS (\r) record delimiters are auto-detected</Trans></li>
<li><Trans>The first line MUST be column headers</Trans></li>
<li>
<Trans>Allowed column headers are, in any order and case-insensitive:</Trans><br/>
<code>type, imdbId, tmdbId, tvdbId, listId, rating, seen, season, episode</code>
</li>
<li><Trans>The only mandatory column is</Trans> <code>type</code></li>
<li>
<Trans>Valid values for <code>type</code> are:</Trans><br/>
<code>tv, movie</code>
</li>
<li>
<Trans>Other columns are optional, but you must include at least one of:</Trans><br/>
<code>tmdbId, imdbId, tvdbId</code>
</li>
<li><Trans>Leading and trailing whitespaces are stripped</Trans></li>
<li><Trans>Any record that cannot be parsed or contains errors will be skipped</Trans></li>
<li><Trans>Any record with missing fields compared to header will be skipped</Trans></li>
<li><Trans>List IDs must exist and be owned by the user</Trans></li>
<li><Trans>Items with invalid or other users list IDs are discarded</Trans></li>
<li><Trans>The watchlist list ID is found on the Lists page</Trans></li>
<li><Trans>Seen is a Y/N column only</Trans></li>
<li>
<Trans>Movies will be looked up in this order:</Trans><br />
<code>tmdbId, imdbId</code>
</li>
<li>
<Trans>TV shows will be looked up in this order:</Trans><br />
<code>tmdbId, imdbId, tvdbId</code>
</li>
<li><Trans>TV shows must only use the show's main ID from tvdb, tmdb, or imdb</Trans></li>
<li>
<Trans>To set episodes of a TV show as Seen, must provide a record for each:</Trans><br />
<code>season</code> <Trans>and</Trans> <code>episode</code>
</li>
<li><Trans>Valid ratings are decimal values between 0.1 and 10.0</Trans></li>
<li>
<Trans>If you use "out of 5" ratings, multiply the value by 2</Trans><br/>
<Trans>Eg: for a rating of 4 out of 5, provide a value of 8</Trans>
</li>
</ul>
</div>
</details>
);
};
110 changes: 110 additions & 0 deletions src/import/csvImport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { parse } from 'csv-parse/sync';
import _ from 'lodash';
import { record, z } from 'zod';

import { ImportDataType, ImportListItem, ImportRatingItem, ImportSeenHistoryItem, ImportWatchlistItem } from '../repository/importRepository.js';
import { listRepository } from '../repository/listRepository.js';
import { mediaTypeSchema } from '../entity/mediaItemModel.js';

export const csvImport = {
async map(user: number, csvData: string): Promise<ImportDataType> {
const data = csvImportSchema.parse(
parse(csvData, {
bom: true, //detects and removes any utf-8 byte order marks
delimiter: ',', //did you know the "C" in CSV stands for "comma"? :)
columns: header => header.map((column: string) => column.toLowerCase()),
skip_empty_lines: true, //blank lines are ignored
skip_records_with_error: true, //if any field parsing errors (eg: invalid numbers), skip entire record
trim: true //strip leading and trailing whitespace in fields
})
)
.filter(item =>
(Object.values(mediaTypeSchema.Values).includes(item.type)) // sanity check
&& ((item.type === 'tv' && (item.tmdbid || item.imdbid || item.tvdbid))
||(item.type === 'movie' && (item.tmdbid || item.imdbid)))
);
Comment thread
ramebd marked this conversation as resolved.

const dateNow = new Date();
const userLists = await listRepository.getLists({userId: user});
const watchListId = userLists.find(userList => userList.isWatchlist)?.id;

const importLists = _(data)
.map((item) => item.listid)
.uniq()
.value();
Comment thread
ramebd marked this conversation as resolved.

const importData: ImportDataType = {
ratings: data
.filter((item) => item.rating && item.rating > 0)
.map((item) => (<ImportRatingItem>{
itemType: item.type,
tmdbId: item.tmdbid ? item.tmdbid : undefined,

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

0 is falsy in JavaScript, and in this very rare case, 0 would be overwritten by undefined.

tmdbId can be null or undefined so passing it without any checks is the fastest option.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had considered this, but a tmdbId value of 0 is invalid anyway, so ending up with undefined ensures no bodgy data is passed any further down the line to be queried against the TMDB API or stored in the database. In that case, it might even be better to explicitly fail the whole import on parsing errors where no (valid) ids are found for a given row, but that might be overkill 🙂

Querying tmdb api for a movie id of "0" returns a 404 with:
{ "success": false, "status_code": 6, "status_message": "Invalid id: The pre-requisite id is invalid or not found." }

imdbId: item.imdbid ? item.imdbid : undefined,
tvdbId: item.tvdbid ? item.tvdbid : undefined,
rating: item.rating,
ratedAt: dateNow,

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

either use the date provided by the user, or do not set date at all

episode: item.type === 'tv' ? {
seasonNumber: item.season ? item.season : undefined,
episodeNumber: item.episode ? item.episode : undefined
} : undefined
})),
watchlist: data
.filter((item) => item.listid && watchListId && item.listid == watchListId)
.map((item) => (<ImportWatchlistItem>{
itemType: item.type,
tmdbId: item.tmdbid ? item.tmdbid : undefined,
imdbId: item.imdbid ? item.imdbid : undefined,
tvdbId: item.tvdbid ? item.tvdbid : undefined,
addedAt: dateNow
})),
seenHistory: data
.filter((item) => item.seen == 'Y')
.map((item) => (<ImportSeenHistoryItem>{
itemType: item.type === 'tv' ? 'episode' : item.type,
tmdbId: item.tmdbid ? item.tmdbid : undefined,
imdbId: item.imdbid ? item.imdbid : undefined,
tvdbId: item.tvdbid ? item.tvdbid : undefined,
seenAt: dateNow,

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

either use the date provided by the user, or do not set date at all

episode: item.type === 'tv' ? {
seasonNumber: item.season ? item.season : undefined,

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if item.season is 0 (special seasons), then this will be undefined

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ahh yep, dang it, I missed that one 🤦‍♂️

episodeNumber: item.episode ? item.episode : undefined
} : undefined
})),
lists: userLists
.filter((userList) => importLists.find(importList =>
!userList.isWatchlist && importList == userList.id))
.map((list) => (<ImportListItem>{
name: list.name,
description: list.description,
traktId: list.traktId,
Comment thread
ramebd marked this conversation as resolved.
createdAt: dateNow,

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

either use the date provided by the user, or do not set date at all

items: data
.filter((item) => item.listid == list.id)
.map((item) => ({
itemType: item.type,
tmdbId: item.tmdbid ? item.tmdbid : undefined,
imdbId: item.imdbid ? item.imdbid : undefined,
tvdbId: item.tvdbid ? item.tvdbid : undefined,
addedAt: dateNow
}))
}))
}

return importData;
},
};

const csvImportSchema = z.array(
z.object({
//lowercase every column name to allow case insensitive parsing
type: z.enum(['tv', 'movie']),
tmdbid: z.coerce.number().optional(),
imdbid: z.string().optional(),
tvdbid: z.coerce.number().optional(),
listid: z.coerce.number().optional(),
rating: z.coerce.number().optional(),
seen: z.string().optional(),
season: z.coerce.number().optional(),
episode: z.coerce.number().optional()
})
);
8 changes: 6 additions & 2 deletions src/routers/importRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { backupImport } from '../import/backupImport.js';
import { floxImport } from '../import/floxImport.js';
import { goodreadsImport } from '../import/goodreadsImport.js';
import { simklImport } from '../import/simklImport.js';
import { csvImport } from '../import/csvImport.js';
import {
importRepository,
ImportState,
Expand All @@ -18,6 +19,7 @@ const importSourceSchema = z.enum([
'Goodreads',
'Simkl',
'MediaTracker',
'CSV',
'Ryot',
]);

Expand Down Expand Up @@ -82,7 +84,7 @@ const importFromFileHandler = async (args: {
});

try {
const importData = await mapImportData(source, data);
const importData = await mapImportData(userId, source, data);

await importRepository.importDataByExternalIds({
userId,
Expand Down Expand Up @@ -125,7 +127,7 @@ const progressMap = (() => {
return { set, get, remove, has };
})();

const mapImportData = async (source: ImportSource, data: string) => {
const mapImportData = async (userId: number, source: ImportSource, data: string) => {
switch (source) {
case 'Flox':
return floxImport.map(data);
Expand All @@ -136,6 +138,8 @@ const mapImportData = async (source: ImportSource, data: string) => {
return simklImport.map(data);
case 'MediaTracker':
return backupImport.map(data);
case 'CSV':
return csvImport.map(userId, data);
case 'Ryot':
return ryotImport.map(data);
}
Expand Down
Loading