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.pexelsKey = process.env.PEXELS_KEY || '';
|
||||||
|
config.jamendoClientId = process.env.JAMENDO_CLIENT_ID || '56d30cce'; // Example/Placeholder
|
||||||
|
|
||||||
config.pexelsQuery = 'City skyline at night';
|
config.pexelsQuery = 'City skyline at night';
|
||||||
config.host = process.env.NODE_ENV === "production" ? config.remote : "http://localhost";
|
config.host = process.env.NODE_ENV === "production" ? config.remote : "http://localhost";
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
const db = require('../models');
|
const db = require('../models');
|
||||||
const FileDBApi = require('./file');
|
const FileDBApi = require('./file');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
@ -21,6 +20,8 @@ module.exports = class DownloadsDBApi {
|
|||||||
{
|
{
|
||||||
id: data.id || undefined,
|
id: data.id || undefined,
|
||||||
|
|
||||||
|
title: data.title || null,
|
||||||
|
|
||||||
download_type: data.download_type
|
download_type: data.download_type
|
||||||
||
|
||
|
||||||
null
|
null
|
||||||
@ -108,7 +109,7 @@ module.exports = class DownloadsDBApi {
|
|||||||
// Prepare data - wrapping individual data transformations in a map() method
|
// Prepare data - wrapping individual data transformations in a map() method
|
||||||
const downloadsData = data.map((item, index) => ({
|
const downloadsData = data.map((item, index) => ({
|
||||||
id: item.id || undefined,
|
id: item.id || undefined,
|
||||||
|
title: item.title || null,
|
||||||
download_type: item.download_type
|
download_type: item.download_type
|
||||||
||
|
||
|
||||||
null
|
null
|
||||||
@ -187,6 +188,8 @@ module.exports = class DownloadsDBApi {
|
|||||||
|
|
||||||
const updatePayload = {};
|
const updatePayload = {};
|
||||||
|
|
||||||
|
if (data.title !== undefined) updatePayload.title = data.title;
|
||||||
|
|
||||||
if (data.download_type !== undefined) updatePayload.download_type = data.download_type;
|
if (data.download_type !== undefined) updatePayload.download_type = data.download_type;
|
||||||
|
|
||||||
|
|
||||||
@ -449,6 +452,16 @@ module.exports = class DownloadsDBApi {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (filter.title) {
|
||||||
|
where = {
|
||||||
|
...where,
|
||||||
|
[Op.and]: Utils.ilike(
|
||||||
|
'downloads',
|
||||||
|
'title',
|
||||||
|
filter.title,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (filter.storage_path) {
|
if (filter.storage_path) {
|
||||||
where = {
|
where = {
|
||||||
@ -716,6 +729,11 @@ module.exports = class DownloadsDBApi {
|
|||||||
where = {
|
where = {
|
||||||
[Op.or]: [
|
[Op.or]: [
|
||||||
{ ['id']: Utils.uuid(query) },
|
{ ['id']: Utils.uuid(query) },
|
||||||
|
Utils.ilike(
|
||||||
|
'downloads',
|
||||||
|
'title',
|
||||||
|
query,
|
||||||
|
),
|
||||||
Utils.ilike(
|
Utils.ilike(
|
||||||
'downloads',
|
'downloads',
|
||||||
'status',
|
'status',
|
||||||
@ -726,19 +744,18 @@ module.exports = class DownloadsDBApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const records = await db.downloads.findAll({
|
const records = await db.downloads.findAll({
|
||||||
attributes: [ 'id', 'status' ],
|
attributes: [ 'id', 'title' ],
|
||||||
where,
|
where,
|
||||||
limit: limit ? Number(limit) : undefined,
|
limit: limit ? Number(limit) : undefined,
|
||||||
offset: offset ? Number(offset) : undefined,
|
offset: offset ? Number(offset) : undefined,
|
||||||
orderBy: [['status', 'ASC']],
|
orderBy: [['title', 'ASC']],
|
||||||
});
|
});
|
||||||
|
|
||||||
return records.map((record) => ({
|
return records.map((record) => ({
|
||||||
id: record.id,
|
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,
|
primaryKey: true,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
title: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
},
|
||||||
|
|
||||||
download_type: {
|
download_type: {
|
||||||
type: DataTypes.ENUM,
|
type: DataTypes.ENUM,
|
||||||
|
|
||||||
@ -24,7 +28,11 @@ download_type: {
|
|||||||
"chapter",
|
"chapter",
|
||||||
|
|
||||||
|
|
||||||
"series"
|
"series",
|
||||||
|
|
||||||
|
"music",
|
||||||
|
|
||||||
|
"video"
|
||||||
|
|
||||||
],
|
],
|
||||||
|
|
||||||
@ -204,5 +212,3 @@ finished_at: {
|
|||||||
|
|
||||||
return downloads;
|
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 searchRoutes = require('./routes/search');
|
||||||
const sqlRoutes = require('./routes/sql');
|
const sqlRoutes = require('./routes/sql');
|
||||||
const pexelsRoutes = require('./routes/pexels');
|
const pexelsRoutes = require('./routes/pexels');
|
||||||
|
const mediaRoutes = require('./routes/media');
|
||||||
|
|
||||||
const openaiRoutes = require('./routes/openai');
|
const openaiRoutes = require('./routes/openai');
|
||||||
const mangaRoutes = require('./routes/manga');
|
const mangaRoutes = require('./routes/manga');
|
||||||
@ -100,29 +101,35 @@ const options = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
security: [{
|
|
||||||
bearerAuth: []
|
|
||||||
}]
|
|
||||||
},
|
},
|
||||||
apis: ["./src/routes/*.js"],
|
apis: ["./src/routes/*.js"],
|
||||||
};
|
};
|
||||||
|
|
||||||
const specs = swaggerJsDoc(options);
|
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}));
|
app.use(cors());
|
||||||
require('./auth/auth');
|
|
||||||
|
|
||||||
app.use(bodyParser.json());
|
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/auth', authRoutes);
|
||||||
app.use('/api/file', fileRoutes);
|
app.use('/api/file', fileRoutes);
|
||||||
app.use('/api/pexels', pexelsRoutes);
|
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);
|
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/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(
|
app.use('/api/file/download', express.static(config.uploadDir));
|
||||||
'/api/manga',
|
|
||||||
passport.authenticate('jwt', { session: false }),
|
|
||||||
mangaRoutes
|
|
||||||
);
|
|
||||||
|
|
||||||
const publicDir = path.join(
|
app.listen(config.port || 8080, () => {
|
||||||
__dirname,
|
console.log(`manhwa Kai backend listening at http://localhost:${config.port || 8080}`);
|
||||||
'../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}`);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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 React from 'react';
|
||||||
import BaseIcon from '../BaseIcon';
|
import BaseIcon from '../BaseIcon';
|
||||||
import { mdiEye, mdiTrashCan, mdiPencilOutline } from '@mdi/js';
|
import { mdiEye, mdiTrashCan, mdiPencilOutline, mdiPlay } from '@mdi/js';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import {
|
import {
|
||||||
GridActionsCellItem,
|
GridActionsCellItem,
|
||||||
@ -12,6 +12,7 @@ import {saveFile} from "../../helpers/fileSaver";
|
|||||||
import dataFormatter from '../../helpers/dataFormatter'
|
import dataFormatter from '../../helpers/dataFormatter'
|
||||||
import DataGridMultiSelect from "../DataGridMultiSelect";
|
import DataGridMultiSelect from "../DataGridMultiSelect";
|
||||||
import ListActionsPopover from '../ListActionsPopover';
|
import ListActionsPopover from '../ListActionsPopover';
|
||||||
|
import { IconButton, Tooltip } from '@mui/material';
|
||||||
|
|
||||||
import {hasPermission} from "../../helpers/userPermissions";
|
import {hasPermission} from "../../helpers/userPermissions";
|
||||||
|
|
||||||
@ -40,7 +41,16 @@ export const loadColumns = async (
|
|||||||
const hasUpdatePermission = hasPermission(user, 'UPDATE_DOWNLOADS')
|
const hasUpdatePermission = hasPermission(user, 'UPDATE_DOWNLOADS')
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
{
|
||||||
|
field: 'title',
|
||||||
|
headerName: 'Title',
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 150,
|
||||||
|
filterable: false,
|
||||||
|
headerClassName: 'datagrid--header',
|
||||||
|
cellClassName: 'datagrid--cell',
|
||||||
|
editable: hasUpdatePermission,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
field: 'download_type',
|
field: 'download_type',
|
||||||
headerName: 'DownloadType',
|
headerName: 'DownloadType',
|
||||||
@ -273,13 +283,28 @@ export const loadColumns = async (
|
|||||||
{
|
{
|
||||||
field: 'actions',
|
field: 'actions',
|
||||||
type: 'actions',
|
type: 'actions',
|
||||||
minWidth: 30,
|
minWidth: 80,
|
||||||
headerClassName: 'datagrid--header',
|
headerClassName: 'datagrid--header',
|
||||||
cellClassName: 'datagrid--cell',
|
cellClassName: 'datagrid--cell',
|
||||||
getActions: (params: GridRowParams) => {
|
getActions: (params: GridRowParams) => {
|
||||||
|
const isMedia = ['music', 'video'].includes(params.row.download_type);
|
||||||
|
const isCompleted = params.row.status === 'completed';
|
||||||
|
|
||||||
return [
|
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
|
<ListActionsPopover
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
itemId={params?.row?.id}
|
itemId={params?.row?.id}
|
||||||
|
|||||||
@ -12,6 +12,11 @@ const menuAside: MenuAsideItem[] = [
|
|||||||
icon: icon.mdiSourceBranch,
|
icon: icon.mdiSourceBranch,
|
||||||
label: 'Browse Extensions',
|
label: 'Browse Extensions',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
href: '/media',
|
||||||
|
icon: icon.mdiPlayCircle,
|
||||||
|
label: 'Media Search',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
href: '/library_entries/library_entries-list',
|
href: '/library_entries/library_entries-list',
|
||||||
label: 'My Library',
|
label: 'My Library',
|
||||||
|
|||||||
@ -477,6 +477,8 @@ const EditDownloads = () => {
|
|||||||
<option value="chapter">chapter</option>
|
<option value="chapter">chapter</option>
|
||||||
|
|
||||||
<option value="series">series</option>
|
<option value="series">series</option>
|
||||||
|
<option value="music">music</option>
|
||||||
|
<option value="video">video</option>
|
||||||
|
|
||||||
</Field>
|
</Field>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|||||||
@ -474,6 +474,8 @@ const EditDownloadsPage = () => {
|
|||||||
<option value="chapter">chapter</option>
|
<option value="chapter">chapter</option>
|
||||||
|
|
||||||
<option value="series">series</option>
|
<option value="series">series</option>
|
||||||
|
<option value="music">music</option>
|
||||||
|
<option value="video">video</option>
|
||||||
|
|
||||||
</Field>
|
</Field>
|
||||||
</FormField>
|
</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');
|
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_DOWNLOADS');
|
||||||
|
|||||||
@ -295,6 +295,8 @@ const DownloadsNew = () => {
|
|||||||
<option value="chapter">chapter</option>
|
<option value="chapter">chapter</option>
|
||||||
|
|
||||||
<option value="series">series</option>
|
<option value="series">series</option>
|
||||||
|
<option value="music">music</option>
|
||||||
|
<option value="video">video</option>
|
||||||
|
|
||||||
</Field>
|
</Field>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|||||||
@ -34,7 +34,7 @@ const DownloadsTablesPage = () => {
|
|||||||
const dispatch = useAppDispatch();
|
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: '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'},
|
{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');
|
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_DOWNLOADS');
|
||||||
|
|||||||
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