@axium/kino 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +157 -0
- package/db.json +106 -0
- package/dist/client/api.d.ts +49 -0
- package/dist/client/api.js +136 -0
- package/dist/client/frontend.d.ts +3 -0
- package/dist/client/frontend.js +33 -0
- package/dist/client/index.d.ts +1 -0
- package/dist/client/index.js +1 -0
- package/dist/client/web_hook.d.ts +8 -0
- package/dist/client/web_hook.js +4 -0
- package/dist/common.d.ts +749 -0
- package/dist/common.js +212 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/server/api/delete.d.ts +1 -0
- package/dist/server/api/delete.js +57 -0
- package/dist/server/api/metadata.d.ts +4 -0
- package/dist/server/api/metadata.js +153 -0
- package/dist/server/api/raw.d.ts +1 -0
- package/dist/server/api/raw.js +86 -0
- package/dist/server/api/search.d.ts +1 -0
- package/dist/server/api/search.js +108 -0
- package/dist/server/api/upload.d.ts +1 -0
- package/dist/server/api/upload.js +122 -0
- package/dist/server/api/views.d.ts +1 -0
- package/dist/server/api/views.js +128 -0
- package/dist/server/db.d.ts +11 -0
- package/dist/server/db.js +100 -0
- package/dist/server/hooks.d.ts +9 -0
- package/dist/server/hooks.js +19 -0
- package/dist/server/images.d.ts +12 -0
- package/dist/server/images.js +152 -0
- package/dist/server/media.d.ts +47 -0
- package/dist/server/media.js +136 -0
- package/dist/server/tmdb.d.ts +2 -0
- package/dist/server/tmdb.js +7 -0
- package/lib/EpisodeList.svelte +89 -0
- package/lib/MediaActions.svelte +110 -0
- package/lib/MediaCard.svelte +41 -0
- package/lib/MediaDetail.svelte +140 -0
- package/lib/MediaGrid.svelte +27 -0
- package/lib/Poster.svelte +49 -0
- package/lib/ProgressBar.svelte +22 -0
- package/lib/RecentGrid.svelte +82 -0
- package/lib/SearchBar.svelte +174 -0
- package/lib/SeasonList.svelte +50 -0
- package/lib/index.ts +10 -0
- package/lib/tsconfig.json +12 -0
- package/lib/watch.svelte.ts +93 -0
- package/locales/en.json +103 -0
- package/package.json +102 -0
- package/routes/+layout.svelte +22 -0
- package/routes/+layout.ts +21 -0
- package/routes/kino/+page.svelte +57 -0
- package/routes/kino/+page.ts +19 -0
- package/routes/movies/+page.svelte +22 -0
- package/routes/movies/+page.ts +12 -0
- package/routes/movies/[id]/+page.svelte +36 -0
- package/routes/movies/[id]/+page.ts +7 -0
- package/routes/movies/[id]/watch/+page.svelte +34 -0
- package/routes/movies/[id]/watch/+page.ts +18 -0
- package/routes/tsconfig.json +12 -0
- package/routes/tv/+page.svelte +22 -0
- package/routes/tv/+page.ts +12 -0
- package/routes/tv/[id]/+page.svelte +39 -0
- package/routes/tv/[id]/+page.ts +7 -0
- package/routes/tv/[id]/[season]/+page.svelte +43 -0
- package/routes/tv/[id]/[season]/+page.ts +11 -0
- package/routes/tv/[id]/[season]/[episode]/+page.svelte +46 -0
- package/routes/tv/[id]/[season]/[episode]/+page.ts +12 -0
- package/routes/tv/[id]/[season]/[episode]/watch/+page.svelte +46 -0
- package/routes/tv/[id]/[season]/[episode]/watch/+page.ts +22 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { getConfig } from '@axium/core';
|
|
2
|
+
import { requireSession } from '@axium/server/auth';
|
|
3
|
+
import { database } from '@axium/server/database';
|
|
4
|
+
import { error, json, parseBody, withError } from '@axium/server/requests';
|
|
5
|
+
import { addRoute } from '@axium/server/routes';
|
|
6
|
+
import { UploadManager } from '@axium/server/uploads';
|
|
7
|
+
import * as kt from 'kinotool';
|
|
8
|
+
import { mkdirSync } from 'node:fs';
|
|
9
|
+
import { dirname } from 'node:path';
|
|
10
|
+
import { omit, pick } from 'utilium';
|
|
11
|
+
import { KinoMovieUploadInit, KinoTvUploadInit } from '../../common.js';
|
|
12
|
+
import { getEpisode, getMovie } from '../db.js';
|
|
13
|
+
import { applyMetadata, episodePath, extensionForType, moviePath, remuxUpload } from '../media.js';
|
|
14
|
+
import { tmdb } from '../tmdb.js';
|
|
15
|
+
import { ID } from './metadata.js';
|
|
16
|
+
const movieUploads = new UploadManager(() => getConfig('@axium/kino').upload);
|
|
17
|
+
addRoute({
|
|
18
|
+
path: '/api/kino/movies/upload',
|
|
19
|
+
async PUT(request) {
|
|
20
|
+
const session = await requireSession(request);
|
|
21
|
+
const { allow_user_uploads } = getConfig('@axium/kino');
|
|
22
|
+
if (!allow_user_uploads && !session.user.isAdmin)
|
|
23
|
+
error(403, 'Only administrators are allowed to upload movies');
|
|
24
|
+
const init = await parseBody(request, KinoMovieUploadInit);
|
|
25
|
+
if (!extensionForType(init.type))
|
|
26
|
+
error(415, 'Only mkv and mp4 files are supported');
|
|
27
|
+
let movie, id = init.id;
|
|
28
|
+
if (!id) {
|
|
29
|
+
const { title, year } = kt.parseTitle(init.name);
|
|
30
|
+
const { results: [movieInit], } = await tmdb().search.movies({ query: title, year, include_adult: true });
|
|
31
|
+
if (!movieInit)
|
|
32
|
+
error(406, 'Could not determine a matching movie');
|
|
33
|
+
movie = kt.Movie.parse(movieInit);
|
|
34
|
+
id = movie.id;
|
|
35
|
+
}
|
|
36
|
+
movie ||= await getMovie(id);
|
|
37
|
+
const existing = await database.selectFrom('kino_movie_uploads').where('id', '=', id).executeTakeFirst();
|
|
38
|
+
if (existing)
|
|
39
|
+
error(409, 'Movie already uploaded');
|
|
40
|
+
return json({
|
|
41
|
+
max_transfer_size: 100,
|
|
42
|
+
movie,
|
|
43
|
+
token: movieUploads.start(init, session, movie),
|
|
44
|
+
}, { status: 202 });
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
movieUploads.addEndpoint('/raw/kino/movies/upload', async (upload) => {
|
|
48
|
+
const base = moviePath(upload.data.id);
|
|
49
|
+
const ext = extensionForType(upload.init.type); // already checked when the upload was started
|
|
50
|
+
const tx = await database.startTransaction().execute();
|
|
51
|
+
try {
|
|
52
|
+
mkdirSync(dirname(base), { recursive: true });
|
|
53
|
+
const item = await tx
|
|
54
|
+
.insertInto('kino_movie_uploads')
|
|
55
|
+
.values({ ...upload.init, id: upload.data.id, hash: upload.hash })
|
|
56
|
+
.returningAll()
|
|
57
|
+
.executeTakeFirstOrThrow();
|
|
58
|
+
upload.writeTo(base + ext);
|
|
59
|
+
await applyMetadata(base + ext, ext, upload.data);
|
|
60
|
+
await tx.commit().execute();
|
|
61
|
+
// Runs in the background; until it finishes the original is served
|
|
62
|
+
remuxUpload(base);
|
|
63
|
+
return item;
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
await tx.rollback().execute();
|
|
67
|
+
throw withError('Could not upload movie', 500)(error);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
const tvUploads = new UploadManager(() => getConfig('@axium/kino').upload);
|
|
71
|
+
addRoute({
|
|
72
|
+
path: '/api/kino/tv/:id/upload',
|
|
73
|
+
params: { id: ID },
|
|
74
|
+
async PUT(request, { id }) {
|
|
75
|
+
const session = await requireSession(request);
|
|
76
|
+
const { allow_user_uploads } = getConfig('@axium/kino');
|
|
77
|
+
if (!allow_user_uploads && !session.user.isAdmin)
|
|
78
|
+
error(403, 'Only administrators are allowed to upload TV shows');
|
|
79
|
+
const init = await parseBody(request, KinoTvUploadInit);
|
|
80
|
+
if (!extensionForType(init.type))
|
|
81
|
+
error(415, 'Only mkv and mp4 files are supported');
|
|
82
|
+
const episode = await getEpisode(id, init.season, init.episode);
|
|
83
|
+
const existing = await database
|
|
84
|
+
.selectFrom('kino_tv_uploads')
|
|
85
|
+
.where(eb => eb.and({ id, season_number: init.season, episode_number: init.episode }))
|
|
86
|
+
.executeTakeFirst();
|
|
87
|
+
if (existing)
|
|
88
|
+
error(409, 'TV episode already uploaded');
|
|
89
|
+
return json({
|
|
90
|
+
max_transfer_size: 100,
|
|
91
|
+
episode,
|
|
92
|
+
token: tvUploads.start(init, session, episode),
|
|
93
|
+
}, { status: 202 });
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
tvUploads.addEndpoint('/raw/kino/tv/:id/upload', async (upload) => {
|
|
97
|
+
const { id, season_number, episode_number } = upload.data;
|
|
98
|
+
const base = episodePath(id, season_number, episode_number);
|
|
99
|
+
const ext = extensionForType(upload.init.type);
|
|
100
|
+
const tx = await database.startTransaction().execute();
|
|
101
|
+
try {
|
|
102
|
+
const item = await tx
|
|
103
|
+
.insertInto('kino_tv_uploads')
|
|
104
|
+
.values({
|
|
105
|
+
...omit(upload.init, 'season', 'episode'),
|
|
106
|
+
...pick(upload.data, 'id', 'season_number', 'episode_number'),
|
|
107
|
+
hash: upload.hash,
|
|
108
|
+
})
|
|
109
|
+
.returningAll()
|
|
110
|
+
.executeTakeFirstOrThrow();
|
|
111
|
+
mkdirSync(dirname(base), { recursive: true });
|
|
112
|
+
upload.writeTo(base + ext);
|
|
113
|
+
await applyMetadata(base + ext, ext, upload.data);
|
|
114
|
+
await tx.commit().execute();
|
|
115
|
+
remuxUpload(base);
|
|
116
|
+
return item;
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
await tx.rollback().execute();
|
|
120
|
+
throw withError('Could not upload tv episode', 500)(error);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { requireSession } from '@axium/server/auth';
|
|
2
|
+
import { database } from '@axium/server/database';
|
|
3
|
+
import { parseBody } from '@axium/server/requests';
|
|
4
|
+
import { addRoute } from '@axium/server/routes';
|
|
5
|
+
import { KinoViewInit } from '../../common.js';
|
|
6
|
+
import { getEpisode, getMovie, getTv } from '../db.js';
|
|
7
|
+
/** How many recently watched items the home page shows */
|
|
8
|
+
const limit = 12;
|
|
9
|
+
addRoute({
|
|
10
|
+
path: '/api/kino/views',
|
|
11
|
+
async GET(req) {
|
|
12
|
+
const { userId } = await requireSession(req);
|
|
13
|
+
/**
|
|
14
|
+
* Only cached rows are joined in — a recently watched list should never fan out into TMDB requests.
|
|
15
|
+
* Anything whose metadata has since been removed simply drops off the list.
|
|
16
|
+
*/
|
|
17
|
+
const [movies, episodes] = await Promise.all([
|
|
18
|
+
database
|
|
19
|
+
.selectFrom('kino_movie_views')
|
|
20
|
+
.innerJoin('kino_movies', 'kino_movies.id', 'kino_movie_views.id')
|
|
21
|
+
.innerJoin('kino_movie_uploads', 'kino_movie_uploads.id', 'kino_movie_views.id')
|
|
22
|
+
.selectAll('kino_movies')
|
|
23
|
+
.select(['kino_movie_views.viewedAt', 'kino_movie_views.position', 'kino_movie_views.duration'])
|
|
24
|
+
.select(['kino_movie_uploads.uploadedAt', 'kino_movie_uploads.name as uploadName'])
|
|
25
|
+
.select(['kino_movie_uploads.size', 'kino_movie_uploads.type'])
|
|
26
|
+
.where('kino_movie_views.userId', '=', userId)
|
|
27
|
+
.orderBy('kino_movie_views.viewedAt', 'desc')
|
|
28
|
+
.limit(limit)
|
|
29
|
+
.execute(),
|
|
30
|
+
database
|
|
31
|
+
.selectFrom('kino_tv_views')
|
|
32
|
+
.innerJoin('kino_tv', 'kino_tv.id', 'kino_tv_views.id')
|
|
33
|
+
.innerJoin('kino_episodes', join => join
|
|
34
|
+
.onRef('kino_episodes.id', '=', 'kino_tv_views.id')
|
|
35
|
+
.onRef('kino_episodes.season_number', '=', 'kino_tv_views.season_number')
|
|
36
|
+
.onRef('kino_episodes.episode_number', '=', 'kino_tv_views.episode_number'))
|
|
37
|
+
.select(['kino_tv_views.viewedAt', 'kino_tv_views.position', 'kino_tv_views.duration'])
|
|
38
|
+
.select(eb => [
|
|
39
|
+
eb.ref('kino_episodes.season_number').as('season_number'),
|
|
40
|
+
eb.ref('kino_episodes.episode_number').as('episode_number'),
|
|
41
|
+
eb.ref('kino_episodes.name').as('episode_name'),
|
|
42
|
+
eb.ref('kino_episodes.air_date').as('air_date'),
|
|
43
|
+
eb.ref('kino_episodes.still_path').as('still_path'),
|
|
44
|
+
])
|
|
45
|
+
.select(eb => [
|
|
46
|
+
eb.ref('kino_tv.id').as('showId'),
|
|
47
|
+
eb.ref('kino_tv.name').as('showName'),
|
|
48
|
+
eb.ref('kino_tv.overview').as('overview'),
|
|
49
|
+
eb.ref('kino_tv.first_air_date').as('first_air_date'),
|
|
50
|
+
eb.ref('kino_tv.adult').as('adult'),
|
|
51
|
+
eb.ref('kino_tv.poster_path').as('poster_path'),
|
|
52
|
+
eb.ref('kino_tv.backdrop_path').as('backdrop_path'),
|
|
53
|
+
])
|
|
54
|
+
.where('kino_tv_views.userId', '=', userId)
|
|
55
|
+
.orderBy('kino_tv_views.viewedAt', 'desc')
|
|
56
|
+
.limit(limit)
|
|
57
|
+
.execute(),
|
|
58
|
+
]);
|
|
59
|
+
const views = [
|
|
60
|
+
...movies.map(({ viewedAt, position, duration, uploadedAt, uploadName, size, type, ...movie }) => ({
|
|
61
|
+
type: 'movie',
|
|
62
|
+
viewedAt,
|
|
63
|
+
position,
|
|
64
|
+
duration,
|
|
65
|
+
movie: { ...movie, upload: { uploadedAt, name: uploadName, size, type } },
|
|
66
|
+
})),
|
|
67
|
+
...episodes.map(row => ({
|
|
68
|
+
type: 'tv',
|
|
69
|
+
viewedAt: row.viewedAt,
|
|
70
|
+
position: row.position,
|
|
71
|
+
duration: row.duration,
|
|
72
|
+
show: {
|
|
73
|
+
id: row.showId,
|
|
74
|
+
name: row.showName,
|
|
75
|
+
overview: row.overview,
|
|
76
|
+
first_air_date: row.first_air_date,
|
|
77
|
+
adult: row.adult,
|
|
78
|
+
poster_path: row.poster_path,
|
|
79
|
+
backdrop_path: row.backdrop_path,
|
|
80
|
+
},
|
|
81
|
+
episode: {
|
|
82
|
+
id: row.showId,
|
|
83
|
+
season_number: row.season_number,
|
|
84
|
+
episode_number: row.episode_number,
|
|
85
|
+
name: row.episode_name,
|
|
86
|
+
air_date: row.air_date,
|
|
87
|
+
still_path: row.still_path,
|
|
88
|
+
},
|
|
89
|
+
})),
|
|
90
|
+
];
|
|
91
|
+
// Both queries are capped at `limit`, so the merged list has to be trimmed again
|
|
92
|
+
return views.sort((a, b) => b.viewedAt.getTime() - a.viewedAt.getTime()).slice(0, limit);
|
|
93
|
+
},
|
|
94
|
+
async PUT(req) {
|
|
95
|
+
const { userId } = await requireSession(req);
|
|
96
|
+
const init = await parseBody(req, KinoViewInit);
|
|
97
|
+
const viewedAt = new Date();
|
|
98
|
+
const { position, duration } = init;
|
|
99
|
+
// The player sends this repeatedly while watching, so an existing row is updated in place
|
|
100
|
+
const progress = { viewedAt, position, duration: duration ?? null };
|
|
101
|
+
if (init.type == 'movie') {
|
|
102
|
+
const movie = await getMovie(init.id);
|
|
103
|
+
await database
|
|
104
|
+
.insertInto('kino_movie_views')
|
|
105
|
+
.values({ userId, id: init.id, ...progress })
|
|
106
|
+
.onConflict(b => b.columns(['userId', 'id']).doUpdateSet(progress))
|
|
107
|
+
.execute();
|
|
108
|
+
const upload = await database
|
|
109
|
+
.selectFrom('kino_movie_uploads')
|
|
110
|
+
.select(['uploadedAt', 'name', 'size', 'type'])
|
|
111
|
+
.where('id', '=', init.id)
|
|
112
|
+
.executeTakeFirst();
|
|
113
|
+
return { type: 'movie', ...progress, movie: { ...movie, upload } };
|
|
114
|
+
}
|
|
115
|
+
const [show, episode] = await Promise.all([getTv(init.id), getEpisode(init.id, init.season, init.episode)]);
|
|
116
|
+
await database
|
|
117
|
+
.insertInto('kino_tv_views')
|
|
118
|
+
.values({ userId, id: init.id, season_number: init.season, episode_number: init.episode, ...progress })
|
|
119
|
+
.onConflict(b => b.columns(['userId', 'id', 'season_number', 'episode_number']).doUpdateSet(progress))
|
|
120
|
+
.execute();
|
|
121
|
+
const upload = await database
|
|
122
|
+
.selectFrom('kino_tv_uploads')
|
|
123
|
+
.select(['uploadedAt', 'name', 'size', 'type'])
|
|
124
|
+
.where(eb => eb.and({ id: init.id, season_number: init.season, episode_number: init.episode }))
|
|
125
|
+
.executeTakeFirst();
|
|
126
|
+
return { type: 'tv', ...progress, show, episode: { ...episode, upload } };
|
|
127
|
+
},
|
|
128
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { FromFile as FromSchemaFile } from '@axium/server/db/schema';
|
|
2
|
+
import * as kt from 'kinotool';
|
|
3
|
+
import type schema from '../../db.json';
|
|
4
|
+
declare module '@axium/server/database' {
|
|
5
|
+
interface Schema extends FromSchemaFile<typeof schema> {
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
export declare function getMovie(id: number): Promise<kt.Movie>;
|
|
9
|
+
export declare function getTv(id: number): Promise<kt.Tv>;
|
|
10
|
+
export declare function getSeason(id: number, season_number: number): Promise<kt.Season>;
|
|
11
|
+
export declare function getEpisode(id: number, season_number: number, episode_number: number): Promise<kt.Episode>;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { database } from '@axium/server/database';
|
|
2
|
+
import * as kt from 'kinotool';
|
|
3
|
+
import { jsonArrayFrom } from 'kysely/helpers/postgres';
|
|
4
|
+
import { omit } from 'utilium';
|
|
5
|
+
import { tmdb } from './tmdb.js';
|
|
6
|
+
export async function getMovie(id) {
|
|
7
|
+
const result = await database.selectFrom('kino_movies').where('id', '=', id).selectAll().executeTakeFirst();
|
|
8
|
+
if (result)
|
|
9
|
+
return result;
|
|
10
|
+
const movie = await tmdb().movies.details(id).then(kt.Movie.parse);
|
|
11
|
+
await database.insertInto('kino_movies').values(movie).returningAll().executeTakeFirstOrThrow();
|
|
12
|
+
return movie;
|
|
13
|
+
}
|
|
14
|
+
/** Seasons are stored in their own table, so pull them in alongside the show */
|
|
15
|
+
function withSeasons(eb) {
|
|
16
|
+
return jsonArrayFrom(eb.selectFrom('kino_seasons').selectAll().whereRef('kino_seasons.id', '=', 'kino_tv.id').orderBy('kino_seasons.season_number'))
|
|
17
|
+
.$castTo()
|
|
18
|
+
.as('seasons');
|
|
19
|
+
}
|
|
20
|
+
/** Likewise for the episodes of a season */
|
|
21
|
+
function withEpisodes(eb) {
|
|
22
|
+
return jsonArrayFrom(eb
|
|
23
|
+
.selectFrom('kino_episodes')
|
|
24
|
+
.selectAll()
|
|
25
|
+
.whereRef('kino_episodes.id', '=', 'kino_seasons.id')
|
|
26
|
+
.whereRef('kino_episodes.season_number', '=', 'kino_seasons.season_number')
|
|
27
|
+
.orderBy('kino_episodes.episode_number'))
|
|
28
|
+
.$castTo()
|
|
29
|
+
.as('episodes');
|
|
30
|
+
}
|
|
31
|
+
export async function getTv(id) {
|
|
32
|
+
const result = await database.selectFrom('kino_tv').where('id', '=', id).selectAll().select(withSeasons).executeTakeFirst();
|
|
33
|
+
// Search caches shows without their seasons, so a row on its own is not enough to skip TMDB
|
|
34
|
+
if (result?.seasons.length)
|
|
35
|
+
return result;
|
|
36
|
+
const tv = await tmdb().tvShows.details(id).then(kt.Tv.parse);
|
|
37
|
+
await database
|
|
38
|
+
.insertInto('kino_tv')
|
|
39
|
+
.values(omit(tv, 'seasons'))
|
|
40
|
+
.onConflict(b => b.doNothing())
|
|
41
|
+
.execute();
|
|
42
|
+
for (const season of tv.seasons || []) {
|
|
43
|
+
season.id = id;
|
|
44
|
+
await database
|
|
45
|
+
.insertInto('kino_seasons')
|
|
46
|
+
.values(omit(season, 'episodes'))
|
|
47
|
+
.onConflict(b => b.doNothing())
|
|
48
|
+
.execute();
|
|
49
|
+
}
|
|
50
|
+
return tv;
|
|
51
|
+
}
|
|
52
|
+
export async function getSeason(id, season_number) {
|
|
53
|
+
const result = await database
|
|
54
|
+
.selectFrom('kino_seasons')
|
|
55
|
+
.where(eb => eb.and({ id, season_number }))
|
|
56
|
+
.selectAll()
|
|
57
|
+
.select(withEpisodes)
|
|
58
|
+
.executeTakeFirst();
|
|
59
|
+
/**
|
|
60
|
+
* `getTv` inserts season rows without any episodes, so the row alone is not enough either.
|
|
61
|
+
* A season that genuinely has no episodes (e.g. one that has not aired) will re-check TMDB each time.
|
|
62
|
+
*/
|
|
63
|
+
if (result?.episodes.length)
|
|
64
|
+
return result;
|
|
65
|
+
const season = await tmdb().tvSeasons.details({ tvShowID: id, seasonNumber: season_number }).then(kt.Season.parse);
|
|
66
|
+
season.id = id;
|
|
67
|
+
for (const episode of season.episodes || []) {
|
|
68
|
+
episode.id = id;
|
|
69
|
+
await database
|
|
70
|
+
.insertInto('kino_episodes')
|
|
71
|
+
.values(episode)
|
|
72
|
+
.onConflict(b => b.doNothing())
|
|
73
|
+
.execute();
|
|
74
|
+
}
|
|
75
|
+
await database
|
|
76
|
+
.insertInto('kino_seasons')
|
|
77
|
+
.values(omit(season, 'episodes'))
|
|
78
|
+
.onConflict(b => b.doNothing())
|
|
79
|
+
.execute();
|
|
80
|
+
return season;
|
|
81
|
+
}
|
|
82
|
+
export async function getEpisode(id, season_number, episode_number) {
|
|
83
|
+
const result = await database
|
|
84
|
+
.selectFrom('kino_episodes')
|
|
85
|
+
.where(eb => eb.and({ id, season_number, episode_number }))
|
|
86
|
+
.selectAll()
|
|
87
|
+
.executeTakeFirst();
|
|
88
|
+
if (result)
|
|
89
|
+
return result;
|
|
90
|
+
const episode = await tmdb()
|
|
91
|
+
.tvEpisode.details({ tvShowID: id, seasonNumber: season_number, episodeNumber: episode_number })
|
|
92
|
+
.then(kt.Episode.parse);
|
|
93
|
+
episode.id = id;
|
|
94
|
+
await database
|
|
95
|
+
.insertInto('kino_episodes')
|
|
96
|
+
.values(episode)
|
|
97
|
+
.onConflict(b => b.doNothing())
|
|
98
|
+
.execute();
|
|
99
|
+
return episode;
|
|
100
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import '../common.js';
|
|
2
|
+
import './api/metadata.js';
|
|
3
|
+
import './api/raw.js';
|
|
4
|
+
import './api/search.js';
|
|
5
|
+
import './api/upload.js';
|
|
6
|
+
import './api/views.js';
|
|
7
|
+
import './images.js';
|
|
8
|
+
export declare function load(): void;
|
|
9
|
+
export declare function statusText(): Promise<string>;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { getConfig } from '@axium/core';
|
|
2
|
+
import { count } from '@axium/server/database';
|
|
3
|
+
import { mkdirSync } from 'node:fs';
|
|
4
|
+
import '../common.js';
|
|
5
|
+
import './api/metadata.js';
|
|
6
|
+
import './api/raw.js';
|
|
7
|
+
import './api/search.js';
|
|
8
|
+
import './api/upload.js';
|
|
9
|
+
import './api/views.js';
|
|
10
|
+
import './images.js';
|
|
11
|
+
export function load() {
|
|
12
|
+
const { data_dir, images } = getConfig('@axium/kino');
|
|
13
|
+
mkdirSync(data_dir, { recursive: true });
|
|
14
|
+
mkdirSync(images.cache.directory, { recursive: true });
|
|
15
|
+
}
|
|
16
|
+
export async function statusText() {
|
|
17
|
+
const { kino_movie_uploads: movies, kino_tv_uploads: episodes } = await count('kino_movie_uploads', 'kino_tv_uploads');
|
|
18
|
+
return `${movies} movies and ${episodes} episodes`;
|
|
19
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type ImageSize, type ImageType } from '../common.js';
|
|
2
|
+
export declare function getCachePath(file: string, size: ImageSize): string;
|
|
3
|
+
export declare function getCachedSizes(file: string, type?: ImageType | null): ImageSize[];
|
|
4
|
+
export declare function removeFromCache(file: string, size: ImageSize): void;
|
|
5
|
+
export type CacheEntry = [name: string, size: bigint, atime: bigint];
|
|
6
|
+
/** Get all the files in the cache */
|
|
7
|
+
export declare function getCacheFiles(): Generator<CacheEntry>;
|
|
8
|
+
/** Compute cache size */
|
|
9
|
+
export declare function computeCacheSize(files?: Iterable<CacheEntry>): bigint;
|
|
10
|
+
/** The maximum cache size in bytes, or false when the cache is unlimited */
|
|
11
|
+
export declare function maxCacheBytes(): bigint | false;
|
|
12
|
+
export declare function pruneCache(): void;
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { getConfig } from '@axium/core';
|
|
2
|
+
import { parseSearch } from '@axium/server/requests';
|
|
3
|
+
import { addRoute } from '@axium/server/routes';
|
|
4
|
+
import * as io from 'ioium/node';
|
|
5
|
+
import mime from 'mime';
|
|
6
|
+
import * as fs from 'node:fs';
|
|
7
|
+
import { dirname, join } from 'node:path';
|
|
8
|
+
import { ImageFile, ImageSelection, imagesSizes, imageWidth } from '../common.js';
|
|
9
|
+
export function getCachePath(file, size) {
|
|
10
|
+
const path = join(getConfig('@axium/kino').images.cache.directory, size, file);
|
|
11
|
+
fs.mkdirSync(dirname(path), { recursive: true });
|
|
12
|
+
return path;
|
|
13
|
+
}
|
|
14
|
+
export function getCachedSizes(file, type) {
|
|
15
|
+
const dir = getConfig('@axium/kino').images.cache.directory;
|
|
16
|
+
return imagesSizes[type || 'all'].filter(size => fs.existsSync(join(dir, size, file)));
|
|
17
|
+
}
|
|
18
|
+
let cacheSize;
|
|
19
|
+
export function removeFromCache(file, size) {
|
|
20
|
+
cacheSize ??= computeCacheSize();
|
|
21
|
+
const path = getCachePath(file, size);
|
|
22
|
+
try {
|
|
23
|
+
const { size } = fs.statSync(path, { bigint: true });
|
|
24
|
+
fs.unlinkSync(path);
|
|
25
|
+
cacheSize -= size;
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
// ignore
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** Get all the files in the cache */
|
|
32
|
+
export function* getCacheFiles() {
|
|
33
|
+
const { cache } = getConfig('@axium/kino').images;
|
|
34
|
+
for (const file of fs.readdirSync(cache.directory, { recursive: true, encoding: 'utf8' })) {
|
|
35
|
+
try {
|
|
36
|
+
const stats = fs.statSync(join(cache.directory, file), { bigint: true });
|
|
37
|
+
if (!stats.isFile())
|
|
38
|
+
continue;
|
|
39
|
+
yield [file, stats.size, stats.atimeMs];
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
// ignore
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/** Compute cache size */
|
|
47
|
+
export function computeCacheSize(files = getCacheFiles()) {
|
|
48
|
+
let sum = 0n;
|
|
49
|
+
for (const [, size] of files)
|
|
50
|
+
sum += size;
|
|
51
|
+
return sum;
|
|
52
|
+
}
|
|
53
|
+
/** The maximum cache size in bytes, or false when the cache is unlimited */
|
|
54
|
+
export function maxCacheBytes() {
|
|
55
|
+
const { max_size } = getConfig('@axium/kino').images.cache;
|
|
56
|
+
return max_size ? BigInt(Math.floor(max_size * 1024 * 1024)) : false;
|
|
57
|
+
}
|
|
58
|
+
export function pruneCache() {
|
|
59
|
+
const { cache } = getConfig('@axium/kino').images;
|
|
60
|
+
const max = maxCacheBytes();
|
|
61
|
+
if (!max)
|
|
62
|
+
return;
|
|
63
|
+
const files = Array.from(getCacheFiles());
|
|
64
|
+
cacheSize ??= computeCacheSize(files);
|
|
65
|
+
// Least recently used first, evicting the largest first when access times are equal
|
|
66
|
+
files.sort(([, sizeA, timeA], [, sizeB, timeB]) => Number(timeA - timeB || sizeB - sizeA));
|
|
67
|
+
for (const [file, size] of files) {
|
|
68
|
+
if (cacheSize < max)
|
|
69
|
+
return;
|
|
70
|
+
try {
|
|
71
|
+
fs.unlinkSync(join(cache.directory, file));
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
continue; // already gone; don't credit its size
|
|
75
|
+
}
|
|
76
|
+
cacheSize -= size;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
addRoute({
|
|
80
|
+
path: '/raw/image/:file',
|
|
81
|
+
params: { file: ImageFile },
|
|
82
|
+
async GET(req, { file }) {
|
|
83
|
+
const selection = parseSearch(req, ImageSelection) || {};
|
|
84
|
+
const { base_url, cache } = getConfig('@axium/kino').images;
|
|
85
|
+
const size = selection.size || (selection.type && cache.max_image_sizes[selection.type]) || 'original', { type } = selection;
|
|
86
|
+
const tmdb = new URL(`${base_url}${size}/${file}`);
|
|
87
|
+
if (cache.mode == 'disabled')
|
|
88
|
+
return Response.redirect(tmdb);
|
|
89
|
+
const cachedSizes = getCachedSizes(file, type), cachedImages = cachedSizes.map(size => [size, imageWidth(size)]), cachedWidths = cachedImages.map(([, width]) => width);
|
|
90
|
+
const targetWidth = imageWidth(size);
|
|
91
|
+
const [closestCachedSize] = cachedImages.toSorted(([, a], [, b]) => Math.abs(a - targetWidth) - Math.abs(b - targetWidth))[0] || [];
|
|
92
|
+
const cachedImage = (size) => {
|
|
93
|
+
const headers = {
|
|
94
|
+
'x-is-cached': '1',
|
|
95
|
+
'Cache-Control': 'public, max-age=31536000, immutable',
|
|
96
|
+
};
|
|
97
|
+
const type = mime.getType(getCachePath(file, size));
|
|
98
|
+
if (type)
|
|
99
|
+
headers['Content-Type'] = type;
|
|
100
|
+
return new Response(fs.readFileSync(getCachePath(file, size)), { headers });
|
|
101
|
+
};
|
|
102
|
+
const fetchImage = async () => {
|
|
103
|
+
const res = await fetch(tmdb, {
|
|
104
|
+
signal: AbortSignal.timeout(5000),
|
|
105
|
+
}).catch(() => null);
|
|
106
|
+
if (!res?.ok || !res.body) {
|
|
107
|
+
if (res?.status === 404)
|
|
108
|
+
return new Response(null, { status: 404 });
|
|
109
|
+
io.warnOnce('Kino: using fallback images because TMDB is unavailable');
|
|
110
|
+
if (!closestCachedSize)
|
|
111
|
+
return new Response('TMDB is unavailable and no sizes of this image are cached', { status: 503 });
|
|
112
|
+
return cachedImage(closestCachedSize);
|
|
113
|
+
}
|
|
114
|
+
if ((cache.largest_only && cachedWidths.some(w => w >= targetWidth)) || cachedSizes.includes(size))
|
|
115
|
+
return res;
|
|
116
|
+
const data = await res.bytes();
|
|
117
|
+
try {
|
|
118
|
+
fs.writeFileSync(getCachePath(file, size), data);
|
|
119
|
+
cacheSize ??= computeCacheSize();
|
|
120
|
+
if (cache.largest_only)
|
|
121
|
+
for (const cachedSize of cachedSizes)
|
|
122
|
+
removeFromCache(file, cachedSize);
|
|
123
|
+
cacheSize += BigInt(data.byteLength);
|
|
124
|
+
const max = maxCacheBytes();
|
|
125
|
+
if (max && cacheSize > max)
|
|
126
|
+
pruneCache();
|
|
127
|
+
}
|
|
128
|
+
catch (e) {
|
|
129
|
+
io.warn('Kino: failed to cache image: ' + io.errorText(e));
|
|
130
|
+
}
|
|
131
|
+
const headers = { 'Cache-Control': 'public, max-age=31536000, immutable' };
|
|
132
|
+
const contentType = res.headers.get('Content-Type');
|
|
133
|
+
if (contentType)
|
|
134
|
+
headers['Content-Type'] = contentType;
|
|
135
|
+
return new Response(data, { headers });
|
|
136
|
+
};
|
|
137
|
+
switch (cache.mode) {
|
|
138
|
+
case 'fallback':
|
|
139
|
+
return fetchImage();
|
|
140
|
+
case 'exact-size':
|
|
141
|
+
return cachedSizes.includes(size) ? cachedImage(size) : await fetchImage();
|
|
142
|
+
case 'quality': {
|
|
143
|
+
const [nearestBigSize] = cachedImages.filter(([, w]) => w >= targetWidth).sort(([, a], [, b]) => a - b)[0] || [];
|
|
144
|
+
if (nearestBigSize)
|
|
145
|
+
return cachedImage(nearestBigSize);
|
|
146
|
+
return await fetchImage();
|
|
147
|
+
}
|
|
148
|
+
case 'prefer':
|
|
149
|
+
return closestCachedSize ? cachedImage(closestCachedSize) : await fetchImage();
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import * as kt from 'kinotool';
|
|
2
|
+
import { type MediaExt } from '../common.js';
|
|
3
|
+
export { extensionForType, mediaExtensions, mediaTypes, type MediaExt } from '../common.js';
|
|
4
|
+
/** Path of a movie's files, without an extension */
|
|
5
|
+
export declare function moviePath(id: number): string;
|
|
6
|
+
/** Path of an episode's files, without an extension */
|
|
7
|
+
export declare function episodePath(id: number, season: number, episode: number): string;
|
|
8
|
+
export interface ResolvedMedia {
|
|
9
|
+
path: string;
|
|
10
|
+
ext: MediaExt;
|
|
11
|
+
type: string;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Pick which of an item's files to serve.
|
|
15
|
+
*
|
|
16
|
+
* This deliberately ignores the `remux` config: it can be changed at any time, and files written under
|
|
17
|
+
* an older setting still have to be served. Only what is actually on disk decides.
|
|
18
|
+
*
|
|
19
|
+
* Playback prefers the MP4, since browsers can seek it without reading the whole file.
|
|
20
|
+
* Downloads prefer the original upload, which still has every audio track and subtitle.
|
|
21
|
+
*/
|
|
22
|
+
export declare function resolveMedia(base: string, download?: boolean): ResolvedMedia | null;
|
|
23
|
+
/** Every file belonging to an item, including a partial remux left behind by a crash */
|
|
24
|
+
export declare function mediaFiles(base: string): string[];
|
|
25
|
+
/** Remove all of an item's files. Returns the paths that were removed. */
|
|
26
|
+
export declare function removeMedia(base: string): string[];
|
|
27
|
+
/**
|
|
28
|
+
* Start remuxing a freshly uploaded MKV to MP4, if the config asks for it.
|
|
29
|
+
*
|
|
30
|
+
* Remuxing multi-GB files takes minutes, so ffmpeg is spawned rather than run through
|
|
31
|
+
* `kt.mkv.remuxToMp4`, whose synchronous `trackCommand` would block the event loop for the duration.
|
|
32
|
+
* Until it finishes, `resolveMedia` keeps serving the MKV.
|
|
33
|
+
*/
|
|
34
|
+
export declare function remuxUpload(base: string): void;
|
|
35
|
+
/**
|
|
36
|
+
* Apply TMDB metadata to a freshly written file.
|
|
37
|
+
*
|
|
38
|
+
* Only Matroska is handled: `mkvpropedit` edits the header in place, so it finishes in milliseconds
|
|
39
|
+
* regardless of file size. `kt.mp4.setFromMovie` would rewrite the entire container, and it runs
|
|
40
|
+
* synchronously, so calling it here would block the event loop for as long as the copy takes.
|
|
41
|
+
*
|
|
42
|
+
* A remuxed MP4 still picks up the title through the remux's `-map_metadata`.
|
|
43
|
+
*
|
|
44
|
+
* @todo Tag directly-uploaded MP4s once kinotool can plan the rewrite the way `planRemuxToMp4` does,
|
|
45
|
+
* so ffmpeg can be spawned instead of run inline.
|
|
46
|
+
*/
|
|
47
|
+
export declare function applyMetadata(path: string, ext: MediaExt, data: kt.Movie | kt.Episode): Promise<void>;
|