@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.
Files changed (72) hide show
  1. package/LICENSE.md +157 -0
  2. package/db.json +106 -0
  3. package/dist/client/api.d.ts +49 -0
  4. package/dist/client/api.js +136 -0
  5. package/dist/client/frontend.d.ts +3 -0
  6. package/dist/client/frontend.js +33 -0
  7. package/dist/client/index.d.ts +1 -0
  8. package/dist/client/index.js +1 -0
  9. package/dist/client/web_hook.d.ts +8 -0
  10. package/dist/client/web_hook.js +4 -0
  11. package/dist/common.d.ts +749 -0
  12. package/dist/common.js +212 -0
  13. package/dist/index.d.ts +1 -0
  14. package/dist/index.js +1 -0
  15. package/dist/server/api/delete.d.ts +1 -0
  16. package/dist/server/api/delete.js +57 -0
  17. package/dist/server/api/metadata.d.ts +4 -0
  18. package/dist/server/api/metadata.js +153 -0
  19. package/dist/server/api/raw.d.ts +1 -0
  20. package/dist/server/api/raw.js +86 -0
  21. package/dist/server/api/search.d.ts +1 -0
  22. package/dist/server/api/search.js +108 -0
  23. package/dist/server/api/upload.d.ts +1 -0
  24. package/dist/server/api/upload.js +122 -0
  25. package/dist/server/api/views.d.ts +1 -0
  26. package/dist/server/api/views.js +128 -0
  27. package/dist/server/db.d.ts +11 -0
  28. package/dist/server/db.js +100 -0
  29. package/dist/server/hooks.d.ts +9 -0
  30. package/dist/server/hooks.js +19 -0
  31. package/dist/server/images.d.ts +12 -0
  32. package/dist/server/images.js +152 -0
  33. package/dist/server/media.d.ts +47 -0
  34. package/dist/server/media.js +136 -0
  35. package/dist/server/tmdb.d.ts +2 -0
  36. package/dist/server/tmdb.js +7 -0
  37. package/lib/EpisodeList.svelte +89 -0
  38. package/lib/MediaActions.svelte +110 -0
  39. package/lib/MediaCard.svelte +41 -0
  40. package/lib/MediaDetail.svelte +140 -0
  41. package/lib/MediaGrid.svelte +27 -0
  42. package/lib/Poster.svelte +49 -0
  43. package/lib/ProgressBar.svelte +22 -0
  44. package/lib/RecentGrid.svelte +82 -0
  45. package/lib/SearchBar.svelte +174 -0
  46. package/lib/SeasonList.svelte +50 -0
  47. package/lib/index.ts +10 -0
  48. package/lib/tsconfig.json +12 -0
  49. package/lib/watch.svelte.ts +93 -0
  50. package/locales/en.json +103 -0
  51. package/package.json +102 -0
  52. package/routes/+layout.svelte +22 -0
  53. package/routes/+layout.ts +21 -0
  54. package/routes/kino/+page.svelte +57 -0
  55. package/routes/kino/+page.ts +19 -0
  56. package/routes/movies/+page.svelte +22 -0
  57. package/routes/movies/+page.ts +12 -0
  58. package/routes/movies/[id]/+page.svelte +36 -0
  59. package/routes/movies/[id]/+page.ts +7 -0
  60. package/routes/movies/[id]/watch/+page.svelte +34 -0
  61. package/routes/movies/[id]/watch/+page.ts +18 -0
  62. package/routes/tsconfig.json +12 -0
  63. package/routes/tv/+page.svelte +22 -0
  64. package/routes/tv/+page.ts +12 -0
  65. package/routes/tv/[id]/+page.svelte +39 -0
  66. package/routes/tv/[id]/+page.ts +7 -0
  67. package/routes/tv/[id]/[season]/+page.svelte +43 -0
  68. package/routes/tv/[id]/[season]/+page.ts +11 -0
  69. package/routes/tv/[id]/[season]/[episode]/+page.svelte +46 -0
  70. package/routes/tv/[id]/[season]/[episode]/+page.ts +12 -0
  71. package/routes/tv/[id]/[season]/[episode]/watch/+page.svelte +46 -0
  72. 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);
@@ -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,4 @@
1
+ import * as z from 'zod';
2
+ export declare const ID: z.ZodCoercedNumber<unknown>;
3
+ /** Specials are season 0, so season numbers may be zero */
4
+ export declare const SeasonNumber: z.ZodCoercedNumber<unknown>;
@@ -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 {};