diff --git a/app/api/me.py b/app/api/me.py index 11f77ce..b6b73df 100644 --- a/app/api/me.py +++ b/app/api/me.py @@ -41,6 +41,60 @@ class AdoptLegacyOut(BaseModel): moved: dict[str, int] +class ImportOut(BaseModel): + device_id: str + imported: dict[str, int] + + +# Tabele do przeniesienia przy transferze między urządzeniami + kolumna-encji do dedupu. +# saved_searches ma własne `id` PK (nie (device_id,encja)), więc dedup po `query`. +_COPY_TABLES: list[tuple[str, str]] = _TABLES + [("saved_searches", "query")] + + +@router.post("/import/{source_device}", response_model=ImportOut) +def import_from_device( + source_device: str, + session: Annotated[Session, Depends(get_session)], + device_id: Annotated[str, Depends(get_device_id)], +) -> ImportOut: + """Backup/transfer stanu (ulubione/blacklisty/progres/zapisane wyszukiwania) z innego + urządzenia na wołające. `source_device` to device_id starego telefonu (pełni rolę kodu + transferu — jest losowym UUID, więc jego znajomość == autoryzacja; instancja bez kont). + + KOPIUJE (nie przenosi): stary telefon zachowuje swoje. Dedup: pomija encje które + urządzenie już ma. Idempotentne. `id`-PK (saved_searches) dostaje nowy gen_random_uuid().""" + imported: dict[str, int] = {} + source = (source_device or "").strip()[:64] + if not source or source == device_id or source == LEGACY_DEVICE: + return ImportOut(device_id=device_id, imported={}) + for table, dedup_col in _COPY_TABLES: + cols = [ + r[0] + for r in session.execute( + text( + "SELECT column_name FROM information_schema.columns " + "WHERE table_name = :t ORDER BY ordinal_position" + ).bindparams(t=table) + ).all() + ] + if not cols: + continue + select_exprs = ", ".join( + ":dev" if c == "device_id" else ("gen_random_uuid()" if c == "id" else f"src.{c}") + for c in cols + ) + sql = ( + f"INSERT INTO {table} ({', '.join(cols)}) " + f"SELECT {select_exprs} FROM {table} src " + f"WHERE src.device_id = :src AND NOT EXISTS (" + f" SELECT 1 FROM {table} t WHERE t.device_id = :dev AND t.{dedup_col} = src.{dedup_col})" + ) + res = session.execute(text(sql).bindparams(dev=device_id, src=source)) + imported[table] = res.rowcount or 0 + session.commit() + return ImportOut(device_id=device_id, imported=imported) + + @router.post("/adopt-legacy", response_model=AdoptLegacyOut) def adopt_legacy( session: Annotated[Session, Depends(get_session)], diff --git a/mobile/src/api.ts b/mobile/src/api.ts index 572fa3c..5fcabc8 100644 --- a/mobile/src/api.ts +++ b/mobile/src/api.ts @@ -61,6 +61,14 @@ export class GoonClient { return this.request('/me/adopt-legacy', { method: 'POST' }); } + // Backup/transfer: skopiuj stan (ulubione/blacklisty/progres/zapisane) z innego + // urządzenia (jego device_id = kod transferu) na to. Nie rusza starego telefonu. + async importFromDevice( + sourceCode: string, + ): Promise<{ device_id: string; imported: Record }> { + return this.request(`/me/import/${encodeURIComponent(sourceCode)}`, { method: 'POST' }); + } + private async request(path: string, init?: RequestInit): Promise { const auth = await this._authHeaders(); const res = await fetch(`${this.baseUrl}${path}`, { diff --git a/mobile/src/changelog.ts b/mobile/src/changelog.ts index 67874e4..e5f1f65 100644 --- a/mobile/src/changelog.ts +++ b/mobile/src/changelog.ts @@ -16,6 +16,13 @@ export type ChangelogEntry = { }; export const CHANGELOG: ChangelogEntry[] = [ + { + id: '2026-07-14b', + date: 'July 2026', + items: [ + 'Backup & sync: move your favorites, saved searches, hidden list and watch progress to another phone. Settings -> Backup & sync: copy this phone\'s sync code, then paste it into Restore on the new phone (your old phone keeps everything).', + ], + }, { id: '2026-07-14', date: 'July 2026', diff --git a/mobile/src/screens/AppLockSettingsScreen.tsx b/mobile/src/screens/AppLockSettingsScreen.tsx index e69ecc0..14fe19c 100644 --- a/mobile/src/screens/AppLockSettingsScreen.tsx +++ b/mobile/src/screens/AppLockSettingsScreen.tsx @@ -8,8 +8,13 @@ import { StyleSheet, Switch, Text, + TextInput, View, } from 'react-native'; +import * as Clipboard from 'expo-clipboard'; +import { useQueryClient } from '@tanstack/react-query'; +import { useClient } from '../ClientContext'; +import { getDeviceId } from '../storage'; import { AppLockSettings, biometricAvailable, @@ -59,17 +64,57 @@ export function AppLockSettingsScreen() { const [errorText, setErrorText] = useState(null); const { gridColumns, setGridColumns, defaultQuality, setDefaultQuality } = usePreferences(); const navigation = useNavigation>(); + const client = useClient(); + const queryClient = useQueryClient(); + const [deviceId, setDeviceId] = useState(''); + const [importCode, setImportCode] = useState(''); + const [importing, setImporting] = useState(false); async function refresh() { - const [s, bio] = await Promise.all([getSettings(), biometricAvailable()]); + const [s, bio, dev] = await Promise.all([getSettings(), biometricAvailable(), getDeviceId()]); setSettings(s); setBioAvailable(bio); + setDeviceId(dev); } useEffect(() => { refresh(); }, []); + const copyCode = async () => { + await Clipboard.setStringAsync(deviceId); + Alert.alert('Copied', 'Your sync code is on the clipboard. Paste it on your other device.'); + }; + + const runImport = async () => { + const code = importCode.trim(); + if (!code) return; + if (code === deviceId) { + Alert.alert('That is this device', 'Enter the sync code from your OTHER device.'); + return; + } + setImporting(true); + try { + const res = await client.importFromDevice(code); + const total = Object.values(res.imported || {}).reduce((a, b) => a + b, 0); + // Odśwież wszystkie widoki stanu, żeby zaimportowane od razu było widać. + ['favorites', 'favorites-scenes', 'favorites-movies', 'favorites-studios'].forEach((k) => + queryClient.invalidateQueries({ queryKey: [k] }), + ); + setImportCode(''); + Alert.alert( + total > 0 ? 'Imported' : 'Nothing new', + total > 0 + ? `Added ${total} item(s) from the other device (favorites, saved searches, hidden list, watch progress).` + : 'That device had nothing you do not already have.', + ); + } catch { + Alert.alert('Import failed', 'Check the code and your connection, then try again.'); + } finally { + setImporting(false); + } + }; + if (!settings) { return ( @@ -331,6 +376,45 @@ export function AppLockSettingsScreen() { + + Backup & sync + + No accounts here, so your favorites, saved searches, hidden list and watch progress + live on this device. To move them to another phone: copy this device's sync code, then + paste it into "Restore" on the other phone. Copying, not moving, so this phone keeps + everything. + + + + Your sync code + + {deviceId || '…'} + + + Copy + + + Restore from another device + + + {importing ? 'Importing…' : 'Import'} + + + + About @@ -420,6 +504,18 @@ const styles = StyleSheet.create({ chipActive: { backgroundColor: theme.accent, borderColor: theme.accent }, versionValue: { color: theme.fg, fontSize: 15, fontWeight: '700', fontVariant: ['tabular-nums'] }, linkValue: { color: theme.accent, fontSize: 15, fontWeight: '700' }, + codeInput: { + backgroundColor: theme.card, + borderColor: theme.border, + borderWidth: 1, + borderRadius: 8, + color: theme.fg, + fontSize: 13, + paddingHorizontal: 12, + paddingVertical: 10, + marginTop: 8, + marginBottom: 10, + }, chipText: { color: theme.muted, fontSize: 13 }, chipTextActive: { color: '#fff', fontWeight: '700' }, });