Restart repository history from target event

This commit is contained in:
2026-07-18 16:54:26 +01:00
commit e08aa0a667
71 changed files with 15963 additions and 0 deletions
+43
View File
@@ -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
+36
View File
@@ -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.
+193
View File
@@ -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 });
}
}
+110
View File
@@ -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 });
}
}
+50
View File
@@ -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 });
}
}
+61
View File
@@ -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 });
}
}
+89
View File
@@ -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 });
}
}
+52
View File
@@ -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 });
}
}
+43
View File
@@ -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 });
}
}
+45
View File
@@ -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 });
}
}
+84
View File
@@ -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 });
}
}
+60
View File
@@ -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 });
}
}
+26
View File
@@ -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 });
}
}
+28
View File
@@ -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 });
}
}
+222
View File
@@ -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 });
}
}
+54
View File
@@ -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 });
}
}
+49
View File
@@ -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 });
}
}
+126
View File
@@ -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',
},
});
}
+7
View File
@@ -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);
}
+286
View File
@@ -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 });
}
}
+90
View File
@@ -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 });
}
}
+48
View File
@@ -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 });
}
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+144
View File
@@ -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;
}
}
+36
View File
@@ -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>
);
}
+5
View File
@@ -0,0 +1,5 @@
import Dashboard from "@/components/dashboard";
export default function Home() {
return <Dashboard />;
}
+22
View File
@@ -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": {}
}
+56
View File
@@ -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
+312
View File
@@ -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>
);
}
+161
View File
@@ -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>
);
}
+46
View File
@@ -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>
);
}
+388
View File
@@ -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>
);
}
+169
View File
@@ -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}</>;
}
+66
View File
@@ -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 }
+63
View File
@@ -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>
</>
);
}
+46
View File
@@ -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 }
+60
View File
@@ -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 }
+92
View File
@@ -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,
}
+122
View File
@@ -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,
}
+21
View File
@@ -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 }
+58
View File
@@ -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 }
+187
View File
@@ -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,
}
+28
View File
@@ -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 }
+13
View File
@@ -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 }
+55
View File
@@ -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 }
+18
View File
@@ -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;
+91
View File
@@ -0,0 +1,91 @@
{
"status": "success",
"response": {
"group": {
"wikiBody": "1. N&atilde;o a N&oacute;s, Senhor<br \/>\r\n2. Jesus &Eacute; Rei<br \/>\r\n3. Jesus &Eacute; 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&oacute;rdias do Senhor<br \/>\r\n8. Os Teus Tabern&aacute;culos<br \/>\r\n9. Gl&oacute;ria, For&ccedil;a, Honra e Poder<br \/>\r\n10. Digno &Eacute;s Tu Senhor<br \/>\r\n11. Te Adorar Meu Senhor<br \/>\r\n12. Sonda Me Senhor<br \/>\r\n13. Jesus &Eacute; o Caminho<br \/>\r\n14. Recebe Senhor<br \/>\r\n15. Ser<br \/>\r\n16. Tu &Eacute;s Deus<br \/>\r\n17. Te Exaltarei<br \/>\r\n18. Reina em Mim<br \/>\r\n19. Buscarei<br \/>\r\n20. Poderoso",
"bbBody": "1. N&atilde;o a N&oacute;s, Senhor\r\n2. Jesus &Eacute; Rei\r\n3. Jesus &Eacute; 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&oacute;rdias do Senhor\r\n8. Os Teus Tabern&aacute;culos\r\n9. Gl&oacute;ria, For&ccedil;a, Honra e Poder\r\n10. Digno &Eacute;s Tu Senhor\r\n11. Te Adorar Meu Senhor\r\n12. Sonda Me Senhor\r\n13. Jesus &Eacute; o Caminho\r\n14. Recebe Senhor\r\n15. Ser\r\n16. Tu &Eacute;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"
}
}
}
+98
View File
@@ -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;
}
+96
View File
@@ -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,
};
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+117
View File
@@ -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;
}
+37
View File
@@ -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);
});
}
+55
View File
@@ -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;
}
+29
View File
@@ -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();
}
+59
View File
@@ -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();
+8
View File
@@ -0,0 +1,8 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
reactCompiler: true,
};
export default nextConfig;
+8345
View File
File diff suppressed because it is too large Load Diff
+51
View File
@@ -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"
}
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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.
+1
View File
@@ -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

+1
View File
@@ -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

+34
View File
@@ -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
View File
@@ -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
View File
@@ -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);
}
});
})();