@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
package/dist/common.js
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { serverConfigs } from '@axium/core';
|
|
2
|
+
import { $API } from '@axium/core/api';
|
|
3
|
+
import { zKeys } from '@axium/core/locales';
|
|
4
|
+
import { UploadConfig, UploadInit, UploadName, UploadSize } from '@axium/core/uploads';
|
|
5
|
+
import * as z from 'zod';
|
|
6
|
+
import * as kt from 'kinotool/tmdb';
|
|
7
|
+
const base62 = /^[0-9A-Za-z]+$/;
|
|
8
|
+
export const ImageExt = z.literal(['jpg', 'png']);
|
|
9
|
+
export const ImageID = z.string().length(27).regex(base62);
|
|
10
|
+
export const ImageFile = z.templateLiteral([ImageID, '.', ImageExt]);
|
|
11
|
+
export const imagesSizes = {
|
|
12
|
+
backdrop: ['w300', 'w780', 'w1280', 'original'],
|
|
13
|
+
logo: ['w45', 'w92', 'w154', 'w185', 'w300', 'w500', 'original'],
|
|
14
|
+
poster: ['w92', 'w154', 'w185', 'w342', 'w500', 'w780', 'original'],
|
|
15
|
+
profile: ['w45', 'w185', 'h632', 'original'],
|
|
16
|
+
still: ['w92', 'w185', 'w300', 'original'],
|
|
17
|
+
all: ['w45', 'w92', 'w154', 'w185', 'w300', 'w342', 'w500', 'w780', 'w1280', 'h632', 'original'],
|
|
18
|
+
};
|
|
19
|
+
export const ImageType = z.literal(Object.keys(imagesSizes));
|
|
20
|
+
export const ImageSize = z.literal(imagesSizes.all).register(zKeys, { key: 'kino.config.image_size', prefix: 'kino.image_size' });
|
|
21
|
+
export function imageWidth(size) {
|
|
22
|
+
if (size[0] === 'w')
|
|
23
|
+
return parseInt(size.slice(1));
|
|
24
|
+
return size == 'original' ? 1e10 : 421;
|
|
25
|
+
}
|
|
26
|
+
/** All the image sizes share one set of translations, since they are the same vocabulary of TMDB buckets */
|
|
27
|
+
const sizeLocale = { prefix: 'kino.image_size' };
|
|
28
|
+
export const PosterImageSize = z.literal(imagesSizes.poster).register(zKeys, { key: 'kino.config.poster_size', ...sizeLocale }), BackdropImageSize = z.literal(imagesSizes.backdrop).register(zKeys, { key: 'kino.config.backdrop_size', ...sizeLocale }), LogoImageSize = z.literal(imagesSizes.logo).register(zKeys, { key: 'kino.config.logo_size', ...sizeLocale }), ProfileImageSize = z.literal(imagesSizes.profile).register(zKeys, { key: 'kino.config.profile_size', ...sizeLocale }), StillImageSize = z.literal(imagesSizes.still).register(zKeys, { key: 'kino.config.still_size', ...sizeLocale });
|
|
29
|
+
export const ImageSelection = z.discriminatedUnion('type', [
|
|
30
|
+
z.object({ type: z.literal('poster'), size: PosterImageSize.optional() }),
|
|
31
|
+
z.object({ type: z.literal('backdrop'), size: BackdropImageSize.optional() }),
|
|
32
|
+
z.object({ type: z.literal('logo'), size: LogoImageSize.optional() }),
|
|
33
|
+
z.object({ type: z.literal('profile'), size: ProfileImageSize.optional() }),
|
|
34
|
+
z.object({ type: z.literal('still'), size: StillImageSize.optional() }),
|
|
35
|
+
z.object({ type: z.union([z.undefined(), z.null()]), size: ImageSize.optional() }),
|
|
36
|
+
]);
|
|
37
|
+
/**
|
|
38
|
+
* How uploads are prepared for playback:
|
|
39
|
+
* original: serve the uploaded file as-is
|
|
40
|
+
* both: keep the original for download and serve a remuxed copy to browsers
|
|
41
|
+
* replace: remux on upload and discard the original
|
|
42
|
+
*/
|
|
43
|
+
export const RemuxMode = z.literal(['original', 'both', 'replace']).register(zKeys, { key: 'kino.config.remux', prefix: 'kino.remux' });
|
|
44
|
+
export const ImageCacheMode = z
|
|
45
|
+
.literal(['disabled', 'fallback', 'exact-size', 'quality', 'prefer'])
|
|
46
|
+
.register(zKeys, { key: 'kino.config.cache_mode', prefix: 'kino.cache_mode' });
|
|
47
|
+
export const KinoConfig = z.object({
|
|
48
|
+
allow_user_uploads: z.boolean(),
|
|
49
|
+
data_dir: z.string().nonempty(),
|
|
50
|
+
tmdb_api_key: z.string(),
|
|
51
|
+
remux: RemuxMode,
|
|
52
|
+
images: z.object({
|
|
53
|
+
cache: z.object({
|
|
54
|
+
/**
|
|
55
|
+
* disabled: images will not be cached
|
|
56
|
+
* fallback: only use cached images if TMDB is unavailable. `largest_only` should be used with this option.
|
|
57
|
+
* exact-size: use cached images when their size is the same as the requested sized
|
|
58
|
+
* quality: use cached images when their size is at least the requested sized
|
|
59
|
+
* prefer: always used cached images
|
|
60
|
+
*/
|
|
61
|
+
mode: ImageCacheMode,
|
|
62
|
+
/** Max cache size in MiB, 0=unlimited */
|
|
63
|
+
max_size: z.number().nonnegative(),
|
|
64
|
+
/** The directory to store cached images */
|
|
65
|
+
directory: z.string().nonempty(),
|
|
66
|
+
/**
|
|
67
|
+
* If set, only the largest size of an image will be cached.
|
|
68
|
+
* This will decrease cache size but increase network bandwidth
|
|
69
|
+
*/
|
|
70
|
+
largest_only: z.boolean(),
|
|
71
|
+
max_image_sizes: z.object({
|
|
72
|
+
poster: PosterImageSize,
|
|
73
|
+
backdrop: BackdropImageSize,
|
|
74
|
+
logo: LogoImageSize,
|
|
75
|
+
profile: ProfileImageSize,
|
|
76
|
+
still: StillImageSize,
|
|
77
|
+
}),
|
|
78
|
+
}),
|
|
79
|
+
base_url: z.url(),
|
|
80
|
+
}),
|
|
81
|
+
upload: UploadConfig,
|
|
82
|
+
});
|
|
83
|
+
serverConfigs.set('@axium/kino', KinoConfig);
|
|
84
|
+
export const KinoMovieUploadInit = z.object({
|
|
85
|
+
...UploadInit.shape,
|
|
86
|
+
id: z.int().positive().nullish(),
|
|
87
|
+
});
|
|
88
|
+
export const KinoMovieUploadResult = z.object({
|
|
89
|
+
/** Maximum size in MiB per transfer/request */
|
|
90
|
+
max_transfer_size: z.int().positive(),
|
|
91
|
+
movie: kt.Movie,
|
|
92
|
+
token: z.base64url(),
|
|
93
|
+
});
|
|
94
|
+
export const KinoTvUploadInit = z.object({
|
|
95
|
+
...UploadInit.shape,
|
|
96
|
+
season: z.int().nonnegative(),
|
|
97
|
+
episode: z.int().positive(),
|
|
98
|
+
});
|
|
99
|
+
export const KinoTvUploadResult = z.object({
|
|
100
|
+
/** Maximum size in MiB per transfer/request */
|
|
101
|
+
max_transfer_size: z.int().positive(),
|
|
102
|
+
episode: kt.Episode,
|
|
103
|
+
token: z.base64url(),
|
|
104
|
+
});
|
|
105
|
+
export const KinoSearchQuery = z.object({
|
|
106
|
+
query: z.string().min(1),
|
|
107
|
+
type: z.literal(['movie', 'tv']).optional(),
|
|
108
|
+
});
|
|
109
|
+
export const KinoSearchResults = z.union([kt.Movie.extend({ type: z.literal('movie') }), kt.Tv.extend({ type: z.literal('tv') })]).array();
|
|
110
|
+
/** How far through a movie or episode a user is, in seconds */
|
|
111
|
+
export const KinoProgress = z.object({
|
|
112
|
+
position: z.number().nonnegative().default(0),
|
|
113
|
+
/** Total runtime, so progress can be shown without loading the file */
|
|
114
|
+
duration: z.number().positive().nullish(),
|
|
115
|
+
});
|
|
116
|
+
/** Fraction of the way through an item, or null when the runtime isn't known yet */
|
|
117
|
+
export function viewProgress(view) {
|
|
118
|
+
if (!view?.duration || !view.position)
|
|
119
|
+
return null;
|
|
120
|
+
return Math.min(Math.max(view.position / view.duration, 0), 1);
|
|
121
|
+
}
|
|
122
|
+
/** Information about the file backing a movie or episode. `null` when nothing has been uploaded yet. */
|
|
123
|
+
export const KinoUpload = z.object({
|
|
124
|
+
uploadedAt: z.coerce.date(),
|
|
125
|
+
name: UploadName.nullish(),
|
|
126
|
+
size: UploadSize,
|
|
127
|
+
type: z.string(),
|
|
128
|
+
});
|
|
129
|
+
export const KinoMovie = kt.Movie.extend({ upload: KinoUpload.nullish() });
|
|
130
|
+
export const KinoEpisode = kt.Episode.extend({
|
|
131
|
+
upload: KinoUpload.nullish(),
|
|
132
|
+
/** How far the requesting user got through this episode, if they have watched it */
|
|
133
|
+
progress: KinoProgress.nullish(),
|
|
134
|
+
});
|
|
135
|
+
export const KinoSeason = kt.Season.extend({ episodes: KinoEpisode.array().optional() });
|
|
136
|
+
/** The containers kino accepts, and the MIME type each is served as */
|
|
137
|
+
export const mediaTypes = {
|
|
138
|
+
'.mkv': 'video/x-matroska',
|
|
139
|
+
'.mp4': 'video/mp4',
|
|
140
|
+
};
|
|
141
|
+
export const mediaExtensions = Object.keys(mediaTypes);
|
|
142
|
+
/** The extension an upload of `type` should be stored with, or null when the type isn't supported */
|
|
143
|
+
export function extensionForType(type) {
|
|
144
|
+
return mediaExtensions.find(ext => mediaTypes[ext] == type) ?? null;
|
|
145
|
+
}
|
|
146
|
+
/** `accept` for a media file picker, listing both MIME types and extensions */
|
|
147
|
+
export const mediaAccept = Object.entries(mediaTypes)
|
|
148
|
+
.flatMap(([ext, type]) => [type, ext])
|
|
149
|
+
.join(',');
|
|
150
|
+
/** Which movie or episode was watched, and how far through it the user is */
|
|
151
|
+
export const KinoViewInit = z.discriminatedUnion('type', [
|
|
152
|
+
z.object({ type: z.literal('movie'), id: z.int().positive(), ...KinoProgress.shape }),
|
|
153
|
+
z.object({
|
|
154
|
+
type: z.literal('tv'),
|
|
155
|
+
id: z.int().positive(),
|
|
156
|
+
season: z.int().nonnegative(),
|
|
157
|
+
episode: z.int().positive(),
|
|
158
|
+
...KinoProgress.shape,
|
|
159
|
+
}),
|
|
160
|
+
]);
|
|
161
|
+
/**
|
|
162
|
+
* A recently watched item, built from already-cached metadata so the list never has to hit TMDB.
|
|
163
|
+
* TV entries carry both the show (for artwork) and the episode that was watched.
|
|
164
|
+
*/
|
|
165
|
+
export const KinoView = z.discriminatedUnion('type', [
|
|
166
|
+
z.object({ type: z.literal('movie'), viewedAt: z.coerce.date(), ...KinoProgress.shape, movie: KinoMovie }),
|
|
167
|
+
z.object({
|
|
168
|
+
type: z.literal('tv'),
|
|
169
|
+
viewedAt: z.coerce.date(),
|
|
170
|
+
...KinoProgress.shape,
|
|
171
|
+
show: kt.Tv,
|
|
172
|
+
episode: KinoEpisode,
|
|
173
|
+
}),
|
|
174
|
+
]);
|
|
175
|
+
const KinoAPI = {
|
|
176
|
+
'kino/movies': {
|
|
177
|
+
GET: KinoMovie.array(),
|
|
178
|
+
},
|
|
179
|
+
'kino/movies/upload': {
|
|
180
|
+
PUT: [KinoMovieUploadInit, KinoMovieUploadResult],
|
|
181
|
+
},
|
|
182
|
+
'kino/movies/:id': {
|
|
183
|
+
GET: KinoMovie,
|
|
184
|
+
/** Removes the upload and its files. Administrators only. */
|
|
185
|
+
DELETE: KinoUpload,
|
|
186
|
+
},
|
|
187
|
+
'kino/views': {
|
|
188
|
+
GET: KinoView.array(),
|
|
189
|
+
PUT: [KinoViewInit, KinoView],
|
|
190
|
+
},
|
|
191
|
+
'kino/tv': {
|
|
192
|
+
GET: kt.Tv.array(),
|
|
193
|
+
},
|
|
194
|
+
'kino/tv/:id': {
|
|
195
|
+
GET: kt.Tv,
|
|
196
|
+
},
|
|
197
|
+
'kino/tv/:id/season/:season': {
|
|
198
|
+
GET: KinoSeason,
|
|
199
|
+
},
|
|
200
|
+
'kino/tv/:id/season/:season/episode/:episode': {
|
|
201
|
+
GET: KinoEpisode,
|
|
202
|
+
/** Removes the upload and its files. Administrators only. */
|
|
203
|
+
DELETE: KinoUpload,
|
|
204
|
+
},
|
|
205
|
+
'kino/tv/:id/upload': {
|
|
206
|
+
PUT: [KinoTvUploadInit, KinoTvUploadResult],
|
|
207
|
+
},
|
|
208
|
+
'kino/search': {
|
|
209
|
+
POST: [KinoSearchQuery, KinoSearchResults],
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
Object.assign($API, KinoAPI);
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './common.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './common.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { requireSession } from '@axium/server/auth';
|
|
2
|
+
import { database } from '@axium/server/database';
|
|
3
|
+
import { error } from '@axium/server/requests';
|
|
4
|
+
import { addRoute } from '@axium/server/routes';
|
|
5
|
+
import * as io from 'ioium/node';
|
|
6
|
+
import { episodePath, moviePath, removeMedia } from '../media.js';
|
|
7
|
+
import { ID, SeasonNumber } from './metadata.js';
|
|
8
|
+
/**
|
|
9
|
+
* Deleting an upload throws away data for everyone, so it is restricted to administrators
|
|
10
|
+
* regardless of `allow_user_uploads`.
|
|
11
|
+
*/
|
|
12
|
+
async function requireAdmin(req) {
|
|
13
|
+
const session = await requireSession(req);
|
|
14
|
+
if (!session.user.isAdmin)
|
|
15
|
+
error(403, 'Only administrators can delete uploads');
|
|
16
|
+
}
|
|
17
|
+
addRoute({
|
|
18
|
+
path: '/api/kino/movies/:id/upload',
|
|
19
|
+
params: { id: ID },
|
|
20
|
+
async DELETE(req, { id }) {
|
|
21
|
+
await requireAdmin(req);
|
|
22
|
+
const upload = await database
|
|
23
|
+
.deleteFrom('kino_movie_uploads')
|
|
24
|
+
.where('id', '=', id)
|
|
25
|
+
.returning(['uploadedAt', 'name', 'size', 'type'])
|
|
26
|
+
.executeTakeFirst();
|
|
27
|
+
if (!upload)
|
|
28
|
+
error(404, 'This movie has not been uploaded');
|
|
29
|
+
// The row is gone either way; a file we can't remove would only waste space
|
|
30
|
+
const removed = removeMedia(moviePath(id));
|
|
31
|
+
io.info(`Kino: deleted movie ${id} (${removed.length} file(s))`);
|
|
32
|
+
// Progress against a movie nobody can watch anymore is meaningless
|
|
33
|
+
await database.deleteFrom('kino_movie_views').where('id', '=', id).execute();
|
|
34
|
+
return upload;
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
addRoute({
|
|
38
|
+
path: '/api/kino/tv/:id/season/:season/episode/:episode/upload',
|
|
39
|
+
params: { id: ID, season: SeasonNumber, episode: ID },
|
|
40
|
+
async DELETE(req, { id, season, episode }) {
|
|
41
|
+
await requireAdmin(req);
|
|
42
|
+
const upload = await database
|
|
43
|
+
.deleteFrom('kino_tv_uploads')
|
|
44
|
+
.where(eb => eb.and({ id, season_number: season, episode_number: episode }))
|
|
45
|
+
.returning(['uploadedAt', 'name', 'size', 'type'])
|
|
46
|
+
.executeTakeFirst();
|
|
47
|
+
if (!upload)
|
|
48
|
+
error(404, 'This episode has not been uploaded');
|
|
49
|
+
const removed = removeMedia(episodePath(id, season, episode));
|
|
50
|
+
io.info(`Kino: deleted episode ${id} S${season}E${episode} (${removed.length} file(s))`);
|
|
51
|
+
await database
|
|
52
|
+
.deleteFrom('kino_tv_views')
|
|
53
|
+
.where(eb => eb.and({ id, season_number: season, episode_number: episode }))
|
|
54
|
+
.execute();
|
|
55
|
+
return upload;
|
|
56
|
+
},
|
|
57
|
+
});
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { requireSession } from '@axium/server/auth';
|
|
2
|
+
import { database } from '@axium/server/database';
|
|
3
|
+
import { error } from '@axium/server/requests';
|
|
4
|
+
import { addRoute } from '@axium/server/routes';
|
|
5
|
+
import * as io from 'ioium/node';
|
|
6
|
+
import * as z from 'zod';
|
|
7
|
+
import { getEpisode, getMovie, getSeason, getTv } from '../db.js';
|
|
8
|
+
import { episodePath, moviePath, removeMedia } from '../media.js';
|
|
9
|
+
/**
|
|
10
|
+
* Deleting an upload throws away data for everyone, so it is restricted to administrators
|
|
11
|
+
* regardless of `allow_user_uploads`.
|
|
12
|
+
*/
|
|
13
|
+
async function requireAdmin(req) {
|
|
14
|
+
const session = await requireSession(req);
|
|
15
|
+
if (!session.user.isAdmin)
|
|
16
|
+
error(403, 'Only administrators can delete uploads');
|
|
17
|
+
}
|
|
18
|
+
export const ID = z.coerce.number().int().positive();
|
|
19
|
+
/** Specials are season 0, so season numbers may be zero */
|
|
20
|
+
export const SeasonNumber = z.coerce.number().int().nonnegative();
|
|
21
|
+
const params = { id: ID };
|
|
22
|
+
/** The columns of `kino_*_uploads` that are exposed to clients */
|
|
23
|
+
const uploadColumns = ['uploadedAt', 'name', 'size', 'type'];
|
|
24
|
+
addRoute({
|
|
25
|
+
path: '/api/kino/movies',
|
|
26
|
+
async GET(req) {
|
|
27
|
+
await requireSession(req);
|
|
28
|
+
const rows = await database
|
|
29
|
+
.selectFrom('kino_movies')
|
|
30
|
+
.innerJoin('kino_movie_uploads', 'kino_movie_uploads.id', 'kino_movies.id')
|
|
31
|
+
.selectAll('kino_movies')
|
|
32
|
+
.select(uploadColumns.map(column => `kino_movie_uploads.${column}`))
|
|
33
|
+
.orderBy('kino_movies.title')
|
|
34
|
+
.execute();
|
|
35
|
+
return rows.map(({ uploadedAt, name, size, type, ...movie }) => ({ ...movie, upload: { uploadedAt, name, size, type } }));
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
addRoute({
|
|
39
|
+
path: '/api/kino/movies/:id',
|
|
40
|
+
params,
|
|
41
|
+
async GET(req, { id }) {
|
|
42
|
+
await requireSession(req);
|
|
43
|
+
const [movie, upload] = await Promise.all([
|
|
44
|
+
getMovie(id),
|
|
45
|
+
database.selectFrom('kino_movie_uploads').select(uploadColumns).where('id', '=', id).executeTakeFirst(),
|
|
46
|
+
]);
|
|
47
|
+
return { ...movie, upload };
|
|
48
|
+
},
|
|
49
|
+
async DELETE(req, { id }) {
|
|
50
|
+
await requireAdmin(req);
|
|
51
|
+
const upload = await database.deleteFrom('kino_movie_uploads').where('id', '=', id).returning(uploadColumns).executeTakeFirst();
|
|
52
|
+
if (!upload)
|
|
53
|
+
error(404, 'This movie has not been uploaded');
|
|
54
|
+
// The row is gone either way; a file we can't remove would only waste space
|
|
55
|
+
const removed = removeMedia(moviePath(id));
|
|
56
|
+
io.info(`Kino: deleted movie ${id} (${removed.length} file(s))`);
|
|
57
|
+
// Progress against something nobody can watch anymore is meaningless
|
|
58
|
+
await database.deleteFrom('kino_movie_views').where('id', '=', id).execute();
|
|
59
|
+
return upload;
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
addRoute({
|
|
63
|
+
path: '/api/kino/tv',
|
|
64
|
+
async GET(req) {
|
|
65
|
+
await requireSession(req);
|
|
66
|
+
// Only shows with at least one uploaded episode
|
|
67
|
+
return await database
|
|
68
|
+
.selectFrom('kino_tv')
|
|
69
|
+
.selectAll()
|
|
70
|
+
.where(eb => eb.exists(eb.selectFrom('kino_tv_uploads').select('kino_tv_uploads.id').whereRef('kino_tv_uploads.id', '=', 'kino_tv.id')))
|
|
71
|
+
.orderBy('name')
|
|
72
|
+
.execute();
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
addRoute({
|
|
76
|
+
path: '/api/kino/tv/:id',
|
|
77
|
+
params,
|
|
78
|
+
async GET(req, { id }) {
|
|
79
|
+
await requireSession(req);
|
|
80
|
+
return await getTv(id);
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
addRoute({
|
|
84
|
+
path: '/api/kino/tv/:id/season/:season',
|
|
85
|
+
params: { id: ID, season: SeasonNumber },
|
|
86
|
+
async GET(req, { id, season }) {
|
|
87
|
+
const { userId } = await requireSession(req);
|
|
88
|
+
const data = await getSeason(id, season);
|
|
89
|
+
if (!data.episodes?.length)
|
|
90
|
+
return data;
|
|
91
|
+
const [uploads, views] = await Promise.all([
|
|
92
|
+
database
|
|
93
|
+
.selectFrom('kino_tv_uploads')
|
|
94
|
+
.select(['episode_number', ...uploadColumns])
|
|
95
|
+
.where(eb => eb.and({ id, season_number: season }))
|
|
96
|
+
.execute(),
|
|
97
|
+
// Progress is per-user, so the episode list shows the requester's own position
|
|
98
|
+
database
|
|
99
|
+
.selectFrom('kino_tv_views')
|
|
100
|
+
.select(['episode_number', 'position', 'duration'])
|
|
101
|
+
.where(eb => eb.and({ userId, id, season_number: season }))
|
|
102
|
+
.execute(),
|
|
103
|
+
]);
|
|
104
|
+
const uploadFor = new Map(uploads.map(({ episode_number, ...upload }) => [episode_number, upload]));
|
|
105
|
+
const progressFor = new Map(views.map(({ episode_number, ...progress }) => [episode_number, progress]));
|
|
106
|
+
return {
|
|
107
|
+
...data,
|
|
108
|
+
episodes: data.episodes.map(episode => ({
|
|
109
|
+
...episode,
|
|
110
|
+
upload: uploadFor.get(episode.episode_number),
|
|
111
|
+
progress: progressFor.get(episode.episode_number),
|
|
112
|
+
})),
|
|
113
|
+
};
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
addRoute({
|
|
117
|
+
path: '/api/kino/tv/:id/season/:season/episode/:episode',
|
|
118
|
+
params: { id: ID, season: SeasonNumber, episode: ID },
|
|
119
|
+
async GET(req, { id, season, episode }) {
|
|
120
|
+
const { userId } = await requireSession(req);
|
|
121
|
+
const [data, upload, progress] = await Promise.all([
|
|
122
|
+
getEpisode(id, season, episode),
|
|
123
|
+
database
|
|
124
|
+
.selectFrom('kino_tv_uploads')
|
|
125
|
+
.select(uploadColumns)
|
|
126
|
+
.where(eb => eb.and({ id, season_number: season, episode_number: episode }))
|
|
127
|
+
.executeTakeFirst(),
|
|
128
|
+
database
|
|
129
|
+
.selectFrom('kino_tv_views')
|
|
130
|
+
.select(['position', 'duration'])
|
|
131
|
+
.where(eb => eb.and({ userId, id, season_number: season, episode_number: episode }))
|
|
132
|
+
.executeTakeFirst(),
|
|
133
|
+
]);
|
|
134
|
+
return { ...data, upload, progress };
|
|
135
|
+
},
|
|
136
|
+
async DELETE(req, { id, season, episode }) {
|
|
137
|
+
await requireAdmin(req);
|
|
138
|
+
const upload = await database
|
|
139
|
+
.deleteFrom('kino_tv_uploads')
|
|
140
|
+
.where(eb => eb.and({ id, season_number: season, episode_number: episode }))
|
|
141
|
+
.returning(uploadColumns)
|
|
142
|
+
.executeTakeFirst();
|
|
143
|
+
if (!upload)
|
|
144
|
+
error(404, 'This episode has not been uploaded');
|
|
145
|
+
const removed = removeMedia(episodePath(id, season, episode));
|
|
146
|
+
io.info(`Kino: deleted episode ${id} S${season}E${episode} (${removed.length} file(s))`);
|
|
147
|
+
await database
|
|
148
|
+
.deleteFrom('kino_tv_views')
|
|
149
|
+
.where(eb => eb.and({ id, season_number: season, episode_number: episode }))
|
|
150
|
+
.execute();
|
|
151
|
+
return upload;
|
|
152
|
+
},
|
|
153
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { requireSession } from '@axium/server/auth';
|
|
2
|
+
import { database } from '@axium/server/database';
|
|
3
|
+
import { contentDispositionFor, error, parseRequestRange } from '@axium/server/requests';
|
|
4
|
+
import { addRoute } from '@axium/server/routes';
|
|
5
|
+
import { createReadStream, statSync } from 'node:fs';
|
|
6
|
+
import { Readable } from 'node:stream';
|
|
7
|
+
import { getEpisode, getMovie } from '../db.js';
|
|
8
|
+
import { episodePath, moviePath, resolveMedia } from '../media.js';
|
|
9
|
+
import { ID, SeasonNumber } from './metadata.js';
|
|
10
|
+
/** `?download` is a bare flag, so it can't go through `parseSearch`, which JSON-parses every value */
|
|
11
|
+
function isDownload(req) {
|
|
12
|
+
return new URL(req.url).searchParams.has('download');
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Serve a media file, honoring range requests so the browser can seek without pulling the whole file.
|
|
16
|
+
*
|
|
17
|
+
* `base` is the path without an extension; which file is actually sent depends on what is on disk
|
|
18
|
+
* and on whether this is a download or playback.
|
|
19
|
+
*/
|
|
20
|
+
function serveMedia(req, base, name) {
|
|
21
|
+
const download = isDownload(req);
|
|
22
|
+
const media = resolveMedia(base, download);
|
|
23
|
+
if (!media)
|
|
24
|
+
error(404, 'The file for this media is missing');
|
|
25
|
+
// The file on disk is the source of truth; the recorded upload size can be for a different container
|
|
26
|
+
let size;
|
|
27
|
+
try {
|
|
28
|
+
({ size } = statSync(media.path, { bigint: true }));
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
error(404, 'The file for this media is missing');
|
|
32
|
+
}
|
|
33
|
+
const range = req.headers.get('range');
|
|
34
|
+
const parsed = parseRequestRange(size, range);
|
|
35
|
+
const { start } = parsed;
|
|
36
|
+
/**
|
|
37
|
+
* RFC 9110 requires a last-byte-pos at or past the end to mean "the rest of the representation",
|
|
38
|
+
* so clamp instead of rejecting. Firefox and Safari both send such ranges while probing.
|
|
39
|
+
*/
|
|
40
|
+
const end = Math.min(parsed.end, Number(size) - 1);
|
|
41
|
+
if (start < 0 || start > end || start >= size) {
|
|
42
|
+
return new Response(null, { status: 416, headers: { 'Content-Range': `bytes */${size}`, 'Accept-Ranges': 'bytes' } });
|
|
43
|
+
}
|
|
44
|
+
const headers = {
|
|
45
|
+
'Accept-Ranges': 'bytes',
|
|
46
|
+
'Content-Length': String(end - start + 1),
|
|
47
|
+
'Content-Type': media.type,
|
|
48
|
+
'Content-Disposition': contentDispositionFor(name, media.ext, download ? 'attachment' : 'inline'),
|
|
49
|
+
'Cache-Control': 'private, max-age=3600',
|
|
50
|
+
};
|
|
51
|
+
// Content-Range is only meaningful on a 206
|
|
52
|
+
if (range)
|
|
53
|
+
headers['Content-Range'] = `bytes ${start}-${end}/${size}`;
|
|
54
|
+
const stream = createReadStream(media.path, { start, end });
|
|
55
|
+
// Without this an ENOENT/EACCES after the headers are sent becomes an unhandled 'error' event
|
|
56
|
+
stream.on('error', () => stream.destroy());
|
|
57
|
+
return new Response(Readable.toWeb(stream), { status: range ? 206 : 200, headers });
|
|
58
|
+
}
|
|
59
|
+
addRoute({
|
|
60
|
+
path: '/raw/kino/movies/:id',
|
|
61
|
+
params: { id: ID },
|
|
62
|
+
async GET(req, { id }) {
|
|
63
|
+
await requireSession(req);
|
|
64
|
+
const upload = await database.selectFrom('kino_movie_uploads').select('id').where('id', '=', id).executeTakeFirst();
|
|
65
|
+
if (!upload)
|
|
66
|
+
error(404, 'This movie has not been uploaded');
|
|
67
|
+
const movie = await getMovie(id);
|
|
68
|
+
return serveMedia(req, moviePath(id), movie.title.replaceAll(/[ :/]/g, '_'));
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
addRoute({
|
|
72
|
+
path: '/raw/kino/tv/:id/:season/:episode_number',
|
|
73
|
+
params: { id: ID, season: SeasonNumber, episode_number: ID },
|
|
74
|
+
async GET(req, { id, season, episode_number }) {
|
|
75
|
+
await requireSession(req);
|
|
76
|
+
const upload = await database
|
|
77
|
+
.selectFrom('kino_tv_uploads')
|
|
78
|
+
.select('id')
|
|
79
|
+
.where(eb => eb.and({ id, season_number: season, episode_number }))
|
|
80
|
+
.executeTakeFirst();
|
|
81
|
+
if (!upload)
|
|
82
|
+
error(404, 'This episode has not been uploaded');
|
|
83
|
+
const episode = await getEpisode(id, season, episode_number);
|
|
84
|
+
return serveMedia(req, episodePath(id, season, episode_number), episode.name.replaceAll(/[ :/]/g, '_'));
|
|
85
|
+
},
|
|
86
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,108 @@
|
|
|
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 * as io from 'ioium/node';
|
|
6
|
+
import * as kt from 'kinotool';
|
|
7
|
+
import { KinoSearchQuery } from '../../common.js';
|
|
8
|
+
import { tmdb } from '../tmdb.js';
|
|
9
|
+
/**
|
|
10
|
+
* Whether TMDB search results are persisted.
|
|
11
|
+
* @todo Replace with the config option
|
|
12
|
+
*/
|
|
13
|
+
const __cache_aggressive__ = true;
|
|
14
|
+
/** Maximum number of results to pull from the database */
|
|
15
|
+
const localLimit = 20;
|
|
16
|
+
/** Escape a user-provided string for use as an `ILIKE` pattern */
|
|
17
|
+
function escapeLike(text) {
|
|
18
|
+
return text.replaceAll(/[\\%_]/g, char => '\\' + char);
|
|
19
|
+
}
|
|
20
|
+
addRoute({
|
|
21
|
+
path: '/api/kino/search',
|
|
22
|
+
async POST(req) {
|
|
23
|
+
await requireSession(req);
|
|
24
|
+
const { query, type } = await parseBody(req, KinoSearchQuery);
|
|
25
|
+
const pattern = `%${escapeLike(query)}%`;
|
|
26
|
+
const [movies, shows, multi] = await Promise.all([
|
|
27
|
+
type == 'tv'
|
|
28
|
+
? []
|
|
29
|
+
: database
|
|
30
|
+
.selectFrom('kino_movies')
|
|
31
|
+
.selectAll()
|
|
32
|
+
.where('title', 'ilike', pattern)
|
|
33
|
+
.orderBy('title')
|
|
34
|
+
.limit(localLimit)
|
|
35
|
+
.execute(),
|
|
36
|
+
type == 'movie'
|
|
37
|
+
? []
|
|
38
|
+
: database.selectFrom('kino_tv').selectAll().where('name', 'ilike', pattern).orderBy('name').limit(localLimit).execute(),
|
|
39
|
+
tmdb()
|
|
40
|
+
.search.multi({ query, language: 'en-US', page: 1, include_adult: true })
|
|
41
|
+
.then(({ results }) => results)
|
|
42
|
+
.catch(e => {
|
|
43
|
+
io.warn('Kino: TMDB search failed: ' + io.errorText(e));
|
|
44
|
+
return [];
|
|
45
|
+
}),
|
|
46
|
+
]);
|
|
47
|
+
const results = [];
|
|
48
|
+
const seen = new Set();
|
|
49
|
+
// Local results come first since media that has actually been uploaded is the most likely target
|
|
50
|
+
for (const movie of movies) {
|
|
51
|
+
seen.add('movie:' + movie.id);
|
|
52
|
+
results.push({ ...movie, type: 'movie' });
|
|
53
|
+
}
|
|
54
|
+
for (const show of shows) {
|
|
55
|
+
seen.add('tv:' + show.id);
|
|
56
|
+
results.push({ ...show, type: 'tv' });
|
|
57
|
+
}
|
|
58
|
+
const newMovies = [], newShows = [];
|
|
59
|
+
for (const result of multi) {
|
|
60
|
+
if (result.media_type == 'person')
|
|
61
|
+
continue;
|
|
62
|
+
if (type && result.media_type != type)
|
|
63
|
+
continue;
|
|
64
|
+
const key = `${result.media_type}:${result.id}`;
|
|
65
|
+
if (seen.has(key))
|
|
66
|
+
continue;
|
|
67
|
+
/**
|
|
68
|
+
* TMDB returns incomplete entries, e.g. an empty `release_date` for unannounced titles.
|
|
69
|
+
* Skip those instead of failing the entire search.
|
|
70
|
+
*/
|
|
71
|
+
if (result.media_type == 'movie') {
|
|
72
|
+
const parsed = kt.Movie.safeParse(result);
|
|
73
|
+
if (!parsed.success)
|
|
74
|
+
continue;
|
|
75
|
+
newMovies.push(parsed.data);
|
|
76
|
+
results.push({ ...parsed.data, type: 'movie' });
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
const parsed = kt.Tv.safeParse(result);
|
|
80
|
+
if (!parsed.success)
|
|
81
|
+
continue;
|
|
82
|
+
newShows.push(parsed.data);
|
|
83
|
+
results.push({ ...parsed.data, type: 'tv' });
|
|
84
|
+
}
|
|
85
|
+
seen.add(key);
|
|
86
|
+
}
|
|
87
|
+
if (__cache_aggressive__) {
|
|
88
|
+
try {
|
|
89
|
+
if (newMovies.length)
|
|
90
|
+
await database
|
|
91
|
+
.insertInto('kino_movies')
|
|
92
|
+
.values(newMovies)
|
|
93
|
+
.onConflict(oc => oc.doNothing())
|
|
94
|
+
.execute();
|
|
95
|
+
if (newShows.length)
|
|
96
|
+
await database
|
|
97
|
+
.insertInto('kino_tv')
|
|
98
|
+
.values(newShows)
|
|
99
|
+
.onConflict(oc => oc.doNothing())
|
|
100
|
+
.execute();
|
|
101
|
+
}
|
|
102
|
+
catch (e) {
|
|
103
|
+
io.warn('Kino: failed to cache search results: ' + io.errorText(e));
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return results;
|
|
107
|
+
},
|
|
108
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|