Auto commit: 2026-02-05T21:00:01.068Z

This commit is contained in:
Flatlogic Bot 2026-02-05 21:00:01 +00:00
parent 4a312f7921
commit 1424e9761c
16 changed files with 470 additions and 77 deletions

View File

@ -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;

View File

@ -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,
}));
}
};
};

View File

@ -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');
}
};

View File

@ -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;
};

View File

@ -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')
},
];

View File

@ -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;

View 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;

View 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);
}
}
}
};

View File

@ -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}

View File

@ -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

View File

@ -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>

View File

@ -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>

View File

@ -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');

View File

@ -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>

View File

@ -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

View 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