mirror of
https://github.com/litruv/AudioSort.git
synced 2026-07-26 03:36:03 +10:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bb7d31e748 | |||
| 472939c7e2 | |||
| c5185f2c70 | |||
| fd85181983 | |||
| 90027cf9b8 |
5
.github/workflows/build.yml
vendored
5
.github/workflows/build.yml
vendored
@@ -1,6 +1,9 @@
|
|||||||
name: Build/release
|
name: Build/release
|
||||||
|
|
||||||
on: push
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
# AudioSort
|
# AudioSort
|
||||||
|
[](https://github.com/litruv/AudioSort/actions/workflows/build.yml)
|
||||||
|
|
||||||
A desktop application for organizing, tagging, and managing WAV audio files with Universal Category System (UCS) support.
|
A desktop application for organizing, tagging, and managing WAV audio files with Universal Category System (UCS) support.
|
||||||
|
|
||||||
@@ -62,7 +63,7 @@ Download the latest `AudioSort Setup.exe` from the [Releases](../../releases) pa
|
|||||||
#### Setup
|
#### Setup
|
||||||
```bash
|
```bash
|
||||||
# Clone the repository
|
# Clone the repository
|
||||||
git clone <your-repo-url>
|
git clone https://github.com/litruv/AudioSort/
|
||||||
cd AudioSort
|
cd AudioSort
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "audio-sort",
|
"name": "audio-sort",
|
||||||
"version": "0.1.0",
|
"version": "0.1.1",
|
||||||
"description": "Electron audio sorting application with fuzzy search, tagging, and library management for WAV files.",
|
"description": "Electron audio sorting application with fuzzy search, tagging, and library management for WAV files.",
|
||||||
"main": "dist/main/main/index.js",
|
"main": "dist/main/main/index.js",
|
||||||
"type": "commonjs",
|
"type": "commonjs",
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export class LibraryService {
|
|||||||
private readonly metadataFailures = new Set<string>();
|
private readonly metadataFailures = new Set<string>();
|
||||||
private readonly checksumFailures = new Set<string>();
|
private readonly checksumFailures = new Set<string>();
|
||||||
private readonly organization: OrganizationService;
|
private readonly organization: OrganizationService;
|
||||||
private metadataSuggestionCache: { authors: Set<string>; copyrights: Set<string> } | null = null;
|
private metadataSuggestionCache: { authors: Set<string> } | null = null;
|
||||||
private readonly waveformPreviewCache = new Map<number, { modifiedAt: number; pointCount: number; samples: number[]; rms: number }>();
|
private readonly waveformPreviewCache = new Map<number, { modifiedAt: number; pointCount: number; samples: number[]; rms: number }>();
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
@@ -378,7 +378,6 @@ export class LibraryService {
|
|||||||
return this.organizeFile(payload.fileId, {
|
return this.organizeFile(payload.fileId, {
|
||||||
customName: updated.customName ?? undefined,
|
customName: updated.customName ?? undefined,
|
||||||
author: metadataSnapshot.author?.trim() || undefined,
|
author: metadataSnapshot.author?.trim() || undefined,
|
||||||
copyright: metadataSnapshot.copyright?.trim() || undefined,
|
|
||||||
rating: metadataSnapshot.rating ?? undefined
|
rating: metadataSnapshot.rating ?? undefined
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -435,7 +434,7 @@ export class LibraryService {
|
|||||||
*/
|
*/
|
||||||
public async organizeFile(
|
public async organizeFile(
|
||||||
fileId: number,
|
fileId: number,
|
||||||
metadata: { customName?: string | null; author?: string | null; copyright?: string | null; rating?: number }
|
metadata: { customName?: string | null; author?: string | null; rating?: number }
|
||||||
): Promise<AudioFileSummary> {
|
): Promise<AudioFileSummary> {
|
||||||
const record = this.database.getFileById(fileId);
|
const record = this.database.getFileById(fileId);
|
||||||
const category = this.organization.getPrimaryCategory(record);
|
const category = this.organization.getPrimaryCategory(record);
|
||||||
@@ -503,9 +502,7 @@ export class LibraryService {
|
|||||||
|
|
||||||
// Merge with new metadata (only update fields that are provided)
|
// Merge with new metadata (only update fields that are provided)
|
||||||
const requestedAuthor = this.normaliseMetadataInput(metadata.author);
|
const requestedAuthor = this.normaliseMetadataInput(metadata.author);
|
||||||
const requestedCopyright = this.normaliseMetadataInput(metadata.copyright);
|
|
||||||
const mergedAuthor = requestedAuthor !== undefined ? requestedAuthor : existing.author ?? null;
|
const mergedAuthor = requestedAuthor !== undefined ? requestedAuthor : existing.author ?? null;
|
||||||
const mergedCopyright = requestedCopyright !== undefined ? requestedCopyright : existing.copyright ?? null;
|
|
||||||
const mergedRating = metadata.rating !== undefined ? metadata.rating : existing.rating;
|
const mergedRating = metadata.rating !== undefined ? metadata.rating : existing.rating;
|
||||||
|
|
||||||
this.tagService.writeMetadataOnly(updatedRecord.absolutePath, {
|
this.tagService.writeMetadataOnly(updatedRecord.absolutePath, {
|
||||||
@@ -513,16 +510,12 @@ export class LibraryService {
|
|||||||
categories: updatedRecord.categories,
|
categories: updatedRecord.categories,
|
||||||
title: effectiveCustomName,
|
title: effectiveCustomName,
|
||||||
author: mergedAuthor,
|
author: mergedAuthor,
|
||||||
copyright: mergedCopyright,
|
|
||||||
rating: mergedRating
|
rating: mergedRating
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update suggestions cache for any metadata that was provided
|
// Update suggestions cache for any metadata that was provided
|
||||||
if (typeof mergedAuthor === 'string' && mergedAuthor.length > 0) {
|
if (typeof mergedAuthor === 'string' && mergedAuthor.length > 0) {
|
||||||
this.updateMetadataSuggestionsCache(mergedAuthor, undefined);
|
this.updateMetadataSuggestionsCache(mergedAuthor);
|
||||||
}
|
|
||||||
if (typeof mergedCopyright === 'string' && mergedCopyright.length > 0) {
|
|
||||||
this.updateMetadataSuggestionsCache(undefined, mergedCopyright);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.resetMetadataSuggestionsCache();
|
this.resetMetadataSuggestionsCache();
|
||||||
@@ -602,9 +595,7 @@ export class LibraryService {
|
|||||||
|
|
||||||
// Merge with new metadata (only update fields that are provided)
|
// Merge with new metadata (only update fields that are provided)
|
||||||
const movedAuthor = this.normaliseMetadataInput(metadata.author);
|
const movedAuthor = this.normaliseMetadataInput(metadata.author);
|
||||||
const movedCopyright = this.normaliseMetadataInput(metadata.copyright);
|
|
||||||
const mergedAuthor = movedAuthor !== undefined ? movedAuthor : existing.author ?? null;
|
const mergedAuthor = movedAuthor !== undefined ? movedAuthor : existing.author ?? null;
|
||||||
const mergedCopyright = movedCopyright !== undefined ? movedCopyright : existing.copyright ?? null;
|
|
||||||
const mergedRating = metadata.rating !== undefined ? metadata.rating : existing.rating;
|
const mergedRating = metadata.rating !== undefined ? metadata.rating : existing.rating;
|
||||||
|
|
||||||
// Write tags, categories, and additional metadata to the organized file
|
// Write tags, categories, and additional metadata to the organized file
|
||||||
@@ -613,16 +604,12 @@ export class LibraryService {
|
|||||||
categories: updated.categories,
|
categories: updated.categories,
|
||||||
title: effectiveCustomName,
|
title: effectiveCustomName,
|
||||||
author: mergedAuthor,
|
author: mergedAuthor,
|
||||||
copyright: mergedCopyright,
|
|
||||||
rating: mergedRating
|
rating: mergedRating
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update suggestions cache for any metadata that was provided
|
// Update suggestions cache for any metadata that was provided
|
||||||
if (typeof mergedAuthor === 'string' && mergedAuthor.length > 0) {
|
if (typeof mergedAuthor === 'string' && mergedAuthor.length > 0) {
|
||||||
this.updateMetadataSuggestionsCache(mergedAuthor, undefined);
|
this.updateMetadataSuggestionsCache(mergedAuthor);
|
||||||
}
|
|
||||||
if (typeof mergedCopyright === 'string' && mergedCopyright.length > 0) {
|
|
||||||
this.updateMetadataSuggestionsCache(undefined, mergedCopyright);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.resetMetadataSuggestionsCache();
|
this.resetMetadataSuggestionsCache();
|
||||||
@@ -666,20 +653,20 @@ export class LibraryService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reads embedded metadata from a WAV file and returns author, copyright, title, and rating.
|
* Reads embedded metadata from a WAV file and returns author, title, and rating.
|
||||||
*/
|
*/
|
||||||
public async readFileMetadata(fileId: number): Promise<{ author?: string; copyright?: string; title?: string; rating?: number }> {
|
public async readFileMetadata(fileId: number): Promise<{ author?: string; title?: string; rating?: number }> {
|
||||||
const record = this.database.getFileById(fileId);
|
const record = this.database.getFileById(fileId);
|
||||||
const metadata = this.tagService.readMetadata(record.absolutePath);
|
const metadata = this.tagService.readMetadata(record.absolutePath);
|
||||||
this.updateMetadataSuggestionsCache(metadata.author, metadata.copyright);
|
this.updateMetadataSuggestionsCache(metadata.author);
|
||||||
return metadata;
|
return metadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates metadata (author, copyright, rating) without organizing the file.
|
* Updates metadata (author, rating) without organizing the file.
|
||||||
* Only writes to the WAV INFO chunk, doesn't move or rename the file.
|
* Only writes to the WAV INFO chunk, doesn't move or rename the file.
|
||||||
*/
|
*/
|
||||||
public async updateFileMetadata(fileId: number, metadata: { author?: string | null; copyright?: string | null; rating?: number }): Promise<void> {
|
public async updateFileMetadata(fileId: number, metadata: { author?: string | null; rating?: number }): Promise<void> {
|
||||||
const record = this.database.getFileById(fileId);
|
const record = this.database.getFileById(fileId);
|
||||||
|
|
||||||
// Read existing metadata from the WAV file
|
// Read existing metadata from the WAV file
|
||||||
@@ -687,13 +674,11 @@ export class LibraryService {
|
|||||||
|
|
||||||
// Merge with new metadata (only update fields that are provided)
|
// Merge with new metadata (only update fields that are provided)
|
||||||
const requestedAuthor = this.normaliseMetadataInput(metadata.author);
|
const requestedAuthor = this.normaliseMetadataInput(metadata.author);
|
||||||
const requestedCopyright = this.normaliseMetadataInput(metadata.copyright);
|
|
||||||
const mergedMetadata = {
|
const mergedMetadata = {
|
||||||
tags: record.tags,
|
tags: record.tags,
|
||||||
categories: record.categories,
|
categories: record.categories,
|
||||||
title: record.customName ?? existing.title,
|
title: record.customName ?? existing.title,
|
||||||
author: requestedAuthor !== undefined ? requestedAuthor : existing.author ?? null,
|
author: requestedAuthor !== undefined ? requestedAuthor : existing.author ?? null,
|
||||||
copyright: requestedCopyright !== undefined ? requestedCopyright : existing.copyright ?? null,
|
|
||||||
rating: metadata.rating !== undefined ? metadata.rating : existing.rating
|
rating: metadata.rating !== undefined ? metadata.rating : existing.rating
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -702,10 +687,7 @@ export class LibraryService {
|
|||||||
|
|
||||||
// Update suggestions cache
|
// Update suggestions cache
|
||||||
if (typeof mergedMetadata.author === 'string' && mergedMetadata.author.length > 0) {
|
if (typeof mergedMetadata.author === 'string' && mergedMetadata.author.length > 0) {
|
||||||
this.updateMetadataSuggestionsCache(mergedMetadata.author, undefined);
|
this.updateMetadataSuggestionsCache(mergedMetadata.author);
|
||||||
}
|
|
||||||
if (typeof mergedMetadata.copyright === 'string' && mergedMetadata.copyright.length > 0) {
|
|
||||||
this.updateMetadataSuggestionsCache(undefined, mergedMetadata.copyright);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.waveformPreviewCache.delete(fileId);
|
this.waveformPreviewCache.delete(fileId);
|
||||||
@@ -715,10 +697,9 @@ export class LibraryService {
|
|||||||
/**
|
/**
|
||||||
* Returns distinct metadata suggestions aggregated from the library.
|
* Returns distinct metadata suggestions aggregated from the library.
|
||||||
*/
|
*/
|
||||||
public async listMetadataSuggestions(): Promise<{ authors: string[]; copyrights: string[] }> {
|
public async listMetadataSuggestions(): Promise<{ authors: string[] }> {
|
||||||
if (!this.metadataSuggestionCache) {
|
if (!this.metadataSuggestionCache) {
|
||||||
const authors = new Set<string>();
|
const authors = new Set<string>();
|
||||||
const copyrights = new Set<string>();
|
|
||||||
const files = this.database.listFiles();
|
const files = this.database.listFiles();
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
@@ -727,9 +708,6 @@ export class LibraryService {
|
|||||||
if (metadata.author) {
|
if (metadata.author) {
|
||||||
this.addSuggestionValue(authors, metadata.author);
|
this.addSuggestionValue(authors, metadata.author);
|
||||||
}
|
}
|
||||||
if (metadata.copyright) {
|
|
||||||
this.addSuggestionValue(copyrights, metadata.copyright);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!this.metadataFailures.has(file.absolutePath)) {
|
if (!this.metadataFailures.has(file.absolutePath)) {
|
||||||
this.metadataFailures.add(file.absolutePath);
|
this.metadataFailures.add(file.absolutePath);
|
||||||
@@ -739,12 +717,11 @@ export class LibraryService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.metadataSuggestionCache = { authors, copyrights };
|
this.metadataSuggestionCache = { authors };
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
authors: Array.from(this.metadataSuggestionCache.authors).sort((a, b) => a.localeCompare(b)),
|
authors: Array.from(this.metadataSuggestionCache.authors).sort((a, b) => a.localeCompare(b))
|
||||||
copyrights: Array.from(this.metadataSuggestionCache.copyrights).sort((a, b) => a.localeCompare(b))
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -769,16 +746,13 @@ export class LibraryService {
|
|||||||
return trimmed.length > 0 ? trimmed : null;
|
return trimmed.length > 0 ? trimmed : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateMetadataSuggestionsCache(author?: string | null, copyright?: string | null): void {
|
private updateMetadataSuggestionsCache(author?: string | null): void {
|
||||||
if (!this.metadataSuggestionCache) {
|
if (!this.metadataSuggestionCache) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (author) {
|
if (author) {
|
||||||
this.addSuggestionValue(this.metadataSuggestionCache.authors, author);
|
this.addSuggestionValue(this.metadataSuggestionCache.authors, author);
|
||||||
}
|
}
|
||||||
if (copyright) {
|
|
||||||
this.addSuggestionValue(this.metadataSuggestionCache.copyrights, copyright);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -8,14 +8,13 @@ import { TagService } from './TagService';
|
|||||||
*/
|
*/
|
||||||
interface CachedMetadata {
|
interface CachedMetadata {
|
||||||
author?: string;
|
author?: string;
|
||||||
copyright?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Represents a structured filter derived from the freeform query.
|
* Represents a structured filter derived from the freeform query.
|
||||||
*/
|
*/
|
||||||
interface AdvancedFilter {
|
interface AdvancedFilter {
|
||||||
field: 'author' | 'copyright';
|
field: 'author';
|
||||||
value: string;
|
value: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,7 +118,7 @@ export class SearchService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pulls out author/copyright filters and returns the remaining free text query.
|
* Pulls out author filters and returns the remaining free text query.
|
||||||
*/
|
*/
|
||||||
private extractAdvancedFilters(query: string): { filters: AdvancedFilter[]; remainingQuery: string } {
|
private extractAdvancedFilters(query: string): { filters: AdvancedFilter[]; remainingQuery: string } {
|
||||||
const tokens = query.split(/\s+/);
|
const tokens = query.split(/\s+/);
|
||||||
@@ -135,13 +134,6 @@ export class SearchService {
|
|||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (lower.startsWith('copyright:')) {
|
|
||||||
const value = token.slice('copyright:'.length).trim();
|
|
||||||
if (value.length > 0) {
|
|
||||||
filters.push({ field: 'copyright', value: value.toLowerCase() });
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
remainingTokens.push(token);
|
remainingTokens.push(token);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,7 +153,7 @@ export class SearchService {
|
|||||||
|
|
||||||
const metadata = this.getCachedMetadata(file);
|
const metadata = this.getCachedMetadata(file);
|
||||||
return filters.every((filter) => {
|
return filters.every((filter) => {
|
||||||
const source = filter.field === 'author' ? metadata.author : metadata.copyright;
|
const source = metadata.author;
|
||||||
if (!source) {
|
if (!source) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -180,8 +172,7 @@ export class SearchService {
|
|||||||
|
|
||||||
const metadata = this.tagService.readMetadata(file.absolutePath);
|
const metadata = this.tagService.readMetadata(file.absolutePath);
|
||||||
const normalized: CachedMetadata = {
|
const normalized: CachedMetadata = {
|
||||||
author: metadata.author?.trim() || undefined,
|
author: metadata.author?.trim() || undefined
|
||||||
copyright: metadata.copyright?.trim() || undefined
|
|
||||||
};
|
};
|
||||||
this.metadataCache.set(file.id, normalized);
|
this.metadataCache.set(file.id, normalized);
|
||||||
return normalized;
|
return normalized;
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ export class TagService {
|
|||||||
*/
|
*/
|
||||||
public readMetadata(filePath: string): {
|
public readMetadata(filePath: string): {
|
||||||
author?: string;
|
author?: string;
|
||||||
copyright?: string;
|
|
||||||
title?: string;
|
title?: string;
|
||||||
rating?: number;
|
rating?: number;
|
||||||
} {
|
} {
|
||||||
@@ -58,7 +57,6 @@ export class TagService {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
author: tags.IART?.trim() || undefined,
|
author: tags.IART?.trim() || undefined,
|
||||||
copyright: tags.ICOP?.trim() || undefined,
|
|
||||||
title: tags.INAM?.trim() || undefined,
|
title: tags.INAM?.trim() || undefined,
|
||||||
rating
|
rating
|
||||||
};
|
};
|
||||||
@@ -79,7 +77,6 @@ export class TagService {
|
|||||||
categories: string[];
|
categories: string[];
|
||||||
title?: string | null;
|
title?: string | null;
|
||||||
author?: string | null;
|
author?: string | null;
|
||||||
copyright?: string | null;
|
|
||||||
rating?: number;
|
rating?: number;
|
||||||
}
|
}
|
||||||
): void {
|
): void {
|
||||||
@@ -96,7 +93,6 @@ export class TagService {
|
|||||||
categories: string[];
|
categories: string[];
|
||||||
title?: string | null;
|
title?: string | null;
|
||||||
author?: string | null;
|
author?: string | null;
|
||||||
copyright?: string | null;
|
|
||||||
rating?: number;
|
rating?: number;
|
||||||
}
|
}
|
||||||
): void {
|
): void {
|
||||||
@@ -123,7 +119,6 @@ export class TagService {
|
|||||||
IGNR: null,
|
IGNR: null,
|
||||||
INAM: metadata.title?.trim()?.length ? metadata.title.trim() : null,
|
INAM: metadata.title?.trim()?.length ? metadata.title.trim() : null,
|
||||||
IART: metadata.author?.trim()?.length ? metadata.author.trim() : null,
|
IART: metadata.author?.trim()?.length ? metadata.author.trim() : null,
|
||||||
ICOP: metadata.copyright?.trim()?.length ? metadata.copyright.trim() : null,
|
|
||||||
IRTD: metadata.rating && metadata.rating > 0 ? String(metadata.rating * 2) : null,
|
IRTD: metadata.rating && metadata.rating > 0 ? String(metadata.rating * 2) : null,
|
||||||
ISFT: 'AudioSort'
|
ISFT: 'AudioSort'
|
||||||
};
|
};
|
||||||
@@ -138,7 +133,6 @@ export class TagService {
|
|||||||
categories: metadata.categories.join(', '),
|
categories: metadata.categories.join(', '),
|
||||||
title: metadata.title,
|
title: metadata.title,
|
||||||
author: metadata.author,
|
author: metadata.author,
|
||||||
copyright: metadata.copyright,
|
|
||||||
rating: metadata.rating
|
rating: metadata.rating
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ export interface FileDetailPanelProps {
|
|||||||
categories: CategoryRecord[];
|
categories: CategoryRecord[];
|
||||||
onRename(newName: string): Promise<void>;
|
onRename(newName: string): Promise<void>;
|
||||||
onMove(targetRelativeDirectory: string): Promise<void>;
|
onMove(targetRelativeDirectory: string): Promise<void>;
|
||||||
/** Organizes a file with optional metadata fields (customName, author, copyright, rating 1-5). */
|
/** Organizes a file with optional metadata fields (customName, author, rating 1-5). */
|
||||||
onOrganize(metadata: { customName?: string | null; author?: string | null; copyright?: string | null; rating?: number }): Promise<void>;
|
onOrganize(metadata: { customName?: string | null; author?: string | null; rating?: number }): Promise<void>;
|
||||||
onUpdateTags(data: { tags: string[]; categories: string[] }): Promise<void>;
|
onUpdateTags(data: { tags: string[]; categories: string[] }): Promise<void>;
|
||||||
onUpdateCustomName(customName: string | null): Promise<void>;
|
onUpdateCustomName(customName: string | null): Promise<void>;
|
||||||
metadataSuggestionsVersion: number;
|
metadataSuggestionsVersion: number;
|
||||||
@@ -29,9 +29,8 @@ export function FileDetailPanel({ file, categories, onRename, onMove, onOrganize
|
|||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [isTagSectionExpanded, setIsTagSectionExpanded] = useState(true);
|
const [isTagSectionExpanded, setIsTagSectionExpanded] = useState(true);
|
||||||
const [suggestions, setSuggestions] = useState<{ authors: string[] }>({ authors: [] });
|
const [suggestions, setSuggestions] = useState<{ authors: string[] }>({ authors: [] });
|
||||||
const initialMetadataRef = useRef<{ author: string; copyright: string; rating: number; customName: string }>({
|
const initialMetadataRef = useRef<{ author: string; rating: number; customName: string }>({
|
||||||
author: '',
|
author: '',
|
||||||
copyright: '',
|
|
||||||
rating: 0,
|
rating: 0,
|
||||||
customName: ''
|
customName: ''
|
||||||
});
|
});
|
||||||
@@ -94,7 +93,7 @@ export function FileDetailPanel({ file, categories, onRename, onMove, onOrganize
|
|||||||
setAuthorDraft('');
|
setAuthorDraft('');
|
||||||
setRatingDraft(0);
|
setRatingDraft(0);
|
||||||
setIsEditingCustomName(false);
|
setIsEditingCustomName(false);
|
||||||
initialMetadataRef.current = { author: '', copyright: '', rating: 0, customName: '' };
|
initialMetadataRef.current = { author: '', rating: 0, customName: '' };
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,7 +101,7 @@ export function FileDetailPanel({ file, categories, onRename, onMove, onOrganize
|
|||||||
setCustomNameDraft(file.customName ?? '');
|
setCustomNameDraft(file.customName ?? '');
|
||||||
setIsEditingCustomName(false);
|
setIsEditingCustomName(false);
|
||||||
const baseCustomName = (file.customName ?? '').trim();
|
const baseCustomName = (file.customName ?? '').trim();
|
||||||
initialMetadataRef.current = { author: '', copyright: '', rating: 0, customName: baseCustomName };
|
initialMetadataRef.current = { author: '', rating: 0, customName: baseCustomName };
|
||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
@@ -118,7 +117,6 @@ export function FileDetailPanel({ file, categories, onRename, onMove, onOrganize
|
|||||||
setRatingDraft(ratingValue);
|
setRatingDraft(ratingValue);
|
||||||
initialMetadataRef.current = {
|
initialMetadataRef.current = {
|
||||||
author: authorValue,
|
author: authorValue,
|
||||||
copyright: '',
|
|
||||||
rating: ratingValue,
|
rating: ratingValue,
|
||||||
customName: customNameValue
|
customName: customNameValue
|
||||||
};
|
};
|
||||||
@@ -129,7 +127,7 @@ export function FileDetailPanel({ file, categories, onRename, onMove, onOrganize
|
|||||||
console.error('Failed to read file metadata:', error);
|
console.error('Failed to read file metadata:', error);
|
||||||
setAuthorDraft('');
|
setAuthorDraft('');
|
||||||
setRatingDraft(0);
|
setRatingDraft(0);
|
||||||
initialMetadataRef.current = { author: '', copyright: '', rating: 0, customName: baseCustomName };
|
initialMetadataRef.current = { author: '', rating: 0, customName: baseCustomName };
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}, [appendSuggestion, file?.id]);
|
}, [appendSuggestion, file?.id]);
|
||||||
@@ -206,7 +204,6 @@ export function FileDetailPanel({ file, categories, onRename, onMove, onOrganize
|
|||||||
const trimmedCustomName = (nextCustomNameRaw ?? '').toString().trim();
|
const trimmedCustomName = (nextCustomNameRaw ?? '').toString().trim();
|
||||||
const comparisonState = {
|
const comparisonState = {
|
||||||
author: trimmedAuthor,
|
author: trimmedAuthor,
|
||||||
copyright: '',
|
|
||||||
rating: nextRatingValue,
|
rating: nextRatingValue,
|
||||||
customName: trimmedCustomName
|
customName: trimmedCustomName
|
||||||
};
|
};
|
||||||
@@ -215,7 +212,6 @@ export function FileDetailPanel({ file, categories, onRename, onMove, onOrganize
|
|||||||
if (
|
if (
|
||||||
previous &&
|
previous &&
|
||||||
previous.author === comparisonState.author &&
|
previous.author === comparisonState.author &&
|
||||||
previous.copyright === comparisonState.copyright &&
|
|
||||||
previous.rating === comparisonState.rating &&
|
previous.rating === comparisonState.rating &&
|
||||||
previous.customName === comparisonState.customName
|
previous.customName === comparisonState.customName
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ export interface MultiFileEditorProps {
|
|||||||
categories: CategoryRecord[];
|
categories: CategoryRecord[];
|
||||||
onUpdateTags(fileId: number, data: { tags: string[]; categories: string[] }): Promise<void>;
|
onUpdateTags(fileId: number, data: { tags: string[]; categories: string[] }): Promise<void>;
|
||||||
onUpdateCustomName(fileId: number, customName: string | null): Promise<void>;
|
onUpdateCustomName(fileId: number, customName: string | null): Promise<void>;
|
||||||
onOrganize(fileId: number, metadata: { customName?: string | null; author?: string | null; copyright?: string | null; rating?: number }): Promise<void>;
|
onOrganize(fileId: number, metadata: { customName?: string | null; author?: string | null; rating?: number }): Promise<void>;
|
||||||
onUpdateMetadata(fileId: number, metadata: { author?: string | null; copyright?: string | null; rating?: number }): Promise<void>;
|
onUpdateMetadata(fileId: number, metadata: { author?: string | null; rating?: number }): Promise<void>;
|
||||||
metadataSuggestionsVersion: number;
|
metadataSuggestionsVersion: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,9 +19,8 @@ export interface MultiFileEditorProps {
|
|||||||
export function MultiFileEditor({ files, categories, onUpdateTags, onUpdateCustomName, onOrganize, onUpdateMetadata, metadataSuggestionsVersion }: MultiFileEditorProps): JSX.Element {
|
export function MultiFileEditor({ files, categories, onUpdateTags, onUpdateCustomName, onOrganize, onUpdateMetadata, metadataSuggestionsVersion }: MultiFileEditorProps): JSX.Element {
|
||||||
const [sharedCustomName, setSharedCustomName] = useState('');
|
const [sharedCustomName, setSharedCustomName] = useState('');
|
||||||
const [sharedAuthor, setSharedAuthor] = useState('');
|
const [sharedAuthor, setSharedAuthor] = useState('');
|
||||||
const [sharedCopyright, setSharedCopyright] = useState('');
|
|
||||||
const [sharedRating, setSharedRating] = useState(0);
|
const [sharedRating, setSharedRating] = useState(0);
|
||||||
const [suggestions, setSuggestions] = useState<{ authors: string[]; copyrights: string[] }>({ authors: [], copyrights: [] });
|
const [suggestions, setSuggestions] = useState<{ authors: string[] }>({ authors: [] });
|
||||||
const [isTagSectionExpanded, setIsTagSectionExpanded] = useState(true);
|
const [isTagSectionExpanded, setIsTagSectionExpanded] = useState(true);
|
||||||
|
|
||||||
// Compute aggregated tags and categories for TagEditor
|
// Compute aggregated tags and categories for TagEditor
|
||||||
@@ -53,7 +52,7 @@ export function MultiFileEditor({ files, categories, onUpdateTags, onUpdateCusto
|
|||||||
|
|
||||||
const listRef = useRef<HTMLDivElement>(null);
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
type SuggestionKey = 'authors' | 'copyrights';
|
type SuggestionKey = 'authors';
|
||||||
|
|
||||||
const appendSuggestion = useCallback((value: string, key: SuggestionKey) => {
|
const appendSuggestion = useCallback((value: string, key: SuggestionKey) => {
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
@@ -84,21 +83,9 @@ export function MultiFileEditor({ files, categories, onUpdateTags, onUpdateCusto
|
|||||||
return base.filter((entry) => entry.toLowerCase().includes(query)).slice(0, 5);
|
return base.filter((entry) => entry.toLowerCase().includes(query)).slice(0, 5);
|
||||||
}, [sharedAuthor, suggestions.authors]);
|
}, [sharedAuthor, suggestions.authors]);
|
||||||
|
|
||||||
const filteredCopyrightSuggestions = useMemo(() => {
|
|
||||||
if (suggestions.copyrights.length === 0) {
|
|
||||||
return [] as string[];
|
|
||||||
}
|
|
||||||
const query = sharedCopyright.trim().toLowerCase();
|
|
||||||
const base = suggestions.copyrights.filter((entry) => entry.toLowerCase() !== query);
|
|
||||||
if (query.length === 0) {
|
|
||||||
return base.slice(0, 5);
|
|
||||||
}
|
|
||||||
return base.filter((entry) => entry.toLowerCase().includes(query)).slice(0, 5);
|
|
||||||
}, [sharedCopyright, suggestions.copyrights]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setSuggestions({ authors: [], copyrights: [] });
|
setSuggestions({ authors: [] });
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const result = await window.api.listMetadataSuggestions();
|
const result = await window.api.listMetadataSuggestions();
|
||||||
@@ -106,8 +93,7 @@ export function MultiFileEditor({ files, categories, onUpdateTags, onUpdateCusto
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSuggestions({
|
setSuggestions({
|
||||||
authors: result.authors,
|
authors: result.authors
|
||||||
copyrights: result.copyrights
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
@@ -188,37 +174,6 @@ export function MultiFileEditor({ files, categories, onUpdateTags, onUpdateCusto
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSharedCopyrightBlur = async () => {
|
|
||||||
const normalized = sharedCopyright.trim();
|
|
||||||
|
|
||||||
if (normalized.length === 0) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
let failureCount = 0;
|
|
||||||
for (const file of files) {
|
|
||||||
try {
|
|
||||||
if (file.categories.length > 0) {
|
|
||||||
await onOrganize(file.id, { copyright: normalized });
|
|
||||||
} else {
|
|
||||||
await onUpdateMetadata(file.id, { copyright: normalized });
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
failureCount += 1;
|
|
||||||
console.error(`Failed to update copyright for file ${file.id} (${file.fileName}):`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (failureCount > 0) {
|
|
||||||
console.error(`${failureCount} file(s) failed to update copyright`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (normalized.length > 0) {
|
|
||||||
appendSuggestion(normalized, 'copyrights');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to update copyright:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSharedRatingChange = async (rating: number) => {
|
const handleSharedRatingChange = async (rating: number) => {
|
||||||
setSharedRating(rating);
|
setSharedRating(rating);
|
||||||
|
|
||||||
@@ -325,39 +280,6 @@ export function MultiFileEditor({ files, categories, onUpdateTags, onUpdateCusto
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</label>
|
</label>
|
||||||
<label>
|
|
||||||
<span>Copyright</span>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={sharedCopyright}
|
|
||||||
onChange={(e) => setSharedCopyright(e.target.value)}
|
|
||||||
onBlur={handleSharedCopyrightBlur}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
e.currentTarget.blur();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
placeholder="Copyright information"
|
|
||||||
/>
|
|
||||||
{filteredCopyrightSuggestions.length > 0 && (
|
|
||||||
<div className="metadata-suggestion-list" role="listbox" aria-label="Copyright suggestions">
|
|
||||||
{filteredCopyrightSuggestions.map((option) => (
|
|
||||||
<button
|
|
||||||
key={option}
|
|
||||||
type="button"
|
|
||||||
className="metadata-suggestion"
|
|
||||||
onMouseDown={(event) => event.preventDefault()}
|
|
||||||
onClick={() => {
|
|
||||||
setSharedCopyright(option);
|
|
||||||
void handleSharedCopyrightBlur();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{option}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</label>
|
|
||||||
<label>
|
<label>
|
||||||
<span>Rating</span>
|
<span>Rating</span>
|
||||||
<div className="star-rating">
|
<div className="star-rating">
|
||||||
|
|||||||
@@ -8,11 +8,10 @@ import {
|
|||||||
type SyntheticEvent
|
type SyntheticEvent
|
||||||
} from 'react';
|
} from 'react';
|
||||||
|
|
||||||
type SuggestionType = 'author' | 'copyright';
|
type SuggestionType = 'author';
|
||||||
|
|
||||||
interface MetadataSuggestionPayload {
|
interface MetadataSuggestionPayload {
|
||||||
authors: string[];
|
authors: string[];
|
||||||
copyrights: string[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SearchBarProps {
|
export interface SearchBarProps {
|
||||||
@@ -30,7 +29,7 @@ export interface SearchBarProps {
|
|||||||
export function SearchBar({ value, onChange, onRescan, onFindDuplicates, onOpenSettings, metadataSuggestionsVersion }: SearchBarProps): JSX.Element {
|
export function SearchBar({ value, onChange, onRescan, onFindDuplicates, onOpenSettings, metadataSuggestionsVersion }: SearchBarProps): JSX.Element {
|
||||||
const [draft, setDraft] = useState(value);
|
const [draft, setDraft] = useState(value);
|
||||||
const [caretIndex, setCaretIndex] = useState<number | null>(null);
|
const [caretIndex, setCaretIndex] = useState<number | null>(null);
|
||||||
const [suggestions, setSuggestions] = useState<MetadataSuggestionPayload>({ authors: [], copyrights: [] });
|
const [suggestions, setSuggestions] = useState<MetadataSuggestionPayload>({ authors: [] });
|
||||||
const [loadingSuggestions, setLoadingSuggestions] = useState(false);
|
const [loadingSuggestions, setLoadingSuggestions] = useState(false);
|
||||||
const [rescanBusy, setRescanBusy] = useState(false);
|
const [rescanBusy, setRescanBusy] = useState(false);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -44,8 +43,7 @@ export function SearchBar({ value, onChange, onRescan, onFindDuplicates, onOpenS
|
|||||||
try {
|
try {
|
||||||
const result = await window.api.listMetadataSuggestions();
|
const result = await window.api.listMetadataSuggestions();
|
||||||
setSuggestions({
|
setSuggestions({
|
||||||
authors: result.authors,
|
authors: result.authors
|
||||||
copyrights: result.copyrights
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load search suggestions', error);
|
console.error('Failed to load search suggestions', error);
|
||||||
@@ -55,7 +53,7 @@ export function SearchBar({ value, onChange, onRescan, onFindDuplicates, onOpenS
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSuggestions({ authors: [], copyrights: [] });
|
setSuggestions({ authors: [] });
|
||||||
void refreshSuggestions();
|
void refreshSuggestions();
|
||||||
}, [refreshSuggestions, metadataSuggestionsVersion]);
|
}, [refreshSuggestions, metadataSuggestionsVersion]);
|
||||||
|
|
||||||
@@ -65,7 +63,7 @@ export function SearchBar({ value, onChange, onRescan, onFindDuplicates, onOpenS
|
|||||||
}, [caretIndex, draft]);
|
}, [caretIndex, draft]);
|
||||||
|
|
||||||
const activeToken = useMemo(() => {
|
const activeToken = useMemo(() => {
|
||||||
const match = analysisTarget.match(/(author|copyright):([^\s]*)$/i);
|
const match = analysisTarget.match(/(author):([^\s]*)$/i);
|
||||||
if (!match) {
|
if (!match) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -80,7 +78,7 @@ export function SearchBar({ value, onChange, onRescan, onFindDuplicates, onOpenS
|
|||||||
if (!activeToken) {
|
if (!activeToken) {
|
||||||
return [] as string[];
|
return [] as string[];
|
||||||
}
|
}
|
||||||
const pool = activeToken.type === 'author' ? suggestions.authors : suggestions.copyrights;
|
const pool = suggestions.authors;
|
||||||
const trimmedTerm = activeToken.term.trim();
|
const trimmedTerm = activeToken.term.trim();
|
||||||
if (trimmedTerm.length === 0) {
|
if (trimmedTerm.length === 0) {
|
||||||
return pool.slice(0, 6);
|
return pool.slice(0, 6);
|
||||||
|
|||||||
@@ -297,7 +297,7 @@ export class LibraryStore extends EventTarget {
|
|||||||
/**
|
/**
|
||||||
* Automatically organizes a file based on its categories.
|
* Automatically organizes a file based on its categories.
|
||||||
*/
|
*/
|
||||||
public async organizeFile(fileId: number, metadata: { customName?: string | null; author?: string | null; copyright?: string | null; rating?: number }): Promise<AudioFileSummary> {
|
public async organizeFile(fileId: number, metadata: { customName?: string | null; author?: string | null; rating?: number }): Promise<AudioFileSummary> {
|
||||||
const currentSelection = this.snapshot.selectedFileId;
|
const currentSelection = this.snapshot.selectedFileId;
|
||||||
const currentFocused = this.snapshot.focusedFile;
|
const currentFocused = this.snapshot.focusedFile;
|
||||||
const updated = await window.api.organizeFile(fileId, metadata);
|
const updated = await window.api.organizeFile(fileId, metadata);
|
||||||
@@ -335,9 +335,9 @@ export class LibraryStore extends EventTarget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates metadata (author, copyright, rating) for a file without organizing it.
|
* Updates metadata (author, rating) for a file without organizing it.
|
||||||
*/
|
*/
|
||||||
public async updateFileMetadata(fileId: number, metadata: { author?: string | null; copyright?: string | null; rating?: number }): Promise<void> {
|
public async updateFileMetadata(fileId: number, metadata: { author?: string | null; rating?: number }): Promise<void> {
|
||||||
await window.api.updateFileMetadata(fileId, metadata);
|
await window.api.updateFileMetadata(fileId, metadata);
|
||||||
|
|
||||||
// Refresh the file list to get updated metadata
|
// Refresh the file list to get updated metadata
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export interface RendererApi {
|
|||||||
/** Moves a file to another subdirectory under the library root. */
|
/** Moves a file to another subdirectory under the library root. */
|
||||||
moveFile(fileId: number, targetRelativeDirectory: string): Promise<import('./models').AudioFileSummary>;
|
moveFile(fileId: number, targetRelativeDirectory: string): Promise<import('./models').AudioFileSummary>;
|
||||||
/** Automatically organizes a file based on its categories. */
|
/** Automatically organizes a file based on its categories. */
|
||||||
organizeFile(fileId: number, metadata: { customName?: string | null; author?: string | null; copyright?: string | null; rating?: number }): Promise<import('./models').AudioFileSummary>;
|
organizeFile(fileId: number, metadata: { customName?: string | null; author?: string | null; rating?: number }): Promise<import('./models').AudioFileSummary>;
|
||||||
/** Updates the custom name for a file. */
|
/** Updates the custom name for a file. */
|
||||||
updateCustomName(fileId: number, customName: string | null): Promise<import('./models').AudioFileSummary>;
|
updateCustomName(fileId: number, customName: string | null): Promise<import('./models').AudioFileSummary>;
|
||||||
/** Opens the file's containing folder in the system file explorer. */
|
/** Opens the file's containing folder in the system file explorer. */
|
||||||
@@ -63,11 +63,11 @@ export interface RendererApi {
|
|||||||
/** Runs fuzzy search returning ranked results. */
|
/** Runs fuzzy search returning ranked results. */
|
||||||
search(query: string): Promise<import('./models').AudioFileSummary[]>;
|
search(query: string): Promise<import('./models').AudioFileSummary[]>;
|
||||||
/** Reads embedded metadata from a WAV file. */
|
/** Reads embedded metadata from a WAV file. */
|
||||||
readFileMetadata(fileId: number): Promise<{ author?: string; copyright?: string; title?: string; rating?: number }>;
|
readFileMetadata(fileId: number): Promise<{ author?: string; title?: string; rating?: number }>;
|
||||||
/** Lists distinct metadata values gathered from the library for quick suggestions. */
|
/** Lists distinct metadata values gathered from the library for quick suggestions. */
|
||||||
listMetadataSuggestions(): Promise<{ authors: string[]; copyrights: string[] }>;
|
listMetadataSuggestions(): Promise<{ authors: string[] }>;
|
||||||
/** Updates metadata (author, copyright, rating) without organizing the file. */
|
/** Updates metadata (author, rating) without organizing the file. */
|
||||||
updateFileMetadata(fileId: number, metadata: { author?: string | null; copyright?: string | null; rating?: number }): Promise<void>;
|
updateFileMetadata(fileId: number, metadata: { author?: string | null; rating?: number }): Promise<void>;
|
||||||
/** Listens for menu actions from the main process. Returns a cleanup function. */
|
/** Listens for menu actions from the main process. Returns a cleanup function. */
|
||||||
onMenuAction(channel: string, callback: () => void): () => void;
|
onMenuAction(channel: string, callback: () => void): () => void;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"extends": "./tsconfig.base.json",
|
"extends": "./tsconfig.base.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"module": "CommonJS",
|
"module": "CommonJS",
|
||||||
"moduleResolution": "Node10",
|
"moduleResolution": "Node",
|
||||||
"outDir": "dist/main",
|
"outDir": "dist/main",
|
||||||
"rootDir": "src",
|
"rootDir": "src",
|
||||||
"types": ["node"],
|
"types": ["node"],
|
||||||
|
|||||||
Reference in New Issue
Block a user