Skip to content
Merged
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
45 changes: 45 additions & 0 deletions backend/api/cl2k_maker.py
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,51 @@ def test_drive(
return ok(detail, {"folder_id": folder_id})


@router.post(
"/gdrive/type-subfolders",
summary="Create logos/backgrounds/squareart under a parent Drive folder",
)
def gdrive_type_subfolders(
req: TestDriveRequest,
db: ChubDB = Depends(get_database),
logger: Any = Depends(get_cl2k_logger),
) -> JSONResponse:
"""Split one parent Drive folder into the community artwork layout.

Creates (or reuses) the three type subfolders and returns their real ids, so
the caller can store one routed destination per type. Purely additive on
Drive — nothing is moved, renamed or deleted, and a drive that should stay
flat simply never calls this.
"""
from backend.util.cl2k.gdrive_upload import ensure_type_subfolders, has_upload_token

folder_id = (req.gdrive_folder_id or "").strip()
if not folder_id:
return error("A Google Drive folder ID is required", "GDRIVE_FOLDER_REQUIRED")
cfg = load_config()
if not has_upload_token(cfg.sync_gdrive):
return error(
"No Google Drive OAuth token configured — set one under Sync GDrive "
"(a service account cannot own files in a personal Drive).",
"GDRIVE_NO_TOKEN",
)
try:
subfolders = ensure_type_subfolders(folder_id, cfg.sync_gdrive, logger)
except ValueError as exc:
return error(f"Invalid folder ID: {exc}", "GDRIVE_FOLDER_INVALID")
except Exception as exc:
return error(
f"Could not create the type subfolders: {exc}",
"GDRIVE_SUBFOLDERS_FAILED",
status_code=502,
)
created = [s["name"] for s in subfolders if s["created"]]
detail = (
f"Created {', '.join(created)}" if created else "All three subfolders already existed"
)
return ok(detail, {"folder_id": folder_id, "subfolders": subfolders})


@router.get(
"/external-ids", summary="TMDB external ids (tvdb_id + imdb_id) for a title"
)
Expand Down
92 changes: 92 additions & 0 deletions backend/util/cl2k/gdrive_upload.py
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,98 @@ def list_files(folder_id: str, sync_cfg: Any, logger) -> List[str]:
return [ln.strip() for ln in result.stdout.splitlines() if ln.strip()]


# image_type -> the Drive subfolder the community artwork drives use for it. A
# FIXED map, never user input, so these never widen the rclone argument surface.
TYPE_SUBFOLDERS = {
"logo": "logos",
"background": "backgrounds",
"squareart": "squareart",
}


def ensure_type_subfolders(folder_id: str, sync_cfg: Any, logger) -> List[dict]:
"""Create (or find) ``logos``/``backgrounds``/``squareart`` under ``folder_id``.

Returns one ``{image_type, name, folder_id, created}`` per subfolder so the
caller can store real child ids — the upload path still addresses exactly one
Drive folder per destination. Raises on a missing token or rclone failure.
"""
_reject_unsafe_id(folder_id, "gdrive_folder_id")
auth = _upload_auth_args(sync_cfg)
if not auth:
raise RuntimeError(
"no usable Google Drive OAuth token configured — set a token under "
"Sync GDrive (a service account cannot own files in a personal Drive)"
)
rclone = _rclone_path()
_ensure_remote(rclone)

def _dir_ids() -> dict:
result = subprocess.run(
[
rclone,
"lsjson",
"posters:",
"--drive-root-folder-id",
folder_id,
"--dirs-only",
*auth,
],
check=False,
capture_output=True,
text=True,
)
if result.returncode != 0:
raise RuntimeError(
f"rclone lsjson failed: {_rclone_error_detail(result.stderr)}"
)
# Keep only usable ids: a blank/absent/non-string ID must NOT register the
# name, or the completeness check below passes and we hand back a routed
# row whose folder_id is empty — which _drive_targets skips silently, so
# that art type would just stop uploading with no error anywhere.
return {
d["Name"]: d["ID"]
for d in json.loads(result.stdout or "[]")
if isinstance(d.get("ID"), str) and d["ID"].strip()
}

existing = _dir_ids()
made = []
for name in TYPE_SUBFOLDERS.values():
if name in existing:
continue
result = subprocess.run(
[rclone, "mkdir", f"posters:{name}", "--drive-root-folder-id", folder_id, *auth],
check=False,
capture_output=True,
text=True,
)
if result.returncode != 0:
raise RuntimeError(
f"rclone mkdir '{name}' failed: {_rclone_error_detail(result.stderr)}"
)
made.append(name)

# Re-list once so newly created folders come back with their real ids.
ids = _dir_ids() if made else existing
missing = [n for n in TYPE_SUBFOLDERS.values() if n not in ids]
if missing:
raise RuntimeError(f"Drive did not return an id for: {', '.join(missing)}")
Comment thread
coderabbitai[bot] marked this conversation as resolved.
logger.info(
f"cl2k: type subfolders under {folder_id} — created {made or 'none'}, "
f"reused {[n for n in TYPE_SUBFOLDERS.values() if n not in made]}"
)
return [
{
"image_type": image_type,
"name": name,
"folder_id": ids[name],
"created": name in made,
}
for image_type, name in TYPE_SUBFOLDERS.items()
]


def delete_file(name: str, folder_id: str, sync_cfg: Any, logger) -> None:
"""Delete the single file ``name`` from Drive folder ``folder_id`` via
``rclone deletefile``. ``name`` is relative to ``folder_id`` (the rclone
Expand Down
79 changes: 79 additions & 0 deletions frontend/src/extensions/cl2k/SaveLocationsFields.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -398,12 +398,63 @@ const TestUploadButton = ({ folderId, disabled }) => {
);
};

// Splits ONE parent Drive into the community artwork layout. Opt-in per row —
// a Drive that should stay flat simply never uses it, and nothing on Drive is
// moved or deleted either way.
const SplitSubfoldersButton = ({ folderId, disabled, onSplit }) => {
const toast = useToast();
const [busy, setBusy] = useState(false);
const canRun = !disabled && !busy && !!(folderId || '').trim();

const run = useCallback(async () => {
setBusy(true);
try {
const res = await apiCore.post('/cl2k-maker/gdrive/type-subfolders', {
gdrive_folder_id: folderId,
});
const subfolders = res?.data?.subfolders || [];
// Refuse to rewrite the row on a partial answer: a missing type or a
// blank folder_id would save a destination that silently uploads
// nothing (_drive_targets skips a blank id).
const usable =
CL2K_ART_TYPES.filter(t => t.value !== 'poster').every(t =>
subfolders.some(s => s.image_type === t.value && (s.folder_id || '').trim())
) && subfolders.every(s => (s.folder_id || '').trim());
if (!usable) throw new Error('Drive returned an incomplete set of subfolders');
onSplit(subfolders);
toast.success(res?.message || 'Split into type subfolders');
} catch (e) {
toast.error(e?.message || 'Could not create the type subfolders');
} finally {
setBusy(false);
}
}, [folderId, toast, onSplit]);

return (
<button
type="button"
onClick={run}
disabled={!canRun}
title={
(folderId || '').trim()
? 'Create logos/backgrounds/squareart in this Drive folder and route each type to its own'
: 'Enter a Folder ID first'
}
className="inline-flex items-center gap-1.5 h-[38px] px-3 shrink-0 bg-surface border border-border rounded-lg text-fg-muted text-[12.5px] font-medium hover:text-fg hover:border-primary/50 disabled:opacity-50 transition-colors"
>
<Icon name="create_new_folder" className="text-[16px] text-accent" />
{busy ? 'Splitting…' : 'Split by type'}
</button>
);
};

const DriveEntry = ({
entry,
disabled,
onPatch,
onDelete,
onToggleType,
onSplit,
autoFocus,
clearFocus,
}) => {
Expand Down Expand Up @@ -431,6 +482,11 @@ const DriveEntry = ({
aria-label="Drive folder ID"
/>
<TestUploadButton folderId={entry.folder_id} disabled={disabled} />
<SplitSubfoldersButton
folderId={entry.folder_id}
disabled={disabled}
onSplit={onSplit}
/>
</div>
<TypeChips
microLabel="Uploads"
Expand All @@ -455,6 +511,28 @@ export const Cl2kGdriveUploadsField = ({ value, onChange, disabled = false }) =>

const clearFocus = useCallback(() => setFocusIndex(null), [setFocusIndex]);

// Replace the split row with one routed row per type. The parent row's own
// claimed types are dropped — they now live on the children — and any type
// it didn't claim is left unclaimed rather than silently switched on.
const splitRow = useCallback(
(index, subfolders) => {
const parent = entries[index] || {};
const claimed = parent.types || [];
const children = subfolders
// Only types the parent actually claimed. An unclaimed row splits
// into nothing and is left untouched by the empty-children return.
.filter(s => claimed.includes(s.image_type))
.map(s => ({
name: `${parent.name || 'Drive'} ${s.name}`.trim(),
folder_id: s.folder_id,
types: [s.image_type],
}));
if (children.length === 0) return;
onChange(entries.flatMap((e, i) => (i === index ? children : [e])));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
},
[entries, onChange]
);

return (
<div>
<CardIntro
Expand Down Expand Up @@ -484,6 +562,7 @@ export const Cl2kGdriveUploadsField = ({ value, onChange, disabled = false }) =>
onPatch={changes => patch(i, changes)}
onDelete={() => remove(i)}
onToggleType={type => toggleType(i, type)}
onSplit={subfolders => splitRow(i, subfolders)}
autoFocus={focusIndex === i}
clearFocus={clearFocus}
/>
Expand Down
Loading