@axium/kino 0.1.3 → 0.1.4
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/dist/client/frontend.d.ts +6 -0
- package/dist/client/frontend.js +187 -0
- package/lib/DropZone.svelte +32 -0
- package/lib/index.ts +1 -0
- package/locales/en.json +12 -0
- package/package.json +1 -1
- package/routes/movies/[id]/+page.svelte +29 -20
- package/routes/tv/[id]/+page.svelte +27 -13
- package/routes/tv/[id]/[season]/+page.svelte +30 -16
- package/routes/tv/[id]/[season]/[episode]/+page.svelte +36 -27
|
@@ -1,3 +1,9 @@
|
|
|
1
1
|
import type { KinoUpload } from '../common.js';
|
|
2
2
|
export declare function uploadMovieFile(file: File, id: number): Promise<KinoUpload | undefined>;
|
|
3
3
|
export declare function uploadEpisodeFile(file: File, id: number, season: number, episode: number): Promise<KinoUpload | undefined>;
|
|
4
|
+
/** Handle a drop on a show page: `S01E07.mkv`, or `E07.mkv`/`07.mkv` inside an `S01`/`01` directory. */
|
|
5
|
+
export declare function uploadShowDrop(entries: Iterable<FileSystemEntry>, id: number): Promise<number>;
|
|
6
|
+
/** Handle a drop on a season page: `E07.mkv`, `07.mkv`, or `S01E07.mkv` for this season. */
|
|
7
|
+
export declare function uploadSeasonDrop(entries: Iterable<FileSystemEntry>, id: number, season: number): Promise<number>;
|
|
8
|
+
export declare function uploadMovieDrop(entries: Iterable<FileSystemEntry>, id: number): Promise<KinoUpload | undefined>;
|
|
9
|
+
export declare function uploadEpisodeDrop(entries: Iterable<FileSystemEntry>, id: number, season: number, episode: number): Promise<KinoUpload | undefined>;
|
package/dist/client/frontend.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { text } from '@axium/client';
|
|
2
2
|
import { setProgressCancel, toast } from '@axium/client/toast';
|
|
3
3
|
import * as io from 'ioium';
|
|
4
|
+
import { mediaExtensions } from '../common.js';
|
|
4
5
|
import { uploadEpisode, uploadMovie } from './api.js';
|
|
5
6
|
/**
|
|
6
7
|
* Report the outcome of an upload as a toast, reporting cancellation as info rather than an error.
|
|
@@ -31,3 +32,189 @@ export function uploadMovieFile(file, id) {
|
|
|
31
32
|
export function uploadEpisodeFile(file, id, season, episode) {
|
|
32
33
|
return toastUpload(signal => uploadEpisode(file, id, season, episode, { signal, onProgress: (uploaded, total) => io.progress(uploaded, total) }), file.name);
|
|
33
34
|
}
|
|
35
|
+
/**
|
|
36
|
+
* Recursively resolve dragged filesystem entries into files, remembering where each one came from.
|
|
37
|
+
*
|
|
38
|
+
* Dropped files have no `webkitRelativePath`, so the equivalent path is built while walking.
|
|
39
|
+
*/
|
|
40
|
+
async function collectDropped(entries, directories = []) {
|
|
41
|
+
const files = [];
|
|
42
|
+
for (const entry of entries) {
|
|
43
|
+
if (entry.isDirectory) {
|
|
44
|
+
const reader = entry.createReader();
|
|
45
|
+
// `readEntries` returns a limited number of entries at a time and an empty batch when done
|
|
46
|
+
const read = () => new Promise(reader.readEntries.bind(reader));
|
|
47
|
+
for (let batch = await read(); batch.length; batch = await read())
|
|
48
|
+
files.push(...(await collectDropped(batch, [...directories, entry.name])));
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
const file = await new Promise(entry.file.bind(entry));
|
|
52
|
+
files.push({ file, directories });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return files;
|
|
56
|
+
}
|
|
57
|
+
/** Split a file name into its base name and lowercased extension */
|
|
58
|
+
function splitExtension(name) {
|
|
59
|
+
const dot = name.lastIndexOf('.');
|
|
60
|
+
return dot < 1 ? [name, ''] : [name.slice(0, dot), name.slice(dot).toLowerCase()];
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Anything that isn't a container we accept — subtitles, artwork, `.nfo` files — is dropped silently,
|
|
64
|
+
* since whole season directories are usually full of them.
|
|
65
|
+
*/
|
|
66
|
+
function onlyMedia(files) {
|
|
67
|
+
return files.filter(({ file }) => mediaExtensions.includes(splitExtension(file.name)[1]));
|
|
68
|
+
}
|
|
69
|
+
/** How a season number can be introduced: `S01`, `Season 01` */
|
|
70
|
+
const seasonPrefix = 's(?:eason)?';
|
|
71
|
+
/** How an episode number can be introduced: `E07`, `Ep07`, `Episode 07` */
|
|
72
|
+
const episodePrefix = 'e(?:pisode|p)?';
|
|
73
|
+
/**
|
|
74
|
+
* Anything that isn't a letter or a digit separates words, so `_`, `.`, `-` and spaces all count.
|
|
75
|
+
* `\b` is not used here since it treats `_` as part of a word.
|
|
76
|
+
*/
|
|
77
|
+
const boundary = '[^a-z0-9]';
|
|
78
|
+
/** A number on its own, e.g. `07` */
|
|
79
|
+
function parseBare(part) {
|
|
80
|
+
return /^\d+$/.test(part.trim()) ? Number(part.trim()) : null;
|
|
81
|
+
}
|
|
82
|
+
/** A prefixed number anywhere in `part`, so long as it starts and ends on a word boundary, e.g. `E07` in `Show_E07_1080p` */
|
|
83
|
+
function parsePrefixed(part, prefix) {
|
|
84
|
+
const match = new RegExp(`(?:^|${boundary})${prefix}${boundary}*(\\d+)(?![a-z0-9])`, 'i').exec(part);
|
|
85
|
+
return match ? Number(match[1]) : null;
|
|
86
|
+
}
|
|
87
|
+
/** The season or episode number a name gives, either as the whole name (`07`) or as a prefixed part of it (`E07`, `Ep 7`) */
|
|
88
|
+
function parseNumbered(part, prefix) {
|
|
89
|
+
return parseBare(part) ?? parsePrefixed(part, prefix);
|
|
90
|
+
}
|
|
91
|
+
/** Both numbers at once, e.g. `S01E07`, `Duck.S1Ep1`, or `Example_S01E01` */
|
|
92
|
+
const codePattern = new RegExp(`(?:^|${boundary})${seasonPrefix}${boundary}*(\\d+)${boundary}*${episodePrefix}${boundary}*(\\d+)(?![a-z0-9])`, 'i');
|
|
93
|
+
function parseCode(base) {
|
|
94
|
+
const match = codePattern.exec(base);
|
|
95
|
+
return match ? { season: Number(match[1]), episode: Number(match[2]) } : null;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Which episode a file dropped on a show page is for.
|
|
99
|
+
* Either the name contains an `S01E07` code, or it gives an episode number inside an `S01`/`01` directory.
|
|
100
|
+
*/
|
|
101
|
+
function showTarget({ file, directories }) {
|
|
102
|
+
const [base] = splitExtension(file.name);
|
|
103
|
+
const code = parseCode(base);
|
|
104
|
+
if (code)
|
|
105
|
+
return code;
|
|
106
|
+
const directory = directories.at(-1);
|
|
107
|
+
if (!directory)
|
|
108
|
+
return 'kino.invalid_show_files';
|
|
109
|
+
const season = parseNumbered(directory, seasonPrefix);
|
|
110
|
+
const episode = parseNumbered(base, episodePrefix);
|
|
111
|
+
return season === null || episode === null ? 'kino.invalid_show_files' : { season, episode };
|
|
112
|
+
}
|
|
113
|
+
/** Which episode a file dropped on a season page is for. The season is already known, so only the number matters. */
|
|
114
|
+
function seasonTarget({ file }, season) {
|
|
115
|
+
const [base] = splitExtension(file.name);
|
|
116
|
+
const code = parseCode(base);
|
|
117
|
+
if (code)
|
|
118
|
+
return code.season == season ? code : 'kino.wrong_season_files';
|
|
119
|
+
const episode = parseNumbered(base, episodePrefix);
|
|
120
|
+
return episode === null ? 'kino.invalid_season_files' : { season, episode };
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Upload episodes one at a time, reporting the overall progress in bytes via `io.progress`.
|
|
124
|
+
* The upload can be cancelled from the progress toast, which leaves already finished uploads in place.
|
|
125
|
+
*
|
|
126
|
+
* Returns how many were uploaded so callers know whether anything needs refreshing.
|
|
127
|
+
*/
|
|
128
|
+
async function uploadAll(id, uploads) {
|
|
129
|
+
const totalBytes = uploads.reduce((total, { file }) => total + file.size, 0);
|
|
130
|
+
let uploadedBytes = 0, count = 0;
|
|
131
|
+
const controller = new AbortController();
|
|
132
|
+
setProgressCancel(() => controller.abort());
|
|
133
|
+
io.start(uploads.length == 1
|
|
134
|
+
? text('kino.uploading', { name: uploads[0].file.name })
|
|
135
|
+
: text('kino.uploading_many', { count: uploads.length }));
|
|
136
|
+
try {
|
|
137
|
+
for (const { file, season, episode } of uploads) {
|
|
138
|
+
await uploadEpisode(file, id, season, episode, {
|
|
139
|
+
signal: controller.signal,
|
|
140
|
+
onProgress: uploaded => io.progress(uploadedBytes + uploaded, totalBytes, uploads.length > 1 ? file.name : undefined),
|
|
141
|
+
});
|
|
142
|
+
uploadedBytes += file.size;
|
|
143
|
+
count++;
|
|
144
|
+
}
|
|
145
|
+
void toast('success', text('kino.upload_success'));
|
|
146
|
+
}
|
|
147
|
+
catch (e) {
|
|
148
|
+
if (e instanceof DOMException && e.name == 'AbortError')
|
|
149
|
+
void toast('info', text('kino.upload_cancelled'));
|
|
150
|
+
else
|
|
151
|
+
void toast('error', e);
|
|
152
|
+
}
|
|
153
|
+
finally {
|
|
154
|
+
io.done(true);
|
|
155
|
+
}
|
|
156
|
+
return count;
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Resolve every dropped file to an episode, then upload them.
|
|
160
|
+
* Nothing is uploaded when a file can't be resolved, so a typo doesn't leave a half-finished drop behind.
|
|
161
|
+
*/
|
|
162
|
+
async function uploadDropped(id, files, resolve) {
|
|
163
|
+
const media = onlyMedia(files);
|
|
164
|
+
if (!media.length) {
|
|
165
|
+
void toast('error', text('kino.no_media_files'));
|
|
166
|
+
return 0;
|
|
167
|
+
}
|
|
168
|
+
const uploads = [];
|
|
169
|
+
/** Names of the files that couldn't be resolved, grouped by the reason they couldn't be */
|
|
170
|
+
const invalid = new Map();
|
|
171
|
+
for (const dropped of media) {
|
|
172
|
+
const target = resolve(dropped);
|
|
173
|
+
if (typeof target != 'string') {
|
|
174
|
+
uploads.push({ file: dropped.file, ...target });
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
const path = [...dropped.directories, dropped.file.name].join('/');
|
|
178
|
+
invalid.set(target, [...(invalid.get(target) ?? []), path]);
|
|
179
|
+
}
|
|
180
|
+
if (invalid.size) {
|
|
181
|
+
for (const [reason, names] of invalid)
|
|
182
|
+
void toast('error', text(reason, { names: names.join(', ') }));
|
|
183
|
+
return 0;
|
|
184
|
+
}
|
|
185
|
+
// Upload in episode order so the progress toast reads sensibly
|
|
186
|
+
uploads.sort((a, b) => a.season - b.season || a.episode - b.episode);
|
|
187
|
+
return await uploadAll(id, uploads);
|
|
188
|
+
}
|
|
189
|
+
/** Handle a drop on a show page: `S01E07.mkv`, or `E07.mkv`/`07.mkv` inside an `S01`/`01` directory. */
|
|
190
|
+
export async function uploadShowDrop(entries, id) {
|
|
191
|
+
return await uploadDropped(id, await collectDropped(entries), showTarget);
|
|
192
|
+
}
|
|
193
|
+
/** Handle a drop on a season page: `E07.mkv`, `07.mkv`, or `S01E07.mkv` for this season. */
|
|
194
|
+
export async function uploadSeasonDrop(entries, id, season) {
|
|
195
|
+
return await uploadDropped(id, await collectDropped(entries), file => seasonTarget(file, season));
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* The single file dropped on a movie or episode page.
|
|
199
|
+
* The name is irrelevant here since the target is already known.
|
|
200
|
+
*/
|
|
201
|
+
async function droppedFile(entries) {
|
|
202
|
+
const media = onlyMedia(await collectDropped(entries));
|
|
203
|
+
if (!media.length) {
|
|
204
|
+
void toast('error', text('kino.no_media_files'));
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
if (media.length > 1) {
|
|
208
|
+
void toast('error', text('kino.one_file_only'));
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
return media[0].file;
|
|
212
|
+
}
|
|
213
|
+
export async function uploadMovieDrop(entries, id) {
|
|
214
|
+
const file = await droppedFile(entries);
|
|
215
|
+
return file && (await uploadMovieFile(file, id));
|
|
216
|
+
}
|
|
217
|
+
export async function uploadEpisodeDrop(entries, id, season, episode) {
|
|
218
|
+
const file = await droppedFile(entries);
|
|
219
|
+
return file && (await uploadEpisodeFile(file, id, season, episode));
|
|
220
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { drag } from '@axium/client/attachments';
|
|
3
|
+
import type { Snippet } from 'svelte';
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
/** Shown next to the cursor while files are dragged over */
|
|
7
|
+
label: string;
|
|
8
|
+
/** Drops are ignored when false, e.g. for media that has already been uploaded */
|
|
9
|
+
enabled?: boolean;
|
|
10
|
+
onDrop(entries: FileSystemEntry[]): unknown;
|
|
11
|
+
children: Snippet;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const { label, enabled = true, onDrop, children }: Props = $props();
|
|
15
|
+
</script>
|
|
16
|
+
|
|
17
|
+
<div class="DropZone" {@attach enabled && drag.uploadTarget(label, onDrop)}>
|
|
18
|
+
{@render children()}
|
|
19
|
+
</div>
|
|
20
|
+
|
|
21
|
+
<style>
|
|
22
|
+
.DropZone {
|
|
23
|
+
min-height: 100%;
|
|
24
|
+
|
|
25
|
+
:global(&.drag-over) {
|
|
26
|
+
outline: var(--border-accent);
|
|
27
|
+
outline-offset: 0.5em;
|
|
28
|
+
border-radius: 1em;
|
|
29
|
+
background-color: hsl(from var(--bg-elevated) h s l / 0.5);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
</style>
|
package/lib/index.ts
CHANGED
package/locales/en.json
CHANGED
|
@@ -61,8 +61,20 @@
|
|
|
61
61
|
"upload_movie": "Upload movie",
|
|
62
62
|
"upload_episode": "Upload episode",
|
|
63
63
|
"uploading": "Uploading {name}",
|
|
64
|
+
"uploading_many": "Uploading {count} files",
|
|
64
65
|
"upload_success": "Upload complete",
|
|
65
66
|
"upload_cancelled": "Upload cancelled",
|
|
67
|
+
"drop_movie": "Drop a movie file to upload",
|
|
68
|
+
"drop_episode": "Drop an episode file to upload",
|
|
69
|
+
"drop_season": "Drop episodes to upload",
|
|
70
|
+
"drop_show": "Drop episodes to upload",
|
|
71
|
+
"drop_season_hint": "Drop episode files here to upload them. Each name needs an episode number, like E01, Ep01, or S01E01, or must be just the number.",
|
|
72
|
+
"drop_show_hint": "Drop episode files here to upload them. Each name needs a season and episode, like S01E01 or S1Ep1, or an episode number inside a folder named S01 or 01.",
|
|
73
|
+
"no_media_files": "No video files were dropped. Only .mkv and .mp4 can be uploaded.",
|
|
74
|
+
"one_file_only": "Only one file can be uploaded here",
|
|
75
|
+
"invalid_season_files": "No episode number in the name (e.g. E01, Ep01, S01E01, or just 01): {names}",
|
|
76
|
+
"invalid_show_files": "No season and episode in the name (e.g. S01E01 or S1Ep1), and not in a season folder: {names}",
|
|
77
|
+
"wrong_season_files": "Named for a different season than this one: {names}",
|
|
66
78
|
"not_uploaded": "This has not been uploaded yet.",
|
|
67
79
|
"uploaded_detail": "Uploaded {date} · {size}",
|
|
68
80
|
"overview": "Overview",
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { text } from '@axium/client';
|
|
3
3
|
import { deleteMovieUpload, movieDataURL } from '@axium/kino/client';
|
|
4
|
-
import { uploadMovieFile } from '@axium/kino/client/frontend';
|
|
5
|
-
import { MediaActions, MediaDetail } from '@axium/kino/components';
|
|
4
|
+
import { uploadMovieDrop, uploadMovieFile } from '@axium/kino/client/frontend';
|
|
5
|
+
import { DropZone, MediaActions, MediaDetail } from '@axium/kino/components';
|
|
6
6
|
|
|
7
7
|
const { data } = $props();
|
|
8
8
|
|
|
@@ -15,22 +15,31 @@
|
|
|
15
15
|
<title>{movie.title}</title>
|
|
16
16
|
</svelte:head>
|
|
17
17
|
|
|
18
|
-
<
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
18
|
+
<DropZone
|
|
19
|
+
label={text('kino.drop_movie')}
|
|
20
|
+
enabled={!upload}
|
|
21
|
+
onDrop={async entries => {
|
|
22
|
+
const result = await uploadMovieDrop(entries, movie.id);
|
|
23
|
+
if (result) upload = result;
|
|
24
|
+
}}
|
|
24
25
|
>
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
26
|
+
<MediaDetail
|
|
27
|
+
title={movie.title}
|
|
28
|
+
imagePath={movie.poster_path}
|
|
29
|
+
backdropPath={movie.backdrop_path}
|
|
30
|
+
date={movie.release_date}
|
|
31
|
+
overview={movie.overview}
|
|
32
|
+
>
|
|
33
|
+
{#snippet actions()}
|
|
34
|
+
<MediaActions
|
|
35
|
+
bind:upload
|
|
36
|
+
watchHref="/movies/{movie.id}/watch"
|
|
37
|
+
dataURL={movieDataURL(movie.id, true)}
|
|
38
|
+
uploadText={text('kino.upload_movie')}
|
|
39
|
+
canDelete={data.session?.user.isAdmin}
|
|
40
|
+
uploadFile={file => uploadMovieFile(file, movie.id)}
|
|
41
|
+
deleteUpload={() => deleteMovieUpload(movie.id)}
|
|
42
|
+
/>
|
|
43
|
+
{/snippet}
|
|
44
|
+
</MediaDetail>
|
|
45
|
+
</DropZone>
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
+
import { invalidateAll } from '$app/navigation';
|
|
2
3
|
import { text } from '@axium/client';
|
|
3
|
-
import {
|
|
4
|
+
import { uploadShowDrop } from '@axium/kino/client/frontend';
|
|
5
|
+
import { DropZone, MediaDetail, SeasonList } from '@axium/kino/components';
|
|
4
6
|
|
|
5
7
|
const { data } = $props();
|
|
6
8
|
|
|
@@ -11,19 +13,27 @@
|
|
|
11
13
|
<title>{show.name}</title>
|
|
12
14
|
</svelte:head>
|
|
13
15
|
|
|
14
|
-
<
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
dateKind="aired"
|
|
20
|
-
overview={show.overview}
|
|
16
|
+
<DropZone
|
|
17
|
+
label={text('kino.drop_show')}
|
|
18
|
+
onDrop={async entries => {
|
|
19
|
+
if (await uploadShowDrop(entries, show.id)) await invalidateAll();
|
|
20
|
+
}}
|
|
21
21
|
>
|
|
22
|
-
<
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
22
|
+
<MediaDetail
|
|
23
|
+
title={show.name}
|
|
24
|
+
imagePath={show.poster_path}
|
|
25
|
+
backdropPath={show.backdrop_path}
|
|
26
|
+
date={show.first_air_date}
|
|
27
|
+
dateKind="aired"
|
|
28
|
+
overview={show.overview}
|
|
29
|
+
>
|
|
30
|
+
<section>
|
|
31
|
+
<h2>{text('kino.seasons')}</h2>
|
|
32
|
+
<SeasonList id={show.id} seasons={show.seasons} />
|
|
33
|
+
<span class="subtle hint">{text('kino.drop_show_hint')}</span>
|
|
34
|
+
</section>
|
|
35
|
+
</MediaDetail>
|
|
36
|
+
</DropZone>
|
|
27
37
|
|
|
28
38
|
<style>
|
|
29
39
|
section {
|
|
@@ -36,4 +46,8 @@
|
|
|
36
46
|
margin: 0;
|
|
37
47
|
font-size: 1.15em;
|
|
38
48
|
}
|
|
49
|
+
|
|
50
|
+
.hint {
|
|
51
|
+
font-size: 0.85em;
|
|
52
|
+
}
|
|
39
53
|
</style>
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
+
import { invalidateAll } from '$app/navigation';
|
|
2
3
|
import { text } from '@axium/client';
|
|
3
|
-
import {
|
|
4
|
+
import { uploadSeasonDrop } from '@axium/kino/client/frontend';
|
|
5
|
+
import { DropZone, EpisodeList, MediaDetail } from '@axium/kino/components';
|
|
4
6
|
|
|
5
7
|
const { data } = $props();
|
|
6
8
|
|
|
@@ -11,23 +13,31 @@
|
|
|
11
13
|
<title>{show.name} — {season.name}</title>
|
|
12
14
|
</svelte:head>
|
|
13
15
|
|
|
14
|
-
<
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
dateKind="aired"
|
|
20
|
-
overview={season.overview}
|
|
16
|
+
<DropZone
|
|
17
|
+
label={text('kino.drop_season')}
|
|
18
|
+
onDrop={async entries => {
|
|
19
|
+
if (await uploadSeasonDrop(entries, show.id, season.season_number)) await invalidateAll();
|
|
20
|
+
}}
|
|
21
21
|
>
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
22
|
+
<MediaDetail
|
|
23
|
+
title={season.name}
|
|
24
|
+
imagePath={season.poster_path}
|
|
25
|
+
backdropPath={show.backdrop_path}
|
|
26
|
+
date={season.air_date}
|
|
27
|
+
dateKind="aired"
|
|
28
|
+
overview={season.overview}
|
|
29
|
+
>
|
|
30
|
+
{#snippet context()}
|
|
31
|
+
<a href="/tv/{show.id}">{show.name}</a>
|
|
32
|
+
{/snippet}
|
|
25
33
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
</
|
|
34
|
+
<section>
|
|
35
|
+
<h2>{text('kino.episodes')}</h2>
|
|
36
|
+
<EpisodeList id={show.id} season={season.season_number} episodes={season.episodes} />
|
|
37
|
+
<span class="subtle hint">{text('kino.drop_season_hint')}</span>
|
|
38
|
+
</section>
|
|
39
|
+
</MediaDetail>
|
|
40
|
+
</DropZone>
|
|
31
41
|
|
|
32
42
|
<style>
|
|
33
43
|
section {
|
|
@@ -40,4 +50,8 @@
|
|
|
40
50
|
margin: 0;
|
|
41
51
|
font-size: 1.15em;
|
|
42
52
|
}
|
|
53
|
+
|
|
54
|
+
.hint {
|
|
55
|
+
font-size: 0.85em;
|
|
56
|
+
}
|
|
43
57
|
</style>
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { text } from '@axium/client';
|
|
3
3
|
import { deleteEpisodeUpload, episodeDataURL } from '@axium/kino/client';
|
|
4
|
-
import { uploadEpisodeFile } from '@axium/kino/client/frontend';
|
|
5
|
-
import { MediaActions, MediaDetail } from '@axium/kino/components';
|
|
4
|
+
import { uploadEpisodeDrop, uploadEpisodeFile } from '@axium/kino/client/frontend';
|
|
5
|
+
import { DropZone, MediaActions, MediaDetail } from '@axium/kino/components';
|
|
6
6
|
|
|
7
7
|
const { data } = $props();
|
|
8
8
|
|
|
@@ -17,30 +17,39 @@
|
|
|
17
17
|
<title>{show.name} — {code}</title>
|
|
18
18
|
</svelte:head>
|
|
19
19
|
|
|
20
|
-
<
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
overview={null}
|
|
20
|
+
<DropZone
|
|
21
|
+
label={text('kino.drop_episode')}
|
|
22
|
+
enabled={!upload}
|
|
23
|
+
onDrop={async entries => {
|
|
24
|
+
const result = await uploadEpisodeDrop(entries, show.id, season, episode.episode_number);
|
|
25
|
+
if (result) upload = result;
|
|
26
|
+
}}
|
|
28
27
|
>
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
28
|
+
<MediaDetail
|
|
29
|
+
title={episode.name}
|
|
30
|
+
imagePath={episode.still_path}
|
|
31
|
+
imageType="still"
|
|
32
|
+
backdropPath={show.backdrop_path}
|
|
33
|
+
date={episode.air_date}
|
|
34
|
+
dateKind="aired"
|
|
35
|
+
overview={null}
|
|
36
|
+
>
|
|
37
|
+
{#snippet context()}
|
|
38
|
+
<a href="/tv/{show.id}">{show.name}</a>
|
|
39
|
+
·
|
|
40
|
+
<a href="/tv/{show.id}/{season}">{code}</a>
|
|
41
|
+
{/snippet}
|
|
34
42
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
</MediaDetail>
|
|
43
|
+
{#snippet actions()}
|
|
44
|
+
<MediaActions
|
|
45
|
+
bind:upload
|
|
46
|
+
watchHref="/tv/{show.id}/{season}/{episode.episode_number}/watch"
|
|
47
|
+
dataURL={episodeDataURL(show.id, season, episode.episode_number, true)}
|
|
48
|
+
uploadText={text('kino.upload_episode')}
|
|
49
|
+
canDelete={data.session?.user.isAdmin}
|
|
50
|
+
uploadFile={file => uploadEpisodeFile(file, show.id, season, episode.episode_number)}
|
|
51
|
+
deleteUpload={() => deleteEpisodeUpload(show.id, season, episode.episode_number)}
|
|
52
|
+
/>
|
|
53
|
+
{/snippet}
|
|
54
|
+
</MediaDetail>
|
|
55
|
+
</DropZone>
|