{layoutOf(pair)};
+ switch (step) {
+ case "structure":
+ return (
+ <>
+ Puts every album in its own folder at the right depth for {layout}. Songs outside an album folder are moved into one named from their tags;
+ extra folders around an album (a composer folder around a soundtrack, a FLAC folder inside an album) are
+ removed; songs in disc folders get their disc number; and missing titles, track numbers, albums and artists are filled in from the folder
+ and file names. Names themselves are fixed in the next steps.
+ >
+ );
+ case "tags":
+ return (
+ <>
+ Looks each album up on MusicBrainz and shows corrected titles, track and disc numbers, album artist and date for you to review.
+ {side === "slave" && " Albums that are also in the master take the master's tags instead, so both libraries match."} Nothing changes until
+ you apply the albums you selected. This step is optional and can take a while: MusicBrainz allows about one lookup a second.
+ >
+ );
+ case "folders":
+ return (
+ <>
+ Renames artist and album folders from the tags, following {layout}. Whole album folders move, so covers, booklets and other files stay with
+ their album.
+ >
+ );
+ case "files":
+ return (
+ <>
+ Renames each song from its tags ({pair.naming.track_format}). Albums with several discs get{" "}
+ Disc 01, Disc 02 folders. Lyrics files move with their song.
+ >
+ );
+ }
+}
+
+function StepPanel({ pair, side, step, fixer, onNext }: { pair: Pair; side: Side; step: FixStep; fixer?: Fixer; onNext: () => void }) {
+ const qc = useQueryClient();
+ const toast = useToast();
+ const [folder, setFolder] = useState("");
+ const [onlyIncomplete, setOnlyIncomplete] = useState(true);
+ const meta = STEPS.find((s) => s.id === step)!;
+ const status = pair.fixer[side][step];
+ const plan = fixer?.plan && fixer.plan.side === side && fixer.plan.step === step ? fixer.plan : null;
+ const running = pair.job?.state === "running";
+ const ourJob = running && pair.job!.kind.startsWith("fix-");
+ const refresh = () => {
+ qc.invalidateQueries({ queryKey: ["pair", pair.id] });
+ qc.invalidateQueries({ queryKey: ["fixer", pair.id] });
+ };
+ const check = useMutation({
+ mutationFn: () => api.post(`/pairs/${pair.id}/fixer/check`, { side, step, folder: folder.trim(), only_incomplete: onlyIncomplete }),
+ onSuccess: refresh,
+ onError: (e) => toast("err", e.message),
+ });
+ const mark = useMutation({
+ mutationFn: (state: StepStatus["state"]) => api.put(`/pairs/${pair.id}/fixer/step`, { side, step, state }),
+ onSuccess: (_, state) => {
+ refresh();
+ if (state) setTimeout(onNext, 300);
+ },
+ onError: (e) => toast("err", e.message),
+ });
+ const masterOpen = side === "slave" && STEPS.some((s) => !finished(pair.fixer.master[s.id]));
+
+ return (
+ {stepText(step, pair, side)}
+ {masterOpen && ( +Complete: every song has a title, track number, album and artist, and they agree.
+| File | +Matched track | +Changes | +
|---|---|---|
| {f.path.slice(a.folder.length + 1)} | +{f.track ?? not matched} | +{Object.keys(own).length ? |
+
+ With both libraries organized the same way, songs match by path. Identical songs are replaced in the slave with a hardlink to the master, so + they take up space only once. Songs that differ (another quality, other tags) are left for you in Review. +
+ {open.length > 0 && ( +Upgrades, other formats and approximate matches are reviewed one album or song at a time.
+ + + +