Skip to content

Commit 87c5d6d

Browse files
authored
Jellyfin import (#863)
* feat(backend): add movies to seen history * fix(backend): always get played episodes * feat(backend): start importing tv shows * feat(backend): add tmdb shows to seen history * feat(backend): load correct season and episode ids * docs: add info about jellyfin shows * fix(backend): handle edge cases for shows * fix(backend): de-duplicate seen items * fix(backend): log additional things * fix(backend): set correct media identifier * feat(backend): create favorites collection * feat(backend): change field name to password * docs: remove advanced steps * feat(frontend): change inputs for jellyfin * fix(backend): allow importing using jellyfin username/pw
1 parent 8497b8f commit 87c5d6d

File tree

8 files changed

+154
-62
lines changed

8 files changed

+154
-62
lines changed

Cargo.lock

+1-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

apps/backend/Cargo.toml

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "ryot"
3-
version = "6.0.1"
3+
version = "6.1.0"
44
edition = "2021"
55
repository = "https://github.com/IgnisDa/ryot"
66
license = "GPL-3.0"

apps/backend/src/importer/jellyfin.rs

+131-37
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
use async_graphql::Result;
22
use database::{MediaLot, MediaSource};
3+
use enum_meta::HashMap;
4+
use itertools::Itertools;
5+
use sea_orm::prelude::DateTimeUtc;
36
use serde::{Deserialize, Serialize};
4-
use serde_json::{json, Value};
7+
use serde_json::json;
58
use surf::{
69
http::headers::{ACCEPT, USER_AGENT},
710
Client, Config, Url,
@@ -11,7 +14,9 @@ use crate::{
1114
importer::{
1215
DeployUrlAndKeyAndUsernameImportInput, ImportFailStep, ImportFailedItem, ImportResult,
1316
},
14-
models::media::{ImportOrExportItemIdentifier, ImportOrExportMediaItem},
17+
models::media::{
18+
ImportOrExportItemIdentifier, ImportOrExportMediaItem, ImportOrExportMediaItemSeen,
19+
},
1520
utils::USER_AGENT_STR,
1621
};
1722

@@ -28,6 +33,7 @@ enum CollectionType {
2833
enum MediaType {
2934
Movie,
3035
Series,
36+
Episode,
3137
#[serde(untagged)]
3238
Unknown(String),
3339
}
@@ -38,13 +44,26 @@ struct ItemProviderIdsPayload {
3844
tmdb: Option<String>,
3945
}
4046

47+
#[derive(Serialize, Deserialize, Debug, Clone)]
48+
#[serde(rename_all = "PascalCase")]
49+
struct ItemUserData {
50+
play_count: Option<i32>,
51+
last_played_date: Option<DateTimeUtc>,
52+
is_favorite: Option<bool>,
53+
}
54+
4155
#[derive(Serialize, Deserialize, Debug, Clone)]
4256
#[serde(rename_all = "PascalCase")]
4357
struct ItemResponse {
4458
id: String,
4559
name: String,
4660
#[serde(rename = "Type")]
4761
typ: Option<MediaType>,
62+
index_number: Option<i32>,
63+
series_id: Option<String>,
64+
series_name: Option<String>,
65+
user_data: Option<ItemUserData>,
66+
parent_index_number: Option<i32>,
4867
collection_type: Option<CollectionType>,
4968
provider_ids: Option<ItemProviderIdsPayload>,
5069
}
@@ -55,32 +74,41 @@ struct ItemsResponse {
5574
items: Vec<ItemResponse>,
5675
}
5776

77+
#[derive(Serialize, Deserialize, Debug, Clone)]
78+
#[serde(rename_all = "PascalCase")]
79+
struct AuthenticateResponse {
80+
user: ItemResponse,
81+
access_token: String,
82+
}
83+
5884
pub async fn import(input: DeployUrlAndKeyAndUsernameImportInput) -> Result<ImportResult> {
59-
let mut media = vec![];
60-
let mut failed_items = vec![];
85+
let authenticate: AuthenticateResponse = surf::post(format!(
86+
"{}/Users/AuthenticateByName",
87+
input.api_url
88+
))
89+
.header(
90+
"X-Emby-Authorization",
91+
r#"MediaBrowser , Client="other", Device="script", DeviceId="script", Version="0.0.0""#,
92+
)
93+
.body_json(&serde_json::json!({ "Username": input.username, "Pw": input.password }))
94+
.unwrap()
95+
.await?
96+
.body_json()
97+
.await?;
6198
let client: Client = Config::new()
6299
.add_header(USER_AGENT, USER_AGENT_STR)
63100
.unwrap()
64101
.add_header(ACCEPT, "application/json")
65102
.unwrap()
66-
.add_header("X-Emby-Token", input.api_key)
103+
.add_header("X-Emby-Token", authenticate.access_token)
67104
.unwrap()
68105
.set_base_url(Url::parse(&input.api_url).unwrap().join("/").unwrap())
69106
.try_into()
70107
.unwrap();
108+
let user_id = authenticate.user.id;
71109

72-
let users_data: Vec<ItemResponse> = client
73-
.get("Users")
74-
.await
75-
.unwrap()
76-
.body_json()
77-
.await
78-
.unwrap();
79-
let user_id = users_data
80-
.into_iter()
81-
.find(|x| x.name == input.username)
82-
.unwrap()
83-
.id;
110+
let mut to_handle_media = vec![];
111+
let mut failed_items = vec![];
84112

85113
let views_data: ItemsResponse = client
86114
.get(&format!("Users/{}/Views", user_id))
@@ -89,6 +117,9 @@ pub async fn import(input: DeployUrlAndKeyAndUsernameImportInput) -> Result<Impo
89117
.body_json()
90118
.await
91119
.unwrap();
120+
121+
let mut series_id_to_tmdb_id: HashMap<String, Option<String>> = HashMap::new();
122+
92123
for library in views_data.items {
93124
let collection_type = library.collection_type.unwrap();
94125
if matches!(collection_type, CollectionType::Unknown(_)) {
@@ -100,13 +131,10 @@ pub async fn import(input: DeployUrlAndKeyAndUsernameImportInput) -> Result<Impo
100131
});
101132
continue;
102133
}
103-
let mut query = json!({
134+
let query = json!({
104135
"parentId": library.id, "recursive": true,
105-
"includeItemTypes": "Movie,Series", "fields": "ProviderIds"
136+
"IsPlayed": true, "fields": "ProviderIds"
106137
});
107-
if collection_type == CollectionType::Movies {
108-
query["filters"] = Value::String("IsPlayed".to_string());
109-
}
110138
let library_data: ItemsResponse = client
111139
.get(&format!("Users/{}/Items", user_id))
112140
.query(&query)
@@ -117,22 +145,34 @@ pub async fn import(input: DeployUrlAndKeyAndUsernameImportInput) -> Result<Impo
117145
.await
118146
.unwrap();
119147
for item in library_data.items {
120-
let typ = item.typ.unwrap();
121-
match typ.clone() {
122-
MediaType::Movie => {
123-
let tmdb_id = item.provider_ids.unwrap().tmdb.unwrap();
124-
media.push(ImportOrExportMediaItem {
125-
source_id: item.name,
126-
lot: MediaLot::Movie,
127-
source: MediaSource::Tmdb,
128-
internal_identifier: Some(ImportOrExportItemIdentifier::NeedsDetails(
148+
let typ = item.typ.clone().unwrap();
149+
tracing::debug!("Processing item: {:?} ({:?})", item.name, typ);
150+
let (lot, tmdb_id, ssn, sen) = match typ.clone() {
151+
MediaType::Movie => (MediaLot::Movie, item.provider_ids.unwrap().tmdb, None, None),
152+
MediaType::Series | MediaType::Episode => {
153+
if let Some(series_id) = item.series_id {
154+
let mut tmdb_id = series_id_to_tmdb_id.get(&series_id).cloned().flatten();
155+
if tmdb_id.is_none() {
156+
let details: ItemResponse = client
157+
.get(&format!("Items/{}", series_id))
158+
.await
159+
.unwrap()
160+
.body_json()
161+
.await
162+
.unwrap();
163+
let insert_id = details.provider_ids.unwrap().tmdb;
164+
series_id_to_tmdb_id.insert(series_id.clone(), insert_id.clone());
165+
tmdb_id = insert_id;
166+
}
167+
(
168+
MediaLot::Show,
129169
tmdb_id,
130-
)),
131-
identifier: "".to_string(),
132-
seen_history: vec![],
133-
reviews: vec![],
134-
collections: vec![],
135-
});
170+
item.parent_index_number,
171+
item.index_number,
172+
)
173+
} else {
174+
continue;
175+
}
136176
}
137177
_ => {
138178
failed_items.push(ImportFailedItem {
@@ -143,10 +183,64 @@ pub async fn import(input: DeployUrlAndKeyAndUsernameImportInput) -> Result<Impo
143183
});
144184
continue;
145185
}
186+
};
187+
if let Some(tmdb_id) = tmdb_id {
188+
let item_user_data = item.user_data.unwrap();
189+
let num_times_seen = item_user_data.play_count.unwrap_or(0);
190+
let mut seen_history = (0..num_times_seen)
191+
.map(|_| ImportOrExportMediaItemSeen {
192+
show_season_number: ssn,
193+
show_episode_number: sen,
194+
..Default::default()
195+
})
196+
.collect_vec();
197+
if let Some(last) = seen_history.last_mut() {
198+
last.ended_on = item_user_data.last_played_date;
199+
};
200+
let mut collections = vec![];
201+
if let Some(true) = item_user_data.is_favorite {
202+
collections.push("Favorites".to_string());
203+
}
204+
to_handle_media.push(ImportOrExportMediaItem {
205+
lot,
206+
source_id: item.series_name.unwrap_or(item.name),
207+
source: MediaSource::Tmdb,
208+
internal_identifier: Some(ImportOrExportItemIdentifier::NeedsDetails(
209+
tmdb_id.clone(),
210+
)),
211+
seen_history,
212+
identifier: tmdb_id,
213+
reviews: vec![],
214+
collections,
215+
});
216+
} else {
217+
failed_items.push(ImportFailedItem {
218+
step: ImportFailStep::ItemDetailsFromSource,
219+
identifier: item.name,
220+
error: Some("No tmdb id found".to_string()),
221+
lot: None,
222+
});
146223
}
147224
}
148225
}
149226

227+
let mut media: Vec<ImportOrExportMediaItem> = vec![];
228+
229+
for item in to_handle_media {
230+
let mut found = false;
231+
for media_item in media.iter_mut() {
232+
if media_item.identifier == item.identifier && media_item.lot == item.lot {
233+
found = true;
234+
media_item.seen_history.extend(item.seen_history.clone());
235+
media_item.collections.extend(item.collections.clone());
236+
break;
237+
}
238+
}
239+
if !found {
240+
media.push(item);
241+
}
242+
}
243+
150244
Ok(ImportResult {
151245
media,
152246
failed_items,

apps/backend/src/importer/mod.rs

+1-1
Original file line numberDiff line numberDiff line change
@@ -106,8 +106,8 @@ pub struct DeployUrlAndKeyImportInput {
106106
#[derive(Debug, InputObject, Serialize, Deserialize, Clone)]
107107
pub struct DeployUrlAndKeyAndUsernameImportInput {
108108
api_url: String,
109-
api_key: String,
110109
username: String,
110+
password: String,
111111
}
112112

113113
#[derive(Debug, InputObject, Serialize, Deserialize, Clone)]

apps/backend/src/miscellaneous/resolver.rs

+1-1
Original file line numberDiff line numberDiff line change
@@ -2314,7 +2314,7 @@ impl MiscellaneousService {
23142314
.await
23152315
.unwrap()
23162316
.unwrap();
2317-
tracing::debug!("Progress update meta = {:?}", meta.title);
2317+
tracing::debug!("Progress update for meta {:?} ({:?})", meta.title, meta.lot);
23182318

23192319
let show_ei = if matches!(meta.lot, MediaLot::Show) {
23202320
let season = input.show_season_number.ok_or_else(|| {

apps/frontend/app/routes/_dashboard.settings.imports-and-exports._index.tsx

+15-12
Original file line numberDiff line numberDiff line change
@@ -167,17 +167,20 @@ export const action = async ({ request }: ActionFunctionArgs) => {
167167
});
168168
};
169169

170-
const urlAndKeyImportFormSchema = z.object({
170+
const usernameImportFormSchema = z.object({ username: z.string() });
171+
172+
const apiUrlImportFormSchema = z.object({
171173
apiUrl: z.string().url(),
172-
apiKey: z.string(),
173174
});
174175

175-
const usernameImportFormSchema = z.object({ username: z.string() });
176-
177-
const jellyfinImportFormSchema = urlAndKeyImportFormSchema.merge(
178-
usernameImportFormSchema,
176+
const urlAndKeyImportFormSchema = apiUrlImportFormSchema.merge(
177+
z.object({ apiKey: z.string() }),
179178
);
180179

180+
const jellyfinImportFormSchema = usernameImportFormSchema
181+
.merge(apiUrlImportFormSchema)
182+
.merge(z.object({ password: z.string() }));
183+
181184
const genericCsvImportFormSchema = z.object({ csvPath: z.string() });
182185

183186
const movaryImportFormSchema = z.object({
@@ -326,17 +329,17 @@ export default function Page() {
326329
required
327330
name="apiUrl"
328331
/>
329-
<PasswordInput
330-
mt="sm"
331-
label="API Key"
332-
required
333-
name="apiKey"
334-
/>
335332
<TextInput
336333
label="Username"
337334
required
338335
name="username"
339336
/>
337+
<PasswordInput
338+
mt="sm"
339+
label="Password"
340+
required
341+
name="password"
342+
/>
340343
</>
341344
))
342345
.with(ImportSource.Movary, () => (

docs/content/importing.md

+3-8
Original file line numberDiff line numberDiff line change
@@ -180,20 +180,15 @@ the input.
180180

181181
## Jellyfin
182182

183-
You can import your watched movies from [Jellyfin](https://jellyfin.org).
183+
You can import your watched movies and shows from [Jellyfin](https://jellyfin.org).
184184

185185
!!! warning
186186

187187
This will only import media that are already finished. Setup an
188188
[integration](./integrations.md#jellyfin) if you want to import media in progress.
189189

190-
### Steps
191-
192-
- Sign in as the admin of your Jellyfin server. Then go to Dashboard (under Administration)
193-
and select API Keys (under Advanced).
194-
- Click on the plus icon and give it a name. Copy the API key.
195-
- Enter the correct details in the input. The username you enter should be the one whose
196-
data you want to import.
190+
Enter the correct details in the input. The username you enter should be of the account
191+
whose data you want to import.
197192

198193
## Generic Json
199194

libs/generated/src/graphql/backend/graphql.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -299,8 +299,8 @@ export type DeployTraktImportInput = {
299299
};
300300

301301
export type DeployUrlAndKeyAndUsernameImportInput = {
302-
apiKey: Scalars['String']['input'];
303302
apiUrl: Scalars['String']['input'];
303+
password: Scalars['String']['input'];
304304
username: Scalars['String']['input'];
305305
};
306306

0 commit comments

Comments
 (0)