From 1424e9761cfb9fb6374677824ca253cd82fcd5e7 Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Thu, 5 Feb 2026 21:00:01 +0000 Subject: [PATCH] Auto commit: 2026-02-05T21:00:01.068Z --- backend/src/config.js | 3 +- backend/src/db/api/downloads.js | 33 +++- .../20260205000000-add-title-to-downloads.js | 14 ++ backend/src/db/models/downloads.js | 12 +- .../db/seeders/20231127130745-sample-data.js | 35 ++++ backend/src/index.js | 76 +++------ backend/src/routes/media.js | 23 +++ backend/src/services/media.js | 152 ++++++++++++++++++ .../Downloads/configureDownloadsCols.tsx | 33 +++- frontend/src/menuAside.ts | 7 +- .../src/pages/downloads/[downloadsId].tsx | 2 + .../src/pages/downloads/downloads-edit.tsx | 2 + .../src/pages/downloads/downloads-list.tsx | 2 +- .../src/pages/downloads/downloads-new.tsx | 2 + .../src/pages/downloads/downloads-table.tsx | 6 +- frontend/src/pages/media/index.tsx | 145 +++++++++++++++++ 16 files changed, 470 insertions(+), 77 deletions(-) create mode 100644 backend/src/db/migrations/20260205000000-add-title-to-downloads.js create mode 100644 backend/src/routes/media.js create mode 100644 backend/src/services/media.js create mode 100644 frontend/src/pages/media/index.tsx diff --git a/backend/src/config.js b/backend/src/config.js index 7406b6a..6d52c63 100644 --- a/backend/src/config.js +++ b/backend/src/config.js @@ -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; \ No newline at end of file +module.exports = config; diff --git a/backend/src/db/api/downloads.js b/backend/src/db/api/downloads.js index dc1a98d..f7e70f5 100644 --- a/backend/src/db/api/downloads.js +++ b/backend/src/db/api/downloads.js @@ -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, })); } -}; - +}; \ No newline at end of file diff --git a/backend/src/db/migrations/20260205000000-add-title-to-downloads.js b/backend/src/db/migrations/20260205000000-add-title-to-downloads.js new file mode 100644 index 0000000..6a71a31 --- /dev/null +++ b/backend/src/db/migrations/20260205000000-add-title-to-downloads.js @@ -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'); + } +}; diff --git a/backend/src/db/models/downloads.js b/backend/src/db/models/downloads.js index 934a7ef..e992f1a 100644 --- a/backend/src/db/models/downloads.js +++ b/backend/src/db/models/downloads.js @@ -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; }; - - diff --git a/backend/src/db/seeders/20231127130745-sample-data.js b/backend/src/db/seeders/20231127130745-sample-data.js index a27963e..a8fdb31 100644 --- a/backend/src/db/seeders/20231127130745-sample-data.js +++ b/backend/src/db/seeders/20231127130745-sample-data.js @@ -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') + }, ]; diff --git a/backend/src/index.js b/backend/src/index.js index 8d47cea..3d8676a 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -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; \ No newline at end of file diff --git a/backend/src/routes/media.js b/backend/src/routes/media.js new file mode 100644 index 0000000..0cc6997 --- /dev/null +++ b/backend/src/routes/media.js @@ -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; diff --git a/backend/src/services/media.js b/backend/src/services/media.js new file mode 100644 index 0000000..e0efa75 --- /dev/null +++ b/backend/src/services/media.js @@ -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); + } + } + } +}; diff --git a/frontend/src/components/Downloads/configureDownloadsCols.tsx b/frontend/src/components/Downloads/configureDownloadsCols.tsx index a37159d..a3d29e9 100644 --- a/frontend/src/components/Downloads/configureDownloadsCols.tsx +++ b/frontend/src/components/Downloads/configureDownloadsCols.tsx @@ -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 [ -
+
+ {isMedia && isCompleted && ( + + { + const url = `${axios.defaults.baseURL}/file/download?privateUrl=${params.row.storage_path}`; + window.open(url, '_blank'); + }} + size="small" + > + + + + )} { + + diff --git a/frontend/src/pages/downloads/downloads-edit.tsx b/frontend/src/pages/downloads/downloads-edit.tsx index 10c59ef..27a2bbe 100644 --- a/frontend/src/pages/downloads/downloads-edit.tsx +++ b/frontend/src/pages/downloads/downloads-edit.tsx @@ -474,6 +474,8 @@ const EditDownloadsPage = () => { + + diff --git a/frontend/src/pages/downloads/downloads-list.tsx b/frontend/src/pages/downloads/downloads-list.tsx index 71b0efa..142f0fc 100644 --- a/frontend/src/pages/downloads/downloads-list.tsx +++ b/frontend/src/pages/downloads/downloads-list.tsx @@ -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'); diff --git a/frontend/src/pages/downloads/downloads-new.tsx b/frontend/src/pages/downloads/downloads-new.tsx index 1d3a2f2..7ab2856 100644 --- a/frontend/src/pages/downloads/downloads-new.tsx +++ b/frontend/src/pages/downloads/downloads-new.tsx @@ -295,6 +295,8 @@ const DownloadsNew = () => { + + diff --git a/frontend/src/pages/downloads/downloads-table.tsx b/frontend/src/pages/downloads/downloads-table.tsx index 2d0e8da..ad9f8ba 100644 --- a/frontend/src/pages/downloads/downloads-table.tsx +++ b/frontend/src/pages/downloads/downloads-table.tsx @@ -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 \ No newline at end of file diff --git a/frontend/src/pages/media/index.tsx b/frontend/src/pages/media/index.tsx new file mode 100644 index 0000000..656799c --- /dev/null +++ b/frontend/src/pages/media/index.tsx @@ -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 ( + <> + + {getPageTitle('Media Search')} + + + + {''} + + + + +
+
+ + + + + + +
+ +
+
+
+
+
+ + {loading &&
Searching...
} + +
+ {results.map((item) => ( + +
+ {item.image ? ( + {item.title} + ) : ( +
+ +
+ )} +
+
+

{item.title}

+

{item.artist}

+ {item.duration && ( +

+ Duration: {Math.floor(item.duration / 60)}:{(item.duration % 60).toString().padStart(2, '0')} +

+ )} +
+
+ handleDownload(item)} + small + /> + + + +
+
+ ))} +
+ + {!loading && results.length === 0 && ( +
+ Search for something to see results. +
+ )} +
+ + ) +} + +MediaSearchPage.getLayout = function getLayout(page: ReactElement) { + return {page} +} + +export default MediaSearchPage