mirror of
https://github.com/zoriya/Kyoo.git
synced 2025-07-09 03:04:20 -04:00
Add identify apis for provider (#419)
This commit is contained in:
commit
9163cef0f0
@ -24,4 +24,4 @@ RUN dotnet ef migrations bundle --no-build --self-contained -r linux-${TARGETARC
|
|||||||
FROM mcr.microsoft.com/dotnet/runtime-deps:8.0
|
FROM mcr.microsoft.com/dotnet/runtime-deps:8.0
|
||||||
COPY --from=builder /app/migrate /app/migrate
|
COPY --from=builder /app/migrate /app/migrate
|
||||||
|
|
||||||
ENTRYPOINT /app/migrate --connection "USER ID=${POSTGRES_USER};PASSWORD=${POSTGRES_PASSWORD};SERVER=${POSTGRES_SERVER};PORT=${POSTGRES_PORT};DATABASE=${POSTGRES_DB};"
|
ENTRYPOINT ["/app/migrate"]
|
||||||
|
@ -24,8 +24,8 @@ namespace Kyoo.Abstractions.Models;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// A show, a movie or a collection.
|
/// A show, a movie or a collection.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[OneOf(Types = new[] { typeof(Episode), typeof(Movie) })]
|
[OneOf(Types = [typeof(Episode), typeof(Movie)])]
|
||||||
public interface INews : IResource, IThumbnails, IMetadata, IAddedDate, IQuery
|
public interface INews : IResource, IThumbnails, IAddedDate, IQuery
|
||||||
{
|
{
|
||||||
static Sort IQuery.DefaultSort => new Sort<INews>.By(nameof(AddedDate), true);
|
static Sort IQuery.DefaultSort => new Sort<INews>.By(nameof(AddedDate), true);
|
||||||
}
|
}
|
||||||
|
@ -33,3 +33,29 @@ public class MetadataId
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public string? Link { get; set; }
|
public string? Link { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ID informations about an episode.
|
||||||
|
/// </summary>
|
||||||
|
public class EpisodeId
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The Id of the show on the metadata database.
|
||||||
|
/// </summary>
|
||||||
|
public string ShowId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The season number or null if absolute numbering is used in this database.
|
||||||
|
/// </summary>
|
||||||
|
public int? Season { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The episode number or absolute number if Season is null.
|
||||||
|
/// </summary>
|
||||||
|
public int Episode { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The URL of the resource on the external provider.
|
||||||
|
/// </summary>
|
||||||
|
public string? Link { get; set; }
|
||||||
|
}
|
||||||
|
@ -28,7 +28,14 @@ namespace Kyoo.Abstractions.Models;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// A class representing collections of <see cref="Show"/>.
|
/// A class representing collections of <see cref="Show"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class Collection : IQuery, IResource, IMetadata, IThumbnails, IAddedDate, ILibraryItem
|
public class Collection
|
||||||
|
: IQuery,
|
||||||
|
IResource,
|
||||||
|
IMetadata,
|
||||||
|
IThumbnails,
|
||||||
|
IAddedDate,
|
||||||
|
IRefreshable,
|
||||||
|
ILibraryItem
|
||||||
{
|
{
|
||||||
public static Sort DefaultSort => new Sort<Collection>.By(nameof(Collection.Name));
|
public static Sort DefaultSort => new Sort<Collection>.By(nameof(Collection.Name));
|
||||||
|
|
||||||
@ -76,6 +83,9 @@ public class Collection : IQuery, IResource, IMetadata, IThumbnails, IAddedDate,
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Dictionary<string, MetadataId> ExternalId { get; set; } = new();
|
public Dictionary<string, MetadataId> ExternalId { get; set; } = new();
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public DateTime? NextMetadataRefresh { get; set; }
|
||||||
|
|
||||||
public Collection() { }
|
public Collection() { }
|
||||||
|
|
||||||
[JsonConstructor]
|
[JsonConstructor]
|
||||||
|
@ -31,7 +31,7 @@ namespace Kyoo.Abstractions.Models;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// A class to represent a single show's episode.
|
/// A class to represent a single show's episode.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class Episode : IQuery, IResource, IMetadata, IThumbnails, IAddedDate, INews
|
public class Episode : IQuery, IResource, IThumbnails, IAddedDate, IRefreshable, INews
|
||||||
{
|
{
|
||||||
// Use absolute numbers by default and fallback to season/episodes if it does not exists.
|
// Use absolute numbers by default and fallback to season/episodes if it does not exists.
|
||||||
public static Sort DefaultSort =>
|
public static Sort DefaultSort =>
|
||||||
@ -166,7 +166,10 @@ public class Episode : IQuery, IResource, IMetadata, IThumbnails, IAddedDate, IN
|
|||||||
public Image? Logo { get; set; }
|
public Image? Logo { get; set; }
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Dictionary<string, MetadataId> ExternalId { get; set; } = new();
|
public Dictionary<string, EpisodeId> ExternalId { get; set; } = [];
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public DateTime? NextMetadataRefresh { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The previous episode that should be seen before viewing this one.
|
/// The previous episode that should be seen before viewing this one.
|
||||||
|
@ -0,0 +1,29 @@
|
|||||||
|
// Kyoo - A portable and vast media library solution.
|
||||||
|
// Copyright (c) Kyoo.
|
||||||
|
//
|
||||||
|
// See AUTHORS.md and LICENSE file in the project root for full license information.
|
||||||
|
//
|
||||||
|
// Kyoo is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// any later version.
|
||||||
|
//
|
||||||
|
// Kyoo is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU General Public License
|
||||||
|
// along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace Kyoo.Abstractions.Models;
|
||||||
|
|
||||||
|
public interface IRefreshable
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The date of the next metadata refresh. Null if auto-refresh is disabled.
|
||||||
|
/// </summary>
|
||||||
|
public DateTime? NextMetadataRefresh { get; set; }
|
||||||
|
}
|
@ -38,6 +38,7 @@ public class Movie
|
|||||||
IMetadata,
|
IMetadata,
|
||||||
IThumbnails,
|
IThumbnails,
|
||||||
IAddedDate,
|
IAddedDate,
|
||||||
|
IRefreshable,
|
||||||
ILibraryItem,
|
ILibraryItem,
|
||||||
INews,
|
INews,
|
||||||
IWatchlist
|
IWatchlist
|
||||||
@ -134,6 +135,9 @@ public class Movie
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Dictionary<string, MetadataId> ExternalId { get; set; } = new();
|
public Dictionary<string, MetadataId> ExternalId { get; set; } = new();
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public DateTime? NextMetadataRefresh { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The ID of the Studio that made this show.
|
/// The ID of the Studio that made this show.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
@ -31,7 +31,7 @@ namespace Kyoo.Abstractions.Models;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// A season of a <see cref="Show"/>.
|
/// A season of a <see cref="Show"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class Season : IQuery, IResource, IMetadata, IThumbnails, IAddedDate
|
public class Season : IQuery, IResource, IMetadata, IThumbnails, IAddedDate, IRefreshable
|
||||||
{
|
{
|
||||||
public static Sort DefaultSort => new Sort<Season>.By(x => x.SeasonNumber);
|
public static Sort DefaultSort => new Sort<Season>.By(x => x.SeasonNumber);
|
||||||
|
|
||||||
@ -119,6 +119,9 @@ public class Season : IQuery, IResource, IMetadata, IThumbnails, IAddedDate
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Dictionary<string, MetadataId> ExternalId { get; set; } = new();
|
public Dictionary<string, MetadataId> ExternalId { get; set; } = new();
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public DateTime? NextMetadataRefresh { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The list of episodes that this season contains.
|
/// The list of episodes that this season contains.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
@ -39,6 +39,7 @@ public class Show
|
|||||||
IOnMerge,
|
IOnMerge,
|
||||||
IThumbnails,
|
IThumbnails,
|
||||||
IAddedDate,
|
IAddedDate,
|
||||||
|
IRefreshable,
|
||||||
ILibraryItem,
|
ILibraryItem,
|
||||||
IWatchlist
|
IWatchlist
|
||||||
{
|
{
|
||||||
@ -126,6 +127,9 @@ public class Show
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Dictionary<string, MetadataId> ExternalId { get; set; } = new();
|
public Dictionary<string, MetadataId> ExternalId { get; set; } = new();
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public DateTime? NextMetadataRefresh { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The ID of the Studio that made this show.
|
/// The ID of the Studio that made this show.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
@ -157,21 +157,11 @@ public abstract class DatabaseContext : DbContext
|
|||||||
optionsBuilder.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);
|
optionsBuilder.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ValueComparer<Dictionary<string, T>> _GetComparer<T>()
|
private static void _HasJson<T, TVal>(
|
||||||
{
|
ModelBuilder builder,
|
||||||
return new(
|
Expression<Func<T, Dictionary<string, TVal>>> property
|
||||||
(c1, c2) => c1!.SequenceEqual(c2!),
|
)
|
||||||
c => c.Aggregate(0, (a, v) => HashCode.Combine(a, v.GetHashCode()))
|
where T : class
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Build the metadata model for the given type.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="modelBuilder">The database model builder</param>
|
|
||||||
/// <typeparam name="T">The type to add metadata to.</typeparam>
|
|
||||||
private static void _HasMetadata<T>(ModelBuilder modelBuilder)
|
|
||||||
where T : class, IMetadata
|
|
||||||
{
|
{
|
||||||
// TODO: Waiting for https://github.com/dotnet/efcore/issues/29825
|
// TODO: Waiting for https://github.com/dotnet/efcore/issues/29825
|
||||||
// modelBuilder.Entity<T>()
|
// modelBuilder.Entity<T>()
|
||||||
@ -179,22 +169,33 @@ public abstract class DatabaseContext : DbContext
|
|||||||
// {
|
// {
|
||||||
// x.ToJson();
|
// x.ToJson();
|
||||||
// });
|
// });
|
||||||
modelBuilder
|
builder
|
||||||
.Entity<T>()
|
.Entity<T>()
|
||||||
.Property(x => x.ExternalId)
|
.Property(property)
|
||||||
.HasConversion(
|
.HasConversion(
|
||||||
v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null),
|
v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null),
|
||||||
v =>
|
v =>
|
||||||
JsonSerializer.Deserialize<Dictionary<string, MetadataId>>(
|
JsonSerializer.Deserialize<Dictionary<string, TVal>>(
|
||||||
v,
|
v,
|
||||||
(JsonSerializerOptions?)null
|
(JsonSerializerOptions?)null
|
||||||
)!
|
)!
|
||||||
)
|
)
|
||||||
.HasColumnType("json");
|
.HasColumnType("json");
|
||||||
modelBuilder
|
builder
|
||||||
.Entity<T>()
|
.Entity<T>()
|
||||||
.Property(x => x.ExternalId)
|
.Property(property)
|
||||||
.Metadata.SetValueComparer(_GetComparer<MetadataId>());
|
.Metadata.SetValueComparer(
|
||||||
|
new ValueComparer<Dictionary<string, TVal>>(
|
||||||
|
(c1, c2) => c1!.SequenceEqual(c2!),
|
||||||
|
c => c.Aggregate(0, (a, v) => HashCode.Combine(a, v.GetHashCode()))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void _HasMetadata<T>(ModelBuilder modelBuilder)
|
||||||
|
where T : class, IMetadata
|
||||||
|
{
|
||||||
|
_HasJson<T, MetadataId>(modelBuilder, x => x.ExternalId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void _HasImages<T>(ModelBuilder modelBuilder)
|
private static void _HasImages<T>(ModelBuilder modelBuilder)
|
||||||
@ -215,6 +216,17 @@ public abstract class DatabaseContext : DbContext
|
|||||||
.ValueGeneratedOnAdd();
|
.ValueGeneratedOnAdd();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void _HasRefreshDate<T>(ModelBuilder builder)
|
||||||
|
where T : class, IRefreshable
|
||||||
|
{
|
||||||
|
// schedule a refresh soon since metadata can change frequently for recently added items ond online databases
|
||||||
|
builder
|
||||||
|
.Entity<T>()
|
||||||
|
.Property(x => x.NextMetadataRefresh)
|
||||||
|
.HasDefaultValueSql("now() at time zone 'utc' + interval '2 hours'")
|
||||||
|
.ValueGeneratedOnAdd();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Create a many to many relationship between the two entities.
|
/// Create a many to many relationship between the two entities.
|
||||||
/// The resulting relationship will have an available <see cref="AddLinks{T1,T2}"/> method.
|
/// The resulting relationship will have an available <see cref="AddLinks{T1,T2}"/> method.
|
||||||
@ -296,8 +308,8 @@ public abstract class DatabaseContext : DbContext
|
|||||||
_HasMetadata<Movie>(modelBuilder);
|
_HasMetadata<Movie>(modelBuilder);
|
||||||
_HasMetadata<Show>(modelBuilder);
|
_HasMetadata<Show>(modelBuilder);
|
||||||
_HasMetadata<Season>(modelBuilder);
|
_HasMetadata<Season>(modelBuilder);
|
||||||
_HasMetadata<Episode>(modelBuilder);
|
|
||||||
_HasMetadata<Studio>(modelBuilder);
|
_HasMetadata<Studio>(modelBuilder);
|
||||||
|
_HasJson<Episode, EpisodeId>(modelBuilder, x => x.ExternalId);
|
||||||
|
|
||||||
_HasImages<Collection>(modelBuilder);
|
_HasImages<Collection>(modelBuilder);
|
||||||
_HasImages<Movie>(modelBuilder);
|
_HasImages<Movie>(modelBuilder);
|
||||||
@ -313,6 +325,12 @@ public abstract class DatabaseContext : DbContext
|
|||||||
_HasAddedDate<User>(modelBuilder);
|
_HasAddedDate<User>(modelBuilder);
|
||||||
_HasAddedDate<Issue>(modelBuilder);
|
_HasAddedDate<Issue>(modelBuilder);
|
||||||
|
|
||||||
|
_HasRefreshDate<Collection>(modelBuilder);
|
||||||
|
_HasRefreshDate<Movie>(modelBuilder);
|
||||||
|
_HasRefreshDate<Show>(modelBuilder);
|
||||||
|
_HasRefreshDate<Season>(modelBuilder);
|
||||||
|
_HasRefreshDate<Episode>(modelBuilder);
|
||||||
|
|
||||||
modelBuilder
|
modelBuilder
|
||||||
.Entity<MovieWatchStatus>()
|
.Entity<MovieWatchStatus>()
|
||||||
.HasKey(x => new { User = x.UserId, Movie = x.MovieId });
|
.HasKey(x => new { User = x.UserId, Movie = x.MovieId });
|
||||||
@ -389,62 +407,9 @@ public abstract class DatabaseContext : DbContext
|
|||||||
|
|
||||||
modelBuilder.Entity<Issue>().HasKey(x => new { x.Domain, x.Cause });
|
modelBuilder.Entity<Issue>().HasKey(x => new { x.Domain, x.Cause });
|
||||||
|
|
||||||
// TODO: Waiting for https://github.com/dotnet/efcore/issues/29825
|
_HasJson<User, string>(modelBuilder, x => x.Settings);
|
||||||
// modelBuilder.Entity<T>()
|
_HasJson<User, ExternalToken>(modelBuilder, x => x.ExternalId);
|
||||||
// .OwnsOne(x => x.ExternalId, x =>
|
_HasJson<Issue, object>(modelBuilder, x => x.Extra);
|
||||||
// {
|
|
||||||
// x.ToJson();
|
|
||||||
// });
|
|
||||||
modelBuilder
|
|
||||||
.Entity<User>()
|
|
||||||
.Property(x => x.Settings)
|
|
||||||
.HasConversion(
|
|
||||||
v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null),
|
|
||||||
v =>
|
|
||||||
JsonSerializer.Deserialize<Dictionary<string, string>>(
|
|
||||||
v,
|
|
||||||
(JsonSerializerOptions?)null
|
|
||||||
)!
|
|
||||||
)
|
|
||||||
.HasColumnType("json");
|
|
||||||
modelBuilder
|
|
||||||
.Entity<User>()
|
|
||||||
.Property(x => x.Settings)
|
|
||||||
.Metadata.SetValueComparer(_GetComparer<string>());
|
|
||||||
|
|
||||||
modelBuilder
|
|
||||||
.Entity<User>()
|
|
||||||
.Property(x => x.ExternalId)
|
|
||||||
.HasConversion(
|
|
||||||
v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null),
|
|
||||||
v =>
|
|
||||||
JsonSerializer.Deserialize<Dictionary<string, ExternalToken>>(
|
|
||||||
v,
|
|
||||||
(JsonSerializerOptions?)null
|
|
||||||
)!
|
|
||||||
)
|
|
||||||
.HasColumnType("json");
|
|
||||||
modelBuilder
|
|
||||||
.Entity<User>()
|
|
||||||
.Property(x => x.ExternalId)
|
|
||||||
.Metadata.SetValueComparer(_GetComparer<ExternalToken>());
|
|
||||||
|
|
||||||
modelBuilder
|
|
||||||
.Entity<Issue>()
|
|
||||||
.Property(x => x.Extra)
|
|
||||||
.HasConversion(
|
|
||||||
v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null),
|
|
||||||
v =>
|
|
||||||
JsonSerializer.Deserialize<Dictionary<string, object>>(
|
|
||||||
v,
|
|
||||||
(JsonSerializerOptions?)null
|
|
||||||
)!
|
|
||||||
)
|
|
||||||
.HasColumnType("json");
|
|
||||||
modelBuilder
|
|
||||||
.Entity<Issue>()
|
|
||||||
.Property(x => x.Extra)
|
|
||||||
.Metadata.SetValueComparer(_GetComparer<object>());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
1347
back/src/Kyoo.Postgresql/Migrations/20240414212454_AddNextRefresh.Designer.cs
generated
Normal file
1347
back/src/Kyoo.Postgresql/Migrations/20240414212454_AddNextRefresh.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,89 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Kyoo.Postgresql.Migrations;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddNextRefresh : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<DateTime>(
|
||||||
|
name: "next_metadata_refresh",
|
||||||
|
table: "shows",
|
||||||
|
type: "timestamp with time zone",
|
||||||
|
nullable: true,
|
||||||
|
defaultValueSql: "now() at time zone 'utc' + interval '2 hours'"
|
||||||
|
);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<DateTime>(
|
||||||
|
name: "next_metadata_refresh",
|
||||||
|
table: "seasons",
|
||||||
|
type: "timestamp with time zone",
|
||||||
|
nullable: true,
|
||||||
|
defaultValueSql: "now() at time zone 'utc' + interval '2 hours'"
|
||||||
|
);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<DateTime>(
|
||||||
|
name: "next_metadata_refresh",
|
||||||
|
table: "movies",
|
||||||
|
type: "timestamp with time zone",
|
||||||
|
nullable: true,
|
||||||
|
defaultValueSql: "now() at time zone 'utc' + interval '2 hours'"
|
||||||
|
);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<DateTime>(
|
||||||
|
name: "next_metadata_refresh",
|
||||||
|
table: "episodes",
|
||||||
|
type: "timestamp with time zone",
|
||||||
|
nullable: true,
|
||||||
|
defaultValueSql: "now() at time zone 'utc' + interval '2 hours'"
|
||||||
|
);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<DateTime>(
|
||||||
|
name: "next_metadata_refresh",
|
||||||
|
table: "collections",
|
||||||
|
type: "timestamp with time zone",
|
||||||
|
nullable: true,
|
||||||
|
defaultValueSql: "now() at time zone 'utc' + interval '2 hours'"
|
||||||
|
);
|
||||||
|
|
||||||
|
// language=PostgreSQL
|
||||||
|
migrationBuilder.Sql(
|
||||||
|
"""
|
||||||
|
update episodes as e set external_id = (
|
||||||
|
SELECT jsonb_build_object(
|
||||||
|
'themoviedatabase', jsonb_build_object(
|
||||||
|
'ShowId', s.external_id->'themoviedatabase'->'DataId',
|
||||||
|
'Season', e.season_number,
|
||||||
|
'Episode', e.episode_number,
|
||||||
|
'Link', null
|
||||||
|
)
|
||||||
|
)
|
||||||
|
FROM shows AS s
|
||||||
|
WHERE s.id = e.show_id
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(name: "next_metadata_refresh", table: "shows");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(name: "next_metadata_refresh", table: "seasons");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(name: "next_metadata_refresh", table: "movies");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(name: "next_metadata_refresh", table: "episodes");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(name: "next_metadata_refresh", table: "collections");
|
||||||
|
|
||||||
|
// language=PostgreSQL
|
||||||
|
migrationBuilder.Sql("update episodes as e set external_id = '{}';");
|
||||||
|
}
|
||||||
|
}
|
@ -19,7 +19,7 @@ namespace Kyoo.Postgresql.Migrations
|
|||||||
{
|
{
|
||||||
#pragma warning disable 612, 618
|
#pragma warning disable 612, 618
|
||||||
modelBuilder
|
modelBuilder
|
||||||
.HasAnnotation("ProductVersion", "8.0.3")
|
.HasAnnotation("ProductVersion", "8.0.4")
|
||||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "genre", new[] { "action", "adventure", "animation", "comedy", "crime", "documentary", "drama", "family", "fantasy", "history", "horror", "music", "mystery", "romance", "science_fiction", "thriller", "war", "western", "kids", "news", "reality", "soap", "talk", "politics" });
|
NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "genre", new[] { "action", "adventure", "animation", "comedy", "crime", "documentary", "drama", "family", "fantasy", "history", "horror", "music", "mystery", "romance", "science_fiction", "thriller", "war", "western", "kids", "news", "reality", "soap", "talk", "politics" });
|
||||||
@ -50,6 +50,12 @@ namespace Kyoo.Postgresql.Migrations
|
|||||||
.HasColumnType("text")
|
.HasColumnType("text")
|
||||||
.HasColumnName("name");
|
.HasColumnName("name");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("NextMetadataRefresh")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("next_metadata_refresh")
|
||||||
|
.HasDefaultValueSql("now() at time zone 'utc' + interval '2 hours'");
|
||||||
|
|
||||||
b.Property<string>("Overview")
|
b.Property<string>("Overview")
|
||||||
.HasColumnType("text")
|
.HasColumnType("text")
|
||||||
.HasColumnName("overview");
|
.HasColumnName("overview");
|
||||||
@ -100,6 +106,12 @@ namespace Kyoo.Postgresql.Migrations
|
|||||||
.HasColumnType("text")
|
.HasColumnType("text")
|
||||||
.HasColumnName("name");
|
.HasColumnName("name");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("NextMetadataRefresh")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("next_metadata_refresh")
|
||||||
|
.HasDefaultValueSql("now() at time zone 'utc' + interval '2 hours'");
|
||||||
|
|
||||||
b.Property<string>("Overview")
|
b.Property<string>("Overview")
|
||||||
.HasColumnType("text")
|
.HasColumnType("text")
|
||||||
.HasColumnName("overview");
|
.HasColumnName("overview");
|
||||||
@ -262,6 +274,12 @@ namespace Kyoo.Postgresql.Migrations
|
|||||||
.HasColumnType("text")
|
.HasColumnType("text")
|
||||||
.HasColumnName("name");
|
.HasColumnName("name");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("NextMetadataRefresh")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("next_metadata_refresh")
|
||||||
|
.HasDefaultValueSql("now() at time zone 'utc' + interval '2 hours'");
|
||||||
|
|
||||||
b.Property<string>("Overview")
|
b.Property<string>("Overview")
|
||||||
.HasColumnType("text")
|
.HasColumnType("text")
|
||||||
.HasColumnName("overview");
|
.HasColumnName("overview");
|
||||||
@ -386,6 +404,12 @@ namespace Kyoo.Postgresql.Migrations
|
|||||||
.HasColumnType("text")
|
.HasColumnType("text")
|
||||||
.HasColumnName("name");
|
.HasColumnName("name");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("NextMetadataRefresh")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("next_metadata_refresh")
|
||||||
|
.HasDefaultValueSql("now() at time zone 'utc' + interval '2 hours'");
|
||||||
|
|
||||||
b.Property<string>("Overview")
|
b.Property<string>("Overview")
|
||||||
.HasColumnType("text")
|
.HasColumnType("text")
|
||||||
.HasColumnName("overview");
|
.HasColumnName("overview");
|
||||||
@ -459,6 +483,12 @@ namespace Kyoo.Postgresql.Migrations
|
|||||||
.HasColumnType("text")
|
.HasColumnType("text")
|
||||||
.HasColumnName("name");
|
.HasColumnName("name");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("NextMetadataRefresh")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("next_metadata_refresh")
|
||||||
|
.HasDefaultValueSql("now() at time zone 'utc' + interval '2 hours'");
|
||||||
|
|
||||||
b.Property<string>("Overview")
|
b.Property<string>("Overview")
|
||||||
.HasColumnType("text")
|
.HasColumnType("text")
|
||||||
.HasColumnName("overview");
|
.HasColumnName("overview");
|
||||||
|
@ -40,6 +40,7 @@ public class PostgresContext(DbContextOptions options, IHttpContextAccessor acce
|
|||||||
{
|
{
|
||||||
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||||
{
|
{
|
||||||
|
optionsBuilder.UseProjectables();
|
||||||
optionsBuilder.UseSnakeCaseNamingConvention();
|
optionsBuilder.UseSnakeCaseNamingConvention();
|
||||||
base.OnConfiguring(optionsBuilder);
|
base.OnConfiguring(optionsBuilder);
|
||||||
}
|
}
|
||||||
@ -81,6 +82,10 @@ public class PostgresContext(DbContextOptions options, IHttpContextAccessor acce
|
|||||||
typeof(Dictionary<string, MetadataId>),
|
typeof(Dictionary<string, MetadataId>),
|
||||||
new JsonTypeHandler<Dictionary<string, MetadataId>>()
|
new JsonTypeHandler<Dictionary<string, MetadataId>>()
|
||||||
);
|
);
|
||||||
|
SqlMapper.AddTypeHandler(
|
||||||
|
typeof(Dictionary<string, EpisodeId>),
|
||||||
|
new JsonTypeHandler<Dictionary<string, EpisodeId>>()
|
||||||
|
);
|
||||||
SqlMapper.AddTypeHandler(
|
SqlMapper.AddTypeHandler(
|
||||||
typeof(Dictionary<string, string>),
|
typeof(Dictionary<string, string>),
|
||||||
new JsonTypeHandler<Dictionary<string, string>>()
|
new JsonTypeHandler<Dictionary<string, string>>()
|
||||||
@ -128,7 +133,11 @@ public class PostgresContextBuilder : IDesignTimeDbContextFactory<PostgresContex
|
|||||||
{
|
{
|
||||||
public PostgresContext CreateDbContext(string[] args)
|
public PostgresContext CreateDbContext(string[] args)
|
||||||
{
|
{
|
||||||
NpgsqlDataSource dataSource = PostgresModule.CreateDataSource(new ConfigurationManager());
|
IConfigurationRoot config = new ConfigurationBuilder()
|
||||||
|
.AddEnvironmentVariables()
|
||||||
|
.AddCommandLine(args)
|
||||||
|
.Build();
|
||||||
|
NpgsqlDataSource dataSource = PostgresModule.CreateDataSource(config);
|
||||||
DbContextOptionsBuilder builder = new();
|
DbContextOptionsBuilder builder = new();
|
||||||
builder.UseNpgsql(dataSource);
|
builder.UseNpgsql(dataSource);
|
||||||
|
|
||||||
|
@ -37,7 +37,7 @@ public static class PostgresModule
|
|||||||
{
|
{
|
||||||
["USER ID"] = configuration.GetValue("POSTGRES_USER", "KyooUser"),
|
["USER ID"] = configuration.GetValue("POSTGRES_USER", "KyooUser"),
|
||||||
["PASSWORD"] = configuration.GetValue("POSTGRES_PASSWORD", "KyooPassword"),
|
["PASSWORD"] = configuration.GetValue("POSTGRES_PASSWORD", "KyooPassword"),
|
||||||
["SERVER"] = configuration.GetValue("POSTGRES_SERVER", "db"),
|
["SERVER"] = configuration.GetValue("POSTGRES_SERVER", "postgres"),
|
||||||
["PORT"] = configuration.GetValue("POSTGRES_PORT", "5432"),
|
["PORT"] = configuration.GetValue("POSTGRES_PORT", "5432"),
|
||||||
["DATABASE"] = configuration.GetValue("POSTGRES_DB", "kyooDB"),
|
["DATABASE"] = configuration.GetValue("POSTGRES_DB", "kyooDB"),
|
||||||
["POOLING"] = "true",
|
["POOLING"] = "true",
|
||||||
@ -55,11 +55,10 @@ public static class PostgresModule
|
|||||||
public static void ConfigurePostgres(this WebApplicationBuilder builder)
|
public static void ConfigurePostgres(this WebApplicationBuilder builder)
|
||||||
{
|
{
|
||||||
NpgsqlDataSource dataSource = CreateDataSource(builder.Configuration);
|
NpgsqlDataSource dataSource = CreateDataSource(builder.Configuration);
|
||||||
|
|
||||||
builder.Services.AddDbContext<DatabaseContext, PostgresContext>(
|
builder.Services.AddDbContext<DatabaseContext, PostgresContext>(
|
||||||
x =>
|
x =>
|
||||||
{
|
{
|
||||||
x.UseNpgsql(dataSource).UseProjectables();
|
x.UseNpgsql(dataSource);
|
||||||
if (builder.Environment.IsDevelopment())
|
if (builder.Environment.IsDevelopment())
|
||||||
x.EnableDetailedErrors().EnableSensitiveDataLogging();
|
x.EnableDetailedErrors().EnableSensitiveDataLogging();
|
||||||
},
|
},
|
||||||
|
@ -13,6 +13,6 @@ async def main():
|
|||||||
logging.getLogger("rebulk").setLevel(logging.WARNING)
|
logging.getLogger("rebulk").setLevel(logging.WARNING)
|
||||||
|
|
||||||
async with KyooClient() as kyoo, Subscriber() as sub:
|
async with KyooClient() as kyoo, Subscriber() as sub:
|
||||||
provider, xem = Provider.get_all(kyoo.client)
|
provider = Provider.get_default(kyoo.client)
|
||||||
scanner = Matcher(kyoo, provider, xem)
|
scanner = Matcher(kyoo, provider)
|
||||||
await sub.listen(scanner)
|
await sub.listen(scanner)
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
import asyncio
|
import asyncio
|
||||||
from logging import getLogger
|
from logging import getLogger
|
||||||
from providers.implementations.thexem import TheXem
|
|
||||||
from providers.provider import Provider, ProviderError
|
from providers.provider import Provider, ProviderError
|
||||||
from providers.types.collection import Collection
|
from providers.types.collection import Collection
|
||||||
from providers.types.show import Show
|
from providers.types.show import Show
|
||||||
@ -15,10 +14,9 @@ logger = getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
class Matcher:
|
class Matcher:
|
||||||
def __init__(self, client: KyooClient, provider: Provider, xem: TheXem) -> None:
|
def __init__(self, client: KyooClient, provider: Provider) -> None:
|
||||||
self._client = client
|
self._client = client
|
||||||
self._provider = provider
|
self._provider = provider
|
||||||
self._xem = xem
|
|
||||||
|
|
||||||
self._collection_cache = {}
|
self._collection_cache = {}
|
||||||
self._show_cache = {}
|
self._show_cache = {}
|
||||||
@ -48,7 +46,7 @@ class Matcher:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
async def _identify(self, path: str):
|
async def _identify(self, path: str):
|
||||||
raw = guessit(path, xem_titles=await self._xem.get_expected_titles())
|
raw = guessit(path, xem_titles=await self._provider.get_expected_titles())
|
||||||
|
|
||||||
if "mimetype" not in raw or not raw["mimetype"].startswith("video"):
|
if "mimetype" not in raw or not raw["mimetype"].startswith("video"):
|
||||||
return
|
return
|
||||||
@ -68,7 +66,7 @@ class Matcher:
|
|||||||
logger.info("Identied %s: %s", path, raw)
|
logger.info("Identied %s: %s", path, raw)
|
||||||
|
|
||||||
if raw["type"] == "movie":
|
if raw["type"] == "movie":
|
||||||
movie = await self._provider.identify_movie(raw["title"], raw.get("year"))
|
movie = await self._provider.search_movie(raw["title"], raw.get("year"))
|
||||||
movie.path = str(path)
|
movie.path = str(path)
|
||||||
logger.debug("Got movie: %s", movie)
|
logger.debug("Got movie: %s", movie)
|
||||||
movie_id = await self._client.post("movies", data=movie.to_kyoo())
|
movie_id = await self._client.post("movies", data=movie.to_kyoo())
|
||||||
@ -81,7 +79,7 @@ class Matcher:
|
|||||||
*(self._client.link_collection(x, "movie", movie_id) for x in ids)
|
*(self._client.link_collection(x, "movie", movie_id) for x in ids)
|
||||||
)
|
)
|
||||||
elif raw["type"] == "episode":
|
elif raw["type"] == "episode":
|
||||||
episode = await self._provider.identify_episode(
|
episode = await self._provider.search_episode(
|
||||||
raw["title"],
|
raw["title"],
|
||||||
season=raw.get("season"),
|
season=raw.get("season"),
|
||||||
episode_nbr=raw.get("episode"),
|
episode_nbr=raw.get("episode"),
|
||||||
|
@ -35,14 +35,14 @@ def guessit(name: str, *, xem_titles: List[str] = []):
|
|||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import sys
|
import sys
|
||||||
import json
|
import json
|
||||||
from providers.implementations.thexem import TheXem
|
from providers.implementations.thexem import TheXemClient
|
||||||
from guessit.jsonutils import GuessitEncoder
|
from guessit.jsonutils import GuessitEncoder
|
||||||
from aiohttp import ClientSession
|
from aiohttp import ClientSession
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
async def main():
|
async def main():
|
||||||
async with ClientSession() as client:
|
async with ClientSession() as client:
|
||||||
xem = TheXem(client)
|
xem = TheXemClient(client)
|
||||||
|
|
||||||
ret = guessit(sys.argv[1], xem_titles=await xem.get_expected_titles())
|
ret = guessit(sys.argv[1], xem_titles=await xem.get_expected_titles())
|
||||||
print(json.dumps(ret, cls=GuessitEncoder, indent=4))
|
print(json.dumps(ret, cls=GuessitEncoder, indent=4))
|
||||||
|
@ -1,32 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from providers.implementations.themoviedatabase import TheMovieDatabase
|
|
||||||
|
|
||||||
from typing import List, Optional
|
|
||||||
from providers.types.metadataid import MetadataID
|
|
||||||
|
|
||||||
|
|
||||||
class IdMapper:
|
|
||||||
def init(self, *, language: str, tmdb: Optional[TheMovieDatabase]):
|
|
||||||
self.language = language
|
|
||||||
self._tmdb = tmdb
|
|
||||||
|
|
||||||
async def get_show(
|
|
||||||
self, show: dict[str, MetadataID], *, required: Optional[List[str]] = None
|
|
||||||
):
|
|
||||||
ids = show
|
|
||||||
|
|
||||||
# Only fetch using tmdb if one of the required ids is not already known.
|
|
||||||
should_fetch = required is not None and any((x not in ids for x in required))
|
|
||||||
if self._tmdb and self._tmdb.name in ids and should_fetch:
|
|
||||||
tmdb_info = await self._tmdb.identify_show(ids[self._tmdb.name].data_id)
|
|
||||||
return {**ids, **tmdb_info.external_id}
|
|
||||||
return ids
|
|
||||||
|
|
||||||
async def get_movie(
|
|
||||||
self, movie: dict[str, MetadataID], *, required: Optional[List[str]] = None
|
|
||||||
):
|
|
||||||
# TODO: actually do something here
|
|
||||||
return movie
|
|
@ -5,15 +5,13 @@ from logging import getLogger
|
|||||||
from typing import Awaitable, Callable, Dict, List, Optional, Any, TypeVar
|
from typing import Awaitable, Callable, Dict, List, Optional, Any, TypeVar
|
||||||
from itertools import accumulate, zip_longest
|
from itertools import accumulate, zip_longest
|
||||||
|
|
||||||
from providers.idmapper import IdMapper
|
|
||||||
from providers.implementations.thexem import TheXem
|
|
||||||
from providers.utils import ProviderError
|
from providers.utils import ProviderError
|
||||||
from matcher.cache import cache
|
from matcher.cache import cache
|
||||||
|
|
||||||
from ..provider import Provider
|
from ..provider import Provider
|
||||||
from ..types.movie import Movie, MovieTranslation, Status as MovieStatus
|
from ..types.movie import Movie, MovieTranslation, Status as MovieStatus
|
||||||
from ..types.season import Season, SeasonTranslation
|
from ..types.season import Season, SeasonTranslation
|
||||||
from ..types.episode import Episode, EpisodeTranslation, PartialShow
|
from ..types.episode import Episode, EpisodeTranslation, PartialShow, EpisodeID
|
||||||
from ..types.studio import Studio
|
from ..types.studio import Studio
|
||||||
from ..types.genre import Genre
|
from ..types.genre import Genre
|
||||||
from ..types.metadataid import MetadataID
|
from ..types.metadataid import MetadataID
|
||||||
@ -29,14 +27,10 @@ class TheMovieDatabase(Provider):
|
|||||||
languages: list[str],
|
languages: list[str],
|
||||||
client: ClientSession,
|
client: ClientSession,
|
||||||
api_key: str,
|
api_key: str,
|
||||||
xem: TheXem,
|
|
||||||
idmapper: IdMapper,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self._languages = languages
|
self._languages = languages
|
||||||
self._client = client
|
self._client = client
|
||||||
self._xem = xem
|
|
||||||
self._idmapper = idmapper
|
|
||||||
self.base = "https://api.themoviedb.org/3"
|
self.base = "https://api.themoviedb.org/3"
|
||||||
self.api_key = api_key
|
self.api_key = api_key
|
||||||
self.genre_map = {
|
self.genre_map = {
|
||||||
@ -142,15 +136,17 @@ class TheMovieDatabase(Provider):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
async def identify_movie(self, name: str, year: Optional[int]) -> Movie:
|
async def search_movie(self, name: str, year: Optional[int]) -> Movie:
|
||||||
search_results = (
|
search_results = (
|
||||||
await self.get("search/movie", params={"query": name, "year": year})
|
await self.get("search/movie", params={"query": name, "year": year})
|
||||||
)["results"]
|
)["results"]
|
||||||
if len(search_results) == 0:
|
if len(search_results) == 0:
|
||||||
raise ProviderError(f"No result for a movie named: {name}")
|
raise ProviderError(f"No result for a movie named: {name}")
|
||||||
search = self.get_best_result(search_results, name, year)
|
search = self.get_best_result(search_results, name, year)
|
||||||
movie_id = search["id"]
|
return await self.identify_movie(search["id"])
|
||||||
languages = self.get_languages(search["original_language"])
|
|
||||||
|
async def identify_movie(self, movie_id: str) -> Movie:
|
||||||
|
languages = self.get_languages()
|
||||||
|
|
||||||
async def for_language(lng: str) -> Movie:
|
async def for_language(lng: str) -> Movie:
|
||||||
movie = await self.get(
|
movie = await self.get(
|
||||||
@ -216,7 +212,7 @@ class TheMovieDatabase(Provider):
|
|||||||
movie["images"]["posters"]
|
movie["images"]["posters"]
|
||||||
+ (
|
+ (
|
||||||
[{"file_path": movie["poster_path"]}]
|
[{"file_path": movie["poster_path"]}]
|
||||||
if lng == search["original_language"]
|
if lng == movie["original_language"]
|
||||||
else []
|
else []
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
@ -225,7 +221,7 @@ class TheMovieDatabase(Provider):
|
|||||||
movie["images"]["backdrops"]
|
movie["images"]["backdrops"]
|
||||||
+ (
|
+ (
|
||||||
[{"file_path": movie["backdrop_path"]}]
|
[{"file_path": movie["backdrop_path"]}]
|
||||||
if lng == search["original_language"]
|
if lng == movie["original_language"]
|
||||||
else []
|
else []
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
@ -239,8 +235,13 @@ class TheMovieDatabase(Provider):
|
|||||||
return ret
|
return ret
|
||||||
|
|
||||||
ret = await self.process_translations(for_language, languages)
|
ret = await self.process_translations(for_language, languages)
|
||||||
# If we have more external_ids freely available, add them.
|
if (
|
||||||
ret.external_id = await self._idmapper.get_movie(ret.external_id)
|
ret.original_language is not None
|
||||||
|
and ret.original_language not in ret.translations
|
||||||
|
):
|
||||||
|
ret.translations[ret.original_language] = (
|
||||||
|
await for_language(ret.original_language)
|
||||||
|
).translations[ret.original_language]
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
@cache(ttl=timedelta(days=1))
|
@cache(ttl=timedelta(days=1))
|
||||||
@ -362,8 +363,6 @@ class TheMovieDatabase(Provider):
|
|||||||
ret.translations[ret.original_language] = (
|
ret.translations[ret.original_language] = (
|
||||||
await for_language(ret.original_language)
|
await for_language(ret.original_language)
|
||||||
).translations[ret.original_language]
|
).translations[ret.original_language]
|
||||||
# If we have more external_ids freely available, add them.
|
|
||||||
ret.external_id = await self._idmapper.get_show(ret.external_id)
|
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
def to_season(
|
def to_season(
|
||||||
@ -396,13 +395,13 @@ class TheMovieDatabase(Provider):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
async def identify_season(self, show_id: str, season_number: int) -> Season:
|
async def identify_season(self, show_id: str, season: int) -> Season:
|
||||||
# We already get seasons info in the identify_show and chances are this gets cached already
|
# We already get seasons info in the identify_show and chances are this gets cached already
|
||||||
show = await self.identify_show(show_id)
|
show = await self.identify_show(show_id)
|
||||||
ret = next((x for x in show.seasons if x.season_number == season_number), None)
|
ret = next((x for x in show.seasons if x.season_number == season), None)
|
||||||
if ret is None:
|
if ret is None:
|
||||||
raise ProviderError(
|
raise ProviderError(
|
||||||
f"Could not find season {season_number} for show {show.to_kyoo()['name']}"
|
f"Could not find season {season} for show {show.to_kyoo()['name']}"
|
||||||
)
|
)
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
@ -413,29 +412,7 @@ class TheMovieDatabase(Provider):
|
|||||||
)["results"]
|
)["results"]
|
||||||
|
|
||||||
if len(search_results) == 0:
|
if len(search_results) == 0:
|
||||||
(new_name, tvdbid) = await self._xem.get_show_override("tvdb", name)
|
raise ProviderError(f"No result for a tv show named: {name}")
|
||||||
if new_name is None or tvdbid is None or name.lower() == new_name.lower():
|
|
||||||
raise ProviderError(f"No result for a tv show named: {name}")
|
|
||||||
ret = PartialShow(
|
|
||||||
name=new_name,
|
|
||||||
original_language=None,
|
|
||||||
external_id={
|
|
||||||
"tvdb": MetadataID(tvdbid, link=None),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
ret.external_id = await self._idmapper.get_show(
|
|
||||||
ret.external_id, required=[self.name]
|
|
||||||
)
|
|
||||||
|
|
||||||
if self.name in ret.external_id:
|
|
||||||
return ret
|
|
||||||
logger.warn(
|
|
||||||
"Could not map xem exception to themoviedb, searching instead for %s",
|
|
||||||
new_name,
|
|
||||||
)
|
|
||||||
nret = await self.search_show(new_name, year)
|
|
||||||
nret.external_id = {**ret.external_id, **nret.external_id}
|
|
||||||
return nret
|
|
||||||
|
|
||||||
search = self.get_best_result(search_results, name, year)
|
search = self.get_best_result(search_results, name, year)
|
||||||
show_id = search["id"]
|
show_id = search["id"]
|
||||||
@ -449,7 +426,7 @@ class TheMovieDatabase(Provider):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
async def identify_episode(
|
async def search_episode(
|
||||||
self,
|
self,
|
||||||
name: str,
|
name: str,
|
||||||
season: Optional[int],
|
season: Optional[int],
|
||||||
@ -458,39 +435,8 @@ class TheMovieDatabase(Provider):
|
|||||||
year: Optional[int],
|
year: Optional[int],
|
||||||
) -> Episode:
|
) -> Episode:
|
||||||
show = await self.search_show(name, year)
|
show = await self.search_show(name, year)
|
||||||
languages = self.get_languages(show.original_language)
|
|
||||||
# Keep it for xem overrides of season/episode
|
|
||||||
old_name = name
|
|
||||||
name = show.name
|
|
||||||
show_id = show.external_id[self.name].data_id
|
show_id = show.external_id[self.name].data_id
|
||||||
|
|
||||||
# Handle weird season names overrides from thexem.
|
|
||||||
# For example when name is "Jojo's bizzare adventure - Stone Ocean", with season None,
|
|
||||||
# We want something like season 6 ep 3.
|
|
||||||
if season is None and absolute is not None:
|
|
||||||
ids = await self._idmapper.get_show(show.external_id, required=["tvdb"])
|
|
||||||
tvdb_id = (
|
|
||||||
ids["tvdb"].data_id
|
|
||||||
if "tvdb" in ids and ids["tvdb"] is not None
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
if tvdb_id is None:
|
|
||||||
logger.info(
|
|
||||||
"Tvdb could not be found, trying xem name lookup for %s", name
|
|
||||||
)
|
|
||||||
_, tvdb_id = await self._xem.get_show_override("tvdb", old_name)
|
|
||||||
if tvdb_id is not None:
|
|
||||||
(
|
|
||||||
tvdb_season,
|
|
||||||
tvdb_episode,
|
|
||||||
absolute,
|
|
||||||
) = await self._xem.get_episode_override(
|
|
||||||
"tvdb", tvdb_id, old_name, absolute
|
|
||||||
)
|
|
||||||
# Most of the time, tvdb absolute and tmdb absolute are in think so we use that as our souce of truth.
|
|
||||||
# tvdb_season/episode are not in sync with tmdb so we discard those and use our usual absolute order fetching.
|
|
||||||
(_, _) = tvdb_season, tvdb_episode
|
|
||||||
|
|
||||||
if absolute is not None and (season is None or episode_nbr is None):
|
if absolute is not None and (season is None or episode_nbr is None):
|
||||||
(season, episode_nbr) = await self.get_episode_from_absolute(
|
(season, episode_nbr) = await self.get_episode_from_absolute(
|
||||||
show_id, absolute
|
show_id, absolute
|
||||||
@ -498,12 +444,16 @@ class TheMovieDatabase(Provider):
|
|||||||
|
|
||||||
if season is None or episode_nbr is None:
|
if season is None or episode_nbr is None:
|
||||||
raise ProviderError(
|
raise ProviderError(
|
||||||
f"Could not guess season or episode number of the episode {name} {season}-{episode_nbr} ({absolute})",
|
f"Could not guess season or episode number of the episode {show.name} {season}-{episode_nbr} ({absolute})",
|
||||||
)
|
)
|
||||||
|
|
||||||
if absolute is None:
|
if absolute is None:
|
||||||
absolute = await self.get_absolute_number(show_id, season, episode_nbr)
|
absolute = await self.get_absolute_number(show_id, season, episode_nbr)
|
||||||
|
return await self.identify_episode(show_id, season, episode_nbr, absolute)
|
||||||
|
|
||||||
|
async def identify_episode(
|
||||||
|
self, show_id: str, season: Optional[int], episode_nbr: int, absolute: int
|
||||||
|
) -> Episode:
|
||||||
async def for_language(lng: str) -> Episode:
|
async def for_language(lng: str) -> Episode:
|
||||||
try:
|
try:
|
||||||
episode = await self.get(
|
episode = await self.get(
|
||||||
@ -518,12 +468,20 @@ class TheMovieDatabase(Provider):
|
|||||||
params={
|
params={
|
||||||
"language": lng,
|
"language": lng,
|
||||||
},
|
},
|
||||||
not_found_fail=f"Could not find episode {episode_nbr} of season {season} of serie {name} (absolute: {absolute})",
|
not_found_fail=f"Could not find episode {episode_nbr} of season {season} of serie {show_id} (absolute: {absolute})",
|
||||||
)
|
)
|
||||||
logger.debug("TMDb responded: %s", episode)
|
logger.debug("TMDb responded: %s", episode)
|
||||||
|
|
||||||
ret = Episode(
|
ret = Episode(
|
||||||
show=show,
|
show=PartialShow(
|
||||||
|
name=show_id,
|
||||||
|
original_language=None,
|
||||||
|
external_id={
|
||||||
|
self.name: MetadataID(
|
||||||
|
show_id, f"https://www.themoviedb.org/tv/{show_id}"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
season_number=episode["season_number"],
|
season_number=episode["season_number"],
|
||||||
episode_number=episode["episode_number"],
|
episode_number=episode["episode_number"],
|
||||||
absolute_number=absolute,
|
absolute_number=absolute,
|
||||||
@ -537,8 +495,10 @@ class TheMovieDatabase(Provider):
|
|||||||
if "still_path" in episode and episode["still_path"] is not None
|
if "still_path" in episode and episode["still_path"] is not None
|
||||||
else None,
|
else None,
|
||||||
external_id={
|
external_id={
|
||||||
self.name: MetadataID(
|
self.name: EpisodeID(
|
||||||
episode["id"],
|
show_id,
|
||||||
|
episode["season_number"],
|
||||||
|
episode["episode_number"],
|
||||||
f"https://www.themoviedb.org/tv/{show_id}/season/{episode['season_number']}/episode/{episode['episode_number']}",
|
f"https://www.themoviedb.org/tv/{show_id}/season/{episode['season_number']}/episode/{episode['episode_number']}",
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@ -550,7 +510,7 @@ class TheMovieDatabase(Provider):
|
|||||||
ret.translations = {lng: translation}
|
ret.translations = {lng: translation}
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
return await self.process_translations(for_language, languages)
|
return await self.process_translations(for_language, self.get_languages())
|
||||||
|
|
||||||
def get_best_result(
|
def get_best_result(
|
||||||
self, search_results: List[Any], name: str, year: Optional[int]
|
self, search_results: List[Any], name: str, year: Optional[int]
|
||||||
@ -654,7 +614,9 @@ class TheMovieDatabase(Provider):
|
|||||||
(seasons_nbrs[0], absolute),
|
(seasons_nbrs[0], absolute),
|
||||||
)
|
)
|
||||||
|
|
||||||
async def get_absolute_number(self, show_id: str, season: int, episode_nbr: int):
|
async def get_absolute_number(
|
||||||
|
self, show_id: str, season: int, episode_nbr: int
|
||||||
|
) -> int:
|
||||||
absgrp = await self.get_absolute_order(show_id)
|
absgrp = await self.get_absolute_order(show_id)
|
||||||
if absgrp is None:
|
if absgrp is None:
|
||||||
# We assume that each season should be played in order with no special episodes.
|
# We assume that each season should be played in order with no special episodes.
|
||||||
@ -684,7 +646,9 @@ class TheMovieDatabase(Provider):
|
|||||||
(x["episode_number"] for x in absgrp if x["season_number"] == season), None
|
(x["episode_number"] for x in absgrp if x["season_number"] == season), None
|
||||||
)
|
)
|
||||||
if start is None or start <= episode_nbr:
|
if start is None or start <= episode_nbr:
|
||||||
return None
|
raise ProviderError(
|
||||||
|
f"Could not guess absolute number of episode {show_id} s{season} e{episode_nbr}"
|
||||||
|
)
|
||||||
# add back the continuous number (imagine the user has one piece S21e31
|
# add back the continuous number (imagine the user has one piece S21e31
|
||||||
# but tmdb registered it as S21E831 since S21's first ep is 800
|
# but tmdb registered it as S21E831 since S21's first ep is 800
|
||||||
return await self.get_absolute_number(show_id, season, episode_nbr + start)
|
return await self.get_absolute_number(show_id, season, episode_nbr + start)
|
||||||
|
@ -3,8 +3,15 @@ from typing import Dict, List, Literal
|
|||||||
from aiohttp import ClientSession
|
from aiohttp import ClientSession
|
||||||
from logging import getLogger
|
from logging import getLogger
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from providers.utils import ProviderError
|
from ..provider import Provider
|
||||||
|
from ..utils import ProviderError
|
||||||
|
from ..types.collection import Collection
|
||||||
|
from ..types.movie import Movie
|
||||||
|
from ..types.show import Show
|
||||||
|
from ..types.season import Season
|
||||||
|
from ..types.episode import Episode
|
||||||
from matcher.cache import cache
|
from matcher.cache import cache
|
||||||
|
|
||||||
logger = getLogger(__name__)
|
logger = getLogger(__name__)
|
||||||
@ -21,7 +28,7 @@ def clean(s: str):
|
|||||||
return s
|
return s
|
||||||
|
|
||||||
|
|
||||||
class TheXem:
|
class TheXemClient:
|
||||||
def __init__(self, client: ClientSession) -> None:
|
def __init__(self, client: ClientSession) -> None:
|
||||||
self._client = client
|
self._client = client
|
||||||
self.base = "https://thexem.info"
|
self.base = "https://thexem.info"
|
||||||
@ -155,3 +162,77 @@ class TheXem:
|
|||||||
for y in x[1:]:
|
for y in x[1:]:
|
||||||
titles.extend(clean(name) for name in y.keys())
|
titles.extend(clean(name) for name in y.keys())
|
||||||
return titles
|
return titles
|
||||||
|
|
||||||
|
|
||||||
|
class TheXem(Provider):
|
||||||
|
def __init__(self, client: ClientSession, base: Provider) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self._client = TheXemClient(client)
|
||||||
|
self._base = base
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
# Use the base name for id lookup on the matcher.
|
||||||
|
return self._base.name
|
||||||
|
|
||||||
|
async def get_expected_titles(self) -> list[str]:
|
||||||
|
return await self._client.get_expected_titles()
|
||||||
|
|
||||||
|
async def search_movie(self, name: str, year: Optional[int]) -> Movie:
|
||||||
|
return await self._base.search_movie(name, year)
|
||||||
|
|
||||||
|
async def search_episode(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
season: Optional[int],
|
||||||
|
episode_nbr: Optional[int],
|
||||||
|
absolute: Optional[int],
|
||||||
|
year: Optional[int],
|
||||||
|
) -> Episode:
|
||||||
|
"""
|
||||||
|
Handle weird season names overrides from thexem.
|
||||||
|
For example when name is "Jojo's bizzare adventure - Stone Ocean", with season None,
|
||||||
|
We want something like season 6 ep 3.
|
||||||
|
"""
|
||||||
|
new_name, tvdb_id = await self._client.get_show_override("tvdb", name)
|
||||||
|
|
||||||
|
if new_name is None:
|
||||||
|
return await self._base.search_episode(
|
||||||
|
name, season, episode_nbr, absolute, year
|
||||||
|
)
|
||||||
|
|
||||||
|
if season is None and absolute is not None:
|
||||||
|
if tvdb_id is not None:
|
||||||
|
(
|
||||||
|
tvdb_season,
|
||||||
|
tvdb_episode,
|
||||||
|
absolute,
|
||||||
|
) = await self._client.get_episode_override(
|
||||||
|
"tvdb", tvdb_id, name, absolute
|
||||||
|
)
|
||||||
|
# Most of the time, tvdb absolute and tmdb absolute are in sync so we use that as our souce of truth.
|
||||||
|
# tvdb_season/episode are not in sync with tmdb so we discard those and use our usual absolute order fetching.
|
||||||
|
if self._base == "tvdb":
|
||||||
|
return await self._base.search_episode(
|
||||||
|
new_name, tvdb_season, tvdb_episode, absolute, year
|
||||||
|
)
|
||||||
|
return await self._base.search_episode(
|
||||||
|
new_name, season, episode_nbr, absolute, year
|
||||||
|
)
|
||||||
|
|
||||||
|
async def identify_movie(self, movie_id: str) -> Movie:
|
||||||
|
return await self._base.identify_movie(movie_id)
|
||||||
|
|
||||||
|
async def identify_show(self, show_id: str) -> Show:
|
||||||
|
return await self._base.identify_show(show_id)
|
||||||
|
|
||||||
|
async def identify_season(self, show_id: str, season: int) -> Season:
|
||||||
|
return await self._base.identify_season(show_id, season)
|
||||||
|
|
||||||
|
async def identify_episode(
|
||||||
|
self, show_id: str, season: Optional[int], episode_nbr: int, absolute: int
|
||||||
|
) -> Episode:
|
||||||
|
return await self._base.identify_episode(show_id, season, episode_nbr, absolute)
|
||||||
|
|
||||||
|
async def identify_collection(self, provider_id: str) -> Collection:
|
||||||
|
return await self._base.identify_collection(provider_id)
|
||||||
|
@ -1,9 +1,8 @@
|
|||||||
import os
|
import os
|
||||||
from aiohttp import ClientSession
|
from aiohttp import ClientSession
|
||||||
from abc import abstractmethod, abstractproperty
|
from abc import abstractmethod, abstractproperty
|
||||||
from typing import Optional, Self
|
from typing import Optional
|
||||||
|
|
||||||
from providers.implementations.thexem import TheXem
|
|
||||||
from providers.utils import ProviderError
|
from providers.utils import ProviderError
|
||||||
|
|
||||||
from .types.show import Show
|
from .types.show import Show
|
||||||
@ -15,7 +14,7 @@ from .types.collection import Collection
|
|||||||
|
|
||||||
class Provider:
|
class Provider:
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_all(cls, client: ClientSession) -> tuple[Self, TheXem]:
|
def get_default(cls, client: ClientSession):
|
||||||
languages = os.environ.get("LIBRARY_LANGUAGES")
|
languages = os.environ.get("LIBRARY_LANGUAGES")
|
||||||
if not languages:
|
if not languages:
|
||||||
print("Missing environment variable 'LIBRARY_LANGUAGES'.")
|
print("Missing environment variable 'LIBRARY_LANGUAGES'.")
|
||||||
@ -23,47 +22,33 @@ class Provider:
|
|||||||
languages = languages.split(",")
|
languages = languages.split(",")
|
||||||
providers = []
|
providers = []
|
||||||
|
|
||||||
from providers.idmapper import IdMapper
|
|
||||||
|
|
||||||
idmapper = IdMapper()
|
|
||||||
xem = TheXem(client)
|
|
||||||
|
|
||||||
from providers.implementations.themoviedatabase import TheMovieDatabase
|
from providers.implementations.themoviedatabase import TheMovieDatabase
|
||||||
|
|
||||||
tmdb = os.environ.get("THEMOVIEDB_APIKEY")
|
tmdb = os.environ.get("THEMOVIEDB_APIKEY")
|
||||||
if tmdb:
|
if tmdb:
|
||||||
tmdb = TheMovieDatabase(languages, client, tmdb, xem, idmapper)
|
tmdb = TheMovieDatabase(languages, client, tmdb)
|
||||||
providers.append(tmdb)
|
providers.append(tmdb)
|
||||||
else:
|
|
||||||
tmdb = None
|
|
||||||
|
|
||||||
if not any(providers):
|
if not any(providers):
|
||||||
raise ProviderError(
|
raise ProviderError(
|
||||||
"No provider configured. You probably forgot to specify an API Key"
|
"No provider configured. You probably forgot to specify an API Key"
|
||||||
)
|
)
|
||||||
|
|
||||||
idmapper.init(tmdb=tmdb, language=languages[0])
|
from providers.implementations.thexem import TheXem
|
||||||
|
|
||||||
return next(iter(providers)), xem
|
provider = next(iter(providers))
|
||||||
|
return TheXem(client, provider)
|
||||||
|
|
||||||
@abstractproperty
|
@abstractproperty
|
||||||
def name(self) -> str:
|
def name(self) -> str:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def identify_movie(self, name: str, year: Optional[int]) -> Movie:
|
async def search_movie(self, name: str, year: Optional[int]) -> Movie:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def identify_show(self, show_id: str) -> Show:
|
async def search_episode(
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
async def identify_season(self, show_id: str, season_number: int) -> Season:
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
async def identify_episode(
|
|
||||||
self,
|
self,
|
||||||
name: str,
|
name: str,
|
||||||
season: Optional[int],
|
season: Optional[int],
|
||||||
@ -73,6 +58,28 @@ class Provider:
|
|||||||
) -> Episode:
|
) -> Episode:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def identify_movie(self, movie_id: str) -> Movie:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def identify_show(self, show_id: str) -> Show:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def identify_season(self, show_id: str, season: int) -> Season:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def identify_episode(
|
||||||
|
self, show_id: str, season: Optional[int], episode_nbr: int, absolute: int
|
||||||
|
) -> Episode:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def identify_collection(self, provider_id: str) -> Collection:
|
async def identify_collection(self, provider_id: str) -> Collection:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_expected_titles(self) -> list[str]:
|
||||||
|
return []
|
||||||
|
@ -14,6 +14,14 @@ class PartialShow:
|
|||||||
external_id: dict[str, MetadataID]
|
external_id: dict[str, MetadataID]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class EpisodeID:
|
||||||
|
show_id: str
|
||||||
|
season: Optional[int]
|
||||||
|
episode: int
|
||||||
|
link: str
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class EpisodeTranslation:
|
class EpisodeTranslation:
|
||||||
name: str
|
name: str
|
||||||
@ -23,13 +31,13 @@ class EpisodeTranslation:
|
|||||||
@dataclass
|
@dataclass
|
||||||
class Episode:
|
class Episode:
|
||||||
show: Show | PartialShow
|
show: Show | PartialShow
|
||||||
season_number: Optional[int]
|
season_number: int
|
||||||
episode_number: Optional[int]
|
episode_number: int
|
||||||
absolute_number: Optional[int]
|
absolute_number: int
|
||||||
runtime: Optional[int]
|
runtime: Optional[int]
|
||||||
release_date: Optional[date | int]
|
release_date: Optional[date | int]
|
||||||
thumbnail: Optional[str]
|
thumbnail: Optional[str]
|
||||||
external_id: dict[str, MetadataID]
|
external_id: dict[str, EpisodeID]
|
||||||
|
|
||||||
path: Optional[str] = None
|
path: Optional[str] = None
|
||||||
show_id: Optional[str] = None
|
show_id: Optional[str] = None
|
||||||
|
@ -59,4 +59,4 @@ ENV NVIDIA_VISIBLE_DEVICES="all"
|
|||||||
ENV NVIDIA_DRIVER_CAPABILITIES="all"
|
ENV NVIDIA_DRIVER_CAPABILITIES="all"
|
||||||
|
|
||||||
EXPOSE 7666
|
EXPOSE 7666
|
||||||
CMD ./transcoder
|
CMD ["./transcoder"]
|
||||||
|
@ -47,4 +47,4 @@ ENV NVIDIA_VISIBLE_DEVICES="all"
|
|||||||
ENV NVIDIA_DRIVER_CAPABILITIES="all"
|
ENV NVIDIA_DRIVER_CAPABILITIES="all"
|
||||||
|
|
||||||
EXPOSE 7666
|
EXPOSE 7666
|
||||||
CMD wgo run -race .
|
CMD ["wgo", "run", "-race", "."]
|
||||||
|
Loading…
x
Reference in New Issue
Block a user