diff --git a/public/i18n/de.json b/public/i18n/de.json
index 5ddc77a..080d69d 100644
--- a/public/i18n/de.json
+++ b/public/i18n/de.json
@@ -143,6 +143,27 @@
"ERROR": "Die Steam-Verknüpfung konnte nicht starten.",
"UNLINKED": "Steam-Verknüpfung gelöst."
},
+ "PSN": {
+ "TITLE": "PlayStation",
+ "OFF": "Die PlayStation-Verknüpfung ist auf diesem Server nicht eingerichtet.",
+ "STEPS_1": "Melde dich im Browser auf playstation.com an.",
+ "STEPS_2": "Öffne im selben Browser diese Adresse:",
+ "STEPS_3": "Kopiere den Wert von „npsso“ und füge ihn unten ein.",
+ "WHAT_IT_GIVES": "Der NPSSO-Code ist deine PlayStation-Anmeldesitzung. Wer ihn hat, kann PlayStation-Seiten als du nutzen. Wir tauschen ihn sofort gegen ein begrenztes Token (Umfang der PlayStation-App) ein, speichern nur dieses Token verschlüsselt und verwerfen den Code. Du kannst das jederzeit beenden: hier trennen oder in den PlayStation-Kontoeinstellungen auf allen Geräten abmelden. Das nutzt dieselbe inoffizielle Schnittstelle wie Trophäen-Seiten von Drittanbietern; Sony unterstützt sie nicht und kann sie sperren.",
+ "NPSSO_LABEL": "NPSSO-Code",
+ "LINK": "PlayStation-Konto verknüpfen",
+ "LINKED": "PlayStation-Konto verknüpft.",
+ "LINKED_SINCE": "Verknüpft seit {{date}}",
+ "EXPIRES_ON": "Verknüpfung endet am {{date}}",
+ "EXPIRED": "Die Verknüpfung ist beendet. Füge einen neuen NPSSO-Code ein, um sie zu erneuern.",
+ "IMPORT": "Gespielte Spiele importieren",
+ "UNLINK": "Trennen",
+ "UNLINKED": "PlayStation-Konto getrennt. Importierte Spiele bleiben in deiner Bibliothek.",
+ "INVALID": "PlayStation hat den Code nicht akzeptiert. Er ist vielleicht abgelaufen: melde dich neu an und kopiere einen neuen.",
+ "UNAVAILABLE": "PlayStation hat nicht geantwortet. Bitte versuche es später noch einmal.",
+ "BUSY": "Eine andere PlayStation-Anfrage von dir läuft noch. Warte einen Moment und versuche es erneut.",
+ "ERROR": "Mit der PlayStation-Verknüpfung ist etwas schiefgelaufen."
+ },
"VERSION": "Version",
"VERSION_UNKNOWN": "unbekannt",
"CHANGE_PASSWORD": {
@@ -1039,6 +1060,13 @@
"START": "Spiele suchen",
"START_FAILED": "Der Import konnte nicht starten.",
"RUNNING": "Ein anderer Import wird noch gesucht. Warte, bis er fertig ist.",
+ "PSN_HINT": "Importiert deine gespielten PS4- und PS5-Titel mit Spielzeit. Titel, die du gekauft, aber nie gestartet hast, stehen nicht in der Liste.",
+ "PSN_NOT_LINKED": "Verknüpfe zuerst dein PlayStation-Konto in den Einstellungen.",
+ "PSN_RELINK": "Die PlayStation-Verknüpfung ist beendet. Füge in den Einstellungen einen neuen NPSSO-Code ein.",
+ "PSN_NO_TITLES": "PlayStation listet für dieses Konto keine gespielten Titel.",
+ "PSN_UNAVAILABLE": "PlayStation hat nicht geantwortet. Bitte versuche es später noch einmal.",
+ "PSN_BUSY": "Eine andere PlayStation-Anfrage von dir läuft noch. Warte einen Moment und versuche es erneut.",
+ "PSN_SETTINGS": "Einstellungen öffnen",
"LOAD_FAILED": "Der Import konnte nicht geladen werden.",
"MATCHING": "Spiele werden gesucht: {{matched}} / {{total}}",
"MATCHING_HINT": "Du kannst die Seite verlassen und später wiederkommen.",
@@ -1102,6 +1130,7 @@
"SOURCE_HLTB": "HowLongToBeat",
"SOURCE_PLAYNITE": "Playnite",
"SOURCE_BOOKMARKLET": "Store-Seite",
+ "SOURCE_PSN": "PlayStation",
"FORMAT_READ": "Gelesen als: {{format}}",
"STORE_HINT": "Zieh den Button unten in deine Lesezeichenleiste. Öffne deine Spielebibliothek auf einer dieser Seiten, melde dich an und klick das Lesezeichen: {{sites}}. Es liest dort die Liste und bringt sie hierher. Die Liste geht nur durch deinen Browser.",
"STORE_LINK": "To Be Played Import",
diff --git a/public/i18n/en.json b/public/i18n/en.json
index 02d926a..74608cb 100644
--- a/public/i18n/en.json
+++ b/public/i18n/en.json
@@ -143,6 +143,27 @@
"ERROR": "Could not start the Steam link.",
"UNLINKED": "Steam account unlinked."
},
+ "PSN": {
+ "TITLE": "PlayStation",
+ "OFF": "The PlayStation link is not set up on this server.",
+ "STEPS_1": "Sign in at playstation.com in your browser.",
+ "STEPS_2": "In the same browser, open this address:",
+ "STEPS_3": "Copy the value of \"npsso\" and paste it below.",
+ "WHAT_IT_GIVES": "The NPSSO code is your PlayStation sign-in session. Whoever has it can use PlayStation sites as you. We exchange it at once for a limited token (PlayStation app scope), store only that token encrypted, and drop the code. You can end it any time: unlink here, or sign out of all devices in your PlayStation account settings. This uses the same unofficial interface as third-party trophy sites; Sony does not support it and may block it.",
+ "NPSSO_LABEL": "NPSSO code",
+ "LINK": "Link PlayStation account",
+ "LINKED": "PlayStation account linked.",
+ "LINKED_SINCE": "Linked since {{date}}",
+ "EXPIRES_ON": "Link ends on {{date}}",
+ "EXPIRED": "The link has ended. Paste a new NPSSO code to link again.",
+ "IMPORT": "Import played games",
+ "UNLINK": "Unlink",
+ "UNLINKED": "PlayStation account unlinked. Imported games stay in your library.",
+ "INVALID": "PlayStation did not accept the code. It may have expired: sign in again and copy a new one.",
+ "UNAVAILABLE": "PlayStation did not answer. Please try again later.",
+ "BUSY": "Another PlayStation request of yours is still running. Wait a moment and try again.",
+ "ERROR": "Something went wrong with the PlayStation link."
+ },
"VERSION": "Version",
"VERSION_UNKNOWN": "unknown",
"CHANGE_PASSWORD": {
@@ -1039,6 +1060,13 @@
"START": "Search games",
"START_FAILED": "Could not start the import.",
"RUNNING": "Another import is still being matched. Wait until it is done.",
+ "PSN_HINT": "Imports your played PS4 and PS5 titles with playtime. Titles you bought but never started are not listed.",
+ "PSN_NOT_LINKED": "Link your PlayStation account in the settings first.",
+ "PSN_RELINK": "The PlayStation link has ended. Paste a new NPSSO code in the settings.",
+ "PSN_NO_TITLES": "PlayStation lists no played titles for this account.",
+ "PSN_UNAVAILABLE": "PlayStation did not answer. Please try again later.",
+ "PSN_BUSY": "Another PlayStation request of yours is still running. Wait a moment and try again.",
+ "PSN_SETTINGS": "Open settings",
"LOAD_FAILED": "Could not load the import.",
"MATCHING": "Searching games: {{matched}} / {{total}}",
"MATCHING_HINT": "You can leave this page and come back later.",
@@ -1102,6 +1130,7 @@
"SOURCE_HLTB": "HowLongToBeat",
"SOURCE_PLAYNITE": "Playnite",
"SOURCE_BOOKMARKLET": "Store page",
+ "SOURCE_PSN": "PlayStation",
"FORMAT_READ": "Read as: {{format}}",
"STORE_HINT": "Drag the button below to your bookmarks bar. Open your game library on one of these sites, log in, and click the bookmark: {{sites}}. It reads the list there and brings it back here. The list goes only through your browser.",
"STORE_LINK": "To Be Played import",
diff --git a/src/components/PsnAccountCard.jsx b/src/components/PsnAccountCard.jsx
new file mode 100644
index 0000000..bb4baf5
--- /dev/null
+++ b/src/components/PsnAccountCard.jsx
@@ -0,0 +1,136 @@
+import {useState} from 'react';
+import {Link} from 'react-router-dom';
+import {useTranslation} from 'react-i18next';
+import {Gamepad, Link2, Loader2, Unlink, Upload} from 'lucide-react';
+import {toast} from 'sonner';
+
+import {Button} from '@/components/ui/button';
+import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
+import {Input} from '@/components/ui/input';
+import usePsnAccount from '../hooks/usePsnAccount.js';
+import {formatDate} from '../utils/format.js';
+import {isExpired, linkPsn, NPSSO_PAGE_URL, unlinkPsn} from '../utils/psn.js';
+
+/**
+ * The linked PlayStation account. The user pastes an NPSSO once; Core keeps only an encrypted token for about 10 days,
+ * so the card shows when the link ends.
+ */
+export default function PsnAccountCard() {
+ const {t, i18n} = useTranslation();
+ const {account, setAccount, failed} = usePsnAccount();
+ const [npsso, setNpsso] = useState('');
+ const [busy, setBusy] = useState(false);
+
+ const link = async (event) => {
+ event.preventDefault();
+ setBusy(true);
+ try {
+ const response = await linkPsn(npsso.trim());
+ if (response.status === 401) return;
+ if (response.ok) {
+ setAccount(await response.json());
+ toast.success(t('SETTINGS.PSN.LINKED'));
+ } else if (response.status === 400) {
+ toast.error(t('SETTINGS.PSN.INVALID'));
+ } else if (response.status === 429) {
+ toast.error(t('SETTINGS.PSN.BUSY'));
+ } else if (response.status === 502) {
+ toast.error(t('SETTINGS.PSN.UNAVAILABLE'));
+ } else if (response.status === 503) {
+ setAccount((old) => ({...old, enabled: false}));
+ } else {
+ toast.error(t('SETTINGS.PSN.ERROR'));
+ }
+ } catch (err) {
+ console.error('PSN link error:', err);
+ toast.error(t('SETTINGS.PSN.ERROR'));
+ } finally {
+ // The NPSSO is a session secret: it does not stay in the page after any answer.
+ setNpsso('');
+ setBusy(false);
+ }
+ };
+
+ const unlink = async () => {
+ setBusy(true);
+ try {
+ const response = await unlinkPsn();
+ if (response.status === 401) return;
+ if (!response.ok) {
+ toast.error(t('SETTINGS.PSN.ERROR'));
+ return;
+ }
+ setAccount((old) => ({...old, linked: false, linkedAt: null, expiresAt: null}));
+ toast.success(t('SETTINGS.PSN.UNLINKED'));
+ } catch (err) {
+ console.error('PSN unlink error:', err);
+ toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const expired = isExpired(account);
+ const form = ;
+
+ return (
+
+
+ {t('SETTINGS.PSN.TITLE')}
+
+
+ {failed ? (
+