Repository: IgnisDa/ryot (language: rust)

Issue:
[FEATURE REQUEST] - IGDB / Twitch import for Game History Tracking
Hello, it'd be great if we could import our game playing lists from IGDB.

Lists are accessible publicly. E.g. Here are my profiles: 

https://www.igdb.com/users/ardakilicdagi
https://www.igdb.com/users/ardakilicdagi/lists/played
https://www.igdb.com/users/ardakilicdagi/lists/playing

"Want to play", "rated" etc. could be omitted for the time being.

Since there is IGDB credentials, I hope this could be done without a big hassle.

I'd appreciate if this is considered.

Thanks in advance!


Discussion / hints:
Seems like a resonable ask. Is there a btn to somehow export them or do I have to access the API to get list's contents?
Hello, sadly I believe there is no such API.

First I checked the [client json](https://www.igdb.com/users/ardakilicdagi/lists/played.json?full=true) , but it's blank for every tine for me. And if I try to fetch it programmatically, it redirects to the HTML page.

Secondly, I checked their APIs, the closest one I could find is [collections](https://api-docs.igdb.com/#collection), but I'm not sure if that would work. There is nothing regarding user data on their API docs, from what I could tell.

Does Ryot have a built-in crawling mechanism? Maybe it would work with minor modifications.
Nope Ryot does not have any crawling. It just uses standard APIs.

I suggest you to write a script to crawl it and then convert it to json so that it can be imported via https://docs.ryot.io/importing#generic-json. Feel free to ping me here or on Discord for help.
Closing since this is out of scope for Ryot.

Fix patch:
diff --git a/apps/backend/src/importer/igdb.rs b/apps/backend/src/importer/igdb.rs
new file mode 100644
index 0000000000..911e502fcd
--- /dev/null
+++ b/apps/backend/src/importer/igdb.rs
@@ -0,0 +1,74 @@
+use async_graphql::Result;
+use csv::Reader;
+use database::{MediaLot, MediaSource};
+use itertools::Itertools;
+use rust_decimal_macros::dec;
+use serde::Deserialize;
+
+use crate::{
+    miscellaneous::DefaultCollection,
+    models::media::{
+        ImportOrExportItemIdentifier, ImportOrExportMediaItem, ImportOrExportMediaItemSeen,
+    },
+};
+
+use super::{DeployIgdbImportInput, ImportFailStep, ImportFailedItem, ImportResult};
+
+#[derive(Debug, Deserialize)]
+struct Item {
+    id: String,
+    game: String,
+}
+
+pub async fn import(input: DeployIgdbImportInput) -> Result<ImportResult> {
+    let lot = MediaLot::VideoGame;
+    let source = MediaSource::Igdb;
+    let collection = input.collection;
+    let mut media = vec![];
+    let mut failed_items = vec![];
+    let items = Reader::from_path(input.csv_path)
+        .unwrap()
+        .deserialize()
+        .collect_vec();
+    let seen_history = if collection == DefaultCollection::Completed.to_string() {
+        vec![ImportOrExportMediaItemSeen {
+            ..Default::default()
+        }]
+    } else if collection == DefaultCollection::InProgress.to_string() {
+        vec![ImportOrExportMediaItemSeen {
+            progress: Some(dec!(5)),
+            ..Default::default()
+        }]
+    } else {
+        vec![]
+    };
+    for (idx, result) in items.into_iter().enumerate() {
+        let record: Item = match result {
+            Ok(r) => r,
+            Err(e) => {
+                failed_items.push(ImportFailedItem {
+                    lot: Some(lot),
+                    step: ImportFailStep::InputTransformation,
+                    identifier: idx.to_string(),
+                    error: Some(e.to_string()),
+                });
+                continue;
+            }
+        };
+        media.push(ImportOrExportMediaItem {
+            collections: vec![collection.clone()],
+            internal_identifier: Some(ImportOrExportItemIdentifier::NeedsDetails(record.id)),
+            lot,
+            source,
+            source_id: record.game,
+            identifier: "".to_string(),
+            seen_history: seen_history.clone(),
+            reviews: vec![],
+        });
+    }
+    Ok(ImportResult {
+        media,
+        failed_items,
+        ..Default::default()
+    })
+}
diff --git a/apps/backend/src/importer/mod.rs b/apps/backend/src/importer/mod.rs
index 4ae96ee816..739e76faf6 100644
--- a/apps/backend/src/importer/mod.rs
+++ b/apps/backend/src/importer/mod.rs
@@ -37,6 +37,7 @@ use crate::{
 mod audiobookshelf;
 mod generic_json;
 mod goodreads;
+mod igdb;
 mod imdb;
 mod jellyfin;
 mod mal;
@@ -91,6 +92,13 @@ pub struct DeployStrongAppImportInput {
     mapping: Vec<StrongAppImportMapping>,
 }
 
+#[derive(Debug, InputObject, Serialize, Deserialize, Clone)]
+pub struct DeployIgdbImportInput {
+    // The path to the CSV file in the local file system.
+    csv_path: String,
+    collection: String,
+}
+
 #[derive(Debug, InputObject, Serialize, Deserialize, Clone)]
 pub struct DeployJsonImportInput {
     // The file path of the uploaded JSON export.
@@ -113,13 +121,14 @@ pub struct DeployUrlAndKeyAndUsernameImportInput {
 #[derive(Debug, InputObject, Serialize, Deserialize, Clone)]
 pub struct DeployImportJobInput {
     pub source: ImportSource,
-    pub generic_csv: Option<DeployGenericCsvImportInput>,
+    pub mal: Option<DeployMalImportInput>,
+    pub igdb: Option<DeployIgdbImportInput>,
     pub trakt: Option<DeployTraktImportInput>,
     pub movary: Option<DeployMovaryImportInput>,
-    pub mal: Option<DeployMalImportInput>,
-    pub strong_app: Option<DeployStrongAppImportInput>,
     pub generic_json: Option<DeployJsonImportInput>,
+    pub strong_app: Option<DeployStrongAppImportInput>,
     pub url_and_key: Option<DeployUrlAndKeyImportInput>,
+    pub generic_csv: Option<DeployGenericCsvImportInput>,
     pub jellyfin: Option<DeployUrlAndKeyAndUsernameImportInput>,
 }
 
@@ -291,6 +300,7 @@ impl ImporterService {
             )
             .await
             .unwrap(),
+            ImportSource::Igdb => igdb::import(input.igdb.unwrap()).await.unwrap(),
             ImportSource::Imdb => imdb::import(
                 input.generic_csv.unwrap(),
                 &self
diff --git a/apps/backend/src/miscellaneous/resolver.rs b/apps/backend/src/miscellaneous/resolver.rs
index 7878e57b31..9053200198 100644
--- a/apps/backend/src/miscellaneous/resolver.rs
+++ b/apps/backend/src/miscellaneous/resolver.rs
@@ -2460,7 +2460,10 @@ impl MiscellaneousService {
                 tracing::debug!("Progress update finished on = {:?}", finished_on);
                 let (progress, started_on) = if matches!(action, ProgressUpdateAction::JustStarted)
                 {
-                    (dec!(0), Some(Utc::now().date_naive()))
+                    (
+                        input.progress.unwrap_or(dec!(0)),
+                        Some(Utc::now().date_naive()),
+                    )
                 } else {
                     (dec!(100), None)
                 };
diff --git a/apps/frontend/app/routes/_dashboard.settings.imports-and-exports._index.tsx b/apps/frontend/app/routes/_dashboard.settings.imports-and-exports._index.tsx
index e843ad2b7d..755e936bc8 100644
--- a/apps/frontend/app/routes/_dashboard.settings.imports-and-exports._index.tsx
+++ b/apps/frontend/app/routes/_dashboard.settings.imports-and-exports._index.tsx
@@ -51,7 +51,7 @@ import { z } from "zod";
 import { confirmWrapper } from "~/components/confirmation";
 import events from "~/lib/events";
 import { dayjsLib } from "~/lib/generals";
-import { useCoreDetails } from "~/lib/hooks";
+import { useCoreDetails, useUserCollections } from "~/lib/hooks";
 import {
 	createToastHeaders,
 	getAuthorizationHeader,
@@ -130,6 +130,9 @@ export const action = unstable_defineAction(async ({ request }) => {
 				.with(ImportSource.Jellyfin, async () => ({
 					jellyfin: processSubmission(formData, jellyfinImportFormSchema),
 				}))
+				.with(ImportSource.Igdb, async () => ({
+					igdb: processSubmission(formData, igdbImportFormSchema),
+				}))
 				.exhaustive();
 			await serverGqlService.request(
 				DeployImportJobDocument,
@@ -182,6 +185,10 @@ const jellyfinImportFormSchema = usernameImportFormSchema
 
 const genericCsvImportFormSchema = z.object({ csvPath: z.string() });
 
+const igdbImportFormSchema = z
+	.object({ collection: z.string() })
+	.merge(genericCsvImportFormSchema);
+
 const movaryImportFormSchema = z.object({
 	ratings: z.string(),
 	history: z.string(),
@@ -207,6 +214,7 @@ const deployExportForm = z.object({
 export default function Page() {
 	const loaderData = useLoaderData<typeof loader>();
 	const coreDetails = useCoreDetails();
+	const userCollections = useUserCollections();
 	const [deployImportSource, setDeployImportSource] = useState<ImportSource>();
 
 	const fetcher = useFetcher<typeof action>();
@@ -251,6 +259,7 @@ export default function Page() {
 													() => "audiobookshelf",
 												)
 												.with(ImportSource.Imdb, () => "imdb")
+												.with(ImportSource.Igdb, () => "igdb")
 												.with(ImportSource.Jellyfin, () => "jellyfin")
 												.with(ImportSource.GenericJson, () => "generic-json")
 												.with(ImportSource.OpenScale, () => "open-scale")
@@ -364,6 +373,22 @@ export default function Page() {
 													/>
 												</>
 											))
+											.with(ImportSource.Igdb, () => (
+												<>
+													<Select
+														label="Collection"
+														required
+														name="collection"
+														data={userCollections.map((c) => c.name)}
+													/>
+													<FileInput
+														label="CSV File"
+														accept=".csv"
+														required
+														name="csvPath"
+													/>
+												</>
+											))
 											.with(ImportSource.Mal, () => (
 												<>
 													<FileInput
diff --git a/docs/content/importing.md b/docs/content/importing.md
index 6c27d35772..e6d3f95a4d 100644
--- a/docs/content/importing.md
+++ b/docs/content/importing.md
@@ -1,9 +1,8 @@
 # Importing
 
 Importing is meant to be a one-time operation. They are irreversible, i.e., importing from
-the same source twice will create duplicates. I recommend you make a database backup before
-starting an import. To start, click on "Imports and Exports" link under the "Settings"
-section in the sidebar.
+the same source twice will create duplicates. I recommend you to make a database backup
+before starting an import.
 
 An import can fail at various steps. Ryot creates a report when an import completes/fails.
 You can see the reports under "Import History" of the imports page.
@@ -12,8 +11,6 @@ You can see the reports under "Import History" of the imports page.
 
 - Imports are very difficult to have 100% success rate. Though we try our best,
   you might have to manually import some data from your previous provider.
-- You can see description of the importing steps by going to `<your instance
-  url>/backend/graphql`, and then searching for `ImportFailStep` enum in search bar.
 - I recommend turning on debug logging for the duration of the import using the
   `RUST_LOG=ryot=debug` environment variable. This will help you help you see import
   progress in the docker logs.
@@ -144,6 +141,22 @@ the "Watchlist" collection.
 - Go the bottom and click on the "Export this list" button.
 - Upload the csv file in the input.
 
+## IGDb
+
+You can import your lists from [IGDb](https://www.igdb.com). Each list has to be imported
+separately. A few points to note:
+
+- Importing into the "In Progress" collection will set 5% progress for the items.
+- Importing into the "Completed" collection will set 100% progress for the items.
+- Import into any other collection will just add the items to the collection.
+
+### Steps
+
+- Login to your account and go to your profile. The default activity lists can be exported
+  from  here. Click on the list you want to export and download it as CSV.
+- For your custom lists, please visit the "My Lists" page.
+- Upload the CSV file and choose the collection you want to import into.
+
 ## Audiobookshelf
 
 The Audiobookshelf importer supports importing all media that have a valid Audible ID or
diff --git a/libs/database/src/definitions.rs b/libs/database/src/definitions.rs
index bdd96e19d1..b9d0e69754 100644
--- a/libs/database/src/definitions.rs
+++ b/libs/database/src/definitions.rs
@@ -154,6 +154,7 @@ pub enum ImportSource {
     Audiobookshelf,
     GenericJson,
     Goodreads,
+    Igdb,
     Imdb,
     Jellyfin,
     Mal,
diff --git a/libs/generated/src/graphql/backend/graphql.ts b/libs/generated/src/graphql/backend/graphql.ts
index 27a2879b7e..6994afbdb1 100644
--- a/libs/generated/src/graphql/backend/graphql.ts
+++ b/libs/generated/src/graphql/backend/graphql.ts
@@ -255,9 +255,15 @@ export type DeployGenericCsvImportInput = {
   csvPath: Scalars['String']['input'];
 };
 
+export type DeployIgdbImportInput = {
+  collection: Scalars['String']['input'];
+  csvPath: Scalars['String']['input'];
+};
+
 export type DeployImportJobInput = {
   genericCsv?: InputMaybe<DeployGenericCsvImportInput>;
   genericJson?: InputMaybe<DeployJsonImportInput>;
+  igdb?: InputMaybe<DeployIgdbImportInput>;
   jellyfin?: InputMaybe<DeployUrlAndKeyAndUsernameImportInput>;
   mal?: InputMaybe<DeployMalImportInput>;
   movary?: InputMaybe<DeployMovaryImportInput>;
@@ -686,6 +692,7 @@ export enum ImportSource {
   Audiobookshelf = 'AUDIOBOOKSHELF',
   GenericJson = 'GENERIC_JSON',
   Goodreads = 'GOODREADS',
+  Igdb = 'IGDB',
   Imdb = 'IMDB',
   Jellyfin = 'JELLYFIN',
   Mal = 'MAL',


Test patch: