switched to using api tokens instead of login credentials
This commit is contained in:
parent
86f273d12d
commit
226caf4c1e
13 changed files with 215 additions and 85 deletions
46
backend/src/Migrations/20260223115822_apiToken.Designer.cs
generated
Normal file
46
backend/src/Migrations/20260223115822_apiToken.Designer.cs
generated
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
// <auto-generated />
|
||||||
|
using JellyGlass.Repositories;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace JellyGlassBackend.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(DatabaseContext))]
|
||||||
|
[Migration("20260223115822_apiToken")]
|
||||||
|
partial class apiToken
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "9.0.11");
|
||||||
|
|
||||||
|
modelBuilder.Entity("JellyGlass.Models.Server", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Id")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("ApiToken")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Owner")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Url")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("Servers");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
39
backend/src/Migrations/20260223115822_apiToken.cs
Normal file
39
backend/src/Migrations/20260223115822_apiToken.cs
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace JellyGlassBackend.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class apiToken : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Password",
|
||||||
|
table: "Servers");
|
||||||
|
|
||||||
|
migrationBuilder.RenameColumn(
|
||||||
|
name: "Username",
|
||||||
|
table: "Servers",
|
||||||
|
newName: "ApiToken");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.RenameColumn(
|
||||||
|
name: "ApiToken",
|
||||||
|
table: "Servers",
|
||||||
|
newName: "Username");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "Password",
|
||||||
|
table: "Servers",
|
||||||
|
type: "TEXT",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -21,22 +21,18 @@ namespace JellyGlassBackend.Migrations
|
||||||
b.Property<string>("Id")
|
b.Property<string>("Id")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("ApiToken")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<string>("Owner")
|
b.Property<string>("Owner")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<string>("Password")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("TEXT");
|
|
||||||
|
|
||||||
b.Property<string>("Url")
|
b.Property<string>("Url")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<string>("Username")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("TEXT");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.ToTable("Servers");
|
b.ToTable("Servers");
|
||||||
|
|
|
||||||
9
backend/src/Models/JellyfinApi/ServerInfo.cs
Normal file
9
backend/src/Models/JellyfinApi/ServerInfo.cs
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
namespace JellyGlass.Models.JellyfinApi;
|
||||||
|
|
||||||
|
public class ServerInfo
|
||||||
|
{
|
||||||
|
public string ServerName { get; set; } = string.Empty;
|
||||||
|
public string Version { get; set; } = string.Empty;
|
||||||
|
public string Id { get; set; } = string.Empty;
|
||||||
|
public bool IsShuttingDown { get; set; } = false;
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,5 @@ public class Server
|
||||||
public string Owner { get; set; } = string.Empty;
|
public string Owner { get; set; } = string.Empty;
|
||||||
public string Url { get; set; } = string.Empty;
|
public string Url { get; set; } = string.Empty;
|
||||||
public string Id { get; set; } = string.Empty;
|
public string Id { get; set; } = string.Empty;
|
||||||
public string Password { get; set; } = string.Empty;
|
public string ApiToken { get; set; } = string.Empty;
|
||||||
public string Username { get; set; } = string.Empty;
|
|
||||||
}
|
}
|
||||||
|
|
@ -10,20 +10,19 @@ namespace JellyGlass.Repositories;
|
||||||
|
|
||||||
public class JellyfinApiClient
|
public class JellyfinApiClient
|
||||||
{
|
{
|
||||||
private string _apiKey = string.Empty;
|
private readonly string _apiKey = string.Empty;
|
||||||
public readonly string InstanceUrl;
|
public readonly string InstanceUrl;
|
||||||
private readonly HttpClient _client;
|
private readonly HttpClient _client;
|
||||||
private readonly string _username, _password;
|
|
||||||
|
|
||||||
public string ID { get; private set; } = string.Empty;
|
public string ID { get; private set; } = string.Empty;
|
||||||
|
public string ServerName { get; private set; } = string.Empty;
|
||||||
|
|
||||||
public JellyfinApiClient(string instanceUrl, string username, string password)
|
public JellyfinApiClient(string instanceUrl, string apiToken)
|
||||||
{
|
{
|
||||||
InstanceUrl = instanceUrl;
|
InstanceUrl = instanceUrl;
|
||||||
_client = new HttpClient();
|
_client = new HttpClient();
|
||||||
_client.DefaultRequestHeaders.Clear();
|
_client.DefaultRequestHeaders.Clear();
|
||||||
_username = username;
|
_apiKey = apiToken;
|
||||||
_password = password;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Item[]> GetInstanceLibraries()
|
public async Task<Item[]> GetInstanceLibraries()
|
||||||
|
|
@ -37,7 +36,7 @@ public class JellyfinApiClient
|
||||||
|
|
||||||
var apiResponse = await response.Content.ReadFromJsonAsync<ItemResponse>();
|
var apiResponse = await response.Content.ReadFromJsonAsync<ItemResponse>();
|
||||||
|
|
||||||
return apiResponse.Items.ToArray();
|
return apiResponse!.Items.ToArray();
|
||||||
}
|
}
|
||||||
catch (HttpRequestException e)
|
catch (HttpRequestException e)
|
||||||
{
|
{
|
||||||
|
|
@ -47,76 +46,98 @@ public class JellyfinApiClient
|
||||||
|
|
||||||
public async Task<Item[]> GetItemChildren(string itemId)
|
public async Task<Item[]> GetItemChildren(string itemId)
|
||||||
{
|
{
|
||||||
try
|
var request = new HttpRequestMessage(HttpMethod.Get, $"{InstanceUrl}/items?ParentId={itemId}");
|
||||||
{
|
|
||||||
var request = new HttpRequestMessage(HttpMethod.Get, $"{InstanceUrl}/items?ParentId={itemId}");
|
|
||||||
|
|
||||||
var response = await MakeRequest(request);
|
var response = await MakeRequest(request);
|
||||||
|
|
||||||
response.EnsureSuccessStatusCode();
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
var apiResponse = await response.Content.ReadFromJsonAsync<ItemResponse>();
|
var apiResponse = await response.Content.ReadFromJsonAsync<ItemResponse>();
|
||||||
|
|
||||||
return apiResponse!.Items.ToArray();
|
return apiResponse!.Items.ToArray();
|
||||||
}
|
|
||||||
catch (HttpRequestException e)
|
|
||||||
{
|
|
||||||
throw new JellyfinApiClientException(e.Message);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Item[]> GetItems(string searchTerm = "", string years = "", string itemTypes = "", string limit = "", string parentId = "")
|
public async Task<Item[]> GetItems(string searchTerm = "", string years = "", string itemTypes = "", string limit = "", string parentId = "")
|
||||||
{
|
{
|
||||||
try
|
var request = new HttpRequestMessage(HttpMethod.Get, $"{InstanceUrl}/items?searchTerm={searchTerm}&recursive=true&includeItemTypes=Series,Movie");
|
||||||
{
|
|
||||||
var request = new HttpRequestMessage(HttpMethod.Get, $"{InstanceUrl}/items?searchTerm={searchTerm}&recursive=true&includeItemTypes=Series,Movie");
|
|
||||||
|
|
||||||
var response = await MakeRequest(request);
|
var response = await MakeRequest(request);
|
||||||
|
|
||||||
response.EnsureSuccessStatusCode();
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
var apiResponse = await response.Content.ReadFromJsonAsync<ItemResponse>();
|
var apiResponse = await response.Content.ReadFromJsonAsync<ItemResponse>();
|
||||||
|
|
||||||
return apiResponse!.Items.ToArray();
|
return apiResponse!.Items.ToArray();
|
||||||
}
|
|
||||||
catch (HttpRequestException e)
|
|
||||||
{
|
|
||||||
throw new JellyfinApiClientException(e.Message);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task Authenticate()
|
public async Task<ServerInfo> GetServerInfo()
|
||||||
{
|
{
|
||||||
var request = new HttpRequestMessage(HttpMethod.Post, $"{InstanceUrl}/Users/AuthenticateByName");
|
var request = new HttpRequestMessage(HttpMethod.Get, $"{InstanceUrl}/System/Info");
|
||||||
|
|
||||||
request.Headers.Authorization = new AuthenticationHeaderValue("MediaBrowser", GetAuthHeader());
|
var response = await MakeRequest(request);
|
||||||
|
|
||||||
var body = new
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
var apiResponse = await response.Content.ReadFromJsonAsync<ServerInfo>();
|
||||||
|
|
||||||
|
if (ID == string.Empty)
|
||||||
{
|
{
|
||||||
Username = _username,
|
ID = apiResponse!.Id;
|
||||||
Pw = _password
|
|
||||||
};
|
|
||||||
|
|
||||||
request.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var response = await _client.SendAsync(request);
|
|
||||||
|
|
||||||
response.EnsureSuccessStatusCode();
|
|
||||||
|
|
||||||
var authResponse = await response.Content.ReadFromJsonAsync<AuthResponse>();
|
|
||||||
|
|
||||||
_apiKey = authResponse!.AccessToken;
|
|
||||||
ID = authResponse.ServerId;
|
|
||||||
}
|
|
||||||
catch (HttpRequestException e)
|
|
||||||
{
|
|
||||||
//TODO: What to do on an exception
|
|
||||||
throw new JellyfinApiClientException(e.Message);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return apiResponse!;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<object> GetPublicServerInfo()
|
||||||
|
{
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Get, $"{InstanceUrl}/System/Info/Public");
|
||||||
|
|
||||||
|
var response = await MakeRequest(request);
|
||||||
|
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
var apiResponse = await response.Content.ReadFromJsonAsync<ServerInfo>();
|
||||||
|
|
||||||
|
if (ID == string.Empty)
|
||||||
|
{
|
||||||
|
ID = apiResponse!.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
return apiResponse!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// public async Task Authenticate()
|
||||||
|
// {
|
||||||
|
// var request = new HttpRequestMessage(HttpMethod.Post, $"{InstanceUrl}/Users/AuthenticateByName");
|
||||||
|
|
||||||
|
// request.Headers.Authorization = new AuthenticationHeaderValue("MediaBrowser", GetAuthHeader());
|
||||||
|
|
||||||
|
// var body = new
|
||||||
|
// {
|
||||||
|
// Username = _username,
|
||||||
|
// Pw = _password
|
||||||
|
// };
|
||||||
|
|
||||||
|
// request.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
|
||||||
|
|
||||||
|
// try
|
||||||
|
// {
|
||||||
|
// var response = await _client.SendAsync(request);
|
||||||
|
|
||||||
|
// response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
// var authResponse = await response.Content.ReadFromJsonAsync<AuthResponse>();
|
||||||
|
|
||||||
|
// _apiKey = authResponse!.AccessToken;
|
||||||
|
// ID = authResponse.ServerId;
|
||||||
|
// }
|
||||||
|
// catch (HttpRequestException e)
|
||||||
|
// {
|
||||||
|
// throw new JellyfinApiClientException(e.Message);
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
private async Task<HttpResponseMessage> MakeRequest(HttpRequestMessage request)
|
private async Task<HttpResponseMessage> MakeRequest(HttpRequestMessage request)
|
||||||
{
|
{
|
||||||
request.Headers.Authorization = new AuthenticationHeaderValue("MediaBrowser", GetAuthHeader());
|
request.Headers.Authorization = new AuthenticationHeaderValue("MediaBrowser", GetAuthHeader());
|
||||||
|
|
@ -129,18 +150,19 @@ public class JellyfinApiClient
|
||||||
}
|
}
|
||||||
catch (HttpRequestException e)
|
catch (HttpRequestException e)
|
||||||
{
|
{
|
||||||
if (e.StatusCode == HttpStatusCode.Unauthorized)
|
// if (e.StatusCode == HttpStatusCode.Unauthorized)
|
||||||
{
|
// {
|
||||||
await Authenticate();
|
// await Authenticate();
|
||||||
|
|
||||||
request.Headers.Authorization = new AuthenticationHeaderValue("MediaBrowser", GetAuthHeader());
|
// request.Headers.Authorization = new AuthenticationHeaderValue("MediaBrowser", GetAuthHeader());
|
||||||
|
|
||||||
response = await _client.SendAsync(request);
|
// response = await _client.SendAsync(request);
|
||||||
}
|
// }
|
||||||
else
|
// else
|
||||||
{
|
// {
|
||||||
throw new JellyfinApiClientException(e.Message);
|
// throw new JellyfinApiClientException(e.Message);
|
||||||
}
|
// }
|
||||||
|
throw new JellyfinApiClientException(e.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
|
|
|
||||||
|
|
@ -50,15 +50,26 @@ public class ClientService : IClientService
|
||||||
|
|
||||||
foreach (var server in servers)
|
foreach (var server in servers)
|
||||||
{
|
{
|
||||||
var client = new JellyfinApiClient(server.Url, server.Username, server.Password);
|
var client = new JellyfinApiClient(server.Url, server.ApiToken);
|
||||||
|
|
||||||
|
// try
|
||||||
|
// {
|
||||||
|
// await client.Authenticate();
|
||||||
|
// }
|
||||||
|
// catch (JellyfinApiClientException e)
|
||||||
|
// {
|
||||||
|
// _logger.LogError($"Error authenticating to {server.Url}. Error: {e.Message} Client will not be used");
|
||||||
|
// continue;
|
||||||
|
// }
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await client.Authenticate();
|
await client.GetServerInfo(); //test the connection
|
||||||
}
|
}
|
||||||
catch (JellyfinApiClientException e)
|
catch (JellyfinApiClientException e)
|
||||||
{
|
{
|
||||||
|
_logger.LogError($"Error authenticating to {server.Url}. Error: {e.Message} Client will not be used");
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
clients.Add(client);
|
clients.Add(client);
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,13 @@ public class ServerService : IServerService
|
||||||
{
|
{
|
||||||
private readonly IServerRepository _repository;
|
private readonly IServerRepository _repository;
|
||||||
private readonly IClientService _service;
|
private readonly IClientService _service;
|
||||||
|
private readonly ILogger<ServerService> _logger;
|
||||||
|
|
||||||
public ServerService(IServerRepository repository, IClientService service)
|
public ServerService(IServerRepository repository, IClientService service, ILogger<ServerService> logger)
|
||||||
{
|
{
|
||||||
_repository = repository;
|
_repository = repository;
|
||||||
_service = service;
|
_service = service;
|
||||||
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<ServerDTO[]> GetServers()
|
public async Task<ServerDTO[]> GetServers()
|
||||||
|
|
@ -24,6 +26,8 @@ public class ServerService : IServerService
|
||||||
|
|
||||||
foreach (var client in clients)
|
foreach (var client in clients)
|
||||||
{
|
{
|
||||||
|
_logger.LogInformation($"ID for server {client.InstanceUrl} is {client.ID}");
|
||||||
|
|
||||||
var dto = new ServerDTO();
|
var dto = new ServerDTO();
|
||||||
var server = servers.First(s => s.Url == client.InstanceUrl);
|
var server = servers.First(s => s.Url == client.InstanceUrl);
|
||||||
dto.Id = client.ID;
|
dto.Id = client.ID;
|
||||||
|
|
|
||||||
|
|
@ -9,12 +9,12 @@ const ServerList = () => {
|
||||||
getServerList().then(serverList => {
|
getServerList().then(serverList => {
|
||||||
setServers(serverList);
|
setServers(serverList);
|
||||||
})
|
})
|
||||||
})
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", flexDirection: "row", flexWrap: "wrap" }}>
|
<div style={{ display: "flex", flexDirection: "row", flexWrap: "wrap" }}>
|
||||||
{servers.map(server => {
|
{servers.map(server => {
|
||||||
return (<ServerCard name={server.owner} online={false} linkTo={server.url} key={server.name} />)
|
return (<ServerCard name={server.owner} online={true} linkTo={server.url} key={server.name} />)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ const ServerSearch = ({ searchTerm, server }: ServerSearchProps) => {
|
||||||
search(searchTerm, server.id).then(results => {
|
search(searchTerm, server.id).then(results => {
|
||||||
setSearchResults(results);
|
setSearchResults(results);
|
||||||
}).catch(err => {
|
}).catch(err => {
|
||||||
|
setSearchResults([]);
|
||||||
alert(err);
|
alert(err);
|
||||||
})
|
})
|
||||||
}, [searchTerm]);
|
}, [searchTerm]);
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ const ServerSearchResult = ({ searchResult, server }: ServerSearchResultProps) =
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link to={resultUrl} target="_blank" rel="noopener noreferrer">
|
<Link to={resultUrl} target="_blank" rel="noopener noreferrer">
|
||||||
<h3>{searchResult.name} - {searchResult.productionYear}</h3>
|
<h3>{searchResult.type} - {searchResult.name} - {searchResult.productionYear}</h3>
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,10 @@ export interface SearchResult {
|
||||||
name: string;
|
name: string;
|
||||||
id: string;
|
id: string;
|
||||||
serverId: string;
|
serverId: string;
|
||||||
type: SearchResultType;
|
type: string;
|
||||||
productionYear: string;
|
productionYear: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SearchResultType = "movie" | "tv show" | "music";
|
|
||||||
|
|
||||||
export const search = async (searchTerm: string, serverId: string): Promise<Array<SearchResult>> => {
|
export const search = async (searchTerm: string, serverId: string): Promise<Array<SearchResult>> => {
|
||||||
const response = await axios.get<Array<SearchResult>>(`${apiUrl}/search?searchTerm=${searchTerm}&serverId=${serverId}`);
|
const response = await axios.get<Array<SearchResult>>(`${apiUrl}/search?searchTerm=${searchTerm}&serverId=${serverId}`);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,11 @@ const Search = () => {
|
||||||
navigate("/");
|
navigate("/");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (servers.length > 0) {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
setServers([]);
|
||||||
|
}
|
||||||
|
|
||||||
getServerList().then(servers => {
|
getServerList().then(servers => {
|
||||||
if (servers.length === 0) {
|
if (servers.length === 0) {
|
||||||
alert("No servers found");
|
alert("No servers found");
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue