Restart repository history from target event
This commit is contained in:
+43
@@ -0,0 +1,43 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# env files (can opt-in for committing if needed)
|
||||||
|
.env*
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
|
|
||||||
|
/spectrograms
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
First, run the development server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
# or
|
||||||
|
yarn dev
|
||||||
|
# or
|
||||||
|
pnpm dev
|
||||||
|
# or
|
||||||
|
bun dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||||
|
|
||||||
|
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||||
|
|
||||||
|
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||||
|
|
||||||
|
## Learn More
|
||||||
|
|
||||||
|
To learn more about Next.js, take a look at the following resources:
|
||||||
|
|
||||||
|
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||||
|
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||||
|
|
||||||
|
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||||
|
|
||||||
|
## Deploy on Vercel
|
||||||
|
|
||||||
|
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||||
|
|
||||||
|
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import fs from 'fs-extra';
|
||||||
|
import path from 'path';
|
||||||
|
import { execFile } from 'child_process';
|
||||||
|
import util from 'util';
|
||||||
|
import { getFlacMetadata, parseLabel } from '@/lib/utils/audio';
|
||||||
|
import { AnalysisTrack, AnalysisResponse } from '@/types';
|
||||||
|
|
||||||
|
const execFileAsync = util.promisify(execFile);
|
||||||
|
|
||||||
|
// This route analyzes the album: extracts metadata and checks quality
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const { albumPath } = await request.json();
|
||||||
|
|
||||||
|
if (!albumPath || !(await fs.pathExists(albumPath))) {
|
||||||
|
return NextResponse.json({ error: 'Invalid album path' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = await fs.readdir(albumPath);
|
||||||
|
const flacFiles = files.filter((file) => file.toLowerCase().endsWith('.flac'));
|
||||||
|
|
||||||
|
if (flacFiles.length === 0) {
|
||||||
|
return NextResponse.json({ error: 'No FLAC files found' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const tracks: (AnalysisTrack & { size?: number })[] = await Promise.all(
|
||||||
|
flacFiles.map(async (file) => {
|
||||||
|
const filePath = path.join(albumPath, file);
|
||||||
|
try {
|
||||||
|
// Check for audio corruption
|
||||||
|
try {
|
||||||
|
await execFileAsync('ffmpeg', ['-v', 'error', '-i', filePath, '-f', 'null', '-']);
|
||||||
|
} catch (ffmpegError) {
|
||||||
|
return {
|
||||||
|
filename: file,
|
||||||
|
path: filePath,
|
||||||
|
error: 'Corrupted audio data or length metadata mismatch',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = await fs.stat(filePath);
|
||||||
|
const metadata = await getFlacMetadata(filePath);
|
||||||
|
return {
|
||||||
|
filename: file,
|
||||||
|
path: filePath,
|
||||||
|
...metadata,
|
||||||
|
fullPathLength: filePath.length,
|
||||||
|
size: stats.size,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
return {
|
||||||
|
filename: file,
|
||||||
|
path: filePath,
|
||||||
|
error: 'Failed to parse metadata',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Calculate total size
|
||||||
|
const totalSize = tracks.reduce((acc, track) => acc + (track.size || 0), 0);
|
||||||
|
|
||||||
|
// Sort tracks by track number if available, else by filename
|
||||||
|
tracks.sort((a, b) => {
|
||||||
|
if ('error' in a || 'error' in b) return 0; // Keep order if error
|
||||||
|
const trackA = a.common?.track?.no ?? 0;
|
||||||
|
const trackB = b.common?.track?.no ?? 0;
|
||||||
|
if (trackA && trackB) return trackA - trackB;
|
||||||
|
return a.filename.localeCompare(b.filename);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Analyze quality
|
||||||
|
let isLossless = true;
|
||||||
|
let isHiRes = true;
|
||||||
|
let mixed = false;
|
||||||
|
let qualityDetails = '';
|
||||||
|
|
||||||
|
const sampleRates = new Set();
|
||||||
|
const bitDepths = new Set();
|
||||||
|
|
||||||
|
tracks.forEach((track) => {
|
||||||
|
if ('error' in track) return;
|
||||||
|
const sr = track.format?.sampleRate;
|
||||||
|
const bd = track.format?.bitsPerSample;
|
||||||
|
|
||||||
|
if (sr) sampleRates.add(sr);
|
||||||
|
if (bd) bitDepths.add(bd);
|
||||||
|
|
||||||
|
if (sr !== 44100 || bd !== 16) {
|
||||||
|
isLossless = false; // Not strictly CD quality
|
||||||
|
}
|
||||||
|
if (!sr || sr < 48000 || !bd || bd < 24) {
|
||||||
|
isHiRes = false; // Not strictly Hi-Res
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (sampleRates.size > 1 || bitDepths.size > 1) {
|
||||||
|
mixed = true;
|
||||||
|
qualityDetails = `NOTE: Mixed formats. Sample Rates: ${Array.from(sampleRates).join(', ')} Hz. Bit Depths: ${Array.from(bitDepths).join(', ')} bit.`;
|
||||||
|
|
||||||
|
// Generate detailed message for mixed content
|
||||||
|
const mixedDetails = tracks.map(t => {
|
||||||
|
if ('error' in t) return `${t.filename}: Error`;
|
||||||
|
return `Track ${t.common?.track?.no || '?'}: ${t.format?.bitsPerSample}bit / ${t.format?.sampleRate}Hz`;
|
||||||
|
}).join('\n');
|
||||||
|
|
||||||
|
qualityDetails += '\n' + mixedDetails;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine overall status
|
||||||
|
let status = 'UNKNOWN';
|
||||||
|
if (tracks.some(t => 'error' in t)) {
|
||||||
|
status = 'CORRUPT';
|
||||||
|
} else if (mixed) {
|
||||||
|
status = 'MIXED';
|
||||||
|
} else if (isHiRes) {
|
||||||
|
status = 'HI-RES'; // 24bit LOSSLESS
|
||||||
|
} else if (sampleRates.has(44100) && bitDepths.has(16) && sampleRates.size === 1 && bitDepths.size === 1) {
|
||||||
|
status = 'LOSSLESS';
|
||||||
|
} else {
|
||||||
|
// Could be 48/16 or something else
|
||||||
|
status = `OTHER (${Array.from(bitDepths)[0]}bit / ${Array.from(sampleRates)[0]}Hz)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract Album Metadata (from first track)
|
||||||
|
const firstValidTrack = tracks.find((t): t is Extract<AnalysisTrack, { error?: never }> => !('error' in t));
|
||||||
|
|
||||||
|
let label = parseLabel(firstValidTrack?.common?.copyright || 'Unknown Label');
|
||||||
|
if (!label) label = 'Unknown Label'; // Fallback if parsing resulted in empty string (e.g. Records DK)
|
||||||
|
|
||||||
|
// Additional artists: filter out album artist from artists list
|
||||||
|
const albumArtist = firstValidTrack?.common?.albumartist || firstValidTrack?.common?.artist || '';
|
||||||
|
// Collect artists from ALL tracks, not just the first one
|
||||||
|
const allArtistsSet = new Set<string>();
|
||||||
|
tracks.forEach(t => {
|
||||||
|
if ('error' in t) return;
|
||||||
|
const trackArtists = t.common?.artists || [];
|
||||||
|
trackArtists.forEach(artist => {
|
||||||
|
// Split by common delimiters to handle combined strings like "A, B & C"
|
||||||
|
const parts = artist.split(/,\s+|&|\s+\/\s+/).map(a => a.trim()).filter(a => a.length > 0);
|
||||||
|
parts.forEach(p => allArtistsSet.add(p));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const allArtists = Array.from(allArtistsSet);
|
||||||
|
|
||||||
|
// Identify all main artists (Album Artist + Track Artist) individually
|
||||||
|
const mainArtistNames = new Set(
|
||||||
|
[albumArtist, firstValidTrack?.common?.artist || '']
|
||||||
|
.flatMap(name => name.split(/,\s+|&|\s+\/\s+/)) // Split by comma, &, /
|
||||||
|
.map(a => a.trim())
|
||||||
|
.filter(a => a.length > 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
const additionalArtists = allArtists.filter(a => !mainArtistNames.has(a));
|
||||||
|
|
||||||
|
const albumMetadata = {
|
||||||
|
artist: firstValidTrack?.common?.artist || 'Unknown Artist',
|
||||||
|
album: firstValidTrack?.common?.album || 'Unknown Album',
|
||||||
|
year: firstValidTrack?.common?.year || (firstValidTrack?.common?.date ? parseInt(firstValidTrack.common.date.split('-')[0]) : undefined),
|
||||||
|
upc: firstValidTrack?.common?.barcode,
|
||||||
|
label: label,
|
||||||
|
tidalUrl: firstValidTrack?.native?.tidalUrl,
|
||||||
|
additionalArtists: additionalArtists.length > 0 ? additionalArtists : undefined,
|
||||||
|
releaseType: (() => {
|
||||||
|
const name = firstValidTrack?.common?.album?.toLowerCase() || '';
|
||||||
|
if (name.includes('live')) return 11; // Live album
|
||||||
|
if (name.includes('soundtrack') || name.includes('ost')) return 3; // Soundtrack
|
||||||
|
|
||||||
|
const validTracks = tracks.filter((t) => !('error' in t)).length;
|
||||||
|
if (validTracks === 1) return 9; // Single
|
||||||
|
if (validTracks <= 5) return 5; // EP
|
||||||
|
|
||||||
|
return 1; // Album
|
||||||
|
})(),
|
||||||
|
totalSize
|
||||||
|
};
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
albumPath,
|
||||||
|
metadata: albumMetadata,
|
||||||
|
tracks,
|
||||||
|
quality: {
|
||||||
|
status,
|
||||||
|
mixed,
|
||||||
|
details: qualityDetails,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error analyzing album:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to analyze album' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import fs from 'fs-extra';
|
||||||
|
import mime from 'mime';
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const filePath = searchParams.get('path');
|
||||||
|
|
||||||
|
if (!filePath) {
|
||||||
|
return NextResponse.json({ error: 'Path is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!await fs.pathExists(filePath)) {
|
||||||
|
return NextResponse.json({ error: 'File not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const stat = await fs.stat(filePath);
|
||||||
|
if (!stat.isFile()) {
|
||||||
|
return NextResponse.json({ error: 'Not a file' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileSize = stat.size;
|
||||||
|
const range = request.headers.get('range');
|
||||||
|
const contentType = mime.getType(filePath) || 'application/octet-stream';
|
||||||
|
|
||||||
|
if (range) {
|
||||||
|
const parts = range.replace(/bytes=/, "").split("-");
|
||||||
|
const start = parseInt(parts[0], 10);
|
||||||
|
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
|
||||||
|
const chunksize = (end - start) + 1;
|
||||||
|
const file = fs.createReadStream(filePath, { start, end });
|
||||||
|
|
||||||
|
// Convert stream to ReadableStream for NextResponse
|
||||||
|
const stream = new ReadableStream({
|
||||||
|
start(controller) {
|
||||||
|
file.on('data', (chunk) => {
|
||||||
|
try {
|
||||||
|
controller.enqueue(chunk);
|
||||||
|
} catch (e) {
|
||||||
|
// Controller likely closed, stop reading
|
||||||
|
file.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
file.on('end', () => {
|
||||||
|
try {
|
||||||
|
controller.close();
|
||||||
|
} catch (e) { }
|
||||||
|
});
|
||||||
|
file.on('error', (err) => {
|
||||||
|
try {
|
||||||
|
controller.error(err);
|
||||||
|
} catch (e) { }
|
||||||
|
});
|
||||||
|
},
|
||||||
|
cancel() {
|
||||||
|
file.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return new NextResponse(stream, {
|
||||||
|
status: 206,
|
||||||
|
headers: {
|
||||||
|
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
|
||||||
|
'Accept-Ranges': 'bytes',
|
||||||
|
'Content-Length': chunksize.toString(),
|
||||||
|
'Content-Type': contentType,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const file = fs.createReadStream(filePath);
|
||||||
|
|
||||||
|
// Convert stream to ReadableStream for NextResponse
|
||||||
|
const stream = new ReadableStream({
|
||||||
|
start(controller) {
|
||||||
|
file.on('data', (chunk) => {
|
||||||
|
try {
|
||||||
|
controller.enqueue(chunk);
|
||||||
|
} catch (e) {
|
||||||
|
file.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
file.on('end', () => {
|
||||||
|
try {
|
||||||
|
controller.close();
|
||||||
|
} catch (e) { }
|
||||||
|
});
|
||||||
|
file.on('error', (err) => {
|
||||||
|
try {
|
||||||
|
controller.error(err);
|
||||||
|
} catch (e) { }
|
||||||
|
});
|
||||||
|
},
|
||||||
|
cancel() {
|
||||||
|
file.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return new NextResponse(stream, {
|
||||||
|
headers: {
|
||||||
|
'Content-Length': fileSize.toString(),
|
||||||
|
'Content-Type': contentType,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error serving audio file:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to serve file' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import fs from 'fs-extra';
|
||||||
|
import path from 'path';
|
||||||
|
import { BatchRenameResponse, RenameOperation, RenameResult } from '@/types';
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const { albumPath, renames } = await request.json();
|
||||||
|
|
||||||
|
if (!albumPath || !(await fs.pathExists(albumPath))) {
|
||||||
|
return NextResponse.json({ error: 'Invalid album path' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!renames || !Array.isArray(renames)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid renames list' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const results: RenameResult[] = [];
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
for (const { original, newName } of renames as RenameOperation[]) {
|
||||||
|
try {
|
||||||
|
const oldPath = path.join(albumPath, original);
|
||||||
|
const newPath = path.join(albumPath, newName);
|
||||||
|
|
||||||
|
if (await fs.pathExists(oldPath)) {
|
||||||
|
await fs.rename(oldPath, newPath);
|
||||||
|
results.push({ original, newName, success: true });
|
||||||
|
} else {
|
||||||
|
errors.push(`File not found: ${original}`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
const errorMessage = e instanceof Error ? e.message : String(e);
|
||||||
|
errors.push(`Failed to rename ${original}: ${errorMessage}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response: BatchRenameResponse = {
|
||||||
|
success: errors.length === 0,
|
||||||
|
results,
|
||||||
|
errors: errors.length > 0 ? errors : undefined
|
||||||
|
};
|
||||||
|
|
||||||
|
return NextResponse.json(response);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error executing renames:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to execute renames' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { exec } from 'child_process';
|
||||||
|
import util from 'util';
|
||||||
|
import fs from 'fs-extra';
|
||||||
|
import path from 'path';
|
||||||
|
import { CheckMQAResponse } from '@/types';
|
||||||
|
|
||||||
|
const execPromise = util.promisify(exec);
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const { albumPath } = await request.json();
|
||||||
|
|
||||||
|
if (!albumPath || !(await fs.pathExists(albumPath))) {
|
||||||
|
return NextResponse.json({ error: 'Invalid album path' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find all FLAC files
|
||||||
|
const files = await fs.readdir(albumPath);
|
||||||
|
const flacFiles = files.filter(f => f.toLowerCase().endsWith('.flac'));
|
||||||
|
|
||||||
|
if (flacFiles.length === 0) {
|
||||||
|
return NextResponse.json({ error: 'No FLAC files found' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let mqaDetected = false;
|
||||||
|
let outputLog = "";
|
||||||
|
|
||||||
|
// Check all files
|
||||||
|
for (const file of flacFiles) {
|
||||||
|
const filePath = path.join(albumPath, file);
|
||||||
|
const command = `salmon check mqa "${filePath}"`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { stdout } = await execPromise(command);
|
||||||
|
const isMQA = !stdout.includes('Did not find MQA syncword');
|
||||||
|
|
||||||
|
outputLog += `[${file}]: ${isMQA ? "MQA DETECTED" : "Clean"}\n`;
|
||||||
|
|
||||||
|
if (isMQA) {
|
||||||
|
mqaDetected = true;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
const errorMessage = e instanceof Error ? e.message : String(e);
|
||||||
|
outputLog += `[${file}]: Error - ${errorMessage}\n`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response: CheckMQAResponse = {
|
||||||
|
success: true,
|
||||||
|
isMQA: mqaDetected,
|
||||||
|
output: outputLog
|
||||||
|
};
|
||||||
|
|
||||||
|
return NextResponse.json(response);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking MQA:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { exec } from 'child_process';
|
||||||
|
import util from 'util';
|
||||||
|
import fs from 'fs-extra';
|
||||||
|
import path from 'path';
|
||||||
|
import * as mm from 'music-metadata';
|
||||||
|
import { CheckUpconvResponse } from '@/types';
|
||||||
|
|
||||||
|
const execPromise = util.promisify(exec);
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const { albumPath } = await request.json();
|
||||||
|
|
||||||
|
if (!albumPath || !(await fs.pathExists(albumPath))) {
|
||||||
|
return NextResponse.json({ error: 'Invalid album path' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find all FLAC files
|
||||||
|
const files = await fs.readdir(albumPath);
|
||||||
|
const flacFiles = files.filter(f => f.toLowerCase().endsWith('.flac'));
|
||||||
|
|
||||||
|
if (flacFiles.length === 0) {
|
||||||
|
return NextResponse.json({ error: 'No FLAC files found' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let outputLog = "";
|
||||||
|
let failureCount = 0;
|
||||||
|
let checkedCount = 0;
|
||||||
|
|
||||||
|
// Check all 24-bit files
|
||||||
|
for (const file of flacFiles) {
|
||||||
|
const filePath = path.join(albumPath, file);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check bit depth first
|
||||||
|
const metadata = await mm.parseFile(filePath);
|
||||||
|
const bitsPerSample = metadata.format.bitsPerSample || 16;
|
||||||
|
|
||||||
|
if (bitsPerSample < 24) {
|
||||||
|
continue; // Skip non-24-bit files
|
||||||
|
}
|
||||||
|
|
||||||
|
checkedCount++;
|
||||||
|
const command = `salmon check upconv "${filePath}"`;
|
||||||
|
const { stdout } = await execPromise(command);
|
||||||
|
|
||||||
|
// Output example: "01...flac: does not have a high number of wasted bits (Wasted bits: 0/24)"
|
||||||
|
const match = stdout.match(/Wasted bits: (\d+\/\d+)/);
|
||||||
|
const wastedBits = match ? match[1] : 'Unknown';
|
||||||
|
|
||||||
|
// Determine pass/fail based on wasted bits (simplified logic: if wasted bits > 0, it might be upconv, but usually salmon says "has a high number..." if it fails)
|
||||||
|
// Actually salmon output usually says "does not have a high number..." for pass.
|
||||||
|
// Let's rely on the text "does not have a high number".
|
||||||
|
const passed = stdout.includes('does not have a high number');
|
||||||
|
|
||||||
|
outputLog += `[${file}]: ${passed ? "OK" : "POSSIBLE UPCONV"} (${wastedBits})\n`;
|
||||||
|
|
||||||
|
if (!passed) {
|
||||||
|
failureCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
const errorMessage = e instanceof Error ? e.message : String(e);
|
||||||
|
outputLog += `[${file}]: Error - ${errorMessage}\n`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (checkedCount === 0) {
|
||||||
|
const response: CheckUpconvResponse = {
|
||||||
|
success: true,
|
||||||
|
output: "No 24-bit files found to check.",
|
||||||
|
wastedBits: "N/A"
|
||||||
|
};
|
||||||
|
return NextResponse.json(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response: CheckUpconvResponse = {
|
||||||
|
success: true,
|
||||||
|
output: outputLog,
|
||||||
|
wastedBits: failureCount > 0 ? "Issues Found" : "Clean"
|
||||||
|
};
|
||||||
|
return NextResponse.json(response);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking upconv:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import fs from 'fs-extra';
|
||||||
|
import path from 'path';
|
||||||
|
import { getOutputDir } from '@/lib/utils/paths';
|
||||||
|
import { copyDirRecursive } from '@/lib/utils/file-ops';
|
||||||
|
import { CopyResponse } from '@/types';
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const { albumPath } = await request.json();
|
||||||
|
|
||||||
|
if (!albumPath || !(await fs.pathExists(albumPath))) {
|
||||||
|
return NextResponse.json({ error: 'Invalid album path' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const outputDir = getOutputDir();
|
||||||
|
if (!outputDir) {
|
||||||
|
return NextResponse.json({ error: 'OUTPUT_DIR not configured in .env.local' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure output directory exists
|
||||||
|
await fs.ensureDir(outputDir);
|
||||||
|
|
||||||
|
const albumName = path.basename(albumPath);
|
||||||
|
const destPath = path.join(outputDir, albumName);
|
||||||
|
|
||||||
|
// Check if destination already exists
|
||||||
|
if (await fs.pathExists(destPath)) {
|
||||||
|
return NextResponse.json({ error: 'Album already exists in output directory' }, { status: 409 });
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.copy(albumPath, destPath);
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as NodeJS.ErrnoException;
|
||||||
|
if (err.code === 'EINVAL') {
|
||||||
|
console.warn('fs-extra copy failed with EINVAL, attempting fallback to stream-based copy', error);
|
||||||
|
// Fallback to custom stream-based copy to avoid copyfile syscall
|
||||||
|
await copyDirRecursive(albumPath, destPath);
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response: CopyResponse = { success: true, destPath };
|
||||||
|
return NextResponse.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error copying album:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to copy album' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import fs from 'fs-extra';
|
||||||
|
import path from 'path';
|
||||||
|
import mime from 'mime';
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const filePath = searchParams.get('path');
|
||||||
|
|
||||||
|
if (!filePath) {
|
||||||
|
return NextResponse.json({ error: 'Path is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Security check: Ensure we are only serving files from allowed locations?
|
||||||
|
// For this local tool, we might be lenient, but ideally we should check if it's in the watch folder.
|
||||||
|
// However, the watch folder location is dynamic.
|
||||||
|
// Let's at least check if it exists.
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!await fs.pathExists(filePath)) {
|
||||||
|
return NextResponse.json({ error: 'File not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const stat = await fs.stat(filePath);
|
||||||
|
if (!stat.isFile()) {
|
||||||
|
return NextResponse.json({ error: 'Not a file' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileBuffer = await fs.readFile(filePath);
|
||||||
|
const contentType = mime.getType(filePath) || 'application/octet-stream';
|
||||||
|
|
||||||
|
return new NextResponse(fileBuffer, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': contentType,
|
||||||
|
'Content-Length': stat.size.toString(),
|
||||||
|
'Cache-Control': 'public, max-age=3600'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error serving file:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to serve file' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import fs from 'fs-extra';
|
||||||
|
import path from 'path';
|
||||||
|
import { getSpectrogramsPath } from '@/lib/utils/paths';
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const { albumPath, upc } = await request.json();
|
||||||
|
|
||||||
|
if (!albumPath || !(await fs.pathExists(albumPath))) {
|
||||||
|
return NextResponse.json({ error: 'Invalid album path' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Delete the album directory
|
||||||
|
await fs.remove(albumPath);
|
||||||
|
|
||||||
|
// 2. Delete the spectrograms directory for this album
|
||||||
|
const specRoot = getSpectrogramsPath();
|
||||||
|
const albumName = path.basename(albumPath);
|
||||||
|
|
||||||
|
// Try UPC first, then album name
|
||||||
|
let specDir = path.join(specRoot, upc || albumName);
|
||||||
|
if (await fs.pathExists(specDir)) {
|
||||||
|
await fs.remove(specDir);
|
||||||
|
} else if (upc && await fs.pathExists(path.join(specRoot, albumName))) {
|
||||||
|
// Fallback: check album name if UPC dir didn't exist but UPC was provided
|
||||||
|
await fs.remove(path.join(specRoot, albumName));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Conditionally delete the generated torrent file
|
||||||
|
if (process.env.DELETE_GENERATED_TORRENTS === 'true') {
|
||||||
|
const parentDir = path.dirname(albumPath);
|
||||||
|
const torrentPath = path.join(parentDir, `${albumName}.torrent`);
|
||||||
|
if (await fs.pathExists(torrentPath)) {
|
||||||
|
await fs.remove(torrentPath);
|
||||||
|
console.log(`Deleted generated torrent: ${torrentPath}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting album:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to delete album' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import fs from 'fs-extra';
|
||||||
|
import path from 'path';
|
||||||
|
import { ProposeRenameResponse, RenameProposal } from '@/types';
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const { albumPath } = await request.json();
|
||||||
|
|
||||||
|
if (!albumPath || !(await fs.pathExists(albumPath))) {
|
||||||
|
return NextResponse.json({ error: 'Invalid album path' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = await fs.readdir(albumPath);
|
||||||
|
const folderName = path.basename(albumPath);
|
||||||
|
// Max length for file + folder + separator is 179 (to match frontend check > 179)
|
||||||
|
// So max file length = 179 - folderName.length - 1 (separator)
|
||||||
|
const maxFileLength = 179 - folderName.length - 1;
|
||||||
|
|
||||||
|
const problematicFiles: RenameProposal[] = [];
|
||||||
|
const allFiles: RenameProposal[] = [];
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const filePath = path.join(albumPath, file);
|
||||||
|
const stat = await fs.stat(filePath);
|
||||||
|
|
||||||
|
if (stat.isFile()) {
|
||||||
|
const ext = path.extname(file);
|
||||||
|
const name = path.basename(file, ext);
|
||||||
|
|
||||||
|
// Strategy 1: Remove Brackets []
|
||||||
|
const nameNoBrackets = name.replace(/\[.*?\]/g, '').replace(/\s+/g, ' ').trim();
|
||||||
|
const fileNoBrackets = nameNoBrackets + ext;
|
||||||
|
|
||||||
|
// Strategy 2: Remove Parentheses ()
|
||||||
|
const nameNoParens = name.replace(/\(.*?\)/g, '').replace(/\s+/g, ' ').trim();
|
||||||
|
const fileNoParens = nameNoParens + ext;
|
||||||
|
|
||||||
|
// Strategy 3: Remove Both
|
||||||
|
const nameClean = name.replace(/\[.*?\]/g, '').replace(/\(.*?\)/g, '').replace(/\s+/g, ' ').trim();
|
||||||
|
const fileClean = nameClean + ext;
|
||||||
|
|
||||||
|
// Strategy 4: Truncate (fallback if others fail or just as an option)
|
||||||
|
// We need to truncate 'name' so that name + ext <= maxFileLength
|
||||||
|
const maxNameLength = maxFileLength - ext.length;
|
||||||
|
let nameTruncated = name;
|
||||||
|
if (name.length > maxNameLength) {
|
||||||
|
nameTruncated = name.substring(0, maxNameLength).trim();
|
||||||
|
}
|
||||||
|
const fileTruncated = nameTruncated + ext;
|
||||||
|
|
||||||
|
const proposal: RenameProposal = {
|
||||||
|
original: file,
|
||||||
|
strategies: {
|
||||||
|
remove_brackets: fileNoBrackets.length <= maxFileLength ? fileNoBrackets : null,
|
||||||
|
remove_parens: fileNoParens.length <= maxFileLength ? fileNoParens : null,
|
||||||
|
remove_both: fileClean.length <= maxFileLength ? fileClean : null,
|
||||||
|
truncate: fileTruncated
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
allFiles.push(proposal);
|
||||||
|
|
||||||
|
// Check if file exceeds limit
|
||||||
|
if (file.length > maxFileLength) {
|
||||||
|
problematicFiles.push(proposal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response: ProposeRenameResponse = {
|
||||||
|
success: true,
|
||||||
|
problematicFiles,
|
||||||
|
allFiles,
|
||||||
|
maxFileLength
|
||||||
|
};
|
||||||
|
|
||||||
|
return NextResponse.json(response);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error proposing renames:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to propose renames' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import fs from 'fs-extra';
|
||||||
|
import path from 'path';
|
||||||
|
import { addTorrent, recheckTorrent } from '@/lib/qbittorrent';
|
||||||
|
import { QbittorrentInjectResponse } from '@/types';
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const { albumPath } = await request.json();
|
||||||
|
|
||||||
|
if (!albumPath || !(await fs.pathExists(albumPath))) {
|
||||||
|
return NextResponse.json({ error: 'Invalid album path' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentDir = path.dirname(albumPath);
|
||||||
|
const albumDirName = path.basename(albumPath);
|
||||||
|
|
||||||
|
// Logic to find the torrent file (similar to torrent creation route)
|
||||||
|
let torrentPath = path.join(parentDir, `${albumDirName}.torrent`);
|
||||||
|
|
||||||
|
if (!await fs.pathExists(torrentPath)) {
|
||||||
|
// Try to find a recent torrent file if exact match fails
|
||||||
|
const files = await fs.readdir(parentDir);
|
||||||
|
const recentTorrent = (await Promise.all(files
|
||||||
|
.filter(f => f.endsWith('.torrent'))
|
||||||
|
.map(async f => {
|
||||||
|
const stat = await fs.stat(path.join(parentDir, f));
|
||||||
|
return { name: f, mtime: stat.mtimeMs };
|
||||||
|
})
|
||||||
|
))
|
||||||
|
.sort((a, b) => b.mtime - a.mtime)[0];
|
||||||
|
|
||||||
|
if (recentTorrent) {
|
||||||
|
torrentPath = path.join(parentDir, recentTorrent.name);
|
||||||
|
} else {
|
||||||
|
return NextResponse.json({ error: 'Torrent file not found. Please create it first.' }, { status: 404 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const torrentData = await fs.readFile(torrentPath);
|
||||||
|
const filename = path.basename(torrentPath);
|
||||||
|
|
||||||
|
// 1. Add torrent (paused)
|
||||||
|
const hash = await addTorrent(torrentData, filename, 'RED');
|
||||||
|
|
||||||
|
// 2. Trigger recheck
|
||||||
|
await recheckTorrent(hash);
|
||||||
|
|
||||||
|
const response: QbittorrentInjectResponse = { success: true, hash };
|
||||||
|
return NextResponse.json(response);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error injecting torrent:', error);
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
return NextResponse.json({
|
||||||
|
error: 'Failed to inject torrent',
|
||||||
|
details: errorMessage
|
||||||
|
}, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { resumeTorrent } from '@/lib/qbittorrent';
|
||||||
|
import { QbittorrentResumeResponse } from '@/types';
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const { hash } = await request.json();
|
||||||
|
|
||||||
|
if (!hash) {
|
||||||
|
return NextResponse.json({ error: 'Hash is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await resumeTorrent(hash);
|
||||||
|
|
||||||
|
const response: QbittorrentResumeResponse = { success: true };
|
||||||
|
return NextResponse.json(response);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error resuming torrent:', error);
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
return NextResponse.json({
|
||||||
|
error: 'Failed to resume torrent',
|
||||||
|
details: errorMessage
|
||||||
|
}, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getTorrentInfo } from '@/lib/qbittorrent';
|
||||||
|
import { QbittorrentStatusResponse } from '@/types';
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const hash = searchParams.get('hash');
|
||||||
|
|
||||||
|
if (!hash) {
|
||||||
|
return NextResponse.json({ error: 'Hash is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const info = await getTorrentInfo(hash);
|
||||||
|
if (!info) {
|
||||||
|
return NextResponse.json({ error: 'Torrent not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
const response: QbittorrentStatusResponse = { success: true, info };
|
||||||
|
return NextResponse.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting torrent status:', error);
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
return NextResponse.json({
|
||||||
|
error: 'Failed to get status',
|
||||||
|
details: errorMessage
|
||||||
|
}, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import fs from 'fs-extra';
|
||||||
|
import path from 'path';
|
||||||
|
import { getFlacMetadata, parseLabel } from '@/lib/utils/audio';
|
||||||
|
import { AnalysisTrack, ApiResponse, AlbumResponse } from '@/types';
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const { albumPath, releaseType } = await request.json();
|
||||||
|
|
||||||
|
if (!albumPath || !(await fs.pathExists(albumPath))) {
|
||||||
|
return NextResponse.json({ error: 'Invalid album path' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = await fs.readdir(albumPath);
|
||||||
|
const flacFiles = files.filter((file) => file.toLowerCase().endsWith('.flac'));
|
||||||
|
|
||||||
|
if (flacFiles.length === 0) {
|
||||||
|
return NextResponse.json({ error: 'No FLAC files found' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const tracks: AnalysisTrack[] = await Promise.all(
|
||||||
|
flacFiles.map(async (file) => {
|
||||||
|
const filePath = path.join(albumPath, file);
|
||||||
|
const metadata = await getFlacMetadata(filePath);
|
||||||
|
return {
|
||||||
|
filename: file,
|
||||||
|
path: filePath,
|
||||||
|
...metadata,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sort tracks
|
||||||
|
tracks.sort((a, b) => {
|
||||||
|
if ('error' in a || 'error' in b) return 0;
|
||||||
|
const trackA = a.common?.track?.no ?? 0;
|
||||||
|
const trackB = b.common?.track?.no ?? 0;
|
||||||
|
if (trackA && trackB) return trackA - trackB;
|
||||||
|
return a.filename.localeCompare(b.filename);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Analyze quality for description
|
||||||
|
let isLossless = true;
|
||||||
|
let isHiRes = true;
|
||||||
|
let mixed = false;
|
||||||
|
let qualityDetails = '';
|
||||||
|
const sampleRates = new Set();
|
||||||
|
const bitDepths = new Set();
|
||||||
|
|
||||||
|
tracks.forEach((track) => {
|
||||||
|
if ('error' in track) return;
|
||||||
|
const sr = track.format?.sampleRate;
|
||||||
|
const bd = track.format?.bitsPerSample;
|
||||||
|
if (sr) sampleRates.add(sr);
|
||||||
|
if (bd) bitDepths.add(bd);
|
||||||
|
if (sr !== 44100 || bd !== 16) isLossless = false;
|
||||||
|
if (!sr || sr < 48000 || !bd || bd < 24) isHiRes = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (sampleRates.size > 1 || bitDepths.size > 1) {
|
||||||
|
mixed = true;
|
||||||
|
qualityDetails = `\n\nNOTE: Mixed formats. Sample Rates: ${Array.from(sampleRates).join(', ')} Hz. Bit Depths: ${Array.from(bitDepths).join(', ')} bit.`;
|
||||||
|
|
||||||
|
const mixedDetails = tracks.map(t => {
|
||||||
|
if ('error' in t) return `${t.filename}: Error`;
|
||||||
|
return `Track ${t.common?.track?.no || '?'}: ${t.format?.bitsPerSample}bit / ${t.format?.sampleRate}Hz`;
|
||||||
|
}).join('\n');
|
||||||
|
|
||||||
|
qualityDetails += '\n' + mixedDetails;
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstTrack = tracks.find((t): t is Extract<AnalysisTrack, { error?: never }> => !('error' in t));
|
||||||
|
if (!firstTrack) {
|
||||||
|
return NextResponse.json({ error: 'No valid tracks found' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const albumName = firstTrack.common?.album || 'Unknown Album';
|
||||||
|
const year = firstTrack.common?.year || (firstTrack.common?.date ? parseInt(firstTrack.common.date.split('-')[0]) : 2025);
|
||||||
|
|
||||||
|
let label = parseLabel(firstTrack.common?.copyright || '');
|
||||||
|
|
||||||
|
const upc = firstTrack.native?.vorbis?.find(tag => tag.id === 'UPC')?.value || firstTrack.common?.barcode || '';
|
||||||
|
|
||||||
|
// Artists
|
||||||
|
// Artists Logic
|
||||||
|
|
||||||
|
// Helper to split artists
|
||||||
|
const splitArtists = (artistString: string) => {
|
||||||
|
return artistString.split(/,\s+|\s+\/\s+|;\s+/).map((a: string) => a.trim()).filter((a: string) => a.length > 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Collect all artists and their frequencies
|
||||||
|
const artistCounts = new Map<string, number>();
|
||||||
|
const allUniqueArtists = new Set<string>();
|
||||||
|
|
||||||
|
tracks.forEach(t => {
|
||||||
|
if ('error' in t) return;
|
||||||
|
let trackArtists: string[] = [];
|
||||||
|
// music-metadata might return artists as an array, but sometimes elements are comma-separated strings
|
||||||
|
if (t.common?.artists) {
|
||||||
|
t.common.artists.forEach((a: string) => trackArtists.push(...splitArtists(a)));
|
||||||
|
} else if (t.common?.artist) {
|
||||||
|
trackArtists = splitArtists(t.common.artist);
|
||||||
|
}
|
||||||
|
|
||||||
|
trackArtists.forEach(a => {
|
||||||
|
allUniqueArtists.add(a);
|
||||||
|
artistCounts.set(a, (artistCounts.get(a) || 0) + 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
let mainArtists: { name: string }[] = [];
|
||||||
|
const withArtists: { name: string }[] = [];
|
||||||
|
|
||||||
|
if (releaseType === 7) {
|
||||||
|
// Compilation: All artists in mainArtists, no withArtists
|
||||||
|
allUniqueArtists.forEach(a => {
|
||||||
|
if (a !== 'Various Artists') {
|
||||||
|
mainArtists.push({ name: a });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Standard Logic
|
||||||
|
// Determine Main Artist String
|
||||||
|
let mainArtistName = firstTrack.common?.albumartist || 'Various Artists';
|
||||||
|
|
||||||
|
// If album artist is Various Artists (or missing), find the most common artist
|
||||||
|
if (mainArtistName === 'Various Artists' || !mainArtistName) {
|
||||||
|
let maxCount = 0;
|
||||||
|
let mostFrequent = '';
|
||||||
|
artistCounts.forEach((count, artist) => {
|
||||||
|
if (count > maxCount) {
|
||||||
|
maxCount = count;
|
||||||
|
mostFrequent = artist;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (mostFrequent) {
|
||||||
|
mainArtistName = mostFrequent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split main artist into components for the array
|
||||||
|
// Use the same split logic as above
|
||||||
|
const mainArtistComponents = splitArtists(mainArtistName);
|
||||||
|
mainArtists = mainArtistComponents.map(name => ({ name }));
|
||||||
|
|
||||||
|
// Determine With Artists
|
||||||
|
allUniqueArtists.forEach(a => {
|
||||||
|
// Exclude all main artist components and "Various Artists" from "with" list
|
||||||
|
if (!mainArtistComponents.includes(a) && a !== 'Various Artists') {
|
||||||
|
withArtists.push({ name: a });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tracklist
|
||||||
|
const tracklist = tracks.map(t => {
|
||||||
|
if ('error' in t) return `${t.filename} (Error)`;
|
||||||
|
return `${t.common?.track?.no || '00'}. ${t.common?.title || t.filename}`;
|
||||||
|
}).join('\n');
|
||||||
|
|
||||||
|
// Description
|
||||||
|
let description = "Sourced from TIDAL";
|
||||||
|
if (mixed) {
|
||||||
|
description += qualityDetails;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encoding
|
||||||
|
let encoding = "Lossless";
|
||||||
|
const maxBitDepth = Math.max(...Array.from(bitDepths).map(Number));
|
||||||
|
if (maxBitDepth >= 24) {
|
||||||
|
encoding = "24bit Lossless";
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = {
|
||||||
|
status: "success",
|
||||||
|
response: {
|
||||||
|
source: "Crossfade",
|
||||||
|
group: {
|
||||||
|
wikiBody: tracklist,
|
||||||
|
bbBody: tracklist,
|
||||||
|
name: albumName,
|
||||||
|
year: year,
|
||||||
|
categoryId: 1,
|
||||||
|
releaseType: releaseType || 1, // Default to Album (1)
|
||||||
|
categoryName: "Music",
|
||||||
|
musicInfo: {
|
||||||
|
composers: [],
|
||||||
|
dj: [],
|
||||||
|
artists: mainArtists,
|
||||||
|
with: withArtists,
|
||||||
|
conductor: [],
|
||||||
|
remixedBy: [],
|
||||||
|
producer: []
|
||||||
|
},
|
||||||
|
recordLabel: label,
|
||||||
|
catalogueNumber: upc,
|
||||||
|
tags: [] // Empty for now, hard to guess
|
||||||
|
},
|
||||||
|
torrent: {
|
||||||
|
media: "WEB",
|
||||||
|
format: "FLAC",
|
||||||
|
encoding: encoding,
|
||||||
|
//remastered: true,
|
||||||
|
remasterYear: year,
|
||||||
|
remasterRecordLabel: label,
|
||||||
|
remasterCatalogueNumber: upc,
|
||||||
|
description: description,
|
||||||
|
//fileCount: tracks.length,
|
||||||
|
//filePath: `${mainArtistName} - ${albumName} (${year}) [FLAC]`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return NextResponse.json(response);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error generating RED template:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to generate template' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import fs from 'fs-extra';
|
||||||
|
import path from 'path';
|
||||||
|
import { sanitizeName, sanitizeAlbumName } from '@/lib/utils/sanitize';
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const { albumPath } = await request.json();
|
||||||
|
|
||||||
|
if (!albumPath || !(await fs.pathExists(albumPath))) {
|
||||||
|
return NextResponse.json({ error: 'Invalid album path' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = await fs.readdir(albumPath);
|
||||||
|
|
||||||
|
// 1. Rename files
|
||||||
|
for (const file of files) {
|
||||||
|
const oldPath = path.join(albumPath, file);
|
||||||
|
const stat = await fs.stat(oldPath);
|
||||||
|
|
||||||
|
if (stat.isFile()) {
|
||||||
|
const ext = path.extname(file);
|
||||||
|
const name = path.basename(file, ext);
|
||||||
|
const cleanName = sanitizeName(name) + ext;
|
||||||
|
|
||||||
|
if (cleanName !== file) {
|
||||||
|
const newPath = path.join(albumPath, cleanName);
|
||||||
|
await fs.rename(oldPath, newPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Rename album folder
|
||||||
|
const parentDir = path.dirname(albumPath);
|
||||||
|
const oldDirName = path.basename(albumPath);
|
||||||
|
const cleanDirName = sanitizeAlbumName(oldDirName);
|
||||||
|
|
||||||
|
let newAlbumPath = albumPath;
|
||||||
|
|
||||||
|
if (cleanDirName !== oldDirName) {
|
||||||
|
newAlbumPath = path.join(parentDir, cleanDirName);
|
||||||
|
await fs.rename(albumPath, newAlbumPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
newAlbumPath,
|
||||||
|
message: 'Sanitization complete'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error sanitizing album:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to sanitize album' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import fs from 'fs-extra';
|
||||||
|
import path from 'path';
|
||||||
|
import { getWatchPath, ensureWatchDir } from '@/lib/utils/paths';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const watchPath = await ensureWatchDir();
|
||||||
|
const entries = await fs.readdir(watchPath, { withFileTypes: true });
|
||||||
|
|
||||||
|
const albums = await Promise.all(
|
||||||
|
entries
|
||||||
|
.filter((entry) => entry.isDirectory())
|
||||||
|
.map(async (entry) => {
|
||||||
|
const fullPath = path.join(watchPath, entry.name);
|
||||||
|
|
||||||
|
let coverPath: string | null = null;
|
||||||
|
try {
|
||||||
|
const files = await fs.readdir(fullPath);
|
||||||
|
const imageFiles = files.filter(f => /\.(jpg|jpeg|png)$/i.test(f));
|
||||||
|
|
||||||
|
// Priority 1: cover.jpg/jpeg/png
|
||||||
|
const explicitCover = imageFiles.find(f => /^cover\.(jpg|jpeg|png)$/i.test(f));
|
||||||
|
|
||||||
|
if (explicitCover) {
|
||||||
|
coverPath = path.join(fullPath, explicitCover);
|
||||||
|
} else if (imageFiles.length > 0) {
|
||||||
|
// Priority 2: Random image
|
||||||
|
coverPath = path.join(fullPath, imageFiles[0]);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore errors reading directory
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: entry.name,
|
||||||
|
path: fullPath,
|
||||||
|
hasCover: !!coverPath,
|
||||||
|
coverPath
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json({ albums });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error scanning watch folder:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to scan watch folder' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import fs from 'fs-extra';
|
||||||
|
import path from 'path';
|
||||||
|
import { execFile } from 'child_process';
|
||||||
|
import util from 'util';
|
||||||
|
import { getSpectrogramsPath, ensureSpectrogramsDir } from '@/lib/utils/paths';
|
||||||
|
import { AnalysisTrack, SpectrogramResponse } from '@/types';
|
||||||
|
|
||||||
|
const execFileAsync = util.promisify(execFile);
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const { albumPath, tracks, upc } = await request.json();
|
||||||
|
|
||||||
|
if (!albumPath || !tracks || !Array.isArray(tracks)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid request' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const specRoot = await ensureSpectrogramsDir();
|
||||||
|
// Use UPC if available, otherwise fallback to album directory name
|
||||||
|
const albumId = upc || path.basename(albumPath);
|
||||||
|
const albumSpecDir = path.join(specRoot, albumId);
|
||||||
|
|
||||||
|
await fs.ensureDir(albumSpecDir);
|
||||||
|
|
||||||
|
const results: Record<string, { exists: boolean, url?: string }> = {};
|
||||||
|
|
||||||
|
// Process tracks
|
||||||
|
// We use Promise.all to process in parallel.
|
||||||
|
// If this causes performance issues with sox, we might need to limit concurrency.
|
||||||
|
await Promise.all(tracks.map(async (track: AnalysisTrack) => {
|
||||||
|
if ('error' in track) return;
|
||||||
|
|
||||||
|
const trackNo = track.common?.track?.no;
|
||||||
|
if (trackNo === undefined || trackNo === null) return;
|
||||||
|
|
||||||
|
const outputFilename = `${trackNo}.png`;
|
||||||
|
const outputPath = path.join(albumSpecDir, outputFilename);
|
||||||
|
const relativePath = path.join(albumId, outputFilename);
|
||||||
|
const publicUrl = `/api/spectrogram?path=${encodeURIComponent(relativePath)}`;
|
||||||
|
|
||||||
|
const exists = await fs.pathExists(outputPath);
|
||||||
|
|
||||||
|
if (!exists) {
|
||||||
|
// Generate
|
||||||
|
const title = `${trackNo} - ${track.common?.title || track.filename}`;
|
||||||
|
|
||||||
|
const args = [
|
||||||
|
track.path,
|
||||||
|
'-n',
|
||||||
|
];
|
||||||
|
|
||||||
|
if (process.env.GENERATE_MONO_SPECTROGRAMS) {
|
||||||
|
args.push('remix', '1');
|
||||||
|
}
|
||||||
|
|
||||||
|
args.push(
|
||||||
|
'spectrogram',
|
||||||
|
'-t', title,
|
||||||
|
'-o', outputPath
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await execFileAsync('sox', args);
|
||||||
|
results[track.filename] = { exists: true, url: publicUrl };
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Failed to generate spectrogram for ${track.filename}:`, e);
|
||||||
|
results[track.filename] = { exists: false };
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
results[track.filename] = { exists: true, url: publicUrl };
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
return NextResponse.json({ results });
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error generating spectrograms:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to generate spectrograms' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE() {
|
||||||
|
try {
|
||||||
|
const specPath = getSpectrogramsPath();
|
||||||
|
await fs.remove(specPath);
|
||||||
|
await fs.ensureDir(specPath);
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error purging spectrograms:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to purge spectrograms' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const relativePath = searchParams.get('path');
|
||||||
|
|
||||||
|
if (!relativePath) {
|
||||||
|
return NextResponse.json({ error: 'Missing path' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const specRoot = getSpectrogramsPath();
|
||||||
|
const fullPath = path.join(specRoot, relativePath);
|
||||||
|
|
||||||
|
// Security check: ensure fullPath is within specRoot (prevent directory traversal)
|
||||||
|
const resolvedPath = path.resolve(fullPath);
|
||||||
|
const resolvedRoot = path.resolve(specRoot);
|
||||||
|
|
||||||
|
if (!resolvedPath.startsWith(resolvedRoot)) {
|
||||||
|
return NextResponse.json({ error: 'Access denied' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!await fs.pathExists(resolvedPath)) {
|
||||||
|
return NextResponse.json({ error: 'File not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileBuffer = await fs.readFile(resolvedPath);
|
||||||
|
|
||||||
|
return new NextResponse(fileBuffer, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'image/png',
|
||||||
|
'Cache-Control': 'public, max-age=31536000, immutable',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { checkTools } from '@/lib/tool-check';
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const result = await checkTools();
|
||||||
|
return NextResponse.json(result);
|
||||||
|
}
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
|
// Global cache for the access token to avoid refetching on every request.
|
||||||
|
// This persists in memory as long as the server (or lambda container) is alive.
|
||||||
|
let cachedToken: string | null = null;
|
||||||
|
let tokenExpiry: number = 0;
|
||||||
|
|
||||||
|
async function getAccessToken(clientId: string, clientSecret: string): Promise<string | null> {
|
||||||
|
// Check if we have a valid cached token (with 5 minute buffer)
|
||||||
|
if (cachedToken && Date.now() < tokenExpiry) {
|
||||||
|
return cachedToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Refreshing TIDAL access token...");
|
||||||
|
try {
|
||||||
|
const authString = Buffer.from(`${clientId}:${clientSecret}`).toString("base64");
|
||||||
|
const res = await fetch("https://auth.tidal.com/v1/oauth2/token", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Authorization": `Basic ${authString}`,
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
},
|
||||||
|
body: "grant_type=client_credentials",
|
||||||
|
cache: 'no-store' // Ensure we don't get a stale cached response from Next.js
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
console.error("TIDAL Auth Failed:", await res.text());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
cachedToken = data.access_token;
|
||||||
|
// Set expiry buffer (e.g., expires in 1 hour, we refresh after 55 mins)
|
||||||
|
// expires_in is in seconds
|
||||||
|
const expiresIn = data.expires_in || 3600;
|
||||||
|
tokenExpiry = Date.now() + (expiresIn * 1000) - 300000;
|
||||||
|
|
||||||
|
return cachedToken;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("TIDAL Auth Exception:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrapper for TIDAL API requests that handles auth and 401 retries
|
||||||
|
async function fetchTidal(url: string, clientId: string, clientSecret: string) {
|
||||||
|
let token = await getAccessToken(clientId, clientSecret);
|
||||||
|
if (!token) throw new Error("Could not obtain access token");
|
||||||
|
|
||||||
|
let res = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
"Authorization": `Bearer ${token}`,
|
||||||
|
"Content-Type": "application/vnd.api+json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// If unauthorized, invalid token, or expired: clear cache and retry once
|
||||||
|
if (res.status === 401) {
|
||||||
|
console.warn("Got 401 from TIDAL, retrying with new token...");
|
||||||
|
cachedToken = null;
|
||||||
|
tokenExpiry = 0;
|
||||||
|
|
||||||
|
token = await getAccessToken(clientId, clientSecret);
|
||||||
|
if (!token) throw new Error("Could not refresh access token");
|
||||||
|
|
||||||
|
res = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
"Authorization": `Bearer ${token}`,
|
||||||
|
"Content-Type": "application/vnd.api+json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const searchParams = request.nextUrl.searchParams;
|
||||||
|
const query = searchParams.get("query");
|
||||||
|
const countryCode = searchParams.get("countryCode") || "US";
|
||||||
|
|
||||||
|
if (!query) {
|
||||||
|
return NextResponse.json({ error: "Query parameter is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientId = process.env.TIDAL_CLIENT_ID;
|
||||||
|
const clientSecret = process.env.TIDAL_CLIENT_SECRET;
|
||||||
|
|
||||||
|
if (!clientId || !clientSecret) {
|
||||||
|
return NextResponse.json({ error: "TIDAL credentials not configured" }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Search TIDAL
|
||||||
|
const searchUrl = `https://openapi.tidal.com/v2/searchResults/${encodeURIComponent(query)}?countryCode=${countryCode}&explicitFilter=include%2C%20exclude&include=albums,albums.artists,albums.coverArt`;
|
||||||
|
|
||||||
|
const searchRes = await fetchTidal(searchUrl, clientId, clientSecret);
|
||||||
|
|
||||||
|
if (!searchRes.ok) {
|
||||||
|
const errorText = await searchRes.text();
|
||||||
|
console.error("TIDAL Search Error:", errorText);
|
||||||
|
return NextResponse.json({ error: "Failed to search TIDAL", details: errorText }, { status: searchRes.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchData = await searchRes.json();
|
||||||
|
|
||||||
|
//console.log("TIDAL Search Data:", JSON.stringify(searchData, null, 2));
|
||||||
|
|
||||||
|
// Parse JSON:API response
|
||||||
|
const albumsMap = new Map<string, unknown>();
|
||||||
|
const artistIds = new Set<string>();
|
||||||
|
const artworkIds = new Set<string>();
|
||||||
|
|
||||||
|
// Process Albums from 'included' to get IDs
|
||||||
|
if (searchData.included) {
|
||||||
|
searchData.included.forEach((item: Record<string, any>) => {
|
||||||
|
if (item.type === "albums") {
|
||||||
|
const attr = item.attributes;
|
||||||
|
|
||||||
|
// Duration parsing
|
||||||
|
let duration = 0;
|
||||||
|
if (attr.duration) {
|
||||||
|
try {
|
||||||
|
const match = attr.duration.match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/);
|
||||||
|
if (match) {
|
||||||
|
duration = parseInt(match[1] || "0") * 3600 + parseInt(match[2] || "0") * 60 + parseInt(match[3] || "0");
|
||||||
|
}
|
||||||
|
} catch (e) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect Artist IDs
|
||||||
|
if (item.relationships?.artists?.data) {
|
||||||
|
const artistsData = Array.isArray(item.relationships.artists.data)
|
||||||
|
? item.relationships.artists.data
|
||||||
|
: [item.relationships.artists.data];
|
||||||
|
artistsData.forEach((a: { id: string }) => artistIds.add(a.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect CoverArt IDs
|
||||||
|
let coverArtId = null;
|
||||||
|
if (item.relationships?.coverArt?.data) {
|
||||||
|
const coverData = item.relationships.coverArt.data;
|
||||||
|
const firstCover = Array.isArray(coverData) ? coverData[0] : coverData;
|
||||||
|
if (firstCover && firstCover.id) {
|
||||||
|
artworkIds.add(firstCover.id);
|
||||||
|
coverArtId = firstCover.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
albumsMap.set(item.id, {
|
||||||
|
id: item.id,
|
||||||
|
title: attr.title,
|
||||||
|
artists: [], // To be filled
|
||||||
|
duration: duration,
|
||||||
|
url: attr.externalLinks?.[0]?.href || `https://tidal.com/browse/album/${item.id}`,
|
||||||
|
releaseDate: attr.releaseDate,
|
||||||
|
mediaTags: attr.mediaTags || [],
|
||||||
|
imageCover: [], // To be filled
|
||||||
|
_artistIds: item.relationships?.artists?.data?.map((a: { id: string }) => a.id) || [],
|
||||||
|
_coverArtId: coverArtId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to chunk array
|
||||||
|
const chunkArray = (array: string[], size: number) => {
|
||||||
|
const chunks = [];
|
||||||
|
for (let i = 0; i < array.length; i += size) {
|
||||||
|
chunks.push(array.slice(i, i + size));
|
||||||
|
}
|
||||||
|
return chunks;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2. Bulk Fetch Artists
|
||||||
|
const artistsMap = new Map<string, { name: string }>();
|
||||||
|
if (artistIds.size > 0) {
|
||||||
|
const allArtistIds = Array.from(artistIds);
|
||||||
|
const chunks = chunkArray(allArtistIds, 20); // API Limit: 20
|
||||||
|
|
||||||
|
await Promise.all(chunks.map(async (chunk) => {
|
||||||
|
const ids = chunk.join(",");
|
||||||
|
const artistsUrl = `https://openapi.tidal.com/v2/artists?countryCode=${countryCode}&filter[id]=${ids}`;
|
||||||
|
try {
|
||||||
|
const res = await fetchTidal(artistsUrl, clientId, clientSecret);
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
data.data?.forEach((item: Record<string, any>) => {
|
||||||
|
artistsMap.set(item.id, { name: item.attributes.name });
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.error("Failed to fetch artists batch:", await res.text());
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error fetching artists batch", e);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Bulk Fetch Artworks
|
||||||
|
const artworksMap = new Map<string, { url: string, width: number, height: number }[]>();
|
||||||
|
if (artworkIds.size > 0) {
|
||||||
|
const allArtworkIds = Array.from(artworkIds);
|
||||||
|
const chunks = chunkArray(allArtworkIds, 20); // API Limit: 20 (assumed same as artists)
|
||||||
|
|
||||||
|
await Promise.all(chunks.map(async (chunk) => {
|
||||||
|
const ids = chunk.join(",");
|
||||||
|
const artworksUrl = `https://openapi.tidal.com/v2/artworks?countryCode=${countryCode}&filter[id]=${ids}`;
|
||||||
|
try {
|
||||||
|
const res = await fetchTidal(artworksUrl, clientId, clientSecret);
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
data.data?.forEach((item: Record<string, any>) => {
|
||||||
|
const images: { url: string, width: number, height: number }[] = [];
|
||||||
|
|
||||||
|
// Structure seen in logs: item.attributes.files is an array of { href, meta: { width, height } }
|
||||||
|
if (item.attributes.files && Array.isArray(item.attributes.files)) {
|
||||||
|
item.attributes.files.forEach((file: Record<string, any>) => {
|
||||||
|
images.push({
|
||||||
|
url: file.href,
|
||||||
|
width: file.meta?.width || 0,
|
||||||
|
height: file.meta?.height || 0
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Fallback structure (imageLinks)
|
||||||
|
else if (item.attributes.imageLinks && Array.isArray(item.attributes.imageLinks)) {
|
||||||
|
item.attributes.imageLinks.forEach((link: Record<string, any>) => {
|
||||||
|
images.push({ url: link.href, width: link.width || 0, height: link.height || 0 });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority 3: Fallback - Construct URLs manually if we have an ID and parsing failed
|
||||||
|
if (images.length === 0) {
|
||||||
|
const availableSizes = [80, 160, 320, 640, 1280];
|
||||||
|
availableSizes.forEach(size => {
|
||||||
|
images.push({
|
||||||
|
url: `https://resources.tidal.com/images/${item.id.replace(/-/g, '/')}/${size}x${size}.jpg`,
|
||||||
|
width: size,
|
||||||
|
height: size
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
artworksMap.set(item.id, images);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) { console.error("Error fetching artworks batch", e); }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Merge Data
|
||||||
|
const albums = Array.from(albumsMap.values() as IterableIterator<Record<string, any>>).map(album => {
|
||||||
|
// Fill Artists
|
||||||
|
if (album._artistIds) {
|
||||||
|
album.artists = album._artistIds.map((id: string) => artistsMap.get(id) || { name: "Unknown" });
|
||||||
|
}
|
||||||
|
// Fill Artwork
|
||||||
|
if (album._coverArtId) {
|
||||||
|
const covers = artworksMap.get(album._coverArtId);
|
||||||
|
if (covers) {
|
||||||
|
album.imageCover = covers;
|
||||||
|
} else {
|
||||||
|
// Fallback to direct resource URL if we have the ID but fetch failed
|
||||||
|
album.imageCover = [
|
||||||
|
{ url: `https://resources.tidal.com/images/${album._coverArtId.replace(/-/g, '/')}/640x640.jpg`, width: 640, height: 640 },
|
||||||
|
{ url: `https://resources.tidal.com/images/${album._coverArtId.replace(/-/g, '/')}/320x320.jpg`, width: 320, height: 320 }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup internal keys
|
||||||
|
delete album._artistIds;
|
||||||
|
delete album._coverArtId;
|
||||||
|
|
||||||
|
return album;
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ albums });
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Search API Error:", error);
|
||||||
|
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { exec } from 'child_process';
|
||||||
|
import util from 'util';
|
||||||
|
import fs from 'fs-extra';
|
||||||
|
import path from 'path';
|
||||||
|
import { TorrentCreationResponse } from '@/types';
|
||||||
|
|
||||||
|
const execPromise = util.promisify(exec);
|
||||||
|
|
||||||
|
interface ExecError extends Error {
|
||||||
|
stdout?: string;
|
||||||
|
stderr?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const { albumPath } = await request.json();
|
||||||
|
|
||||||
|
if (!albumPath || !(await fs.pathExists(albumPath))) {
|
||||||
|
return NextResponse.json({ error: 'Invalid album path' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentDir = path.dirname(albumPath);
|
||||||
|
const albumDirName = path.basename(albumPath);
|
||||||
|
|
||||||
|
// Command: mkbrr create <folder> -P red
|
||||||
|
// Run in parent directory so the torrent file is created there
|
||||||
|
const command = `mkbrr create "${albumDirName}" -P red`;
|
||||||
|
|
||||||
|
console.log('Running command:', command, 'in', parentDir);
|
||||||
|
|
||||||
|
const { stdout, stderr } = await execPromise(command, { cwd: parentDir });
|
||||||
|
|
||||||
|
// Expected torrent file path
|
||||||
|
let torrentPath = path.join(parentDir, `${albumDirName}.torrent`);
|
||||||
|
|
||||||
|
if (!await fs.pathExists(torrentPath)) {
|
||||||
|
console.log(`Expected torrent file not found at: ${torrentPath}`);
|
||||||
|
console.log('Listing files in parent directory to find match...');
|
||||||
|
|
||||||
|
const files = await fs.readdir(parentDir);
|
||||||
|
console.log('Files in directory:', files);
|
||||||
|
|
||||||
|
// Try to find a .torrent file that was created very recently (last 10 seconds)
|
||||||
|
// and looks related (contains part of the name or just is a torrent file)
|
||||||
|
const recentTorrent = (await Promise.all(files
|
||||||
|
.filter(f => f.endsWith('.torrent'))
|
||||||
|
.map(async f => {
|
||||||
|
const stat = await fs.stat(path.join(parentDir, f));
|
||||||
|
return { name: f, mtime: stat.mtimeMs };
|
||||||
|
})
|
||||||
|
))
|
||||||
|
.sort((a, b) => b.mtime - a.mtime)[0]; // Sort by newest
|
||||||
|
|
||||||
|
if (recentTorrent) {
|
||||||
|
console.log(`Found recent torrent file: ${recentTorrent.name}`);
|
||||||
|
torrentPath = path.join(parentDir, recentTorrent.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await fs.pathExists(torrentPath)) {
|
||||||
|
const torrentData = await fs.readFile(torrentPath);
|
||||||
|
const response: TorrentCreationResponse = {
|
||||||
|
success: true,
|
||||||
|
output: stdout,
|
||||||
|
filename: path.basename(torrentPath),
|
||||||
|
data: torrentData.toString('base64')
|
||||||
|
};
|
||||||
|
return NextResponse.json(response);
|
||||||
|
} else {
|
||||||
|
const response: TorrentCreationResponse = {
|
||||||
|
success: false,
|
||||||
|
output: stdout,
|
||||||
|
errorOutput: stderr || 'Torrent file not found after generation. Check server logs for file listing.',
|
||||||
|
error: 'Torrent file not found'
|
||||||
|
};
|
||||||
|
return NextResponse.json(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating torrent:', error);
|
||||||
|
const execError = error as ExecError;
|
||||||
|
return NextResponse.json({
|
||||||
|
error: 'Failed to create torrent',
|
||||||
|
details: execError.message,
|
||||||
|
output: execError.stdout,
|
||||||
|
errorOutput: execError.stderr
|
||||||
|
}, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const file = formData.get('file') as File | null;
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
return NextResponse.json({ error: 'No file provided' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiKey = process.env.RED_IMAGE_API_KEY;
|
||||||
|
if (!apiKey) {
|
||||||
|
return NextResponse.json({ error: 'Server misconfiguration: No RED API Key' }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// RED image host requires 'file' POST argument for local file upload
|
||||||
|
const upstreamFormData = new FormData();
|
||||||
|
upstreamFormData.append('file', file);
|
||||||
|
|
||||||
|
const response = await fetch('https://redacted.sh/ajax.php?action=upload_image', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': apiKey,
|
||||||
|
},
|
||||||
|
body: upstreamFormData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Upload failed with status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.status !== 'success' || !data.response || !data.response.url) {
|
||||||
|
throw new Error(data.error || data.status || 'Invalid response from RED image host');
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageUrl = data.response.url;
|
||||||
|
|
||||||
|
return NextResponse.json({ url: imageUrl });
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('RED image host upload error:', error);
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Internal Server Error';
|
||||||
|
return NextResponse.json({ error: errorMessage }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
+144
@@ -0,0 +1,144 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@plugin "tailwindcss-animate";
|
||||||
|
|
||||||
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--font-sans: var(--font-geist-sans);
|
||||||
|
--font-mono: var(--font-geist-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--background: oklch(1 0 0);
|
||||||
|
--foreground: oklch(0.145 0 0);
|
||||||
|
--card: oklch(1 0 0);
|
||||||
|
--card-foreground: oklch(0.145 0 0);
|
||||||
|
--popover: oklch(1 0 0);
|
||||||
|
--popover-foreground: oklch(0.145 0 0);
|
||||||
|
--primary: oklch(0.205 0 0);
|
||||||
|
--primary-foreground: oklch(0.985 0 0);
|
||||||
|
--secondary: oklch(0.97 0 0);
|
||||||
|
--secondary-foreground: oklch(0.205 0 0);
|
||||||
|
--muted: oklch(0.97 0 0);
|
||||||
|
--muted-foreground: oklch(0.556 0 0);
|
||||||
|
--accent: oklch(0.97 0 0);
|
||||||
|
--accent-foreground: oklch(0.205 0 0);
|
||||||
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
|
--destructive-foreground: oklch(0.577 0.245 27.325);
|
||||||
|
--border: oklch(0.922 0 0);
|
||||||
|
--input: oklch(0.922 0 0);
|
||||||
|
--ring: oklch(0.708 0 0);
|
||||||
|
--chart-1: oklch(0.646 0.222 41.116);
|
||||||
|
--chart-2: oklch(0.6 0.118 184.704);
|
||||||
|
--chart-3: oklch(0.398 0.07 227.392);
|
||||||
|
--chart-4: oklch(0.828 0.189 84.429);
|
||||||
|
--chart-5: oklch(0.769 0.188 70.08);
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--sidebar: oklch(0.985 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.588 0 0);
|
||||||
|
--sidebar-primary: oklch(0.205 0 0);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.97 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||||
|
--sidebar-border: oklch(0.922 0 0);
|
||||||
|
--sidebar-ring: oklch(0.708 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
/* Darker background (Zinc 950ish) */
|
||||||
|
--background: oklch(0.1 0 0);
|
||||||
|
--foreground: oklch(0.985 0 0);
|
||||||
|
|
||||||
|
/* Slightly lighter card background (Zinc 900) */
|
||||||
|
--card: oklch(0.14 0 0);
|
||||||
|
--card-foreground: oklch(0.985 0 0);
|
||||||
|
|
||||||
|
--popover: oklch(0.14 0 0);
|
||||||
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
|
|
||||||
|
/* Primary color - maybe a vibrant purple/blue or just white for clean look? User said "Crossfade", maybe a gradient?
|
||||||
|
Let's stick to a clean white/gray primary for now, or a subtle accent. */
|
||||||
|
--primary: oklch(0.985 0 0);
|
||||||
|
--primary-foreground: oklch(0.205 0 0);
|
||||||
|
|
||||||
|
--secondary: oklch(0.2 0 0);
|
||||||
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
|
|
||||||
|
--muted: oklch(0.2 0 0);
|
||||||
|
--muted-foreground: oklch(0.708 0 0);
|
||||||
|
|
||||||
|
--accent: oklch(0.2 0 0);
|
||||||
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
|
|
||||||
|
--destructive: oklch(0.396 0.141 25.723);
|
||||||
|
--destructive-foreground: oklch(0.637 0.237 25.331);
|
||||||
|
|
||||||
|
--border: oklch(0.2 0 0);
|
||||||
|
--input: oklch(0.2 0 0);
|
||||||
|
--ring: oklch(0.439 0 0);
|
||||||
|
|
||||||
|
--chart-1: oklch(0.488 0.243 264.376);
|
||||||
|
--chart-2: oklch(0.696 0.17 162.48);
|
||||||
|
--chart-3: oklch(0.769 0.188 70.08);
|
||||||
|
--chart-4: oklch(0.627 0.265 303.9);
|
||||||
|
--chart-5: oklch(0.645 0.246 16.439);
|
||||||
|
|
||||||
|
--sidebar: oklch(0.205 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.269 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-border: oklch(0.269 0 0);
|
||||||
|
--sidebar-ring: oklch(0.439 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-destructive-foreground: var(--destructive-foreground);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--color-chart-1: var(--chart-1);
|
||||||
|
--color-chart-2: var(--chart-2);
|
||||||
|
--color-chart-3: var(--chart-3);
|
||||||
|
--color-chart-4: var(--chart-4);
|
||||||
|
--color-chart-5: var(--chart-5);
|
||||||
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
|
--color-sidebar: var(--sidebar);
|
||||||
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border outline-ring/50;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
|
import "./globals.css";
|
||||||
|
|
||||||
|
const geistSans = Geist({
|
||||||
|
variable: "--font-geist-sans",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const geistMono = Geist_Mono({
|
||||||
|
variable: "--font-geist-mono",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Create Next App",
|
||||||
|
description: "Generated by create next app",
|
||||||
|
};
|
||||||
|
|
||||||
|
import { ToolChecker } from "@/components/tool-checker";
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<html lang="en" className="dark">
|
||||||
|
<body
|
||||||
|
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||||
|
>
|
||||||
|
<ToolChecker>{children}</ToolChecker>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import Dashboard from "@/components/dashboard";
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return <Dashboard />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "new-york",
|
||||||
|
"rsc": true,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "app/globals.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide",
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"registries": {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { Disc, Music, ChevronRight } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { motion } from "motion/react";
|
||||||
|
import { Album } from "@/types";
|
||||||
|
|
||||||
|
interface AlbumSelectorProps {
|
||||||
|
albums: Album[];
|
||||||
|
selectedAlbum: Album | null;
|
||||||
|
onSelect: (album: Album) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AlbumSelector({ albums, selectedAlbum, onSelect }: AlbumSelectorProps) {
|
||||||
|
if (albums.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
|
||||||
|
{albums.map((album, i) => (
|
||||||
|
<motion.div
|
||||||
|
key={album.path}
|
||||||
|
initial={{ opacity: 0, scale: 0.9 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
transition={{ duration: 0.2, delay: i * 0.03 }}
|
||||||
|
onClick={() => onSelect(album)}
|
||||||
|
className={cn(
|
||||||
|
"group cursor-pointer flex flex-col gap-3 relative",
|
||||||
|
selectedAlbum?.path === album.path ? "opacity-80" : ""
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="aspect-square rounded-lg bg-muted/20 overflow-hidden shadow-lg border border-white/5 group-hover:border-primary/50 group-hover:shadow-primary/10 transition-all duration-300 relative">
|
||||||
|
{album.coverPath ? (
|
||||||
|
<img
|
||||||
|
src={`/api/cover?path=${encodeURIComponent(album.coverPath)}`}
|
||||||
|
alt={album.name}
|
||||||
|
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex items-center justify-center bg-muted/10">
|
||||||
|
<Music className="w-16 h-16 text-muted-foreground/20" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="font-semibold text-sm leading-tight truncate group-hover:text-primary transition-colors">
|
||||||
|
{album.name}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-muted-foreground truncate font-mono opacity-60">
|
||||||
|
{album.path.split('/').pop()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,312 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { FolderOpen, RefreshCw, Disc, Music, ArrowLeft, Trash2, Search, Waves } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { AlbumSelector } from "@/components/album-selector";
|
||||||
|
import { AlbumView } from "@/components/album-view";
|
||||||
|
import { StatusLog } from "@/components/status-log";
|
||||||
|
import { AudioWave } from "@/components/ui/audio-wave";
|
||||||
|
import { TidalSearch } from "@/components/tidal-search";
|
||||||
|
import { motion, AnimatePresence } from "motion/react";
|
||||||
|
import { Album } from "@/types";
|
||||||
|
|
||||||
|
export default function Dashboard() {
|
||||||
|
const [albums, setAlbums] = useState<Album[]>([]);
|
||||||
|
const [selectedAlbum, setSelectedAlbum] = useState<Album | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [logs, setLogs] = useState<string[]>([]);
|
||||||
|
const [view, setView] = useState<"idle" | "selection" | "details" | "tidal">("idle");
|
||||||
|
|
||||||
|
const addLog = (message: string) => {
|
||||||
|
setLogs((prev) => {
|
||||||
|
const newLogs = [...prev, `[${new Date().toLocaleTimeString()}] ${message}`];
|
||||||
|
if (newLogs.length > 50) return newLogs.slice(newLogs.length - 50);
|
||||||
|
return newLogs;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const scanWatchFolder = async (preserveView = false) => {
|
||||||
|
setLoading(true);
|
||||||
|
addLog("Scanning watch folder...");
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/scan");
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.albums) {
|
||||||
|
setAlbums(data.albums);
|
||||||
|
addLog(`Found ${data.albums.length} albums.`);
|
||||||
|
|
||||||
|
if (!preserveView) {
|
||||||
|
if (data.albums.length > 0) {
|
||||||
|
setView("selection");
|
||||||
|
} else {
|
||||||
|
setView("idle");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
addLog("Error scanning watch folder.");
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
scanWatchFolder();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Auto-refresh watch folder
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
// Silent scan - don't show loading spinner for auto-refresh
|
||||||
|
fetch("/api/scan")
|
||||||
|
.then(res => res.json())
|
||||||
|
.then((data: { albums: Album[] }) => {
|
||||||
|
if (data.albums) {
|
||||||
|
// Only update if count changed or deep comparison (simplified here by length/names check)
|
||||||
|
setAlbums(prev => {
|
||||||
|
const prevNames = prev.map(a => a.path).sort().join(',');
|
||||||
|
const newNames = data.albums.map((a) => a.path).sort().join(',');
|
||||||
|
if (prevNames !== newNames) {
|
||||||
|
addLog(`Watch folder updated: ${data.albums.length} albums found.`);
|
||||||
|
return data.albums;
|
||||||
|
}
|
||||||
|
// Even if list didn't change structure, update to get new cover paths if they appeared
|
||||||
|
// But we need to be careful not to cause re-renders if nothing changed.
|
||||||
|
// Let's check if any coverPath changed for the selected album
|
||||||
|
return data.albums;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sync selectedAlbum with new data to ensure cover art updates
|
||||||
|
if (selectedAlbum) {
|
||||||
|
const updatedSelected = data.albums.find((a) => a.path === selectedAlbum.path);
|
||||||
|
if (updatedSelected && updatedSelected.coverPath !== selectedAlbum.coverPath) {
|
||||||
|
setSelectedAlbum(updatedSelected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we are in idle view and albums appear, switch to selection
|
||||||
|
if (view === "idle" && data.albums.length > 0) {
|
||||||
|
setView("selection");
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we are in selection view and albums disappear, switch to idle.
|
||||||
|
// However, if we are in TIDAL view, we shouldn't force switch back.
|
||||||
|
if (view === "selection" && data.albums.length === 0) {
|
||||||
|
setView("idle");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => console.error("Auto-scan failed", err));
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [view, selectedAlbum]);
|
||||||
|
|
||||||
|
const handleAlbumUpdate = async (newPath?: string) => {
|
||||||
|
await scanWatchFolder(true);
|
||||||
|
if (newPath) {
|
||||||
|
const name = newPath.split('/').pop() || "";
|
||||||
|
if (selectedAlbum) {
|
||||||
|
setSelectedAlbum({ ...selectedAlbum, path: newPath, name });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectAlbum = (album: Album) => {
|
||||||
|
setSelectedAlbum(album);
|
||||||
|
setView("details");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
setSelectedAlbum(null);
|
||||||
|
setView(albums.length > 0 ? "selection" : "idle");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background text-foreground flex flex-col font-sans selection:bg-primary/20 max-w-5xl mx-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="flex justify-between items-center p-6 shrink-0 z-40 max-w-5xl">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<motion.div
|
||||||
|
animate={{ rotate: 360 }}
|
||||||
|
transition={{ duration: 20, repeat: Infinity, ease: "linear" }}
|
||||||
|
>
|
||||||
|
<Disc className="w-8 h-8 text-primary" />
|
||||||
|
</motion.div>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight bg-linear-to-r from-white to-white/50 bg-clip-text text-transparent">
|
||||||
|
crossfade
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{view === "details" && (
|
||||||
|
<Button onClick={handleBack} variant="ghost" className="text-muted-foreground hover:text-foreground">
|
||||||
|
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||||
|
Back to Albums
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{view === "tidal" ? (
|
||||||
|
<Button onClick={handleBack} variant="ghost" className="text-muted-foreground hover:text-foreground cursor-pointer">
|
||||||
|
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
onClick={() => setView("tidal")}
|
||||||
|
variant="secondary"
|
||||||
|
className="bg-primary/10 text-primary hover:bg-primary/20 border-primary/20"
|
||||||
|
>
|
||||||
|
<Waves className="w-4 h-4 mr-2" />
|
||||||
|
TIDAL Search
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={async () => {
|
||||||
|
if (confirm("Are you sure you want to delete ALL generated spectrograms? This cannot be undone.")) {
|
||||||
|
addLog("Purging spectrograms...");
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/spectrogram", { method: "DELETE" });
|
||||||
|
const result = await res.json();
|
||||||
|
if (result.success) {
|
||||||
|
addLog("Spectrograms purged successfully.");
|
||||||
|
} else {
|
||||||
|
addLog(`Failed to purge: ${result.error}`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
addLog("Purge request failed.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
variant="destructive"
|
||||||
|
className="bg-red-500/10 hover:bg-red-500/20 text-red-500 border-red-500/20"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4 mr-2" />
|
||||||
|
Purge Spectrograms
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => scanWatchFolder()} disabled={loading} variant="outline" className="bg-background/50 backdrop-blur border-white/10 hover:bg-white/10">
|
||||||
|
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? "animate-spin" : ""}`} />
|
||||||
|
Manual Rescan
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Main Content Area */}
|
||||||
|
<main className="flex-1 relative flex flex-col">
|
||||||
|
<AnimatePresence mode="wait" initial={true}>
|
||||||
|
{view === "idle" && (
|
||||||
|
<motion.div
|
||||||
|
key="idle"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="flex-1 flex flex-col items-center justify-center gap-8 p-6"
|
||||||
|
>
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute inset-0 bg-primary/20 blur-3xl rounded-full" />
|
||||||
|
<AudioWave />
|
||||||
|
</div>
|
||||||
|
<div className="text-center space-y-2 max-w-md z-10">
|
||||||
|
<h2 className="text-2xl font-medium text-foreground">Waiting for Music</h2>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Place albums in the watch folder to begin.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{view === "selection" && (
|
||||||
|
<motion.div
|
||||||
|
key="selection"
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -10 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="flex-1 p-6 overflow-hidden flex flex-col"
|
||||||
|
>
|
||||||
|
<div className="max-w-5xl mx-auto w-full h-full flex flex-col">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h2 className="text-xl font-semibold flex items-center gap-2">
|
||||||
|
<FolderOpen className="w-5 h-5 text-primary" />
|
||||||
|
Select Album
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Found {albums.length} albums ready for processing
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ScrollArea className="flex-1 -mx-6 px-6">
|
||||||
|
<div className="pb-6">
|
||||||
|
<AlbumSelector
|
||||||
|
albums={albums}
|
||||||
|
selectedAlbum={selectedAlbum}
|
||||||
|
onSelect={handleSelectAlbum}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{view === "tidal" && (
|
||||||
|
<motion.div
|
||||||
|
key="tidal"
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -10 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="flex-1 p-6 overflow-hidden flex flex-col"
|
||||||
|
>
|
||||||
|
<div className="max-w-5xl mx-auto w-full h-full flex flex-col">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h2 className="text-xl font-semibold flex items-center gap-2">
|
||||||
|
<Waves className="w-5 h-5 text-primary" />
|
||||||
|
TIDAL Search
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Search and copy links from TIDAL
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ScrollArea className="flex-1 -mx-6 px-6">
|
||||||
|
<div className="pb-6">
|
||||||
|
<TidalSearch />
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{view === "details" && selectedAlbum && (
|
||||||
|
<motion.div
|
||||||
|
key="details"
|
||||||
|
initial={{ opacity: 0, x: 20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
exit={{ opacity: 0, x: 20 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="flex-1 p-6 overflow-y-auto flex flex-col"
|
||||||
|
>
|
||||||
|
<div className="max-w-5xl mx-auto w-full">
|
||||||
|
<AlbumView
|
||||||
|
album={selectedAlbum}
|
||||||
|
addLog={addLog}
|
||||||
|
onReset={handleBack}
|
||||||
|
onAlbumUpdate={handleAlbumUpdate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* Status Log */}
|
||||||
|
{process.env.NEXT_PUBLIC_SHOW_SYSTEM_LOG === "true" && (
|
||||||
|
<div className="shrink-0 p-6 pt-0 z-10">
|
||||||
|
<StatusLog logs={logs} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { AlertTriangle, ArrowRight, Check, X } from "lucide-react";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { RenameProposal, ProposeRenameResponse, BatchRenameResponse } from "@/types";
|
||||||
|
|
||||||
|
interface RenameDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
albumPath: string;
|
||||||
|
onRenameComplete: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RenameDialog({ open, onOpenChange, albumPath, onRenameComplete }: RenameDialogProps) {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [proposals, setProposals] = useState<RenameProposal[]>([]);
|
||||||
|
const [allFiles, setAllFiles] = useState<RenameProposal[]>([]);
|
||||||
|
const [applyToAll, setApplyToAll] = useState(true);
|
||||||
|
const [maxFileLength, setMaxFileLength] = useState(0);
|
||||||
|
const [selectedStrategy, setSelectedStrategy] = useState<"remove_brackets" | "remove_parens" | "remove_both" | "truncate">("remove_both");
|
||||||
|
const [renaming, setRenaming] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && albumPath) {
|
||||||
|
fetchProposals();
|
||||||
|
}
|
||||||
|
}, [open, albumPath]);
|
||||||
|
|
||||||
|
const fetchProposals = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/propose-rename", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ albumPath }),
|
||||||
|
});
|
||||||
|
const data = await res.json() as ProposeRenameResponse;
|
||||||
|
if (data.success) {
|
||||||
|
setProposals(data.problematicFiles);
|
||||||
|
setAllFiles(data.allFiles || []);
|
||||||
|
setMaxFileLength(data.maxFileLength);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to fetch proposals", e);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRename = async () => {
|
||||||
|
setRenaming(true);
|
||||||
|
try {
|
||||||
|
const activeProposals = applyToAll ? allFiles : proposals;
|
||||||
|
const renames = activeProposals.map(p => ({
|
||||||
|
original: p.original,
|
||||||
|
newName: p.strategies[selectedStrategy] || p.strategies.truncate // Fallback to truncate if strategy returns null (shouldn't happen for truncate)
|
||||||
|
}));
|
||||||
|
|
||||||
|
const res = await fetch("/api/batch-rename", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ albumPath, renames }),
|
||||||
|
});
|
||||||
|
const result = await res.json() as BatchRenameResponse;
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
onRenameComplete();
|
||||||
|
onOpenChange(false);
|
||||||
|
} else {
|
||||||
|
alert(`Rename failed: ${result.errors?.join(', ')}`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert("Rename request failed");
|
||||||
|
} finally {
|
||||||
|
setRenaming(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPreview = (proposal: RenameProposal) => {
|
||||||
|
const newName = proposal.strategies[selectedStrategy];
|
||||||
|
if (!newName) return <span className="text-red-500 italic">Strategy not applicable (still too long)</span>;
|
||||||
|
return <span className="text-green-500 font-medium">{newName}</span>;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-4xl max-h-[80vh] flex flex-col">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2 text-destructive">
|
||||||
|
<AlertTriangle className="w-5 h-5" />
|
||||||
|
Long Filenames Detected
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
The following files exceed the maximum allowed length ({maxFileLength} characters).
|
||||||
|
Please select a renaming strategy to fix them.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex-1 flex flex-col min-h-0 gap-4 py-4">
|
||||||
|
<Tabs value={selectedStrategy} onValueChange={(v) => setSelectedStrategy(v as "remove_brackets" | "remove_parens" | "remove_both" | "truncate")} className="flex-1 flex flex-col min-h-0">
|
||||||
|
<TabsList className="grid w-full grid-cols-4">
|
||||||
|
<TabsTrigger value="remove_brackets">Remove []</TabsTrigger>
|
||||||
|
<TabsTrigger value="remove_parens">Remove ()</TabsTrigger>
|
||||||
|
<TabsTrigger value="remove_both">Remove Both</TabsTrigger>
|
||||||
|
<TabsTrigger value="truncate">Smart Truncate</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 mt-4">
|
||||||
|
<label className="flex items-center gap-2 text-sm text-muted-foreground cursor-pointer select-none hover:text-foreground transition-colors overflow-hidden">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={applyToAll}
|
||||||
|
onChange={(e) => setApplyToAll(e.target.checked)}
|
||||||
|
className="w-4 h-4 rounded border-white/20 bg-black/20 text-primary focus:ring-primary/50"
|
||||||
|
/>
|
||||||
|
Apply chosen resolution to all files in the album
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 border rounded-md mt-4 overflow-hidden bg-muted/20">
|
||||||
|
<ScrollArea className="h-[400px] p-4">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{(applyToAll ? allFiles : proposals).map((p, i) => (
|
||||||
|
<div key={i} className="grid grid-cols-[1fr_auto_1fr] gap-4 items-center text-sm border-b border-border/50 pb-4 last:border-0 last:pb-0">
|
||||||
|
<div className="break-all text-muted-foreground">
|
||||||
|
{p.original}
|
||||||
|
<Badge variant="outline" className="ml-2 text-xs">{p.original.length}</Badge>
|
||||||
|
</div>
|
||||||
|
<ArrowRight className="w-4 h-4 text-muted-foreground/50" />
|
||||||
|
<div className="break-all">
|
||||||
|
{getPreview(p)}
|
||||||
|
{p.strategies[selectedStrategy] && (
|
||||||
|
<Badge variant="outline" className="ml-2 text-xs border-green-500/30 text-green-500">
|
||||||
|
{p.strategies[selectedStrategy]!.length}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
|
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||||
|
Ignore & Close
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleRename} disabled={renaming}>
|
||||||
|
{renaming ? "Renaming..." : "Apply Changes"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { Terminal } from "lucide-react";
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { motion, AnimatePresence } from "motion/react";
|
||||||
|
|
||||||
|
interface StatusLogProps {
|
||||||
|
logs: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatusLog({ logs }: StatusLogProps) {
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Auto-scroll to bottom when logs change
|
||||||
|
useEffect(() => {
|
||||||
|
if (scrollRef.current) {
|
||||||
|
scrollRef.current.scrollIntoView({ behavior: "smooth" });
|
||||||
|
}
|
||||||
|
}, [logs]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border bg-neutral-900/40 backdrop-blur text-gray-500 font-mono text-xs p-4 h-48 flex flex-col shadow-inner border-gray-900/30">
|
||||||
|
<div className="flex items-center gap-2 mb-2 border-b border-gray-900/50 pb-1">
|
||||||
|
<Terminal className="w-3 h-3" />
|
||||||
|
<span className="uppercase tracking-wider text-[10px] text-gray-400">System Log</span>
|
||||||
|
</div>
|
||||||
|
<ScrollArea className="flex-1">
|
||||||
|
<div className="flex flex-col gap-1 max-h-10">
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
{logs.map((log, i) => (
|
||||||
|
<motion.div
|
||||||
|
key={`${i}-${log.substring(0, 10)}`} // Use index + content snippet for key to ensure uniqueness but allow animation
|
||||||
|
initial={{ opacity: 0, x: -10 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
className="break-all"
|
||||||
|
>
|
||||||
|
<span className="opacity-50 mr-2 select-none">{">"}</span>
|
||||||
|
{log}
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
<div ref={scrollRef} />
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,388 @@
|
|||||||
|
import { Search, Loader2, Music, Clock, Copy, Filter, ExternalLink } from "lucide-react";
|
||||||
|
import { useState, useRef, useEffect } from "react";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
interface Album {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
artists: { name: string }[];
|
||||||
|
duration: number;
|
||||||
|
url: string;
|
||||||
|
imageCover?: { url: string; width: number; height: number }[];
|
||||||
|
mediaTags?: string[];
|
||||||
|
releaseDate?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TidalSearch() {
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [results, setResults] = useState<Album[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [sortBy, setSortBy] = useState<"relevance" | "duration" | "year">("relevance");
|
||||||
|
const [filterQuality, setFilterQuality] = useState<"all" | "hires" | "lossless">("all");
|
||||||
|
const [countryCode, setCountryCode] = useState("US");
|
||||||
|
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||||
|
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
// Initial load
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function handleSearch() {
|
||||||
|
if (!query.trim()) return;
|
||||||
|
setLoading(true);
|
||||||
|
setResults([]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/tidal/search?query=${encodeURIComponent(query)}&countryCode=${countryCode}`);
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (data && data.albums) {
|
||||||
|
setResults(data.albums);
|
||||||
|
} else if (data && Array.isArray(data)) {
|
||||||
|
setResults(data);
|
||||||
|
} else {
|
||||||
|
console.warn("Unexpected API response structure:", data);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Search failed:", error);
|
||||||
|
toast.error("Search failed. Please try again.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredResults = results.filter(album => {
|
||||||
|
if (filterQuality === "all") return true;
|
||||||
|
if (!album.mediaTags) return false;
|
||||||
|
if (filterQuality === "hires") {
|
||||||
|
return album.mediaTags.includes("HIRES_LOSSLESS");
|
||||||
|
}
|
||||||
|
if (filterQuality === "lossless") {
|
||||||
|
// Exclusive filter: Must be Lossless AND NOT Hi-Res/MQA
|
||||||
|
const isHires = album.mediaTags.includes("HIRES_LOSSLESS");
|
||||||
|
return album.mediaTags.includes("LOSSLESS") && !isHires;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const sortedResults = [...filteredResults].sort((a, b) => {
|
||||||
|
if (sortBy === "duration") {
|
||||||
|
return (b.duration || 0) - (a.duration || 0);
|
||||||
|
}
|
||||||
|
if (sortBy === "year") {
|
||||||
|
const dateA = new Date(a.releaseDate || 0).getFullYear();
|
||||||
|
const dateB = new Date(b.releaseDate || 0).getFullYear();
|
||||||
|
return dateB - dateA; // Newest first
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
function formatDuration(seconds: number) {
|
||||||
|
const m = Math.floor(seconds / 60);
|
||||||
|
const s = seconds % 60;
|
||||||
|
return `${m}:${s.toString().padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyToClipboard = async (text: string, id: string) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
|
||||||
|
// Clear existing timeout to prevent premature hiding
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
setCopiedId(id);
|
||||||
|
toast.success("Copied to clipboard!");
|
||||||
|
|
||||||
|
timeoutRef.current = setTimeout(() => {
|
||||||
|
setCopiedId(null);
|
||||||
|
timeoutRef.current = null;
|
||||||
|
}, 2000);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to copy:", err);
|
||||||
|
toast.error("Failed to copy link");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function getYear(dateString?: string) {
|
||||||
|
if (!dateString) return "";
|
||||||
|
return new Date(dateString).getFullYear().toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full gap-6 mx-auto w-full">
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center justify-between bg-neutral-800/90 p-2 rounded-xl border shadow-sm backdrop-blur-sm">
|
||||||
|
<div className="flex flex-1 gap-2 items-center bg-background/50 p-1.5 rounded-lg border border-border/40 focus-within:border-primary/50 transition-colors">
|
||||||
|
<Search className="w-4 h-4 ml-2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search for music..."
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||||
|
className="flex-1 border-none shadow-none bg-transparent focus-visible:ring-0 h-9 font-medium"
|
||||||
|
/>
|
||||||
|
<div className="h-5 w-[1px] bg-border/60 mx-1" />
|
||||||
|
<Input
|
||||||
|
placeholder="US"
|
||||||
|
value={countryCode}
|
||||||
|
onChange={(e) => setCountryCode(e.target.value.toUpperCase().slice(0, 2))}
|
||||||
|
className="w-14 border-none shadow-none bg-transparent focus-visible:ring-0 h-9 text-center font-mono text-xs uppercase text-muted-foreground focus:text-foreground transition-colors"
|
||||||
|
maxLength={2}
|
||||||
|
title="Region (ISO 3166-1 alpha-2)"
|
||||||
|
/>
|
||||||
|
<Button onClick={handleSearch} disabled={loading} size="sm" className="h-8 px-4 font-semibold shadow-none">
|
||||||
|
{loading ? <Loader2 className="w-3 h-3 animate-spin" /> : "Search"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 items-center px-1">
|
||||||
|
<Select value={sortBy} onValueChange={(v) => setSortBy(v as "relevance" | "duration" | "year")}>
|
||||||
|
<SelectTrigger className="w-[140px] h-9 text-xs border-border/40 !bg-background/50 hover:!bg-background/70 backdrop-blur-sm font-medium">
|
||||||
|
<SelectValue placeholder="Sort by" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="relevance">Relevance</SelectItem>
|
||||||
|
<SelectItem value="duration">Duration</SelectItem>
|
||||||
|
<SelectItem value="year">Release Year</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Select value={filterQuality} onValueChange={(v) => setFilterQuality(v as "all" | "hires" | "lossless")}>
|
||||||
|
<SelectTrigger className="w-[130px] h-9 text-xs border-border/40 !bg-background/50 hover:!bg-background/70 backdrop-blur-sm font-medium">
|
||||||
|
<SelectValue placeholder="Quality" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All Qualities</SelectItem>
|
||||||
|
<SelectItem value="hires">Hi-Res Only</SelectItem>
|
||||||
|
<SelectItem value="lossless">Lossless Only</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollArea className="flex-1 -mx-4 px-4">
|
||||||
|
{sortedResults.length === 0 && !loading && (
|
||||||
|
<div className="flex flex-col items-center justify-center h-[50vh] text-muted-foreground gap-4">
|
||||||
|
<div className="p-6 rounded-full bg-muted/30">
|
||||||
|
<Music className="w-12 h-12 opacity-30" />
|
||||||
|
</div>
|
||||||
|
<p className="font-medium text-lg text-muted-foreground/60">{query ? "No results found" : "Start your search above"}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-6 pb-6">
|
||||||
|
{sortedResults.map((album) => {
|
||||||
|
const artworkUrl = album.imageCover && album.imageCover.length > 0
|
||||||
|
? album.imageCover.find(img => img.width === 640)?.url || album.imageCover[0].url
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={album.id}
|
||||||
|
layout
|
||||||
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.95 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="group flex flex-col gap-3 cursor-pointer"
|
||||||
|
onClick={() => copyToClipboard(album.url, album.id)}
|
||||||
|
>
|
||||||
|
<div className="aspect-square rounded-xl overflow-hidden bg-muted/50 relative shadow-md group-hover:shadow-xl transition-all duration-300 ">
|
||||||
|
{artworkUrl ? (
|
||||||
|
<img
|
||||||
|
src={artworkUrl}
|
||||||
|
alt={album.title}
|
||||||
|
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center h-full text-muted-foreground bg-secondary/30">
|
||||||
|
<Music className="w-16 h-16 opacity-10" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Overlay */}
|
||||||
|
<div className={cn(
|
||||||
|
"absolute inset-0 bg-black/60 backdrop-blur-[1px] flex items-center justify-center opacity-0 transition-opacity duration-200",
|
||||||
|
copiedId === album.id ? "opacity-100 bg-emerald-500/90 backdrop-blur-sm" : "group-hover:opacity-100"
|
||||||
|
)}>
|
||||||
|
<div className={cn(
|
||||||
|
"flex items-center gap-3 transform transition-transform duration-200",
|
||||||
|
copiedId === album.id ? "translate-y-0" : "translate-y-4 group-hover:translate-y-0"
|
||||||
|
)}>
|
||||||
|
{copiedId === album.id ? (
|
||||||
|
<div className="flex flex-col items-center text-white animate-in zoom-in duration-200">
|
||||||
|
<div className="h-12 w-12 rounded-full bg-white text-emerald-600 flex items-center justify-center shadow-lg mb-2">
|
||||||
|
<Copy className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<span className="font-bold tracking-widest text-sm">COPIED</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-10 w-10 rounded-full bg-black/40 hover:!bg-white text-white hover:!text-black transition-all duration-100 backdrop-blur-md shadow-lg border border-white/20 cursor-pointer"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
copyToClipboard(album.url, album.id);
|
||||||
|
}}
|
||||||
|
title="Copy Link"
|
||||||
|
>
|
||||||
|
<Copy className="w-5 h-5" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-10 w-10 rounded-full bg-black/40 hover:!bg-white text-white hover:!text-black transition-all duration-100 backdrop-blur-md shadow-lg border border-white/20 cursor-pointer"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
window.open(album.url, '_blank');
|
||||||
|
}}
|
||||||
|
title="Open in TIDAL"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-5 h-5" />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quality Badges - Refined Layout */}
|
||||||
|
{album.mediaTags && (
|
||||||
|
<div className="absolute bottom-2 right-2 flex flex-wrap justify-end gap-1.5 pointer-events-none">
|
||||||
|
{album.mediaTags.includes("HIRES_LOSSLESS") && (
|
||||||
|
<span className="px-1.5 py-0.5 rounded-sm bg-[#FFD700] text-black text-[0.6rem] font-bold tracking-wider shadow-sm border border-yellow-600/20">HI-RES</span>
|
||||||
|
)}
|
||||||
|
{album.mediaTags.includes("MQA") && (
|
||||||
|
<span className="px-1.5 py-0.5 rounded-sm bg-black text-[#FFD700] border border-[#FFD700] text-[0.6rem] font-bold tracking-wider shadow-sm">MQA</span>
|
||||||
|
)}
|
||||||
|
{!album.mediaTags.includes("HIRES_LOSSLESS") && !album.mediaTags.includes("MQA") && album.mediaTags.includes("LOSSLESS") && (
|
||||||
|
<span className="px-1.5 py-0.5 rounded-sm bg-cyan-500 text-black text-[0.6rem] font-bold tracking-wider shadow-sm">LOSSLESS</span>
|
||||||
|
)}
|
||||||
|
{album.mediaTags.includes("DOLBY_ATMOS") && (
|
||||||
|
<span className="px-1.5 py-0.5 rounded-sm bg-indigo-600 text-white text-[0.6rem] font-bold tracking-wider shadow-sm border border-white/10">ATMOS</span>
|
||||||
|
)}
|
||||||
|
{album.mediaTags.includes("SONY_360RA") && (
|
||||||
|
<span className="px-1.5 py-0.5 rounded-sm bg-blue-600 text-white text-[0.6rem] font-bold tracking-wider shadow-sm border border-white/10">360RA</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<h3 className="font-semibold text-sm leading-tight line-clamp-1 group-hover:text-primary transition-colors" title={album.title}>{album.title}</h3>
|
||||||
|
<div className="h-6 w-full relative overflow-hidden">
|
||||||
|
<ScrollableContent>
|
||||||
|
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
|
{album.artists && album.artists.length > 0 ? (
|
||||||
|
album.artists.map((artist, index) => (
|
||||||
|
<div key={index} className="flex items-center shrink-0">
|
||||||
|
<span
|
||||||
|
className="hover:text-primary cursor-copy transition-colors hover:underline decoration-primary/50 underline-offset-2"
|
||||||
|
title={`Copy "${artist.name}"`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
navigator.clipboard.writeText(artist.name);
|
||||||
|
toast.success(`Copied "${artist.name}"`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{artist.name}
|
||||||
|
</span>
|
||||||
|
{index < album.artists.length - 1 && (
|
||||||
|
<span className="text-muted-foreground/60 mr-1">,</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span>Unknown Artist</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollableContent>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 text-[0.65rem] font-medium text-muted-foreground/70 uppercase tracking-widest h-4">
|
||||||
|
{album.releaseDate && (
|
||||||
|
<span>{getYear(album.releaseDate)}</span>
|
||||||
|
)}
|
||||||
|
{formatDuration(album.duration) && (
|
||||||
|
<>
|
||||||
|
<span className="w-0.5 h-0.5 rounded-full bg-border" />
|
||||||
|
<span>{formatDuration(album.duration)}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScrollableContent({ children, className }: { children: React.ReactNode, className?: string }) {
|
||||||
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [scrollDistance, setScrollDistance] = useState(0);
|
||||||
|
const [duration, setDuration] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (containerRef.current && contentRef.current) {
|
||||||
|
const containerWidth = containerRef.current.offsetWidth;
|
||||||
|
const contentWidth = contentRef.current.offsetWidth;
|
||||||
|
|
||||||
|
if (contentWidth > containerWidth) {
|
||||||
|
const distance = contentWidth - containerWidth;
|
||||||
|
setScrollDistance(distance);
|
||||||
|
setDuration(distance * 0.01); // Adjust speed
|
||||||
|
} else {
|
||||||
|
setScrollDistance(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [children]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className={cn("overflow-hidden relative flex items-center w-full", className)}
|
||||||
|
onMouseEnter={() => setIsHovered(true)}
|
||||||
|
onMouseLeave={() => setIsHovered(false)}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
ref={contentRef}
|
||||||
|
className="whitespace-nowrap flex items-center"
|
||||||
|
animate={{ x: isHovered && scrollDistance > 0 ? -scrollDistance : 0 }}
|
||||||
|
transition={{
|
||||||
|
duration: duration,
|
||||||
|
ease: "linear",
|
||||||
|
repeat: isHovered ? Infinity : 0,
|
||||||
|
repeatType: "mirror",
|
||||||
|
repeatDelay: 1
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { ToolCheckResult } from '@/lib/tool-check';
|
||||||
|
import { RefreshCw, AlertTriangle, CheckCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
export function ToolChecker({ children }: { children: React.ReactNode }) {
|
||||||
|
const [result, setResult] = useState<ToolCheckResult | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [skipped, setSkipped] = useState(false);
|
||||||
|
|
||||||
|
const checkTools = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/system/check-tools');
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error('Failed to check tools');
|
||||||
|
}
|
||||||
|
const data: ToolCheckResult = await res.json();
|
||||||
|
setResult(data);
|
||||||
|
} catch (err) {
|
||||||
|
setError('Failed to communicate with the server.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
checkTools();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSkip = () => {
|
||||||
|
if (confirm("WARNING: Skipping these checks may cause the application to fail or produce corrupt files. Are you sure you want to proceed without the required tools?")) {
|
||||||
|
setSkipped(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background text-foreground">
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<RefreshCw className="h-8 w-8 animate-spin text-primary" />
|
||||||
|
<p className="text-lg font-medium">Checking system prerequisites...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background text-foreground p-4">
|
||||||
|
<div className="max-w-md w-full bg-card border border-border rounded-lg shadow-lg p-6 flex flex-col items-center text-center gap-4">
|
||||||
|
<div className="h-12 w-12 rounded-full bg-red-500/10 flex items-center justify-center">
|
||||||
|
<AlertTriangle className="h-6 w-6 text-red-400" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-bold">System Check Failed</h2>
|
||||||
|
<p className="text-muted-foreground">{error}</p>
|
||||||
|
<div className="flex flex-col gap-2 w-full">
|
||||||
|
<button
|
||||||
|
onClick={checkTools}
|
||||||
|
className="w-full px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-colors flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSkip}
|
||||||
|
className="w-full px-4 py-2 text-sm text-yellow-500/80 hover:text-yellow-500 hover:bg-yellow-500/10 rounded-md transition-colors"
|
||||||
|
>
|
||||||
|
Skip Checks (Unsafe)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result && !result.allExists && !skipped) {
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background text-foreground p-4">
|
||||||
|
<div className="max-w-md w-full bg-card border border-border rounded-lg shadow-lg p-6 flex flex-col gap-6">
|
||||||
|
<div className="flex flex-col items-center text-center gap-2">
|
||||||
|
<div className="h-12 w-12 rounded-full bg-red-500/10 flex items-center justify-center">
|
||||||
|
<AlertTriangle className="h-6 w-6 text-red-400" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-bold">System Check Failed</h2>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{result.mkbrrPresetExists === false
|
||||||
|
? 'The required "red" preset for mkbrr is missing.'
|
||||||
|
: 'Some required CLI tools are missing from your system. Please install them to continue.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{result.tools.map((tool) => (
|
||||||
|
<div
|
||||||
|
key={tool.name}
|
||||||
|
className={`flex items-center justify-between p-3 rounded-md border ${tool.exists
|
||||||
|
? 'bg-muted/50 border-border'
|
||||||
|
: 'bg-red-400/5 border-red-400/20'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="font-mono text-sm font-medium">{tool.name}</span>
|
||||||
|
</div>
|
||||||
|
{tool.exists ? (
|
||||||
|
<div className="flex items-center gap-2 text-green-500 text-sm">
|
||||||
|
<CheckCircle className="h-4 w-4" />
|
||||||
|
<span>Installed</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2 text-red-400 text-sm">
|
||||||
|
<AlertTriangle className="h-4 w-4" />
|
||||||
|
<span>Missing</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{result.mkbrrPresetExists === false && (
|
||||||
|
<div className="p-3 rounded-md border bg-red-400/5 border-red-400/20">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="font-mono text-sm font-medium">mkbrr preset: red</span>
|
||||||
|
<div className="flex items-center gap-2 text-red-400 text-sm">
|
||||||
|
<AlertTriangle className="h-4 w-4" />
|
||||||
|
<span>Missing</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Please create the preset by following the documentation.
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href="https://mkbrr.com/features/presets"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-xs text-primary hover:underline"
|
||||||
|
>
|
||||||
|
View Documentation
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<button
|
||||||
|
onClick={checkTools}
|
||||||
|
className="w-full px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-colors flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
Recheck Dependencies
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSkip}
|
||||||
|
className="w-full px-4 py-2 text-sm text-yellow-500/80 hover:text-yellow-500 hover:bg-yellow-500/10 rounded-md transition-colors"
|
||||||
|
>
|
||||||
|
Skip Checks (Unsafe)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const alertVariants = cva(
|
||||||
|
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-card text-card-foreground",
|
||||||
|
destructive:
|
||||||
|
"text-red-400 bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-red-400/90",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Alert({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert"
|
||||||
|
role="alert"
|
||||||
|
className={cn(alertVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-title"
|
||||||
|
className={cn(
|
||||||
|
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-description"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Alert, AlertTitle, AlertDescription }
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { motion } from "motion/react";
|
||||||
|
|
||||||
|
export function AudioWave() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-center gap-1 h-32">
|
||||||
|
{[...Array(20)].map((_, i) => (
|
||||||
|
<motion.div
|
||||||
|
key={i}
|
||||||
|
custom={i}
|
||||||
|
variants={{
|
||||||
|
initial: { height: "20%" },
|
||||||
|
animate: (i: number) => ({
|
||||||
|
height: ["20%", "70%"],
|
||||||
|
transition: {
|
||||||
|
duration: 3,
|
||||||
|
repeat: Infinity,
|
||||||
|
type: "spring",
|
||||||
|
repeatType: "reverse",
|
||||||
|
stiffness: Math.random() * 100 + 10,
|
||||||
|
damping: Math.random() * 5 + 10,
|
||||||
|
delay: i * 0.05,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}}
|
||||||
|
initial="initial"
|
||||||
|
animate="animate"
|
||||||
|
className="w-2 bg-primary/50 rounded-full"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-center gap-1 h-32 scale-120 absolute top-0 left-0 right-0">
|
||||||
|
{[...Array(20)].map((_, i) => (
|
||||||
|
<motion.div
|
||||||
|
key={i}
|
||||||
|
custom={i}
|
||||||
|
variants={{
|
||||||
|
initial: { height: "20%" },
|
||||||
|
animate: (i: number) => ({
|
||||||
|
height: ["20%", "70%"],
|
||||||
|
transition: {
|
||||||
|
duration: 8,
|
||||||
|
repeat: Infinity,
|
||||||
|
type: "spring",
|
||||||
|
repeatType: "reverse",
|
||||||
|
stiffness: Math.random() * 100 + 10,
|
||||||
|
damping: Math.random() * 5 + 10,
|
||||||
|
delay: i * 0.05,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}}
|
||||||
|
initial="initial"
|
||||||
|
animate="animate"
|
||||||
|
className="w-2 bg-primary/10 rounded-full"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||||
|
secondary:
|
||||||
|
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||||
|
destructive:
|
||||||
|
"border-transparent bg-red-400 text-white [a&]:hover:bg-red-400/90 focus-visible:ring-red-400/20 dark:focus-visible:ring-red-400/40 dark:bg-red-400/60",
|
||||||
|
outline:
|
||||||
|
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Badge({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span"> &
|
||||||
|
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||||
|
const Comp = asChild ? Slot : "span"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="badge"
|
||||||
|
className={cn(badgeVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants }
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground hover:bg-primary/90 cursor-pointer",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 cursor-pointer",
|
||||||
|
outline:
|
||||||
|
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 cursor-pointer",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
ghost:
|
||||||
|
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||||
|
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||||
|
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||||
|
icon: "size-9",
|
||||||
|
"icon-sm": "size-8",
|
||||||
|
"icon-lg": "size-10",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Button({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
size,
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"button"> &
|
||||||
|
VariantProps<typeof buttonVariants> & {
|
||||||
|
asChild?: boolean
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot : "button"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="button"
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card"
|
||||||
|
className={cn(
|
||||||
|
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-header"
|
||||||
|
className={cn(
|
||||||
|
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-title"
|
||||||
|
className={cn("leading-none font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-action"
|
||||||
|
className={cn(
|
||||||
|
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-content"
|
||||||
|
className={cn("px-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-footer"
|
||||||
|
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardFooter,
|
||||||
|
CardTitle,
|
||||||
|
CardAction,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||||
|
import { X } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Dialog = DialogPrimitive.Root
|
||||||
|
|
||||||
|
const DialogTrigger = DialogPrimitive.Trigger
|
||||||
|
|
||||||
|
const DialogPortal = DialogPrimitive.Portal
|
||||||
|
|
||||||
|
const DialogClose = DialogPrimitive.Close
|
||||||
|
|
||||||
|
const DialogOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||||
|
|
||||||
|
const DialogContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
))
|
||||||
|
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const DialogHeader = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
DialogHeader.displayName = "DialogHeader"
|
||||||
|
|
||||||
|
const DialogFooter = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
DialogFooter.displayName = "DialogFooter"
|
||||||
|
|
||||||
|
const DialogTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"text-lg font-semibold leading-none tracking-tight",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||||
|
|
||||||
|
const DialogDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogPortal,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogClose,
|
||||||
|
DialogTrigger,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogFooter,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
data-slot="input"
|
||||||
|
className={cn(
|
||||||
|
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||||
|
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Input }
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function ScrollArea({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<ScrollAreaPrimitive.Root
|
||||||
|
data-slot="scroll-area"
|
||||||
|
className={cn("relative", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.Viewport
|
||||||
|
data-slot="scroll-area-viewport"
|
||||||
|
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ScrollAreaPrimitive.Viewport>
|
||||||
|
<ScrollBar />
|
||||||
|
<ScrollAreaPrimitive.Corner />
|
||||||
|
</ScrollAreaPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScrollBar({
|
||||||
|
className,
|
||||||
|
orientation = "vertical",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||||
|
return (
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||||
|
data-slot="scroll-area-scrollbar"
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"flex touch-none p-px transition-colors select-none",
|
||||||
|
orientation === "vertical" &&
|
||||||
|
"h-full w-2.5 border-l border-l-transparent",
|
||||||
|
orientation === "horizontal" &&
|
||||||
|
"h-2.5 flex-col border-t border-t-transparent",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||||
|
data-slot="scroll-area-thumb"
|
||||||
|
className="bg-border relative flex-1 rounded-full"
|
||||||
|
/>
|
||||||
|
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ScrollArea, ScrollBar }
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||||
|
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Select({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||||
|
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||||
|
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectValue({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||||
|
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectTrigger({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||||
|
size?: "sm" | "default"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
data-slot="select-trigger"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDownIcon className="size-4 opacity-50" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
position = "popper",
|
||||||
|
align = "center",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
data-slot="select-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||||
|
position === "popper" &&
|
||||||
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
position={position}
|
||||||
|
align={align}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
className={cn(
|
||||||
|
"p-1",
|
||||||
|
position === "popper" &&
|
||||||
|
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectLabel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Label
|
||||||
|
data-slot="select-label"
|
||||||
|
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
data-slot="select-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
data-slot="select-separator"
|
||||||
|
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollUpButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollUpButton
|
||||||
|
data-slot="select-scroll-up-button"
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUpIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ScrollUpButton>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollDownButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollDownButton
|
||||||
|
data-slot="select-scroll-down-button"
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDownIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Separator({
|
||||||
|
className,
|
||||||
|
orientation = "horizontal",
|
||||||
|
decorative = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<SeparatorPrimitive.Root
|
||||||
|
data-slot="separator"
|
||||||
|
decorative={decorative}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Separator }
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="skeleton"
|
||||||
|
className={cn("bg-accent animate-pulse rounded-md", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Skeleton }
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Tabs = TabsPrimitive.Root
|
||||||
|
|
||||||
|
const TabsList = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.List>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.List
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsList.displayName = TabsPrimitive.List.displayName
|
||||||
|
|
||||||
|
const TabsTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||||
|
|
||||||
|
const TabsContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||||
|
|
||||||
|
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig, globalIgnores } from "eslint/config";
|
||||||
|
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||||
|
import nextTs from "eslint-config-next/typescript";
|
||||||
|
|
||||||
|
const eslintConfig = defineConfig([
|
||||||
|
...nextVitals,
|
||||||
|
...nextTs,
|
||||||
|
// Override default ignores of eslint-config-next.
|
||||||
|
globalIgnores([
|
||||||
|
// Default ignores of eslint-config-next:
|
||||||
|
".next/**",
|
||||||
|
"out/**",
|
||||||
|
"build/**",
|
||||||
|
"next-env.d.ts",
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export default eslintConfig;
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"response": {
|
||||||
|
"group": {
|
||||||
|
"wikiBody": "1. Não a Nós, Senhor<br \/>\r\n2. Jesus É Rei<br \/>\r\n3. Jesus É o Rei de Toda a Terra<br \/>\r\n4. Te Adoramos Rei dos Reis<br \/>\r\n5. Eu Te Amo<br \/>\r\n6. Salmo 103<br \/>\r\n7. As Misericórdias do Senhor<br \/>\r\n8. Os Teus Tabernáculos<br \/>\r\n9. Glória, Força, Honra e Poder<br \/>\r\n10. Digno És Tu Senhor<br \/>\r\n11. Te Adorar Meu Senhor<br \/>\r\n12. Sonda Me Senhor<br \/>\r\n13. Jesus É o Caminho<br \/>\r\n14. Recebe Senhor<br \/>\r\n15. Ser<br \/>\r\n16. Tu És Deus<br \/>\r\n17. Te Exaltarei<br \/>\r\n18. Reina em Mim<br \/>\r\n19. Buscarei<br \/>\r\n20. Poderoso",
|
||||||
|
"bbBody": "1. Não a Nós, Senhor\r\n2. Jesus É Rei\r\n3. Jesus É o Rei de Toda a Terra\r\n4. Te Adoramos Rei dos Reis\r\n5. Eu Te Amo\r\n6. Salmo 103\r\n7. As Misericórdias do Senhor\r\n8. Os Teus Tabernáculos\r\n9. Glória, Força, Honra e Poder\r\n10. Digno És Tu Senhor\r\n11. Te Adorar Meu Senhor\r\n12. Sonda Me Senhor\r\n13. Jesus É o Caminho\r\n14. Recebe Senhor\r\n15. Ser\r\n16. Tu És Deus\r\n17. Te Exaltarei\r\n18. Reina em Mim\r\n19. Buscarei\r\n20. Poderoso",
|
||||||
|
"wikiImage": "https:\/\/ptpimg.me\/b2f93a.jpg",
|
||||||
|
"id": 2658523,
|
||||||
|
"name": "Ca\u0302nticos de Intimidade",
|
||||||
|
"year": 2007,
|
||||||
|
"recordLabel": "",
|
||||||
|
"catalogueNumber": "",
|
||||||
|
"releaseType": 1,
|
||||||
|
"categoryId": 1,
|
||||||
|
"categoryName": "Music",
|
||||||
|
"time": "2025-11-30 23:22:30",
|
||||||
|
"collages": null,
|
||||||
|
"personalCollages": null,
|
||||||
|
"vanityHouse": false,
|
||||||
|
"isBookmarked": false,
|
||||||
|
"musicInfo": {
|
||||||
|
"composers": [],
|
||||||
|
"dj": [],
|
||||||
|
"artists": [
|
||||||
|
{
|
||||||
|
"id": 1053992,
|
||||||
|
"name": "Daniel Souza"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"with": [
|
||||||
|
{
|
||||||
|
"id": 1053995,
|
||||||
|
"name": "Asaph Borba"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 1867489,
|
||||||
|
"name": "Luciene Silva"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"conductor": [],
|
||||||
|
"remixedBy": [],
|
||||||
|
"producer": []
|
||||||
|
},
|
||||||
|
"tags": [
|
||||||
|
"gospel",
|
||||||
|
"jazz",
|
||||||
|
"christian",
|
||||||
|
"worship",
|
||||||
|
"brazilian"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"torrent": {
|
||||||
|
"id": 5909266,
|
||||||
|
"infoHash": "3C2146F36B2F1E4DAE3EF38FFDE1ECA215AA64AD",
|
||||||
|
"media": "WEB",
|
||||||
|
"format": "FLAC",
|
||||||
|
"encoding": "Lossless",
|
||||||
|
"remastered": true,
|
||||||
|
"remasterYear": 2007,
|
||||||
|
"remasterTitle": "",
|
||||||
|
"remasterRecordLabel": "Daniel Souza",
|
||||||
|
"remasterCatalogueNumber": "7027306347229",
|
||||||
|
"scene": false,
|
||||||
|
"hasLog": false,
|
||||||
|
"hasCue": false,
|
||||||
|
"logScore": 0,
|
||||||
|
"ripLogIds": [],
|
||||||
|
"fileCount": 21,
|
||||||
|
"size": 502086702,
|
||||||
|
"canUseToken": true,
|
||||||
|
"seeders": 1,
|
||||||
|
"leechers": 0,
|
||||||
|
"snatched": 0,
|
||||||
|
"has_snatched": false,
|
||||||
|
"trumpable": false,
|
||||||
|
"trumpable_reasons": [],
|
||||||
|
"lossyWebApproved": false,
|
||||||
|
"lossyMasterApproved": false,
|
||||||
|
"freeTorrent": false,
|
||||||
|
"isNeutralleech": false,
|
||||||
|
"isFreeload": false,
|
||||||
|
"reported": false,
|
||||||
|
"time": "2025-11-30 23:22:30",
|
||||||
|
"description": "Sourced from TIDAL",
|
||||||
|
"fileList": "01 - Nao a Nos, Senhor.flac{{{29622105}}}|||02 - Jesus E Rei.flac{{{22892945}}}|||03 - Jesus E o Rei de Toda a Terra.flac{{{23442726}}}|||04 - Te Adoramos Rei dos Reis.flac{{{38852326}}}|||05 - Eu Te Amo.flac{{{19745511}}}|||06 - Salmo 103.flac{{{23952192}}}|||07 - As Misericordias do Senhor.flac{{{23323793}}}|||08 - Os Teus Tabernaculos.flac{{{18271370}}}|||09 - Gloria, Forca, Honra e Poder.flac{{{12328958}}}|||10 - Digno Es Tu Senhor.flac{{{25236570}}}|||11 - Te Adorar Meu Senhor.flac{{{37041630}}}|||12 - Sonda Me Senhor.flac{{{21337370}}}|||13 - Jesus E o Caminho.flac{{{21333085}}}|||14 - Recebe Senhor.flac{{{31844584}}}|||15 - Ser.flac{{{30673580}}}|||16 - Tu Es Deus.flac{{{18789112}}}|||17 - Te Exaltarei.flac{{{18988544}}}|||18 - Reina em Mim.flac{{{38962809}}}|||19 - Buscarei.flac{{{23038407}}}|||20 - Poderoso.flac{{{22170771}}}|||cover.jpg{{{238314}}}",
|
||||||
|
"filePath": "Daniel Souza - Canticos de Intimidade (2007) [FLAC]",
|
||||||
|
"userId": 67749,
|
||||||
|
"username": "anon28410"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import parseTorrent from 'parse-torrent';
|
||||||
|
|
||||||
|
const QBIT_URL = process.env.QUI_CLIENT_PROXY;
|
||||||
|
|
||||||
|
if (!QBIT_URL) {
|
||||||
|
console.warn("QUI_CLIENT_PROXY is not set. qBittorrent integration will not work.");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addTorrent(fileBuffer: Buffer, filename: string, category: string = 'RED'): Promise<string> {
|
||||||
|
if (!QBIT_URL) throw new Error("QUI_CLIENT_PROXY not configured");
|
||||||
|
|
||||||
|
// Parse torrent to get hash
|
||||||
|
const parsed = await parseTorrent(fileBuffer);
|
||||||
|
const hash = parsed.infoHash;
|
||||||
|
|
||||||
|
if (!hash) throw new Error("Could not parse info hash from torrent file");
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
const blob = new Blob([new Uint8Array(fileBuffer)], { type: 'application/x-bittorrent' });
|
||||||
|
formData.append('torrents', blob, filename);
|
||||||
|
formData.append('stopped', 'true'); // Add in paused state
|
||||||
|
formData.append('tags', 'Crossfade');
|
||||||
|
formData.append('category', category); // Add category
|
||||||
|
formData.append('skip_checking', 'true');
|
||||||
|
// formData.append('autoTMM', 'false');
|
||||||
|
// formData.append('savepath', '/downloads/'); // Optional: let qbit use default
|
||||||
|
|
||||||
|
const res = await fetch(`${QBIT_URL}/api/v2/torrents/add`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Failed to add torrent: ${res.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function recheckTorrent(hash: string) {
|
||||||
|
if (!QBIT_URL) throw new Error("QUI_CLIENT_PROXY not configured");
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('hashes', hash);
|
||||||
|
|
||||||
|
const res = await fetch(`${QBIT_URL}/api/v2/torrents/recheck`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Failed to trigger recheck: ${res.statusText}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resumeTorrent(hash: string) {
|
||||||
|
if (!QBIT_URL) throw new Error("QUI_CLIENT_PROXY not configured");
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('hashes', hash);
|
||||||
|
|
||||||
|
const res = await fetch(`${QBIT_URL}/api/v2/torrents/start`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Failed to resume torrent: ${res.statusText}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TorrentInfo {
|
||||||
|
hash: string;
|
||||||
|
name: string;
|
||||||
|
state: string;
|
||||||
|
progress: number;
|
||||||
|
size: number;
|
||||||
|
added_on: number;
|
||||||
|
completion_on: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTorrentInfo(hash: string): Promise<TorrentInfo | null> {
|
||||||
|
if (!QBIT_URL) throw new Error("QUI_CLIENT_PROXY not configured");
|
||||||
|
|
||||||
|
const res = await fetch(`${QBIT_URL}/api/v2/torrents/info?hashes=${hash}`);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Failed to get torrent info: ${res.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (Array.isArray(data) && data.length > 0) {
|
||||||
|
//console.log(data);
|
||||||
|
return data[0] as TorrentInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { exec } from 'child_process';
|
||||||
|
import { promisify } from 'util';
|
||||||
|
import * as fs from 'fs/promises';
|
||||||
|
import * as path from 'path';
|
||||||
|
import * as os from 'os';
|
||||||
|
|
||||||
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
|
export type ToolStatus = {
|
||||||
|
name: string;
|
||||||
|
exists: boolean;
|
||||||
|
path?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ToolCheckResult = {
|
||||||
|
allExists: boolean;
|
||||||
|
tools: ToolStatus[];
|
||||||
|
mkbrrPresetExists?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const REQUIRED_TOOLS = ['sox', 'mkbrr', 'salmon'];
|
||||||
|
|
||||||
|
async function checkMkbrrPreset(): Promise<boolean> {
|
||||||
|
const tempDir = os.tmpdir();
|
||||||
|
const dummyFile = path.join(tempDir, `mkbrr_check_${Date.now()}`);
|
||||||
|
const outputFile = `${dummyFile}.torrent`;
|
||||||
|
const presetName = 'red';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create a dummy file
|
||||||
|
await fs.writeFile(dummyFile, 'test');
|
||||||
|
|
||||||
|
// Try to create a torrent with the red preset
|
||||||
|
// We expect this to fail if the preset doesn't exist
|
||||||
|
// If it succeeds (or fails for other reasons but finds the preset), we're good
|
||||||
|
try {
|
||||||
|
// Explicitly set output file to ensure we know where it is for cleanup
|
||||||
|
await execAsync(`mkbrr create "${dummyFile}" -P ${presetName} -o "${outputFile}"`);
|
||||||
|
return true;
|
||||||
|
} catch (error: any) {
|
||||||
|
// Check for the specific error message indicating the preset is missing
|
||||||
|
// We use the presetName variable so this logic holds if we change the preset for testing
|
||||||
|
if (error.stderr && error.stderr.includes(`preset "${presetName}" not found`)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// If it failed for another reason (e.g. file issue), but didn't complain about the preset,
|
||||||
|
// we assume the preset exists.
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking mkbrr preset:', error);
|
||||||
|
return false; // Fail safe
|
||||||
|
} finally {
|
||||||
|
// Cleanup
|
||||||
|
try {
|
||||||
|
await fs.unlink(dummyFile);
|
||||||
|
await fs.unlink(outputFile).catch(() => { }); // Ignore if torrent wasn't created
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore cleanup errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkTools(): Promise<ToolCheckResult> {
|
||||||
|
const results: ToolStatus[] = await Promise.all(
|
||||||
|
REQUIRED_TOOLS.map(async (tool) => {
|
||||||
|
try {
|
||||||
|
const { stdout } = await execAsync(`which ${tool}`);
|
||||||
|
return {
|
||||||
|
name: tool,
|
||||||
|
exists: true,
|
||||||
|
path: stdout.trim(),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
name: tool,
|
||||||
|
exists: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const allExists = results.every((result) => result.exists);
|
||||||
|
|
||||||
|
let mkbrrPresetExists = true;
|
||||||
|
const mkbrrStatus = results.find(r => r.name === 'mkbrr');
|
||||||
|
if (mkbrrStatus && mkbrrStatus.exists) {
|
||||||
|
mkbrrPresetExists = await checkMkbrrPreset();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
allExists: allExists && mkbrrPresetExists,
|
||||||
|
tools: results,
|
||||||
|
mkbrrPresetExists,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { parseFile } from 'music-metadata';
|
||||||
|
|
||||||
|
export interface AudioMetadata {
|
||||||
|
format: {
|
||||||
|
sampleRate?: number;
|
||||||
|
bitsPerSample?: number;
|
||||||
|
duration?: number;
|
||||||
|
bitrate?: number;
|
||||||
|
};
|
||||||
|
common: {
|
||||||
|
title?: string;
|
||||||
|
artist?: string;
|
||||||
|
album?: string;
|
||||||
|
track?: { no: number | null; of: number | null };
|
||||||
|
disk?: { no: number | null; of: number | null };
|
||||||
|
year?: number;
|
||||||
|
date?: string;
|
||||||
|
copyright?: string;
|
||||||
|
barcode?: string; // UPC
|
||||||
|
artists?: string[];
|
||||||
|
albumartist?: string;
|
||||||
|
};
|
||||||
|
native?: {
|
||||||
|
tidalUrl?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getFlacMetadata(filePath: string): Promise<AudioMetadata> {
|
||||||
|
try {
|
||||||
|
const metadata = await parseFile(filePath);
|
||||||
|
|
||||||
|
// Extract Tidal URL from native tags if available
|
||||||
|
let tidalUrl: string | undefined;
|
||||||
|
if (metadata.native && metadata.native.vorbis) {
|
||||||
|
const urlTag = metadata.native.vorbis.find(tag => tag.id === 'URL');
|
||||||
|
if (urlTag) {
|
||||||
|
tidalUrl = String(urlTag.value);
|
||||||
|
}
|
||||||
|
//console.log(metadata.native.vorbis);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
format: {
|
||||||
|
sampleRate: metadata.format.sampleRate,
|
||||||
|
bitsPerSample: metadata.format.bitsPerSample,
|
||||||
|
duration: metadata.format.duration,
|
||||||
|
bitrate: metadata.format.bitrate,
|
||||||
|
},
|
||||||
|
common: {
|
||||||
|
title: metadata.common.title,
|
||||||
|
artist: metadata.common.artist,
|
||||||
|
album: metadata.common.album,
|
||||||
|
track: metadata.common.track,
|
||||||
|
disk: metadata.common.disk,
|
||||||
|
year: metadata.common.year,
|
||||||
|
date: metadata.common.date,
|
||||||
|
copyright: metadata.common.copyright,
|
||||||
|
barcode: (metadata.native?.vorbis?.find(tag => tag.id === 'UPC')?.value as string) || metadata.common.barcode,
|
||||||
|
artists: metadata.common.artists,
|
||||||
|
albumartist: metadata.common.albumartist,
|
||||||
|
},
|
||||||
|
native: {
|
||||||
|
tidalUrl
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error parsing metadata for ${filePath}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseLabel(rawLabel: string): string {
|
||||||
|
let label = rawLabel;
|
||||||
|
|
||||||
|
const labelCleaners = [
|
||||||
|
// Remove year and copyright/phonorecord symbols (strict 1900-2099 range)
|
||||||
|
(l: string) => l.replace(/(\(c\)|\(p\)|\u00A9|\u2117)?\s*\b(19|20)\d{2}\b\s*/gi, '').trim(),
|
||||||
|
// Remove leading/trailing copyright and phonorecord symbols
|
||||||
|
(l: string) => l.replace(/^(\(c\)|\(p\)|\u00A9|\u2117|\s)+/gi, '').replace(/(\(c\)|\(p\)|\u00A9|\u2117|\s)+$/gi, '').trim(),
|
||||||
|
// Handle "under exclusive license to"
|
||||||
|
(l: string) => {
|
||||||
|
const match = l.match(/under exclusive license to\s+(.*)/i);
|
||||||
|
return match ? match[1].trim() : l;
|
||||||
|
},
|
||||||
|
// Handle "exclusively distributed by"
|
||||||
|
(l: string) => {
|
||||||
|
const match = l.match(/exclusively distributed by\s+(.*)/i);
|
||||||
|
return match ? match[1].trim() : l;
|
||||||
|
},
|
||||||
|
// Handle "sob licença exclusiva de" (Portuguese)
|
||||||
|
(l: string) => {
|
||||||
|
const match = l.match(/sob licen[çc]a exclusiva de\s+(.*)/i);
|
||||||
|
return match ? match[1].trim() : l;
|
||||||
|
},
|
||||||
|
// Handle "under license to" (non-exclusive) - keep text BEFORE this
|
||||||
|
(l: string) => {
|
||||||
|
const match = l.match(/(.*)\s+under license to/i);
|
||||||
|
return match ? match[1].trim().replace(/,$/, '').trim() : l;
|
||||||
|
},
|
||||||
|
// Handle ", a division of"
|
||||||
|
(l: string) => {
|
||||||
|
const match = l.match(/(.*), a division of/i);
|
||||||
|
return match ? match[1].trim() : l;
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const cleaner of labelCleaners) {
|
||||||
|
label = cleaner(label);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blank out if it contains "Records DK"
|
||||||
|
if (label.includes("Records DK")) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return label;
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
|
||||||
|
import fs from 'fs-extra';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively copies a directory or file using streams to avoid 'copyfile' syscall issues (EINVAL)
|
||||||
|
* on some cross-volume operations.
|
||||||
|
*/
|
||||||
|
export async function copyDirRecursive(src: string, dest: string) {
|
||||||
|
const stats = await fs.stat(src);
|
||||||
|
|
||||||
|
if (stats.isDirectory()) {
|
||||||
|
await fs.ensureDir(dest);
|
||||||
|
const files = await fs.readdir(src);
|
||||||
|
|
||||||
|
await Promise.all(files.map(async (file) => {
|
||||||
|
const srcPath = path.join(src, file);
|
||||||
|
const destPath = path.join(dest, file);
|
||||||
|
await copyDirRecursive(srcPath, destPath);
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
await copyFileStream(src, dest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyFileStream(src: string, dest: string) {
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
const readStream = fs.createReadStream(src);
|
||||||
|
const writeStream = fs.createWriteStream(dest);
|
||||||
|
|
||||||
|
readStream.on('error', reject);
|
||||||
|
writeStream.on('error', reject);
|
||||||
|
writeStream.on('finish', resolve);
|
||||||
|
|
||||||
|
readStream.pipe(writeStream);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import path from 'path';
|
||||||
|
import fs from 'fs-extra';
|
||||||
|
|
||||||
|
// The watch folder is in the project root, which is the parent of the 'src' directory.
|
||||||
|
// process.cwd() in Next.js usually points to the project root (where package.json is).
|
||||||
|
// If package.json is in 'src', then process.cwd() is 'src'.
|
||||||
|
// Let's assume the structure:
|
||||||
|
// /Project
|
||||||
|
// /src (Next.js app)
|
||||||
|
// /watch
|
||||||
|
|
||||||
|
export const WATCH_DIR_NAME = 'watch';
|
||||||
|
export const SPECTROGRAMS_DIR_NAME = 'spectrograms';
|
||||||
|
|
||||||
|
export function getWatchPath(): string {
|
||||||
|
if (process.env.WATCH_DIR) {
|
||||||
|
return process.env.WATCH_DIR;
|
||||||
|
}
|
||||||
|
|
||||||
|
// We assume the app is running from 'src' or the root containing 'src'.
|
||||||
|
// If process.cwd() is .../Crossfade/src, then watch is ../watch
|
||||||
|
// If process.cwd() is .../Crossfade, then watch is ./watch
|
||||||
|
|
||||||
|
const cwd = process.cwd();
|
||||||
|
if (cwd.endsWith('src')) {
|
||||||
|
return path.join(cwd, '..', WATCH_DIR_NAME);
|
||||||
|
}
|
||||||
|
return path.join(cwd, WATCH_DIR_NAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSpectrogramsPath(): string {
|
||||||
|
const cwd = process.cwd();
|
||||||
|
if (cwd.endsWith('src')) {
|
||||||
|
return path.join(cwd, '..', SPECTROGRAMS_DIR_NAME);
|
||||||
|
}
|
||||||
|
return path.join(cwd, SPECTROGRAMS_DIR_NAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureWatchDir() {
|
||||||
|
const watchPath = getWatchPath();
|
||||||
|
await fs.ensureDir(watchPath);
|
||||||
|
return watchPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureSpectrogramsDir() {
|
||||||
|
const specPath = getSpectrogramsPath();
|
||||||
|
await fs.ensureDir(specPath);
|
||||||
|
return specPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOutputDir(): string | null {
|
||||||
|
const outputDir = process.env.OUTPUT_DIR;
|
||||||
|
if (!outputDir) return null;
|
||||||
|
return outputDir;
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import sanitize from 'sanitize-filename';
|
||||||
|
|
||||||
|
export function sanitizeName(name: string): string {
|
||||||
|
// Use the same logic as album name to keep consistency and allow brackets
|
||||||
|
return sanitizeAlbumName(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeAlbumName(name: string): string {
|
||||||
|
// 1. Replace " / " or "/" with ", "
|
||||||
|
let clean = name.replace(/\s*\/\s*/g, ', ');
|
||||||
|
|
||||||
|
// 1.5 Replace "&" with "and"
|
||||||
|
clean = clean.replace(/&/g, 'and');
|
||||||
|
|
||||||
|
// 2. Remove accents/diacritics (e.g. "Espírito" -> "Espirito")
|
||||||
|
// Note: Re-normalizing to NFC to correctly recompose Korean Hangul characters
|
||||||
|
clean = clean.normalize("NFD").replace(/[\u0300-\u036f]/g, "").normalize("NFC");
|
||||||
|
|
||||||
|
// 3. Remove non-ascii characters but keep [], (), spaces, hyphens and plus signs.
|
||||||
|
// Also allow Asian characters (Chinese, Japanese, Korean) along with CJK punctuation.
|
||||||
|
const allowed = /[^a-zA-Z0-9 \-_.\[\](),+\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}\u3000-\u303F\uFF00-\uFFEF]/gu;
|
||||||
|
|
||||||
|
clean = clean.replace(allowed, '');
|
||||||
|
|
||||||
|
// 4. Remove double spaces
|
||||||
|
clean = clean.replace(/\s+/g, ' ');
|
||||||
|
|
||||||
|
return clean.trim();
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
|
||||||
|
function verifyRedFixes() {
|
||||||
|
console.log("--- Verifying RED Template Helper Functions ---");
|
||||||
|
|
||||||
|
// Test Split Logic which was duplicated
|
||||||
|
const splitArtists = (artistString: string) => {
|
||||||
|
return artistString.split(/,\s+|&|\s+\/\s+/).map((a: string) => a.trim()).filter((a: string) => a.length > 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("--- Artist Split Check ---");
|
||||||
|
const testStr = "Artist A, Artist B & Artist C";
|
||||||
|
const res = splitArtists(testStr);
|
||||||
|
console.log(`Input: "${testStr}"`);
|
||||||
|
console.log(`Output: ${JSON.stringify(res)}`);
|
||||||
|
if (res.length === 3 && res[1] === "Artist B") {
|
||||||
|
console.log("PASS: Artist split logic works.");
|
||||||
|
} else {
|
||||||
|
console.log("FAIL: Artist split logic failed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("\n--- RED Artist Logic Check ---");
|
||||||
|
// Simulate the logic in route.ts
|
||||||
|
const mainArtistName = "Artist A, Artist B";
|
||||||
|
const allUniqueArtists = new Set(["Artist A", "Artist B", "Artist C"]);
|
||||||
|
|
||||||
|
const mainArtistComponents = splitArtists(mainArtistName);
|
||||||
|
const mainArtists = mainArtistComponents.map(name => ({ name }));
|
||||||
|
const withArtists: { name: string }[] = [];
|
||||||
|
|
||||||
|
allUniqueArtists.forEach(a => {
|
||||||
|
if (!mainArtistComponents.includes(a) && a !== 'Various Artists') {
|
||||||
|
withArtists.push({ name: a });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Main Artist Input: "${mainArtistName}"`);
|
||||||
|
console.log(`Main Artists Output: ${JSON.stringify(mainArtists)}`);
|
||||||
|
console.log(`With Artists Output: ${JSON.stringify(withArtists)}`);
|
||||||
|
|
||||||
|
if (mainArtists.length === 2 && withArtists.length === 1 && withArtists[0].name === "Artist C") {
|
||||||
|
console.log("PASS: RED Template artist logic correct.");
|
||||||
|
} else {
|
||||||
|
console.log("FAIL: RED Template artist logic incorrect.");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("\n--- RED Label Logic Check ---");
|
||||||
|
let label = "4059882 Records DK";
|
||||||
|
label = label.replace(/(\(c\)|\u00A9|\u2117)?\s*\b(19|20)\d{2}\b\s*/gi, '').trim();
|
||||||
|
console.log(`Label Input: "4059882 Records DK"`);
|
||||||
|
console.log(`Label Output: "${label}"`);
|
||||||
|
|
||||||
|
if (label === "4059882 Records DK") {
|
||||||
|
console.log("PASS: Label preserved.");
|
||||||
|
} else {
|
||||||
|
console.log("FAIL: Label corrupted.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
verifyRedFixes();
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
/* config options here */
|
||||||
|
reactCompiler: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
Generated
+8345
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,51 @@
|
|||||||
|
{
|
||||||
|
"name": "app",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "eslint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@ctrl/qbittorrent": "^9.11.0",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
|
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||||
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
|
"@types/fs-extra": "^11.0.4",
|
||||||
|
"@types/mime": "^3.0.4",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"fs-extra": "^11.3.2",
|
||||||
|
"lucide-react": "^0.555.0",
|
||||||
|
"mime": "^4.1.0",
|
||||||
|
"motion": "^12.23.25",
|
||||||
|
"music-metadata": "^11.10.3",
|
||||||
|
"next": "^16.0.7",
|
||||||
|
"parse-torrent": "^11.0.19",
|
||||||
|
"react": "19.2.0",
|
||||||
|
"react-dom": "19.2.0",
|
||||||
|
"sanitize-filename": "^1.6.3",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
|
"tailwind-merge": "^3.4.0",
|
||||||
|
"tailwindcss-animate": "^1.0.7"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4.1.17",
|
||||||
|
"@types/node": "^20.19.25",
|
||||||
|
"@types/parse-torrent": "^5.8.8",
|
||||||
|
"@types/react": "^19.2.7",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"babel-plugin-react-compiler": "1.0.0",
|
||||||
|
"baseline-browser-mapping": "^2.10.18",
|
||||||
|
"eslint": "^9.39.1",
|
||||||
|
"eslint-config-next": "16.0.6",
|
||||||
|
"tailwindcss": "^4",
|
||||||
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"typescript": "^5.9.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||||
|
After Width: | Height: | Size: 391 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||||
|
After Width: | Height: | Size: 128 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||||
|
After Width: | Height: | Size: 385 B |
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts",
|
||||||
|
"**/*.mts"
|
||||||
|
],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
+258
@@ -0,0 +1,258 @@
|
|||||||
|
export interface Artist {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MusicInfo {
|
||||||
|
composers: Artist[];
|
||||||
|
dj: Artist[];
|
||||||
|
artists: Artist[];
|
||||||
|
with: Artist[];
|
||||||
|
conductor: Artist[];
|
||||||
|
remixedBy: Artist[];
|
||||||
|
producer: Artist[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AlbumGroup {
|
||||||
|
wikiBody: string;
|
||||||
|
bbBody: string;
|
||||||
|
wikiImage: string;
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
year: number;
|
||||||
|
recordLabel: string;
|
||||||
|
catalogueNumber: string;
|
||||||
|
releaseType: number;
|
||||||
|
categoryId: number;
|
||||||
|
categoryName: string;
|
||||||
|
time: string;
|
||||||
|
collages: unknown[] | null;
|
||||||
|
personalCollages: unknown[] | null;
|
||||||
|
vanityHouse: boolean;
|
||||||
|
isBookmarked: boolean;
|
||||||
|
musicInfo: MusicInfo;
|
||||||
|
tags: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TorrentFile {
|
||||||
|
fileList: string;
|
||||||
|
filePath: string;
|
||||||
|
userId: number;
|
||||||
|
username: string;
|
||||||
|
id: number;
|
||||||
|
infoHash: string;
|
||||||
|
media: string;
|
||||||
|
format: string;
|
||||||
|
encoding: string;
|
||||||
|
remastered: boolean;
|
||||||
|
remasterYear: number;
|
||||||
|
remasterTitle: string;
|
||||||
|
remasterRecordLabel: string;
|
||||||
|
remasterCatalogueNumber: string;
|
||||||
|
scene: boolean;
|
||||||
|
hasLog: boolean;
|
||||||
|
hasCue: boolean;
|
||||||
|
logScore: number;
|
||||||
|
ripLogIds: unknown[];
|
||||||
|
fileCount: number;
|
||||||
|
size: number;
|
||||||
|
canUseToken: boolean;
|
||||||
|
seeders: number;
|
||||||
|
leechers: number;
|
||||||
|
snatched: number;
|
||||||
|
has_snatched: boolean;
|
||||||
|
trumpable: boolean;
|
||||||
|
trumpable_reasons: unknown[];
|
||||||
|
lossyWebApproved: boolean;
|
||||||
|
lossyMasterApproved: boolean;
|
||||||
|
freeTorrent: boolean;
|
||||||
|
isNeutralleech: boolean;
|
||||||
|
isFreeload: boolean;
|
||||||
|
reported: boolean;
|
||||||
|
time: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AlbumResponse {
|
||||||
|
group: AlbumGroup;
|
||||||
|
torrent: TorrentFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiResponse<T> {
|
||||||
|
status: string;
|
||||||
|
response: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Track {
|
||||||
|
filename: string;
|
||||||
|
title: string;
|
||||||
|
trackNumber: string;
|
||||||
|
length?: string; // Duration might be useful
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalAlbumData {
|
||||||
|
path: string;
|
||||||
|
artist: string;
|
||||||
|
title: string;
|
||||||
|
year: string;
|
||||||
|
tracks: Track[];
|
||||||
|
coverArtPath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AudioMetadata {
|
||||||
|
format: {
|
||||||
|
sampleRate?: number;
|
||||||
|
bitsPerSample?: number;
|
||||||
|
duration?: number;
|
||||||
|
bitrate?: number;
|
||||||
|
};
|
||||||
|
common: {
|
||||||
|
title?: string;
|
||||||
|
artist?: string;
|
||||||
|
album?: string;
|
||||||
|
track?: { no: number | null; of: number | null };
|
||||||
|
disk?: { no: number | null; of: number | null };
|
||||||
|
year?: number;
|
||||||
|
date?: string;
|
||||||
|
copyright?: string;
|
||||||
|
barcode?: string; // UPC
|
||||||
|
artists?: string[];
|
||||||
|
albumartist?: string;
|
||||||
|
};
|
||||||
|
native?: {
|
||||||
|
tidalUrl?: string;
|
||||||
|
vorbis?: { id: string; value: any }[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AnalysisTrack =
|
||||||
|
| { filename: string; path: string; error: string }
|
||||||
|
| {
|
||||||
|
filename: string;
|
||||||
|
path: string;
|
||||||
|
format: AudioMetadata['format'];
|
||||||
|
common: AudioMetadata['common'];
|
||||||
|
native?: AudioMetadata['native'];
|
||||||
|
error?: never;
|
||||||
|
fullPathLength?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface AnalysisResponse {
|
||||||
|
albumPath: string;
|
||||||
|
metadata: {
|
||||||
|
artist: string;
|
||||||
|
album: string;
|
||||||
|
year?: number;
|
||||||
|
upc?: string;
|
||||||
|
label: string;
|
||||||
|
tidalUrl?: string;
|
||||||
|
additionalArtists?: string[];
|
||||||
|
releaseType: number;
|
||||||
|
totalSize: number;
|
||||||
|
};
|
||||||
|
tracks: AnalysisTrack[];
|
||||||
|
quality: {
|
||||||
|
status: string;
|
||||||
|
mixed: boolean;
|
||||||
|
details: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TorrentCreationResponse =
|
||||||
|
| {
|
||||||
|
success: true;
|
||||||
|
output: string;
|
||||||
|
filename: string;
|
||||||
|
data: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
success: false;
|
||||||
|
output: string;
|
||||||
|
errorOutput: string;
|
||||||
|
error: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface SpectrogramResponse {
|
||||||
|
results: Record<string, { exists: boolean; url?: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CopyResponse {
|
||||||
|
success: boolean;
|
||||||
|
destPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RenameOperation {
|
||||||
|
original: string;
|
||||||
|
newName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RenameResult extends RenameOperation {
|
||||||
|
success: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BatchRenameResponse {
|
||||||
|
success: boolean;
|
||||||
|
results: RenameResult[];
|
||||||
|
errors?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TorrentInfo {
|
||||||
|
hash: string;
|
||||||
|
name: string;
|
||||||
|
state: string;
|
||||||
|
progress: number;
|
||||||
|
size: number;
|
||||||
|
added_on: number;
|
||||||
|
completion_on: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QbittorrentInjectResponse {
|
||||||
|
success: boolean;
|
||||||
|
hash: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QbittorrentResumeResponse {
|
||||||
|
success: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QbittorrentStatusResponse {
|
||||||
|
success: boolean;
|
||||||
|
info: TorrentInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CheckMQAResponse {
|
||||||
|
success: boolean;
|
||||||
|
isMQA: boolean;
|
||||||
|
output: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CheckUpconvResponse {
|
||||||
|
success: boolean;
|
||||||
|
output: string;
|
||||||
|
wastedBits: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Album {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
hasCover: boolean;
|
||||||
|
coverPath: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RenameProposal {
|
||||||
|
original: string;
|
||||||
|
strategies: {
|
||||||
|
remove_brackets: string | null;
|
||||||
|
remove_parens: string | null;
|
||||||
|
remove_both: string | null;
|
||||||
|
truncate: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProposeRenameResponse {
|
||||||
|
success: boolean;
|
||||||
|
problematicFiles: RenameProposal[];
|
||||||
|
allFiles?: RenameProposal[];
|
||||||
|
maxFileLength: number;
|
||||||
|
}
|
||||||
|
|
||||||
+353
@@ -0,0 +1,353 @@
|
|||||||
|
// ==UserScript==
|
||||||
|
// @name Redacted :: Import JSON to Upload Form
|
||||||
|
// @namespace https://greasyfork.org/en/scripts/393534-redacted-import-json-to-upload-form
|
||||||
|
// @version 2.0.0
|
||||||
|
// @description Adds buttons to attach and parse a .json file from NWCD, OPS, or Crossfade to fill in the upload form.
|
||||||
|
// @author newstarshipsmell & rewritten by anon28410 for Crossfade
|
||||||
|
// @include /https://redacted\.sh/upload\.php/
|
||||||
|
// @icon data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAOTSURBVFhH7ZdbSJNhHMY/TTfCusjMtCiaMjFdXmQwL2wX4YgowguDEsGEKIjCSJC8yc4SeeguKI2oC7uolZrK1kkJs1Ir0dKmlkWGh06C0Zmn//9l73pbn+sru3TwY9vzPv/nfeb3bh9qADSDjxgil6gk7hOvCR5mxog24gSRTURNTED7E2JvAwVSCBchNzPKZcKut7HESIGThAg0m81YsyYLpaWVaGpqx8DAKMbHv+P9+2/o7x/B9et3UVJyAk5npvDKOaKKfJoewQrMJvgTICwsDNnZ2/HkySu8ffvFEH19w9iypUAt0kjMoTVNJViBWgIxMbGoqWnG6OhHQXt7HwoKDiM9fS1iYxeIciaTGfPmRSM1dSW2bSvEjRsdfr/Hcw+LFi2RJTxEOOmaZLICfMhog4Vobe3B0NAEentHkJOzQ2zIa3/C4cgUZXmWny2WOLl2ljRNolfASSA83ASXqxmDg+NoaelFQsJSGWCYqKhof4bH04GIiAi5tok0jdEr4CawdWsRvN431P4ZrNZkOfjXREbOxbVrD0VWYWGZ1G/Se40JLLCaoOs5nzZ+ju7uEWRkbJRD/8yyZXZ0dQ2js3MIixf7L0UO5f9WoJpAbu4edNwfQmVlA0JCQuTAlDh4sEpk7io4JrU6ev9LgRm8EBoaStftHh2+F0hLWyfNUyYpKVVkut3d9K0xSX2mWiCVRavVhqbmp6ipe2j4xBvlzNkmkW2zOaS2Wi2Qz6LTuRnuq17k51dI038jL2+fyM7K2i21Q2oBcf237yxHXX0PHKtypEkXE1FC9BMDvtes6XklK1asF9mFRSel1qAW6GSx5KgLFy91ISFxuTTpcoR4E8BeQs8rsViSRHZFRb3UvGoBcWu12zeQMVEaJoU/eWAB1vS8Kpydlu7/an9QC3zziYb41wKBqAWEYLEko/jAedQ3PEZb20u6zb7D2Ngn/4BE7xIUE4E+uueLec5pvfMCpRWXEB9v86+rBT6yULz/AqrPP0CjuzdoAXkI+QAGO4RqgbuUx7nl5Q1y/dNvf4FTVXcMFTCKXgHOl+vTBaYLTBeYLqAW6GPB6C+hUfQKKL+Eg2qB0yzGxSVj775q1F55NFkBB3GMaCGGic8++DVrvMYe4VcLtNweRFmZC/HWFJl1Ti1gJjp8C5Nxi71GYK8ypwf/dz1LeAMGjxPtxFdCHeBAm+oNBnt9M2oGZ3L28Z9eaD8A1I4FiHzaEHoAAAAASUVORK5CYII=
|
||||||
|
// @grant none
|
||||||
|
// ==/UserScript==
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
var sourceWebsites = ['NWCD', 'OPS', 'Crossfade'];
|
||||||
|
var sourceWebsiteDomains = ['notwhat.cd', 'orpheus.network', 'crossfade.local'];
|
||||||
|
var sourceWebsite, sourceWebsiteIndex;
|
||||||
|
var JSONReleaseTypes = {
|
||||||
|
'NWCD': {
|
||||||
|
'1': 'Album', '3': 'Soundtrack', '5': 'EP', '6': 'Anthology', '7': 'Compilation', '9': 'Single', '11': 'Live album', '13': 'Remix', '14': 'Bootleg', '15': 'Interview', '16': 'Mixtape', '17': 'Sampler', '21': 'Unknown', '22': 'Demo', '23': 'DJ Mix', '24': 'Concert Recording'
|
||||||
|
},
|
||||||
|
'OPS': {
|
||||||
|
'1': 'Album', '3': 'Soundtrack', '5': 'EP', '6': 'Anthology', '7': 'Compilation', '9': 'Single', '11': 'Live album', '13': 'Remix', '14': 'Bootleg', '15': 'Interview', '16': 'Mixtape', '17': 'DJ Mix', '18': 'Concert recording', '21': 'Unknown'
|
||||||
|
},
|
||||||
|
'Crossfade': {
|
||||||
|
'1': 'Album', '3': 'Soundtrack', '5': 'EP', '6': 'Anthology', '7': 'Compilation', '9': 'Single', '11': 'Live album', '13': 'Remix', '14': 'Bootleg', '15': 'Interview', '16': 'Mixtape', '17': 'DJ Mix', '18': 'Concert recording', '21': 'Unknown'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var addFormat = /(\?|&)groupid=/.test(location.href) ? true : false;
|
||||||
|
var uploadRequest = /(\?|&)requestid=/.test(location.href) ? true : false;
|
||||||
|
|
||||||
|
var ChooseTypeDropdown = document.getElementById('categories');
|
||||||
|
|
||||||
|
var ChooseJSONTR = document.createElement('tr');
|
||||||
|
|
||||||
|
var ChooseJSONTD = document.createElement('td');
|
||||||
|
ChooseJSONTD.classList.add('label');
|
||||||
|
ChooseJSONTD.textContent = 'JSON file:';
|
||||||
|
|
||||||
|
var ChooseJSONBtnTD = document.createElement('td');
|
||||||
|
|
||||||
|
var ChooseJSONBtn = document.createElement('input');
|
||||||
|
ChooseJSONBtn.id = 'json';
|
||||||
|
ChooseJSONBtn.type = 'file';
|
||||||
|
ChooseJSONBtn.name = 'json_input';
|
||||||
|
ChooseJSONBtn.accept = '.application/json,.json';
|
||||||
|
|
||||||
|
var ChooseJSONPasteBtn = document.createElement('button');
|
||||||
|
ChooseJSONPasteBtn.textContent = 'Paste JSON';
|
||||||
|
ChooseJSONPasteBtn.type = 'button';
|
||||||
|
ChooseJSONPasteBtn.style.marginLeft = '10px';
|
||||||
|
ChooseJSONPasteBtn.style.padding = '2px 8px';
|
||||||
|
ChooseJSONPasteBtn.style.cursor = 'pointer';
|
||||||
|
|
||||||
|
ChooseJSONTR.appendChild(ChooseJSONTD);
|
||||||
|
ChooseJSONTR.appendChild(ChooseJSONBtnTD);
|
||||||
|
ChooseJSONBtnTD.appendChild(ChooseJSONBtn);
|
||||||
|
ChooseJSONBtnTD.appendChild(ChooseJSONPasteBtn);
|
||||||
|
ChooseTypeDropdown.parentNode.parentNode.parentNode.insertBefore(ChooseJSONTR, ChooseTypeDropdown.parentNode.parentNode);
|
||||||
|
|
||||||
|
function processJSON(releaseJSON, filename) {
|
||||||
|
// Determine source
|
||||||
|
if (releaseJSON.response && releaseJSON.response.source === 'Crossfade') {
|
||||||
|
sourceWebsite = 'Crossfade';
|
||||||
|
sourceWebsiteIndex = 2;
|
||||||
|
} else {
|
||||||
|
sourceWebsiteIndex = /NWCD .+\.json/.test(filename) ? 0 : (/.+(\.opsnet| \[orpheus\.network\])\.json/.test(filename) ? 1 : -1);
|
||||||
|
sourceWebsite = sourceWebsiteIndex > -1 && sourceWebsiteIndex < sourceWebsites.length ? sourceWebsites[sourceWebsiteIndex] : "N/A";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sourceWebsite == 'N/A') {
|
||||||
|
alert('The userscript failed to parse a supported website from the json filename or content! Aborting...')
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var categories = ['Music', 'Applications', 'E-Books', 'Audiobooks', 'E-Learning Videos', 'Comedy', 'Comics'];
|
||||||
|
var category = document.getElementById('categories');
|
||||||
|
var categoryJSON = parseInt(releaseJSON.response.group.categoryId);
|
||||||
|
var categoryNameJSON = releaseJSON.response.group.categoryName;
|
||||||
|
var categoryIndex = categories.indexOf(categoryNameJSON);
|
||||||
|
|
||||||
|
if (categoryIndex > -1) {
|
||||||
|
if (categoryIndex != 0) {
|
||||||
|
alert('Currently only Music category torrents are supported. Aborting...');
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
category.selectedIndex = categories.indexOf(categoryNameJSON);
|
||||||
|
// Trigger change event to load fields
|
||||||
|
var event = new Event('change');
|
||||||
|
category.dispatchEvent(event);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
alert('The category name indicated in the JSON (' + categoryNameJSON + ') is not one of the available category types! Aborting...');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait a bit for fields to load if category changed
|
||||||
|
setTimeout(function () {
|
||||||
|
switch (categoryIndex) {
|
||||||
|
case 0:
|
||||||
|
if (!addFormat) {
|
||||||
|
if (!uploadRequest) {
|
||||||
|
var artists = [];
|
||||||
|
var artistRoles = [
|
||||||
|
{ 'name': 'artists', 'index': 0 },
|
||||||
|
{ 'name': 'with', 'index': 1 },
|
||||||
|
{ 'name': 'composers', 'index': 2 },
|
||||||
|
{ 'name': 'conductor', 'index': 3 },
|
||||||
|
{ 'name': 'dj', 'index': 4 },
|
||||||
|
{ 'name': 'remixedBy', 'index': 5 },
|
||||||
|
{ 'name': 'producer', 'index': 6 },
|
||||||
|
];
|
||||||
|
var artistsJSON = releaseJSON.response.group.musicInfo;
|
||||||
|
|
||||||
|
for (var i = 0, len = artistRoles.length; i < len; i++) {
|
||||||
|
if (!artistsJSON[artistRoles[i].name] || artistsJSON[artistRoles[i].name].length == 0) continue;
|
||||||
|
for (var j = 0, lenj = artistsJSON[artistRoles[i].name].length; j < lenj; j++) {
|
||||||
|
artists.push({ 'name': artistsJSON[artistRoles[i].name][j].name, 'index': artistRoles[i].index });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (artists.length > 0) {
|
||||||
|
var artistInputs = [];
|
||||||
|
artistInputs.length = artists.length;
|
||||||
|
for (i = 0, len = artists.length; i < len; i++) {
|
||||||
|
if (i > 0) {
|
||||||
|
// Check if AddArtistField exists (it should on upload page)
|
||||||
|
if (typeof window.AddArtistField === 'function') {
|
||||||
|
window.AddArtistField();
|
||||||
|
} else {
|
||||||
|
// Fallback for some versions of the site
|
||||||
|
var addLink = document.querySelector('a[onclick="AddArtistField()"]');
|
||||||
|
if (addLink) addLink.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
artistInputs[i] = document.getElementById('artist' + (i > 0 ? '_' + i : ''));
|
||||||
|
if (artistInputs[i]) {
|
||||||
|
artistInputs[i].value = artists[i].name;
|
||||||
|
var roles = document.querySelectorAll('select#importance'); // Changed selector to match typical RED form
|
||||||
|
if (roles[i]) roles[i].selectedIndex = artists[i].index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// alert('No artists are included in the JSON!');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var albumTitle = document.getElementById('title');
|
||||||
|
var albumTitleJSON = releaseJSON.response.group.name;
|
||||||
|
|
||||||
|
if (albumTitleJSON != '') {
|
||||||
|
albumTitle.value = albumTitleJSON;
|
||||||
|
} else {
|
||||||
|
alert('No album title is included in the JSON!');
|
||||||
|
}
|
||||||
|
|
||||||
|
var initialYear = document.getElementById('year');
|
||||||
|
var initialYearJSON = releaseJSON.response.group.year;
|
||||||
|
|
||||||
|
if (initialYearJSON != '') {
|
||||||
|
initialYear.value = initialYearJSON;
|
||||||
|
} else {
|
||||||
|
alert('No initial year is included in the JSON!');
|
||||||
|
}
|
||||||
|
|
||||||
|
var releaseType = document.getElementById('releasetype');
|
||||||
|
var releaseTypes = [];
|
||||||
|
for (i = 0, len = releaseType.options.length; i < len; i++) {
|
||||||
|
releaseTypes.push(releaseType.options[i].textContent.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
var releaseTypeIndexJSON = parseInt(releaseJSON.response.group.releaseType || 1); // Default to Album if missing
|
||||||
|
var releaseTypeNameJSON = JSONReleaseTypes[sourceWebsite][releaseTypeIndexJSON] || 'Album';
|
||||||
|
var releaseTypeIndex = releaseTypes.indexOf(releaseTypeNameJSON.toLowerCase());
|
||||||
|
|
||||||
|
if (releaseTypeIndex > -1) {
|
||||||
|
releaseType.selectedIndex = releaseTypeIndex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var editionYear = document.getElementById('remaster_year');
|
||||||
|
var editionTitle = document.getElementById('remaster_title');
|
||||||
|
var editionLabel = document.getElementById('remaster_record_label');
|
||||||
|
var editionCatNo = document.getElementById('remaster_catalogue_number');
|
||||||
|
var edition = releaseJSON.response.torrent.remastered;
|
||||||
|
|
||||||
|
if (edition) {
|
||||||
|
var editionYearJSON = releaseJSON.response.torrent.remasterYear;
|
||||||
|
var editionTitleJSON = releaseJSON.response.torrent.remasterTitle;
|
||||||
|
var editionLabelJSON = releaseJSON.response.torrent.remasterRecordLabel;
|
||||||
|
var editionCatNoJSON = releaseJSON.response.torrent.remasterCatalogueNumber;
|
||||||
|
} else {
|
||||||
|
editionYearJSON = initialYearJSON ? initialYearJSON : releaseJSON.response.group.year;
|
||||||
|
editionTitleJSON = '';
|
||||||
|
editionLabelJSON = releaseJSON.response.group.recordLabel;
|
||||||
|
editionCatNoJSON = releaseJSON.response.group.catalogueNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editionYear) editionYear.value = editionYearJSON || '';
|
||||||
|
if (editionTitle) editionTitle.value = editionTitleJSON || '';
|
||||||
|
if (editionLabel) editionLabel.value = editionLabelJSON || '';
|
||||||
|
if (editionCatNo) editionCatNo.value = editionCatNoJSON || '';
|
||||||
|
|
||||||
|
var scene = document.getElementById('scene');
|
||||||
|
var sceneJSON = releaseJSON.response.torrent.scene;
|
||||||
|
if (sceneJSON && scene) scene.checked = true;
|
||||||
|
|
||||||
|
var format = document.getElementById('format');
|
||||||
|
var formats = [];
|
||||||
|
for (i = 0, len = format.options.length; i < len; i++) {
|
||||||
|
formats.push(format.options[i].textContent.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
var formatJSON = releaseJSON.response.torrent.format;
|
||||||
|
var formatIndex = formats.indexOf(formatJSON.toLowerCase());
|
||||||
|
|
||||||
|
if (formatIndex > -1) {
|
||||||
|
format.selectedIndex = formatIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
var bitrate = document.getElementById('bitrate');
|
||||||
|
var bitrates = [];
|
||||||
|
for (i = 0, len = bitrate.options.length; i < len; i++) {
|
||||||
|
bitrates.push(bitrate.options[i].textContent.toLowerCase());
|
||||||
|
}
|
||||||
|
var otherBitrate = document.getElementById('other_bitrate');
|
||||||
|
var otherBitrateVBR = document.getElementById('vbr');
|
||||||
|
|
||||||
|
var bitrateJSON = releaseJSON.response.torrent.encoding;
|
||||||
|
var bitrateIndex = bitrates.indexOf(bitrateJSON.toLowerCase());
|
||||||
|
|
||||||
|
if (bitrateIndex > -1) {
|
||||||
|
bitrate.selectedIndex = bitrateIndex;
|
||||||
|
} else {
|
||||||
|
var otherIndex = bitrates.indexOf('other');
|
||||||
|
if (otherIndex > -1) {
|
||||||
|
bitrate.selectedIndex = otherIndex;
|
||||||
|
var span = document.getElementById('other_bitrate_span');
|
||||||
|
if (span) span.classList.remove('hidden');
|
||||||
|
if (otherBitrate) otherBitrate.value = bitrateJSON.replace(/ \(VBR\)$/i, '');
|
||||||
|
if (otherBitrateVBR) otherBitrateVBR.checked = /.+ \(VBR\)$/i.test(bitrateJSON);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var media = document.getElementById('media');
|
||||||
|
var medias = [];
|
||||||
|
for (i = 0, len = media.options.length; i < len; i++) {
|
||||||
|
medias.push(media.options[i].textContent.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
var mediaJSON = releaseJSON.response.torrent.media;
|
||||||
|
var mediaIndex = medias.indexOf(mediaJSON.toLowerCase());
|
||||||
|
|
||||||
|
if (mediaIndex > -1) {
|
||||||
|
media.selectedIndex = mediaIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!addFormat) {
|
||||||
|
var tags = document.getElementById('tags');
|
||||||
|
var tagsJSON = releaseJSON.response.group.tags;
|
||||||
|
var tagList = '';
|
||||||
|
|
||||||
|
if (tagsJSON && tagsJSON.length > 0) {
|
||||||
|
for (i = 0, len = tagsJSON.length; i < len; i++) {
|
||||||
|
tagList += (i > 0 ? ', ' : '') + tagsJSON[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tags) tags.value = tagList;
|
||||||
|
|
||||||
|
var image = document.getElementById('image');
|
||||||
|
var imageJSON = releaseJSON.response.group.wikiImage;
|
||||||
|
if (image && imageJSON) image.value = imageJSON;
|
||||||
|
|
||||||
|
var albumDesc = document.getElementById('album_desc');
|
||||||
|
var albumDescJSON = releaseJSON.response.group.wikiBBcode ? releaseJSON.response.group.wikiBBcode : releaseJSON.response.group.wikiBody;
|
||||||
|
|
||||||
|
if (albumDescJSON != '') {
|
||||||
|
if (!releaseJSON.response.group.wikiBBcode) {
|
||||||
|
// Basic cleanup if needed, but Crossfade provides clean tracklist
|
||||||
|
}
|
||||||
|
|
||||||
|
if (albumDesc) {
|
||||||
|
albumDesc.value = albumDescJSON;
|
||||||
|
// Trigger preview if possible
|
||||||
|
// document.querySelector('input.button_preview_0').click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var relDesc = document.getElementById('release_desc');
|
||||||
|
var relDescJSON = releaseJSON.response.torrent.description;
|
||||||
|
|
||||||
|
if (sourceWebsite !== 'Crossfade') {
|
||||||
|
var groupIDJSON = releaseJSON.response.group.id;
|
||||||
|
var torrentIDJSON = releaseJSON.response.torrent.id;
|
||||||
|
relDescJSON += (relDescJSON ? '\n\n' : '') + 'Cross-posted from ' + sourceWebsite + ': https://';
|
||||||
|
relDescJSON += sourceWebsiteDomains[sourceWebsiteIndex] + '/torrents.php?id=' + groupIDJSON + '&torrentid=' + torrentIDJSON;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (relDesc) relDesc.value = relDescJSON;
|
||||||
|
|
||||||
|
// Show success message
|
||||||
|
ChooseJSONTR.innerHTML = '';
|
||||||
|
var successTD = document.createElement('td');
|
||||||
|
successTD.colSpan = 2;
|
||||||
|
successTD.style.fontWeight = 'bold';
|
||||||
|
successTD.style.color = 'green';
|
||||||
|
successTD.style.textAlign = 'center';
|
||||||
|
successTD.textContent = 'Data imported successfully!';
|
||||||
|
ChooseJSONTR.appendChild(successTD);
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}, 500); // Delay to allow category change to propagate
|
||||||
|
}
|
||||||
|
|
||||||
|
ChooseJSONBtn.addEventListener('change', function (evt) {
|
||||||
|
var file = document.getElementById('json').files[0];
|
||||||
|
|
||||||
|
if (file) {
|
||||||
|
var reader = new FileReader();
|
||||||
|
reader.readAsText(file, "UTF-8");
|
||||||
|
reader.onload = function (evt) {
|
||||||
|
var releaseJSON = JSON.parse(evt.target.result);
|
||||||
|
processJSON(releaseJSON, file.name);
|
||||||
|
}
|
||||||
|
reader.onerror = function (evt) {
|
||||||
|
alert('There was an error reading the file.');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
alert('No JSON file has been chosen!');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ChooseJSONPasteBtn.addEventListener('click', async function () {
|
||||||
|
try {
|
||||||
|
const text = await navigator.clipboard.readText();
|
||||||
|
if (!text) {
|
||||||
|
alert('Clipboard is empty!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const releaseJSON = JSON.parse(text);
|
||||||
|
processJSON(releaseJSON, 'clipboard.json');
|
||||||
|
} catch (e) {
|
||||||
|
alert('Failed to parse JSON from clipboard. Make sure you copied the correct content.');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert('Failed to read clipboard: ' + err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
Reference in New Issue
Block a user