Auto commit: 2026-02-05T21:00:01.068Z
This commit is contained in:
parent
4a312f7921
commit
1424e9761c
@ -65,6 +65,7 @@ const config = {
|
||||
};
|
||||
|
||||
config.pexelsKey = process.env.PEXELS_KEY || '';
|
||||
config.jamendoClientId = process.env.JAMENDO_CLIENT_ID || '56d30cce'; // Example/Placeholder
|
||||
|
||||
config.pexelsQuery = 'City skyline at night';
|
||||
config.host = process.env.NODE_ENV === "production" ? config.remote : "http://localhost";
|
||||
@ -73,4 +74,4 @@ config.swaggerUrl = `${config.swaggerUI}${config.swaggerPort}`;
|
||||
config.uiUrl = `${config.hostUI}${config.portUI ? `:${config.portUI}` : ``}/#`;
|
||||
config.backUrl = `${config.hostUI}${config.portUI ? `:${config.portUI}` : ``}`;
|
||||
|
||||
module.exports = config;
|
||||
module.exports = config;
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
|
||||
const db = require('../models');
|
||||
const FileDBApi = require('./file');
|
||||
const crypto = require('crypto');
|
||||
@ -21,6 +20,8 @@ module.exports = class DownloadsDBApi {
|
||||
{
|
||||
id: data.id || undefined,
|
||||
|
||||
title: data.title || null,
|
||||
|
||||
download_type: data.download_type
|
||||
||
|
||||
null
|
||||
@ -108,7 +109,7 @@ module.exports = class DownloadsDBApi {
|
||||
// Prepare data - wrapping individual data transformations in a map() method
|
||||
const downloadsData = data.map((item, index) => ({
|
||||
id: item.id || undefined,
|
||||
|
||||
title: item.title || null,
|
||||
download_type: item.download_type
|
||||
||
|
||||
null
|
||||
@ -187,6 +188,8 @@ module.exports = class DownloadsDBApi {
|
||||
|
||||
const updatePayload = {};
|
||||
|
||||
if (data.title !== undefined) updatePayload.title = data.title;
|
||||
|
||||
if (data.download_type !== undefined) updatePayload.download_type = data.download_type;
|
||||
|
||||
|
||||
@ -449,7 +452,17 @@ module.exports = class DownloadsDBApi {
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
if (filter.title) {
|
||||
where = {
|
||||
...where,
|
||||
[Op.and]: Utils.ilike(
|
||||
'downloads',
|
||||
'title',
|
||||
filter.title,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (filter.storage_path) {
|
||||
where = {
|
||||
...where,
|
||||
@ -716,6 +729,11 @@ module.exports = class DownloadsDBApi {
|
||||
where = {
|
||||
[Op.or]: [
|
||||
{ ['id']: Utils.uuid(query) },
|
||||
Utils.ilike(
|
||||
'downloads',
|
||||
'title',
|
||||
query,
|
||||
),
|
||||
Utils.ilike(
|
||||
'downloads',
|
||||
'status',
|
||||
@ -726,19 +744,18 @@ module.exports = class DownloadsDBApi {
|
||||
}
|
||||
|
||||
const records = await db.downloads.findAll({
|
||||
attributes: [ 'id', 'status' ],
|
||||
attributes: [ 'id', 'title' ],
|
||||
where,
|
||||
limit: limit ? Number(limit) : undefined,
|
||||
offset: offset ? Number(offset) : undefined,
|
||||
orderBy: [['status', 'ASC']],
|
||||
orderBy: [['title', 'ASC']],
|
||||
});
|
||||
|
||||
return records.map((record) => ({
|
||||
id: record.id,
|
||||
label: record.status,
|
||||
label: record.title || record.id,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
};
|
||||
|
||||
};
|
||||
@ -0,0 +1,14 @@
|
||||
'use strict';
|
||||
|
||||
module.exports = {
|
||||
up: async (queryInterface, Sequelize) => {
|
||||
await queryInterface.addColumn('downloads', 'title', {
|
||||
type: Sequelize.STRING,
|
||||
allowNull: true,
|
||||
});
|
||||
},
|
||||
|
||||
down: async (queryInterface, Sequelize) => {
|
||||
await queryInterface.removeColumn('downloads', 'title');
|
||||
}
|
||||
};
|
||||
@ -14,6 +14,10 @@ module.exports = function(sequelize, DataTypes) {
|
||||
primaryKey: true,
|
||||
},
|
||||
|
||||
title: {
|
||||
type: DataTypes.STRING,
|
||||
},
|
||||
|
||||
download_type: {
|
||||
type: DataTypes.ENUM,
|
||||
|
||||
@ -24,7 +28,11 @@ download_type: {
|
||||
"chapter",
|
||||
|
||||
|
||||
"series"
|
||||
"series",
|
||||
|
||||
"music",
|
||||
|
||||
"video"
|
||||
|
||||
],
|
||||
|
||||
@ -204,5 +212,3 @@ finished_at: {
|
||||
|
||||
return downloads;
|
||||
};
|
||||
|
||||
|
||||
|
||||
@ -343,6 +343,17 @@ const SourcesData = [
|
||||
|
||||
|
||||
},
|
||||
{
|
||||
"name": "Keiyoushi Extensions",
|
||||
"base_url": "https://raw.githubusercontent.com/keiyoushi/extensions/repo/index.min.json",
|
||||
"source_type": "custom_repo",
|
||||
"enabled": true,
|
||||
"supports_nsfw": true,
|
||||
"default_language": "en",
|
||||
"region": "global",
|
||||
"rate_limit_per_minute": 100,
|
||||
"last_healthcheck_at": new Date('2026-02-05T08:40:00Z')
|
||||
},
|
||||
|
||||
];
|
||||
|
||||
@ -673,6 +684,30 @@ const ExtensionsData = [
|
||||
|
||||
|
||||
},
|
||||
{
|
||||
"name": "MangaDex",
|
||||
"package_name": "eu.kanade.tachiyomi.extension.all.mangadex",
|
||||
"version": "1.4.206",
|
||||
"website_url": "https://mangadex.org",
|
||||
"repo_url": "https://raw.githubusercontent.com/keiyoushi/extensions/repo/index.min.json",
|
||||
"install_status": "available",
|
||||
"nsfw_capable": true,
|
||||
"signature_verified": true,
|
||||
"installed_at": new Date('2026-02-05T08:50:00Z'),
|
||||
"last_updated_at": new Date('2026-02-05T08:50:00Z')
|
||||
},
|
||||
{
|
||||
"name": "Comick",
|
||||
"package_name": "eu.kanade.tachiyomi.extension.all.comicklive",
|
||||
"version": "1.4.3",
|
||||
"website_url": "https://comick.live",
|
||||
"repo_url": "https://raw.githubusercontent.com/keiyoushi/extensions/repo/index.min.json",
|
||||
"install_status": "available",
|
||||
"nsfw_capable": true,
|
||||
"signature_verified": true,
|
||||
"installed_at": new Date('2026-02-05T08:55:00Z'),
|
||||
"last_updated_at": new Date('2026-02-05T08:55:00Z')
|
||||
},
|
||||
|
||||
];
|
||||
|
||||
|
||||
@ -15,6 +15,7 @@ const fileRoutes = require('./routes/file');
|
||||
const searchRoutes = require('./routes/search');
|
||||
const sqlRoutes = require('./routes/sql');
|
||||
const pexelsRoutes = require('./routes/pexels');
|
||||
const mediaRoutes = require('./routes/media');
|
||||
|
||||
const openaiRoutes = require('./routes/openai');
|
||||
const mangaRoutes = require('./routes/manga');
|
||||
@ -100,29 +101,35 @@ const options = {
|
||||
}
|
||||
}
|
||||
},
|
||||
security: [{
|
||||
bearerAuth: []
|
||||
}]
|
||||
},
|
||||
apis: ["./src/routes/*.js"],
|
||||
};
|
||||
|
||||
const specs = swaggerJsDoc(options);
|
||||
app.use('/api-docs', function (req, res, next) {
|
||||
swaggerUI.host = getBaseUrl(process.env.NEXT_PUBLIC_BACK_API) || req.get('host');
|
||||
next()
|
||||
}, swaggerUI.serve, swaggerUI.setup(specs))
|
||||
|
||||
app.use(cors({origin: true}));
|
||||
require('./auth/auth');
|
||||
app.use(cors());
|
||||
|
||||
app.use(bodyParser.json());
|
||||
|
||||
app.use('/api-docs', swaggerUI.serve, swaggerUI.setup(specs));
|
||||
|
||||
require('./auth/auth');
|
||||
|
||||
app.get('/api', (req, res) => {
|
||||
res.send('Hello from manhwa Kai API!');
|
||||
});
|
||||
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/file', fileRoutes);
|
||||
app.use('/api/pexels', pexelsRoutes);
|
||||
app.enable('trust proxy');
|
||||
app.use('/api/media', passport.authenticate('jwt', {session: false}), mediaRoutes);
|
||||
|
||||
app.use('/api/openai', passport.authenticate('jwt', {session: false}), openaiRoutes);
|
||||
app.use('/api/manga', passport.authenticate('jwt', {session: false}), mangaRoutes);
|
||||
|
||||
app.use('/api/search', passport.authenticate('jwt', {session: false}), searchRoutes);
|
||||
|
||||
app.use('/api/sql', passport.authenticate('jwt', {session: false}), sqlRoutes);
|
||||
|
||||
app.use('/api/users', passport.authenticate('jwt', {session: false}), usersRoutes);
|
||||
|
||||
@ -170,53 +177,10 @@ app.use('/api/search_history', passport.authenticate('jwt', {session: false}), s
|
||||
|
||||
app.use('/api/reading_progress', passport.authenticate('jwt', {session: false}), reading_progressRoutes);
|
||||
|
||||
app.use(
|
||||
'/api/openai',
|
||||
passport.authenticate('jwt', { session: false }),
|
||||
openaiRoutes,
|
||||
);
|
||||
app.use(
|
||||
'/api/ai',
|
||||
passport.authenticate('jwt', { session: false }),
|
||||
openaiRoutes,
|
||||
);
|
||||
|
||||
app.use(
|
||||
'/api/search',
|
||||
passport.authenticate('jwt', { session: false }),
|
||||
searchRoutes);
|
||||
app.use(
|
||||
'/api/sql',
|
||||
passport.authenticate('jwt', { session: false }),
|
||||
sqlRoutes);
|
||||
|
||||
app.use(
|
||||
'/api/manga',
|
||||
passport.authenticate('jwt', { session: false }),
|
||||
mangaRoutes
|
||||
);
|
||||
app.use('/api/file/download', express.static(config.uploadDir));
|
||||
|
||||
const publicDir = path.join(
|
||||
__dirname,
|
||||
'../public',
|
||||
);
|
||||
|
||||
if (fs.existsSync(publicDir)) {
|
||||
app.use('/', express.static(publicDir));
|
||||
|
||||
app.get('*', function(request, response) {
|
||||
response.sendFile(
|
||||
path.resolve(publicDir, 'index.html'),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const PORT = process.env.NODE_ENV === 'dev_stage' ? 3000 : 8080;
|
||||
|
||||
db.sequelize.sync().then(function () {
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Listening on port ${PORT}`);
|
||||
});
|
||||
app.listen(config.port || 8080, () => {
|
||||
console.log(`manhwa Kai backend listening at http://localhost:${config.port || 8080}`);
|
||||
});
|
||||
|
||||
module.exports = app;
|
||||
23
backend/src/routes/media.js
Normal file
23
backend/src/routes/media.js
Normal file
@ -0,0 +1,23 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const MediaService = require('../services/media');
|
||||
const { wrapAsync } = require('../helpers');
|
||||
|
||||
router.get('/search/music', wrapAsync(async (req, res) => {
|
||||
const { query } = req.query;
|
||||
const results = await MediaService.searchMusic(query);
|
||||
res.status(200).json(results);
|
||||
}));
|
||||
|
||||
router.get('/search/video', wrapAsync(async (req, res) => {
|
||||
const { query } = req.query;
|
||||
const results = await MediaService.searchVideo(query);
|
||||
res.status(200).json(results);
|
||||
}));
|
||||
|
||||
router.post('/download', wrapAsync(async (req, res) => {
|
||||
const result = await MediaService.startDownload(req.body, req.currentUser);
|
||||
res.status(200).json(result);
|
||||
}));
|
||||
|
||||
module.exports = router;
|
||||
152
backend/src/services/media.js
Normal file
152
backend/src/services/media.js
Normal file
@ -0,0 +1,152 @@
|
||||
const axios = require('axios');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const config = require('../config');
|
||||
const db = require('../db/models');
|
||||
const DownloadsDBApi = require('../db/api/downloads');
|
||||
|
||||
module.exports = class MediaService {
|
||||
static async searchMusic(query) {
|
||||
const url = `https://api.jamendo.com/v3.0/tracks/?client_id=${config.jamendoClientId}&format=json&limit=10&search=${encodeURIComponent(query)}&include=musicinfo&audioformat=mp32`;
|
||||
try {
|
||||
const response = await axios.get(url);
|
||||
return response.data.results.map(track => ({
|
||||
id: track.id,
|
||||
title: track.name,
|
||||
artist: track.artist_name,
|
||||
album: track.album_name,
|
||||
duration: track.duration,
|
||||
url: track.audio,
|
||||
image: track.image,
|
||||
type: 'music'
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Jamendo search error:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
static async searchVideo(query) {
|
||||
const url = `https://api.pexels.com/videos/search?query=${encodeURIComponent(query)}&per_page=10`;
|
||||
try {
|
||||
const response = await axios.get(url, {
|
||||
headers: {
|
||||
Authorization: config.pexelsKey
|
||||
}
|
||||
});
|
||||
return response.data.videos.map(video => ({
|
||||
id: video.id,
|
||||
title: `Video by ${video.user.name}`,
|
||||
artist: video.user.name,
|
||||
url: video.video_files.find(f => f.file_type === 'video/mp4')?.link || video.video_files[0].link,
|
||||
image: video.image,
|
||||
duration: video.duration,
|
||||
type: 'video'
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Pexels search error:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
static async startDownload(data, currentUser) {
|
||||
const transaction = await db.sequelize.transaction();
|
||||
try {
|
||||
const downloadData = {
|
||||
title: data.title,
|
||||
download_type: data.type, // 'music' or 'video'
|
||||
status: 'queued',
|
||||
progress_percent: 0,
|
||||
storage_path: data.url,
|
||||
userId: currentUser.id,
|
||||
queued_at: new Date(),
|
||||
};
|
||||
|
||||
const download = await DownloadsDBApi.create(downloadData, {
|
||||
currentUser,
|
||||
transaction,
|
||||
});
|
||||
|
||||
await transaction.commit();
|
||||
|
||||
// Start the real download process asynchronously
|
||||
this.realDownload(download.id, data.url, data.type, currentUser);
|
||||
|
||||
return download;
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async realDownload(id, url, type, currentUser) {
|
||||
try {
|
||||
const fileName = `${id}.${type === 'music' ? 'mp3' : 'mp4'}`;
|
||||
const filePath = path.join(config.uploadDir, fileName);
|
||||
|
||||
const response = await axios({
|
||||
method: 'GET',
|
||||
url: url,
|
||||
responseType: 'stream'
|
||||
});
|
||||
|
||||
const totalLength = response.headers['content-length'];
|
||||
let downloadedLength = 0;
|
||||
|
||||
const writer = fs.createWriteStream(filePath);
|
||||
|
||||
response.data.on('data', (chunk) => {
|
||||
downloadedLength += chunk.length;
|
||||
const progress = totalLength ? Math.round((downloadedLength / totalLength) * 100) : 0;
|
||||
|
||||
// Throttle updates to DB to avoid overloading
|
||||
if (!totalLength || downloadedLength % (1024 * 1024) < chunk.length || progress === 100) {
|
||||
DownloadsDBApi.update(id, {
|
||||
status: 'downloading',
|
||||
progress_percent: progress,
|
||||
downloaded_bytes: downloadedLength,
|
||||
total_bytes: totalLength || 0,
|
||||
started_at: downloadedLength === chunk.length ? new Date() : undefined
|
||||
}, { currentUser }).catch(err => console.error('Update download progress error:', err));
|
||||
}
|
||||
});
|
||||
|
||||
response.data.pipe(writer);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
writer.on('finish', async () => {
|
||||
try {
|
||||
await DownloadsDBApi.update(id, {
|
||||
status: 'completed',
|
||||
progress_percent: 100,
|
||||
storage_path: fileName,
|
||||
finished_at: new Date()
|
||||
}, { currentUser });
|
||||
resolve();
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
writer.on('error', async (err) => {
|
||||
try {
|
||||
await DownloadsDBApi.update(id, {
|
||||
status: 'failed',
|
||||
}, { currentUser });
|
||||
} catch (dbErr) {
|
||||
console.error('Failed to update download status to failed:', dbErr);
|
||||
}
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Download error:', error);
|
||||
try {
|
||||
await DownloadsDBApi.update(id, {
|
||||
status: 'failed',
|
||||
}, { currentUser });
|
||||
} catch (dbErr) {
|
||||
console.error('Failed to update download status to failed after catch:', dbErr);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import BaseIcon from '../BaseIcon';
|
||||
import { mdiEye, mdiTrashCan, mdiPencilOutline } from '@mdi/js';
|
||||
import { mdiEye, mdiTrashCan, mdiPencilOutline, mdiPlay } from '@mdi/js';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
GridActionsCellItem,
|
||||
@ -12,6 +12,7 @@ import {saveFile} from "../../helpers/fileSaver";
|
||||
import dataFormatter from '../../helpers/dataFormatter'
|
||||
import DataGridMultiSelect from "../DataGridMultiSelect";
|
||||
import ListActionsPopover from '../ListActionsPopover';
|
||||
import { IconButton, Tooltip } from '@mui/material';
|
||||
|
||||
import {hasPermission} from "../../helpers/userPermissions";
|
||||
|
||||
@ -40,7 +41,16 @@ export const loadColumns = async (
|
||||
const hasUpdatePermission = hasPermission(user, 'UPDATE_DOWNLOADS')
|
||||
|
||||
return [
|
||||
|
||||
{
|
||||
field: 'title',
|
||||
headerName: 'Title',
|
||||
flex: 1,
|
||||
minWidth: 150,
|
||||
filterable: false,
|
||||
headerClassName: 'datagrid--header',
|
||||
cellClassName: 'datagrid--cell',
|
||||
editable: hasUpdatePermission,
|
||||
},
|
||||
{
|
||||
field: 'download_type',
|
||||
headerName: 'DownloadType',
|
||||
@ -273,13 +283,28 @@ export const loadColumns = async (
|
||||
{
|
||||
field: 'actions',
|
||||
type: 'actions',
|
||||
minWidth: 30,
|
||||
minWidth: 80,
|
||||
headerClassName: 'datagrid--header',
|
||||
cellClassName: 'datagrid--cell',
|
||||
getActions: (params: GridRowParams) => {
|
||||
const isMedia = ['music', 'video'].includes(params.row.download_type);
|
||||
const isCompleted = params.row.status === 'completed';
|
||||
|
||||
return [
|
||||
<div key={params?.row?.id}>
|
||||
<div key={params?.row?.id} style={{ display: 'flex', alignItems: 'center' }}>
|
||||
{isMedia && isCompleted && (
|
||||
<Tooltip title="Play">
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
const url = `${axios.defaults.baseURL}/file/download?privateUrl=${params.row.storage_path}`;
|
||||
window.open(url, '_blank');
|
||||
}}
|
||||
size="small"
|
||||
>
|
||||
<BaseIcon path={mdiPlay} size={20} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
<ListActionsPopover
|
||||
onDelete={onDelete}
|
||||
itemId={params?.row?.id}
|
||||
|
||||
@ -12,6 +12,11 @@ const menuAside: MenuAsideItem[] = [
|
||||
icon: icon.mdiSourceBranch,
|
||||
label: 'Browse Extensions',
|
||||
},
|
||||
{
|
||||
href: '/media',
|
||||
icon: icon.mdiPlayCircle,
|
||||
label: 'Media Search',
|
||||
},
|
||||
{
|
||||
href: '/library_entries/library_entries-list',
|
||||
label: 'My Library',
|
||||
@ -212,4 +217,4 @@ const menuAside: MenuAsideItem[] = [
|
||||
},
|
||||
]
|
||||
|
||||
export default menuAside
|
||||
export default menuAside
|
||||
|
||||
@ -477,6 +477,8 @@ const EditDownloads = () => {
|
||||
<option value="chapter">chapter</option>
|
||||
|
||||
<option value="series">series</option>
|
||||
<option value="music">music</option>
|
||||
<option value="video">video</option>
|
||||
|
||||
</Field>
|
||||
</FormField>
|
||||
|
||||
@ -474,6 +474,8 @@ const EditDownloadsPage = () => {
|
||||
<option value="chapter">chapter</option>
|
||||
|
||||
<option value="series">series</option>
|
||||
<option value="music">music</option>
|
||||
<option value="video">video</option>
|
||||
|
||||
</Field>
|
||||
</FormField>
|
||||
|
||||
@ -52,7 +52,7 @@ const DownloadsTablesPage = () => {
|
||||
|
||||
|
||||
|
||||
{label: 'DownloadType', title: 'download_type', type: 'enum', options: ['chapter','series']},{label: 'Status', title: 'status', type: 'enum', options: ['queued','downloading','paused','completed','failed','cancelled']},
|
||||
{label: 'DownloadType', title: 'download_type', type: 'enum', options: ['chapter','series','music','video']},{label: 'Status', title: 'status', type: 'enum', options: ['queued','downloading','paused','completed','failed','cancelled']},
|
||||
]);
|
||||
|
||||
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_DOWNLOADS');
|
||||
|
||||
@ -295,6 +295,8 @@ const DownloadsNew = () => {
|
||||
<option value="chapter">chapter</option>
|
||||
|
||||
<option value="series">series</option>
|
||||
<option value="music">music</option>
|
||||
<option value="video">video</option>
|
||||
|
||||
</Field>
|
||||
</FormField>
|
||||
|
||||
@ -34,7 +34,7 @@ const DownloadsTablesPage = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
|
||||
const [filters] = useState([{label: 'StoragePath', title: 'storage_path'},
|
||||
const [filters] = useState([{label: 'Title', title: 'title'}, {label: 'StoragePath', title: 'storage_path'},
|
||||
{label: 'ProgressPercent', title: 'progress_percent', number: 'true'},{label: 'TotalBytes', title: 'total_bytes', number: 'true'},{label: 'DownloadedBytes', title: 'downloaded_bytes', number: 'true'},
|
||||
|
||||
{label: 'QueuedAt', title: 'queued_at', date: 'true'},{label: 'StartedAt', title: 'started_at', date: 'true'},{label: 'FinishedAt', title: 'finished_at', date: 'true'},
|
||||
@ -52,7 +52,7 @@ const DownloadsTablesPage = () => {
|
||||
|
||||
|
||||
|
||||
{label: 'DownloadType', title: 'download_type', type: 'enum', options: ['chapter','series']},{label: 'Status', title: 'status', type: 'enum', options: ['queued','downloading','paused','completed','failed','cancelled']},
|
||||
{label: 'DownloadType', title: 'download_type', type: 'enum', options: ['chapter','series','music','video']},{label: 'Status', title: 'status', type: 'enum', options: ['queued','downloading','paused','completed','failed','cancelled']},
|
||||
]);
|
||||
|
||||
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_DOWNLOADS');
|
||||
@ -173,4 +173,4 @@ DownloadsTablesPage.getLayout = function getLayout(page: ReactElement) {
|
||||
)
|
||||
}
|
||||
|
||||
export default DownloadsTablesPage
|
||||
export default DownloadsTablesPage
|
||||
145
frontend/src/pages/media/index.tsx
Normal file
145
frontend/src/pages/media/index.tsx
Normal file
@ -0,0 +1,145 @@
|
||||
import { mdiMusic, mdiVideo, mdiMagnify, mdiDownload } from '@mdi/js'
|
||||
import Head from 'next/head'
|
||||
import React, { ReactElement, useState } from 'react'
|
||||
import CardBox from '../../components/CardBox'
|
||||
import LayoutAuthenticated from '../../layouts/Authenticated'
|
||||
import SectionMain from '../../components/SectionMain'
|
||||
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
|
||||
import { getPageTitle } from '../../config'
|
||||
import FormField from '../../components/FormField'
|
||||
import { Field, Form, Formik } from 'formik'
|
||||
import BaseButton from '../../components/BaseButton'
|
||||
import axios from 'axios'
|
||||
import BaseIcon from '../../components/BaseIcon'
|
||||
|
||||
const MediaSearchPage = () => {
|
||||
const [results, setResults] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [type, setType] = useState('music')
|
||||
|
||||
const handleSearch = async (values) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await axios.get(`/media/search/${type}?query=${values.query}`)
|
||||
setResults(response.data)
|
||||
} catch (error) {
|
||||
console.error('Search error:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownload = async (item) => {
|
||||
try {
|
||||
await axios.post('/media/download', {
|
||||
type: item.type,
|
||||
url: item.url,
|
||||
title: item.title,
|
||||
id: item.id
|
||||
})
|
||||
alert('Download started! Check the Downloads section.')
|
||||
} catch (error) {
|
||||
console.error('Download error:', error)
|
||||
alert('Failed to start download.')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('Media Search')}</title>
|
||||
</Head>
|
||||
<SectionMain>
|
||||
<SectionTitleLineWithButton icon={mdiMusic} title="Media Search" main>
|
||||
{''}
|
||||
</SectionTitleLineWithButton>
|
||||
|
||||
<CardBox className="mb-6">
|
||||
<Formik initialValues={{ query: '' }} onSubmit={handleSearch}>
|
||||
<Form>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 items-end">
|
||||
<FormField label="Type">
|
||||
<select
|
||||
className="w-full h-10 border-gray-300 rounded shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50"
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value)}
|
||||
>
|
||||
<option value="music">Music (Jamendo)</option>
|
||||
<option value="video">Video (Pexels)</option>
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Search Query">
|
||||
<Field
|
||||
name="query"
|
||||
placeholder="Search for legal music or videos..."
|
||||
className="w-full h-10 px-3 border border-gray-300 rounded shadow-sm"
|
||||
/>
|
||||
</FormField>
|
||||
<div className="mb-4">
|
||||
<BaseButton
|
||||
type="submit"
|
||||
color="info"
|
||||
label="Search"
|
||||
icon={mdiMagnify}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
</Formik>
|
||||
</CardBox>
|
||||
|
||||
{loading && <div className="text-center py-10">Searching...</div>}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{results.map((item) => (
|
||||
<CardBox key={item.id} className="flex flex-col">
|
||||
<div className="relative h-48 mb-4">
|
||||
{item.image ? (
|
||||
<img src={item.image} alt={item.title} className="w-full h-full object-cover rounded" />
|
||||
) : (
|
||||
<div className="w-full h-full bg-gray-200 flex items-center justify-center rounded">
|
||||
<BaseIcon path={item.type === 'music' ? mdiMusic : mdiVideo} size={48} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-grow">
|
||||
<h3 className="font-bold text-lg line-clamp-1">{item.title}</h3>
|
||||
<p className="text-gray-600 text-sm mb-2">{item.artist}</p>
|
||||
{item.duration && (
|
||||
<p className="text-xs text-gray-500 mb-4">
|
||||
Duration: {Math.floor(item.duration / 60)}:{(item.duration % 60).toString().padStart(2, '0')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4 flex space-x-2">
|
||||
<BaseButton
|
||||
color="success"
|
||||
label="Download"
|
||||
icon={mdiDownload}
|
||||
onClick={() => handleDownload(item)}
|
||||
small
|
||||
/>
|
||||
<a href={item.url} target="_blank" rel="noopener noreferrer">
|
||||
<BaseButton color="info" label="Preview" icon={mdiVideo} small />
|
||||
</a>
|
||||
</div>
|
||||
</CardBox>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!loading && results.length === 0 && (
|
||||
<div className="text-center py-10 text-gray-500">
|
||||
Search for something to see results.
|
||||
</div>
|
||||
)}
|
||||
</SectionMain>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
MediaSearchPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutAuthenticated>{page}</LayoutAuthenticated>
|
||||
}
|
||||
|
||||
export default MediaSearchPage
|
||||
Loading…
x
Reference in New Issue
Block a user