library: the reading is copied into a folder of the reader's own, so a cleared profile gets it back without a frame being read twice

This commit is contained in:
2026-09-30 16:41:11 +07:00
parent 325d5c0470
commit 3e3045d912
6 changed files with 902 additions and 1 deletions
+137
View File
@@ -27,6 +27,13 @@ import {
type LibraryFolder,
type LibraryPhoto,
} from './engine/library';
import {
backupNow,
backupStatus,
pickBackupFolder,
restoreNow,
type BackupStatus,
} from './engine/libraryBackup';
// The catalogue screen, laid out like the admin's picture manager: the tree of
// folders and subfolders down the left, the frame that is up in the middle, and
@@ -167,12 +174,108 @@ export function Library() {
// the folders and cannot tell whether the remembered subfolder still exists.
const [loaded, setLoaded] = useState(false);
// The folder the catalogue is copied into, and whether the browser is still
// letting this page write to it — which it stops doing when the tab closes, so
// the row below says so rather than letting a write fail quietly. The ref is
// the same value for the scan that ends behind this screen's back: it reads it
// without being subscribed to it.
const [back, setBack] = useState<BackupStatus | null>(null);
const backRef = useRef<BackupStatus | null>(null);
const [backBusy, setBackBusy] = useState(false);
const reload = useCallback(async () => {
const [rows, edits] = await Promise.all([listPhotos(), listEditedIds()]);
setPhotos(rows);
setEdited(edits);
}, []);
// --- the catalogue's own copy ---------------------------------------------
const show = useCallback(async (status: BackupStatus) => {
backRef.current = status;
setBack(status);
}, []);
useEffect(() => {
let alive = true;
void backupStatus().then((status) => {
if (alive) void show(status);
});
return () => {
alive = false;
};
}, [show]);
// Write the catalogue into the folder. With none picked yet this is also the
// way to pick one: one button, because a backup folder is not something to
// choose and then not use. `quiet` is the run that follows a scan — it says
// nothing, since a note about a backup would sit over the note about the scan.
const backup = useCallback(
async (quiet = false) => {
if (backBusy) return;
if (!backRef.current?.folder) {
try {
await pickBackupFolder();
} catch (err) {
// Closing the picker is a "not now", not a failure worth a line of text.
if (err instanceof DOMException && err.name === 'AbortError') return;
setNote(t('lib.failed'));
return;
}
}
setBackBusy(true);
if (!quiet) setNote(t('lib.backupRun', { done: 0, total: backRef.current?.photos ?? 0 }));
try {
const result = await backupNow((p) => setNote(t('lib.backupRun', { done: p.done, total: p.total })));
await show(await backupStatus());
if (!quiet) setNote(t('lib.backupDone', { n: result.photos, w: result.written }));
} catch {
if (!quiet) setNote(t('lib.backupFailed'));
} finally {
setBackBusy(false);
}
},
[backBusy, show, t]
);
// The other direction, and the one that replaces rows: the frames come back
// without a file handle — a handle is not a thing that can be written down —
// and picking the folder again afterwards is what hands every one of them back,
// without a frame being read.
const restore = useCallback(async () => {
const status = backRef.current;
if (!status?.folder) {
setNote(t('lib.restoreNone'));
return;
}
if (!window.confirm(t('lib.restoreConfirm', { folder: status.folder }))) return;
if (backBusy) return;
setBackBusy(true);
setNote(t('lib.restoreRun', { done: 0, total: status.photos }));
try {
const result = await restoreNow((p) => setNote(t('lib.restoreRun', { done: p.done, total: p.total })));
await reload();
setFolders(await listFolders());
setNote(t('lib.restoreDone', { n: result.photos }));
} catch {
setNote(t('lib.restoreFailed'));
} finally {
setBackBusy(false);
}
}, [backBusy, reload, t]);
// A reading that is over is the one moment the rows are finished, so it is the
// moment the catalogue is copied out: a timer would fire in the middle of a
// roll or not at all. Held in a ref because the watch below subscribes once and
// this changes with every render — and nothing is written without a folder
// picked and the browser still allowing the write, which are the two states the
// row on screen is already saying.
const afterScan = useRef(() => {});
afterScan.current = () => {
const status = backRef.current;
if (status?.folder && status.granted) void backup(true);
};
// A scan runs for as long as the roll takes, and this screen watches it rather
// than running it: the studio may be up instead, and the scan carries on. The
// names a pass has walked into are drawn as they arrive, a pass ahead of the
@@ -200,6 +303,7 @@ export function Library() {
if (was && !folder) {
was = null;
void reload();
afterScan.current();
return;
}
was = folder;
@@ -667,6 +771,39 @@ export function Library() {
{t('lib.stop')}
</button>
) : null}
{/* The catalogue's own copy: one button that picks the folder the
first time and writes into it every time after, and one that
reads it back. Both are furniture next to the reading itself,
so they sit behind the hint rather than in front of it. */}
<button
type="button"
className="btn"
data-key="lib-backup"
disabled={backBusy}
onClick={() => void backup()}
>
{t('lib.backup')}
</button>
<button
type="button"
className="btn"
data-key="lib-restore"
disabled={backBusy || !back?.folder}
onClick={() => void restore()}
>
{t('lib.restore')}
</button>
<span className="hint" data-key="lib-backup-state">
{back?.folder
? back.granted
? t('lib.backupAt', {
folder: back.folder,
n: back.photos,
time: back.at ? new Date(back.at).toLocaleString() : '—',
})
: t('lib.backupBlocked', { folder: back.folder })
: t('lib.backupNone')}
</span>
<span className="hint lib-bar-hint" title={t('lib.hint')}>
{t('lib.hint')}
</span>
+91 -1
View File
@@ -572,7 +572,17 @@ export async function scanFolder(
// exactly as the catalogue already holds it. This is what makes a second scan
// of the same folder cheap, and what finishes a roll whose first scan was cut
// short — the frames it got through are skipped, the rest are read.
if (seen && seen.size === file.size && seen.mtime === file.lastModified && seen.thumb) return;
if (seen && seen.size === file.size && seen.mtime === file.lastModified && seen.thumb) {
// A row restored from a backup has no handle: a handle is not a thing that
// can be written to a file, so the row comes back with everything but the
// one thing that opens the frame, and the walk is where it gets it back.
// The row is put again — the bytes of the frame are still never read.
if (!seen.handle) {
batch.push({ ...seen, handle });
if (batch.length >= BATCH) await flush();
}
return;
}
// A RAW is read whole because that is the only way LibRaw can seek to the
// preview the camera wrote inside it; a JPEG is handed to the decoder as it
// is, and only its header is read for the date. Both tiles come off what the
@@ -966,3 +976,83 @@ export async function listEditedIds(): Promise<Set<string>> {
return new Set();
}
}
export async function listEdits(): Promise<LibraryEdit[]> {
try {
return await ask<LibraryEdit[]>(EDITS, 'readonly', (s) => s.getAll());
} catch {
return [];
}
}
// --- restore ---------------------------------------------------------------
// The catalogue as it travels: the rows without the two things that cannot be
// written to a file — a handle, which only the visitor's picker can hand out, and
// the tile itself, which is a blob and would be a second copy of the backup
// folder. Everything else is here, `mtime` and `size` and `star` included, which
// is what lets the walk that follows match every frame and read none of them.
export type CatalogueRow = Omit<LibraryPhoto, 'handle' | 'thumb'>;
// The rows a set of ids stands for, gaps left as gaps — one transaction for the
// lot, where `getPhoto` would be one per frame.
async function rowsAt(ids: string[]): Promise<(LibraryPhoto | undefined)[]> {
const db = await openDb();
return new Promise((resolve, reject) => {
const tx = db.transaction(PHOTOS, 'readonly');
const store = tx.objectStore(PHOTOS);
const out: (LibraryPhoto | undefined)[] = [];
ids.forEach((id, i) => {
const req = store.get(id);
req.onsuccess = () => {
out[i] = req.result as LibraryPhoto | undefined;
};
});
tx.oncomplete = () => resolve(out);
tx.onerror = () => reject(tx.error);
});
}
// Put a catalogue back. A frame the catalogue already holds and can open — one
// with a handle — is left exactly as it is: the backup's copy of it would come
// back without one, which is a working catalogue traded for a worse one. Every
// other row goes in as it came out. `thumb` fetches the tile that was backed up
// beside the row, and a tile that will not read leaves the row without one: the
// grid paints a blank cell until the next scan reads the frame.
export async function restoreCatalogue(
photos: CatalogueRow[],
edits: LibraryEdit[],
thumb?: (id: string) => Promise<Blob | null>
): Promise<number> {
const db = await openDb();
const size = 200;
let put = 0;
for (let at = 0; at < photos.length; at += size) {
const slice = photos.slice(at, at + size);
const had = await rowsAt(slice.map((r) => r.id));
const want = slice.filter((_, i) => !had[i]?.handle);
if (!want.length) continue;
const rows = await Promise.all(
want.map(async (row) => ({ ...row, thumb: thumb ? await thumb(row.id) : null }) as unknown as LibraryPhoto)
);
await new Promise<void>((resolve, reject) => {
const tx = db.transaction(PHOTOS, 'readwrite');
const store = tx.objectStore(PHOTOS);
for (const row of rows) store.put(row);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
put += rows.length;
}
for (let at = 0; at < edits.length; at += size) {
const slice = edits.slice(at, at + size);
await new Promise<void>((resolve, reject) => {
const tx = db.transaction(EDITS, 'readwrite');
const store = tx.objectStore(EDITS);
for (const row of slice) store.put(row);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
return put;
}
+318
View File
@@ -0,0 +1,318 @@
// The catalogue's own copy, kept in a folder on the visitor's own disk. The
// catalogue lives in the browser's storage, which is the one place a cleared
// profile, a new machine or a reinstall takes away — and the frames themselves
// are not in it, so what is lost is the reading: every tile, every star, every
// recipe. This writes that reading out to a folder the visitor picks, and reads
// it back into a catalogue that has nothing.
//
// The folder holds one file of rows — thumbnail, star, recipe, the frame's size
// and write time, and no handle, which is not a thing that can be written down —
// and the tiles beside it, one file per frame under `thumbs/<frame id>`, so the
// folder mirrors the library exactly and a tile can be looked at, copied or
// rsynced by hand. A frame that is being restored is matched to a frame on the
// disk by that size and write time, so putting a catalogue back costs the tiles
// and not one frame read.
//
// ponytail: no incremental tree walk, no deletes. A tile is written when the
// frame is new or its tile changed, and one left behind by a frame that was
// dropped from the library stays there — it is a few dozen kilobytes in a folder
// the visitor owns. Add a sweep when a library that is edited down to a fraction
// of its size makes the leftovers worth the walk.
import type { LibraryEdit, LibraryFolder } from './library';
import {
ensurePermission,
listEdits,
listFolders,
listPhotos,
renameFolder,
restoreCatalogue,
type CatalogueRow,
} from './library';
const DB_NAME = 'recipescam-backup';
// Two stores of its own, not the library's: the library opens without a version
// on purpose (see `openDb` there), and a version it has to reach for is one a
// second tab can block. This one is nothing but a handle and a list, so it can
// have a version and be upgraded.
const WHERE = 'where';
const WRITTEN = 'written';
const DB_VERSION = 1;
const CATALOGUE = 'recipescam-catalogue.json';
const THUMBS = 'thumbs';
// The catalogue is written whole however little changed: it is one file, and a
// row that was left behind in it is a frame that will not come back.
const VERSION = 1;
interface Meta {
id: 'meta';
at: number;
photos: number;
wrote: number;
}
export interface BackupStatus {
// The folder's own name, or null when the visitor has not chosen one.
folder: string | null;
// Whether the browser has let the page write to it. It comes back `prompt`
// after a restart — a permission outlives the tab only while the tab does, and
// Chromium will not hand it back without a click — which is why the screen
// says so rather than failing silently.
granted: boolean;
at: number;
photos: number;
wrote: number;
}
export interface BackupProgress {
done: number;
total: number;
}
let dbPromise: Promise<IDBDatabase> | null = null;
function openDb(): Promise<IDBDatabase> {
dbPromise ??= new Promise<IDBDatabase>((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains(WHERE)) db.createObjectStore(WHERE, { keyPath: 'id' });
if (!db.objectStoreNames.contains(WRITTEN)) db.createObjectStore(WRITTEN, { keyPath: 'id' });
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
return dbPromise;
}
function ask<T>(store: string, mode: IDBTransactionMode, run: (s: IDBObjectStore) => IDBRequest): Promise<T> {
return openDb().then(
(db) =>
new Promise<T>((resolve, reject) => {
const req = run(db.transaction(store, mode).objectStore(store));
req.onsuccess = () => resolve(req.result as T);
req.onerror = () => reject(req.error);
})
);
}
async function keep(store: string, rows: object[]): Promise<void> {
if (!rows.length) return;
const db = await openDb();
await new Promise<void>((resolve, reject) => {
const tx = db.transaction(store, 'readwrite');
const s = tx.objectStore(store);
for (const row of rows) s.put(row);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
async function folderHandle(): Promise<FileSystemDirectoryHandle | null> {
try {
const row = await ask<{ id: string; handle: FileSystemDirectoryHandle } | undefined>(WHERE, 'readonly', (s) =>
s.get('folder')
);
return row?.handle ?? null;
} catch {
return null;
}
}
// The permission is asked for silently and never granted from here: a prompt
// without a click behind it is a prompt the browser may refuse outright, and the
// buttons on the screen are where the click is.
async function granted(handle: FileSystemHandle, write: boolean): Promise<boolean> {
const askable = handle as FileSystemHandle & {
queryPermission?: (o: { mode: string }) => Promise<PermissionState>;
};
try {
return (await askable.queryPermission?.({ mode: write ? 'readwrite' : 'read' })) === 'granted';
} catch {
return false;
}
}
export async function backupStatus(): Promise<BackupStatus> {
const handle = await folderHandle();
const meta = await ask<Meta | undefined>(WHERE, 'readonly', (s) => s.get('meta')).catch(() => undefined);
return {
folder: handle?.name ?? null,
granted: handle ? await granted(handle, true) : false,
at: meta?.at ?? 0,
photos: meta?.photos ?? 0,
wrote: meta?.wrote ?? 0,
};
}
// The picker's own dialog, the same way a folder of frames is picked — a second
// `id` so the browser remembers this folder apart from the library's.
export async function pickBackupFolder(): Promise<string> {
if (!('showDirectoryPicker' in window)) throw new Error('no-folder-picker');
const handle = await (
window as unknown as {
showDirectoryPicker: (o?: unknown) => Promise<FileSystemDirectoryHandle>;
}
).showDirectoryPicker({ mode: 'readwrite', id: 'recipescam-backup' });
const before = await folderHandle();
await keep(WHERE, [{ id: 'folder', handle }]);
// A folder that is not the one the last run wrote into starts empty, and the
// list of tiles already written is about that folder — kept, it would say every
// tile is there and the new folder would come out with none of them.
if (before?.name !== handle.name) await ask(WRITTEN, 'readwrite', (s) => s.clear());
return handle.name;
}
// A handle comes back from IndexedDB without its permission, so every write goes
// through this: the visitor's click is the one thing that can hand it back.
async function writable(): Promise<FileSystemDirectoryHandle> {
const handle = await folderHandle();
if (!handle) throw new Error('no-backup-folder');
if (!(await ensurePermission(handle, true))) throw new Error('no-backup-permission');
return handle;
}
// The directories are asked for by the path in the frame id, and the id of every
// frame in a folder starts with that folder's: a cache of what has been opened
// turns a folder of 220 000 frames into a walk of the folders it is made of.
const opened = new WeakMap<FileSystemDirectoryHandle, Map<string, FileSystemDirectoryHandle>>();
async function at(root: FileSystemDirectoryHandle, rel: string, create = false): Promise<FileSystemDirectoryHandle> {
let cache = opened.get(root);
if (!cache) opened.set(root, (cache = new Map()));
let node = root;
let path = '';
for (const part of rel.split('/')) {
if (!part) continue;
path = path ? `${path}/${part}` : part;
const had = cache.get(path);
if (had) {
node = had;
continue;
}
node = await node.getDirectoryHandle(part, { create });
cache.set(path, node);
}
return node;
}
async function put(root: FileSystemDirectoryHandle, rel: string, blob: Blob): Promise<void> {
const cut = rel.lastIndexOf('/');
const dir = cut < 0 ? root : await at(root, rel.slice(0, cut), true);
const file = await dir.getFileHandle(cut < 0 ? rel : rel.slice(cut + 1), { create: true });
// One write at a time and closed at once: a writable that is left open is a
// file that is not on the disk yet, and a backup that is interrupted is one the
// next run has to write again.
const writer = await file.createWritable();
await writer.write(blob);
await writer.close();
}
// What the last run left in the folder: the tile's own size, per frame. A tile
// that has not changed is not written again, which is the whole of what makes a
// second run cheap.
async function already(): Promise<Map<string, number>> {
const rows = await ask<{ id: string; size: number }[]>(WRITTEN, 'readonly', (s) => s.getAll()).catch(() => []);
return new Map(rows.map((r) => [r.id, r.size]));
}
export interface BackupResult extends BackupStatus {
// The tiles this run actually wrote, and the frames it found.
written: number;
}
export async function backupNow(onProgress?: (p: BackupProgress) => void): Promise<BackupResult> {
const root = await writable();
const [photos, edits, folders] = await Promise.all([listPhotos(), listEdits(), listFolders()]);
const had = await already();
const at0 = Date.now();
const cat = {
version: VERSION,
at: at0,
folders: folders.map(({ name, label }) => ({ name, label })),
// The two things a file cannot hold: the handle, and the tile, which is
// written beside this as a file of its own.
photos: photos.map(({ handle, thumb, ...row }) => row) as CatalogueRow[],
edits,
};
await put(root, CATALOGUE, new Blob([JSON.stringify(cat)], { type: 'application/json' }));
let written = 0;
let done = 0;
const rows: { id: string; size: number }[] = [];
for (const photo of photos) {
// Progress counts frames and not tiles: a folder of frames read without one
// is still a reading, and a bar that does not move is a bar that reads as a
// screen that has stopped.
if (++done % 64 === 0) onProgress?.({ done, total: photos.length });
if (!photo.thumb) continue;
if (had.get(photo.id) === photo.thumb.size) continue;
const parts = photo.id.split('/');
parts.pop();
const dir = await at(root, [THUMBS, ...parts].join('/'), true);
const file = await dir.getFileHandle(photo.name, { create: true });
const writer = await file.createWritable();
await writer.write(photo.thumb);
await writer.close();
rows.push({ id: photo.id, size: photo.thumb.size });
written++;
// The list is kept in step with the writes: a run that is interrupted
// continues from the frame it stopped at instead of writing the folder
// again.
if (rows.length >= 200) await keep(WRITTEN, rows.splice(0, rows.length));
}
await keep(WRITTEN, rows);
onProgress?.({ done: photos.length, total: photos.length });
const meta: Meta = { id: 'meta', at: at0, photos: photos.length, wrote: written };
await keep(WHERE, [meta]);
return { folder: root.name, granted: true, at: at0, photos: photos.length, wrote: written, written };
}
export interface RestoreResult {
photos: number;
at: number;
}
// Put a catalogue back. Nothing is deleted and nothing that is already there is
// spared: the backup is the reading, and a frame it knows about is a frame the
// catalogue should know about too. Folders are not in it — a folder is a handle,
// and a handle only comes from the visitor's own picker — so the frames come back
// without one and the folders are picked again afterwards, which is also what
// gives every frame its handle back without reading a byte of it.
export async function restoreNow(onProgress?: (p: BackupProgress) => void): Promise<RestoreResult> {
const root = await folderHandle();
if (!root) throw new Error('no-backup-folder');
if (!(await ensurePermission(root, false))) throw new Error('no-backup-permission');
const text = await (await (await root.getFileHandle(CATALOGUE)).getFile()).text();
const cat = JSON.parse(text) as {
at?: number;
folders?: { name: string; label?: string }[];
photos: CatalogueRow[];
edits: LibraryEdit[];
};
// The folders this catalogue still has keep the names the backup remembers:
// a rename is the reader's own, and it is not in the frame's file anywhere.
const here = new Map<string, LibraryFolder>((await listFolders()).map((f) => [f.name, f]));
for (const f of cat.folders ?? []) {
const folder = here.get(f.name);
if (folder && f.label && folder.label !== f.label) await renameFolder(folder, f.label);
}
let done = 0;
const photos = await restoreCatalogue(cat.photos ?? [], cat.edits ?? [], async (id) => {
if (++done % 64 === 0) onProgress?.({ done, total: (cat.photos ?? []).length });
try {
const parts = id.split('/');
const name = parts.pop() as string;
const dir = await at(root, [THUMBS, ...parts].join('/'));
const file = await dir.getFileHandle(name);
return new Blob([await (await file.getFile()).arrayBuffer()], { type: 'image/jpeg' });
} catch {
// A tile that is not there — or that has been taken away by hand — leaves
// the row without one, and the next scan of the folder reads the frame and
// paints it. Losing the picture is not worth losing the reading.
return null;
}
});
onProgress?.({ done: photos, total: photos });
return { photos, at: cat.at ?? 0 };
}
+15
View File
@@ -279,6 +279,21 @@ export const en: Dict = {
'lib.failed': 'That folder could not be read.',
'lib.unsupported':
'This browser will not let a page read a folder from the disk (Chrome, Edge, Opera and Brave will). Photos still edit fine — drag and drop them into the studio.',
// The catalogue's own copy: the browser's storage is the one place a cleared
// profile takes the reading away from, and these two are what puts it back.
'lib.backup': 'BACK UP',
'lib.restore': 'RESTORE',
'lib.backupNone': 'No backup folder yet — this picks one and writes the catalogue into it.',
'lib.backupAt': 'Backup: {folder} · {n} frames · {time}',
'lib.backupBlocked': 'The browser has not let this page write to {folder}. Press BACK UP and allow it.',
'lib.backupRun': 'Backing up {done}/{total}…',
'lib.backupDone': 'Backed up {n} frames, {w} tiles written.',
'lib.backupFailed': 'The backup folder could not be written to.',
'lib.restoreConfirm': 'Restore the catalogue from {folder}? Frames the library already holds are left as they are.',
'lib.restoreRun': 'Restoring {done}/{total}…',
'lib.restoreDone': 'Restored {n} frames. Add the folder again so every frame gets its file back.',
'lib.restoreNone': 'Pick a backup folder first — BACK UP does that and writes the catalogue in one go.',
'lib.restoreFailed': 'The backup folder could not be read.',
'nav.admin': 'Admin',
'nav.photos': 'My photos',
+15
View File
@@ -293,6 +293,21 @@ export const vi = {
'lib.failed': 'Không đọc được thư mục này.',
'lib.unsupported':
'Trình duyệt này không cho phép app đọc thư mục trên máy (cần Chrome, Edge, Opera hoặc Brave). Ảnh vẫn chỉnh được bình thường bằng cách kéo & thả vào studio.',
// Bản sao của danh mục: bộ nhớ trình duyệt là chỗ duy nhất mà một profile bị
// xoá lấy mất phần đã đọc, và hai nút này là cách lấy lại.
'lib.backup': 'SAO LƯU',
'lib.restore': 'PHỤC HỒI',
'lib.backupNone': 'Chưa có thư mục sao lưu — bấm để chọn một thư mục và ghi danh mục vào đó.',
'lib.backupAt': 'Sao lưu: {folder} · {n} ảnh · {time}',
'lib.backupBlocked': 'Trình duyệt chưa cho trang ghi vào {folder}. Bấm SAO LƯU và cho phép.',
'lib.backupRun': 'Đang sao lưu {done}/{total}…',
'lib.backupDone': 'Đã sao lưu {n} ảnh, ghi {w} thumbnail.',
'lib.backupFailed': 'Không ghi được vào thư mục sao lưu.',
'lib.restoreConfirm': 'Phục hồi danh mục từ {folder}? Những ảnh thư viện đã có sẽ được giữ nguyên.',
'lib.restoreRun': 'Đang phục hồi {done}/{total}…',
'lib.restoreDone': 'Đã phục hồi {n} ảnh. Thêm lại thư mục ảnh để mỗi ảnh lấy lại được tệp của nó.',
'lib.restoreNone': 'Chọn thư mục sao lưu trước — nút SAO LƯU làm việc đó và ghi danh mục luôn.',
'lib.restoreFailed': 'Không đọc được thư mục sao lưu.',
'adm.title': 'Quản trị dải phim',
'adm.subtitle': 'Ảnh do người dùng đóng góp. Xoá một ảnh để gỡ nó khỏi trang chủ.',