+122
@@ -0,0 +1,122 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT m.id AS \"movie_id!: i64\", m.tmdb_id AS \"tmdb_id!: i64\",\n m.title AS \"title!: String\", m.year, m.poster_path,\n mf.id AS \"media_file_id!: i64\", sa.language AS \"language!: String\",\n sa.state AS \"state!: arr_db::SubtitleState\", sa.last_failure\n FROM subtitle_attempts sa\n JOIN media_files mf ON mf.id = sa.media_file_id AND mf.owner_kind = 'movie'\n JOIN movies m ON m.id = mf.owner_id\n WHERE sa.state IN ('failed', 'capped', 'unavailable')\n ORDER BY m.title, sa.language",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "movie_id!: i64",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "movies",
|
||||
"name": "id"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "tmdb_id!: i64",
|
||||
"ordinal": 1,
|
||||
"type_info": "Integer",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "movies",
|
||||
"name": "tmdb_id"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "title!: String",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "movies",
|
||||
"name": "title"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "year",
|
||||
"ordinal": 3,
|
||||
"type_info": "Integer",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "movies",
|
||||
"name": "year"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "poster_path",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "movies",
|
||||
"name": "poster_path"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "media_file_id!: i64",
|
||||
"ordinal": 5,
|
||||
"type_info": "Integer",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "media_files",
|
||||
"name": "id"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "language!: String",
|
||||
"ordinal": 6,
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "subtitle_attempts",
|
||||
"name": "language"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "state!: arr_db::SubtitleState",
|
||||
"ordinal": 7,
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "subtitle_attempts",
|
||||
"name": "state"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "last_failure",
|
||||
"ordinal": 8,
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "subtitle_attempts",
|
||||
"name": "last_failure"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "2b67abe4db9e00517fecfa53fbad5bbbc633a9f537eaaaa9d46a445d8c25eb2c"
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT m.id AS \"movie_id!: i64\", m.tmdb_id AS \"tmdb_id!: i64\",\n m.title AS \"title!: String\", m.year, m.poster_path,\n mf.id AS \"media_file_id!: i64\", sf.language AS \"language!: String\"\n FROM subtitle_files sf\n JOIN media_files mf ON mf.id = sf.media_file_id AND mf.owner_kind = 'movie'\n JOIN movies m ON m.id = mf.owner_id\n WHERE sf.sync_rejected = 1\n ORDER BY m.title, sf.language",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "movie_id!: i64",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "movies",
|
||||
"name": "id"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "tmdb_id!: i64",
|
||||
"ordinal": 1,
|
||||
"type_info": "Integer",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "movies",
|
||||
"name": "tmdb_id"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "title!: String",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "movies",
|
||||
"name": "title"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "year",
|
||||
"ordinal": 3,
|
||||
"type_info": "Integer",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "movies",
|
||||
"name": "year"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "poster_path",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "movies",
|
||||
"name": "poster_path"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "media_file_id!: i64",
|
||||
"ordinal": 5,
|
||||
"type_info": "Integer",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "media_files",
|
||||
"name": "id"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "language!: String",
|
||||
"ordinal": 6,
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "subtitle_files",
|
||||
"name": "language"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "69f34468a61979b85b7513056d58e34c743af3ae62d5327d4dea49fe7586a85b"
|
||||
}
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year, s.poster_path,\n e.id AS \"episode_id!: i64\", se.number AS \"season_number!: i64\",\n e.number AS \"episode_number!: i64\", mf.id AS \"media_file_id!: i64\",\n sa.language AS \"language!: String\",\n sa.state AS \"state!: arr_db::SubtitleState\", sa.last_failure\n FROM subtitle_attempts sa\n JOIN media_files mf ON mf.id = sa.media_file_id AND mf.owner_kind = 'episode'\n JOIN episodes e ON e.id = mf.owner_id\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE sa.state IN ('failed', 'capped', 'unavailable')\n ORDER BY s.title, se.number, e.number, sa.language",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "series_id!: i64",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "series",
|
||||
"name": "id"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "tmdb_id!: i64",
|
||||
"ordinal": 1,
|
||||
"type_info": "Integer",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "series",
|
||||
"name": "tmdb_id"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "title!: String",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "series",
|
||||
"name": "title"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "year",
|
||||
"ordinal": 3,
|
||||
"type_info": "Integer",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "series",
|
||||
"name": "year"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "poster_path",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "series",
|
||||
"name": "poster_path"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "episode_id!: i64",
|
||||
"ordinal": 5,
|
||||
"type_info": "Integer",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "episodes",
|
||||
"name": "id"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "season_number!: i64",
|
||||
"ordinal": 6,
|
||||
"type_info": "Integer",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "seasons",
|
||||
"name": "number"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "episode_number!: i64",
|
||||
"ordinal": 7,
|
||||
"type_info": "Integer",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "episodes",
|
||||
"name": "number"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "media_file_id!: i64",
|
||||
"ordinal": 8,
|
||||
"type_info": "Integer",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "media_files",
|
||||
"name": "id"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "language!: String",
|
||||
"ordinal": 9,
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "subtitle_attempts",
|
||||
"name": "language"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "state!: arr_db::SubtitleState",
|
||||
"ordinal": 10,
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "subtitle_attempts",
|
||||
"name": "state"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "last_failure",
|
||||
"ordinal": 11,
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "subtitle_attempts",
|
||||
"name": "last_failure"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "6cdb9a9344bbab557433da35471968b6e5cf96b5b220ffb41f10b6d851884803"
|
||||
}
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year, s.poster_path,\n e.id AS \"episode_id!: i64\", se.number AS \"season_number!: i64\",\n e.number AS \"episode_number!: i64\", mf.id AS \"media_file_id!: i64\",\n sf.language AS \"language!: String\"\n FROM subtitle_files sf\n JOIN media_files mf ON mf.id = sf.media_file_id AND mf.owner_kind = 'episode'\n JOIN episodes e ON e.id = mf.owner_id\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE sf.sync_rejected = 1\n ORDER BY s.title, se.number, e.number, sf.language",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "series_id!: i64",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "series",
|
||||
"name": "id"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "tmdb_id!: i64",
|
||||
"ordinal": 1,
|
||||
"type_info": "Integer",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "series",
|
||||
"name": "tmdb_id"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "title!: String",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "series",
|
||||
"name": "title"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "year",
|
||||
"ordinal": 3,
|
||||
"type_info": "Integer",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "series",
|
||||
"name": "year"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "poster_path",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "series",
|
||||
"name": "poster_path"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "episode_id!: i64",
|
||||
"ordinal": 5,
|
||||
"type_info": "Integer",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "episodes",
|
||||
"name": "id"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "season_number!: i64",
|
||||
"ordinal": 6,
|
||||
"type_info": "Integer",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "seasons",
|
||||
"name": "number"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "episode_number!: i64",
|
||||
"ordinal": 7,
|
||||
"type_info": "Integer",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "episodes",
|
||||
"name": "number"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "media_file_id!: i64",
|
||||
"ordinal": 8,
|
||||
"type_info": "Integer",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "media_files",
|
||||
"name": "id"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "language!: String",
|
||||
"ordinal": 9,
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "subtitle_files",
|
||||
"name": "language"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "8543e4ad8c59e83911688ceace72f66d360240a45eee05aaf15dab233e22e7e6"
|
||||
}
|
||||
@@ -48,9 +48,10 @@ pub use state::{
|
||||
};
|
||||
pub use subtitle_settings::{SubtitleSettings, SubtitleSettingsInput};
|
||||
pub use subtitles::{
|
||||
EpisodeSubtitleStatus, MissingSubtitle, Subtitle, SubtitleCandidate, SubtitleGrabInput,
|
||||
SubtitleProviderError, SubtitleSearchInput, SubtitleSearchResults, SubtitleStatus,
|
||||
SubtitleTranslateInput,
|
||||
EpisodeSubtitleGaps, EpisodeSubtitleStatus, MissingSubtitle, MovieSubtitleGaps,
|
||||
SeasonSubtitleGaps, SeriesSubtitleGaps, Subtitle, SubtitleCandidate, SubtitleGap,
|
||||
SubtitleGrabInput, SubtitleProviderError, SubtitleQueue, SubtitleSearchInput,
|
||||
SubtitleSearchResults, SubtitleStatus, SubtitleTranslateInput,
|
||||
};
|
||||
pub use trailer::{Trailer, TrailerKind};
|
||||
|
||||
@@ -136,6 +137,7 @@ fn api_router() -> OpenApiRouter<AppState> {
|
||||
.routes(routes!(subtitles::grab))
|
||||
.routes(routes!(subtitles::translate))
|
||||
.routes(routes!(subtitles::delete))
|
||||
.routes(routes!(subtitles::queue))
|
||||
}
|
||||
|
||||
/// The generated `OpenAPI` document.
|
||||
@@ -392,6 +394,7 @@ mod tests {
|
||||
"post",
|
||||
),
|
||||
("/api/subtitles/{subtitle_id}", "delete"),
|
||||
("/api/queues/subtitles", "get"),
|
||||
] {
|
||||
assert!(
|
||||
json["paths"][path][method].is_object(),
|
||||
|
||||
@@ -173,6 +173,78 @@ pub struct EpisodeSubtitleStatus {
|
||||
pub missing: Vec<MissingSubtitle>,
|
||||
}
|
||||
|
||||
/// One wanted language a media file lacks, or a subtitle it has but `alass`
|
||||
/// flagged (§15, issue #202).
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct SubtitleGap {
|
||||
pub language: String,
|
||||
/// [`reason_of`]'s words — `no_candidates`, `capped` or `failed` — plus
|
||||
/// `sync_rejected` for a subtitle that exists but whose sync `alass`
|
||||
/// rejected (§15): not a missing language, but still something the
|
||||
/// operator did not see happen.
|
||||
pub reason: String,
|
||||
/// Set only when `reason` is `failed`.
|
||||
pub detail: Option<String>,
|
||||
}
|
||||
|
||||
/// A movie with at least one subtitle gap, for the missing-subtitles queue.
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct MovieSubtitleGaps {
|
||||
pub movie_id: i64,
|
||||
pub tmdb_id: i64,
|
||||
pub title: String,
|
||||
pub year: Option<i64>,
|
||||
pub poster_path: Option<String>,
|
||||
pub media_file_id: i64,
|
||||
pub gaps: Vec<SubtitleGap>,
|
||||
}
|
||||
|
||||
/// One episode's gaps, named for the operator the way [`QueuedEpisode`] is
|
||||
/// (`SxxEyy` comes from season and episode numbers).
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct EpisodeSubtitleGaps {
|
||||
pub episode_id: i64,
|
||||
pub media_file_id: i64,
|
||||
pub season_number: i64,
|
||||
pub episode_number: i64,
|
||||
pub gaps: Vec<SubtitleGap>,
|
||||
}
|
||||
|
||||
/// A season collapsed into one row because every one of its episodes carries
|
||||
/// the identical gap — a season-wide provider or budget failure otherwise
|
||||
/// floods the queue one row per episode, the restraint §9.5 already applies
|
||||
/// to the TV attention queues.
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct SeasonSubtitleGaps {
|
||||
pub season_number: i64,
|
||||
pub media_file_ids: Vec<i64>,
|
||||
pub gaps: Vec<SubtitleGap>,
|
||||
}
|
||||
|
||||
/// A series with at least one subtitle gap. One row per series (§9.5's
|
||||
/// restraint): episodes that did not collapse into a season stay listed
|
||||
/// individually, the rest roll up into `seasons`.
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct SeriesSubtitleGaps {
|
||||
pub series_id: i64,
|
||||
pub tmdb_id: i64,
|
||||
pub title: String,
|
||||
pub year: Option<i64>,
|
||||
pub poster_path: Option<String>,
|
||||
pub episodes: Vec<EpisodeSubtitleGaps>,
|
||||
pub seasons: Vec<SeasonSubtitleGaps>,
|
||||
}
|
||||
|
||||
/// The missing-subtitles queue (issue #202): every title with an unsatisfied
|
||||
/// wanted language and why, plus subtitles a sync rejected. Each entry's
|
||||
/// gaps are resolved with the manual actions this module already offers —
|
||||
/// search, translate, or a retried search.
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct SubtitleQueue {
|
||||
pub movies: Vec<MovieSubtitleGaps>,
|
||||
pub series: Vec<SeriesSubtitleGaps>,
|
||||
}
|
||||
|
||||
/// A provider that could not answer this search.
|
||||
///
|
||||
/// One unreachable provider does not fail the search: §15 configures two at
|
||||
@@ -1191,6 +1263,326 @@ async fn refresh_jellyfin(state: &AppState) {
|
||||
}
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/queues/subtitles", tag = "subtitles",
|
||||
responses(
|
||||
(status = 200, body = SubtitleQueue),
|
||||
(status = 500, body = ErrorBody),
|
||||
(status = 503, body = ErrorBody)
|
||||
)
|
||||
)]
|
||||
pub async fn queue(State(state): State<AppState>) -> Result<Json<SubtitleQueue>, ApiError> {
|
||||
// A language dropped from the wanted set (§15) is not a gap any more —
|
||||
// its attempt row just has not been cleaned up yet. `missing_for` (#201)
|
||||
// already applies this bound; the queue reads the same way.
|
||||
let wanted: BTreeSet<String> = wanted_languages(&state).await?.into_iter().collect();
|
||||
Ok(Json(SubtitleQueue {
|
||||
movies: movie_gaps(&state, &wanted).await?,
|
||||
series: series_gaps(&state, &wanted).await?,
|
||||
}))
|
||||
}
|
||||
|
||||
/// `subtitle_attempts.state` in [`SubtitleGap`]'s words — the same vocabulary
|
||||
/// [`reason_of`] uses for the title detail page (#201), so a gap reads the
|
||||
/// same wherever it is shown.
|
||||
fn attempt_reason(
|
||||
state: arr_db::SubtitleState,
|
||||
last_failure: Option<String>,
|
||||
) -> (String, Option<String>) {
|
||||
match state {
|
||||
arr_db::SubtitleState::Wanted | arr_db::SubtitleState::Satisfied => {
|
||||
("searching".to_owned(), None)
|
||||
}
|
||||
arr_db::SubtitleState::Unavailable => ("no_candidates".to_owned(), None),
|
||||
arr_db::SubtitleState::Capped => ("capped".to_owned(), None),
|
||||
arr_db::SubtitleState::Failed => ("failed".to_owned(), last_failure),
|
||||
}
|
||||
}
|
||||
|
||||
async fn movie_gaps(
|
||||
state: &AppState,
|
||||
wanted: &BTreeSet<String>,
|
||||
) -> Result<Vec<MovieSubtitleGaps>, ApiError> {
|
||||
let database = pool(state)?;
|
||||
let mut out: Vec<MovieSubtitleGaps> = Vec::new();
|
||||
|
||||
let attempts = sqlx::query!(
|
||||
r#"SELECT m.id AS "movie_id!: i64", m.tmdb_id AS "tmdb_id!: i64",
|
||||
m.title AS "title!: String", m.year, m.poster_path,
|
||||
mf.id AS "media_file_id!: i64", sa.language AS "language!: String",
|
||||
sa.state AS "state!: arr_db::SubtitleState", sa.last_failure
|
||||
FROM subtitle_attempts sa
|
||||
JOIN media_files mf ON mf.id = sa.media_file_id AND mf.owner_kind = 'movie'
|
||||
JOIN movies m ON m.id = mf.owner_id
|
||||
WHERE sa.state IN ('failed', 'capped', 'unavailable')
|
||||
ORDER BY m.title, sa.language"#
|
||||
)
|
||||
.fetch_all(database)
|
||||
.await?;
|
||||
for row in attempts {
|
||||
if !wanted.contains(&row.language) {
|
||||
continue;
|
||||
}
|
||||
let (reason, detail) = attempt_reason(row.state, row.last_failure);
|
||||
push_movie_gap(
|
||||
&mut out,
|
||||
row.movie_id,
|
||||
row.tmdb_id,
|
||||
&row.title,
|
||||
row.year,
|
||||
row.poster_path,
|
||||
row.media_file_id,
|
||||
SubtitleGap {
|
||||
language: row.language,
|
||||
reason,
|
||||
detail,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
let rejected = sqlx::query!(
|
||||
r#"SELECT m.id AS "movie_id!: i64", m.tmdb_id AS "tmdb_id!: i64",
|
||||
m.title AS "title!: String", m.year, m.poster_path,
|
||||
mf.id AS "media_file_id!: i64", sf.language AS "language!: String"
|
||||
FROM subtitle_files sf
|
||||
JOIN media_files mf ON mf.id = sf.media_file_id AND mf.owner_kind = 'movie'
|
||||
JOIN movies m ON m.id = mf.owner_id
|
||||
WHERE sf.sync_rejected = 1
|
||||
ORDER BY m.title, sf.language"#
|
||||
)
|
||||
.fetch_all(database)
|
||||
.await?;
|
||||
for row in rejected {
|
||||
push_movie_gap(
|
||||
&mut out,
|
||||
row.movie_id,
|
||||
row.tmdb_id,
|
||||
&row.title,
|
||||
row.year,
|
||||
row.poster_path,
|
||||
row.media_file_id,
|
||||
SubtitleGap {
|
||||
language: row.language,
|
||||
reason: "sync_rejected".to_owned(),
|
||||
detail: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn push_movie_gap(
|
||||
entries: &mut Vec<MovieSubtitleGaps>,
|
||||
movie_id: i64,
|
||||
tmdb_id: i64,
|
||||
title: &str,
|
||||
year: Option<i64>,
|
||||
poster_path: Option<String>,
|
||||
media_file_id: i64,
|
||||
gap: SubtitleGap,
|
||||
) {
|
||||
if let Some(entry) = entries.iter_mut().find(|entry| entry.movie_id == movie_id) {
|
||||
entry.gaps.push(gap);
|
||||
return;
|
||||
}
|
||||
entries.push(MovieSubtitleGaps {
|
||||
movie_id,
|
||||
tmdb_id,
|
||||
title: title.to_owned(),
|
||||
year,
|
||||
poster_path,
|
||||
media_file_id,
|
||||
gaps: vec![gap],
|
||||
});
|
||||
}
|
||||
|
||||
async fn series_gaps(
|
||||
state: &AppState,
|
||||
wanted: &BTreeSet<String>,
|
||||
) -> Result<Vec<SeriesSubtitleGaps>, ApiError> {
|
||||
let database = pool(state)?;
|
||||
let mut out: Vec<SeriesSubtitleGaps> = Vec::new();
|
||||
|
||||
let attempts = sqlx::query!(
|
||||
r#"SELECT s.id AS "series_id!: i64", s.tmdb_id AS "tmdb_id!: i64",
|
||||
s.title AS "title!: String", s.year, s.poster_path,
|
||||
e.id AS "episode_id!: i64", se.number AS "season_number!: i64",
|
||||
e.number AS "episode_number!: i64", mf.id AS "media_file_id!: i64",
|
||||
sa.language AS "language!: String",
|
||||
sa.state AS "state!: arr_db::SubtitleState", sa.last_failure
|
||||
FROM subtitle_attempts sa
|
||||
JOIN media_files mf ON mf.id = sa.media_file_id AND mf.owner_kind = 'episode'
|
||||
JOIN episodes e ON e.id = mf.owner_id
|
||||
JOIN seasons se ON se.id = e.season_id
|
||||
JOIN series s ON s.id = se.series_id
|
||||
WHERE sa.state IN ('failed', 'capped', 'unavailable')
|
||||
ORDER BY s.title, se.number, e.number, sa.language"#
|
||||
)
|
||||
.fetch_all(database)
|
||||
.await?;
|
||||
for row in attempts {
|
||||
if !wanted.contains(&row.language) {
|
||||
continue;
|
||||
}
|
||||
let (reason, detail) = attempt_reason(row.state, row.last_failure);
|
||||
push_episode_gap(
|
||||
&mut out,
|
||||
row.series_id,
|
||||
row.tmdb_id,
|
||||
&row.title,
|
||||
row.year,
|
||||
row.poster_path,
|
||||
row.episode_id,
|
||||
row.season_number,
|
||||
row.episode_number,
|
||||
row.media_file_id,
|
||||
SubtitleGap {
|
||||
language: row.language,
|
||||
reason,
|
||||
detail,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
let rejected = sqlx::query!(
|
||||
r#"SELECT s.id AS "series_id!: i64", s.tmdb_id AS "tmdb_id!: i64",
|
||||
s.title AS "title!: String", s.year, s.poster_path,
|
||||
e.id AS "episode_id!: i64", se.number AS "season_number!: i64",
|
||||
e.number AS "episode_number!: i64", mf.id AS "media_file_id!: i64",
|
||||
sf.language AS "language!: String"
|
||||
FROM subtitle_files sf
|
||||
JOIN media_files mf ON mf.id = sf.media_file_id AND mf.owner_kind = 'episode'
|
||||
JOIN episodes e ON e.id = mf.owner_id
|
||||
JOIN seasons se ON se.id = e.season_id
|
||||
JOIN series s ON s.id = se.series_id
|
||||
WHERE sf.sync_rejected = 1
|
||||
ORDER BY s.title, se.number, e.number, sf.language"#
|
||||
)
|
||||
.fetch_all(database)
|
||||
.await?;
|
||||
for row in rejected {
|
||||
push_episode_gap(
|
||||
&mut out,
|
||||
row.series_id,
|
||||
row.tmdb_id,
|
||||
&row.title,
|
||||
row.year,
|
||||
row.poster_path,
|
||||
row.episode_id,
|
||||
row.season_number,
|
||||
row.episode_number,
|
||||
row.media_file_id,
|
||||
SubtitleGap {
|
||||
language: row.language,
|
||||
reason: "sync_rejected".to_owned(),
|
||||
detail: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
for series in &mut out {
|
||||
collapse_seasons(series);
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn push_episode_gap(
|
||||
entries: &mut Vec<SeriesSubtitleGaps>,
|
||||
series_id: i64,
|
||||
tmdb_id: i64,
|
||||
title: &str,
|
||||
year: Option<i64>,
|
||||
poster_path: Option<String>,
|
||||
episode_id: i64,
|
||||
season_number: i64,
|
||||
episode_number: i64,
|
||||
media_file_id: i64,
|
||||
gap: SubtitleGap,
|
||||
) {
|
||||
if !entries.iter().any(|entry| entry.series_id == series_id) {
|
||||
entries.push(SeriesSubtitleGaps {
|
||||
series_id,
|
||||
tmdb_id,
|
||||
title: title.to_owned(),
|
||||
year,
|
||||
poster_path,
|
||||
episodes: Vec::new(),
|
||||
seasons: Vec::new(),
|
||||
});
|
||||
}
|
||||
let entry = entries
|
||||
.iter_mut()
|
||||
.find(|entry| entry.series_id == series_id)
|
||||
.unwrap_or_else(|| unreachable!());
|
||||
match entry
|
||||
.episodes
|
||||
.iter_mut()
|
||||
.find(|episode| episode.episode_id == episode_id)
|
||||
{
|
||||
Some(episode) => episode.gaps.push(gap),
|
||||
None => entry.episodes.push(EpisodeSubtitleGaps {
|
||||
episode_id,
|
||||
media_file_id,
|
||||
season_number,
|
||||
episode_number,
|
||||
gaps: vec![gap],
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Roll a season's episodes into one [`SeasonSubtitleGaps`] row when two or
|
||||
/// more of them carry the identical set of gaps — the season-wide failure
|
||||
/// §9.5's restraint is meant to catch. A season with only one gapped episode,
|
||||
/// or episodes whose gaps differ, stays as individual episode rows.
|
||||
fn collapse_seasons(series: &mut SeriesSubtitleGaps) {
|
||||
let mut by_group: std::collections::BTreeMap<(i64, String), Vec<EpisodeSubtitleGaps>> =
|
||||
std::collections::BTreeMap::new();
|
||||
for episode in std::mem::take(&mut series.episodes) {
|
||||
let key = (episode.season_number, gap_signature(&episode.gaps));
|
||||
by_group.entry(key).or_default().push(episode);
|
||||
}
|
||||
for ((season_number, _signature), mut episodes) in by_group {
|
||||
if let [first, ..] = episodes.as_slice() {
|
||||
if episodes.len() >= 2 {
|
||||
series.seasons.push(SeasonSubtitleGaps {
|
||||
season_number,
|
||||
media_file_ids: episodes
|
||||
.iter()
|
||||
.map(|episode| episode.media_file_id)
|
||||
.collect(),
|
||||
gaps: first.gaps.clone(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
series.episodes.append(&mut episodes);
|
||||
}
|
||||
series
|
||||
.episodes
|
||||
.sort_by_key(|episode| (episode.season_number, episode.episode_number));
|
||||
series.seasons.sort_by_key(|season| season.season_number);
|
||||
}
|
||||
|
||||
fn gap_signature(gaps: &[SubtitleGap]) -> String {
|
||||
let mut parts: Vec<String> = gaps
|
||||
.iter()
|
||||
.map(|gap| {
|
||||
format!(
|
||||
"{}|{}|{}",
|
||||
gap.language,
|
||||
gap.reason,
|
||||
gap.detail.as_deref().unwrap_or("")
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
parts.sort();
|
||||
parts.join(",")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::too_many_lines)]
|
||||
mod tests {
|
||||
@@ -2315,4 +2707,230 @@ mod tests {
|
||||
assert_eq!(missing.len(), 1);
|
||||
assert_eq!(missing[0]["language"], "pt-PT");
|
||||
}
|
||||
|
||||
async fn queue(base: &str) -> serde_json::Value {
|
||||
reqwest::get(format!("{base}/api/queues/subtitles"))
|
||||
.await
|
||||
.expect("queue")
|
||||
.json()
|
||||
.await
|
||||
.expect("json")
|
||||
}
|
||||
|
||||
/// #202: a title with no gap at all does not appear in either lane.
|
||||
#[tokio::test]
|
||||
async fn subtitle_queue_omits_titles_with_no_gaps() {
|
||||
let fixture = stub_application().await;
|
||||
let body = queue(&fixture.base).await;
|
||||
assert!(body["movies"].as_array().expect("movies").is_empty());
|
||||
assert!(body["series"].as_array().expect("series").is_empty());
|
||||
}
|
||||
|
||||
/// #202: an attempt row for a language no longer in the wanted set is
|
||||
/// not a gap any more — `missing_for` (#201) already draws this line.
|
||||
#[tokio::test]
|
||||
async fn subtitle_queue_drops_a_language_no_longer_wanted() {
|
||||
let fixture = stub_application().await;
|
||||
arr_db::subtitles::record_attempt(
|
||||
&fixture.pool,
|
||||
fixture.media_file_id,
|
||||
"en",
|
||||
arr_db::SubtitleState::Failed,
|
||||
Some("429 from opensubtitles"),
|
||||
)
|
||||
.await
|
||||
.expect("record failed attempt");
|
||||
sqlx::query("UPDATE subtitle_settings SET wanted_languages = '[\"pt-PT\"]' WHERE id = 1")
|
||||
.execute(&fixture.pool)
|
||||
.await
|
||||
.expect("narrow wanted set");
|
||||
|
||||
let body = queue(&fixture.base).await;
|
||||
assert!(body["movies"].as_array().expect("movies").is_empty());
|
||||
}
|
||||
|
||||
/// #202: a movie's failed attempt surfaces with its language and detail.
|
||||
#[tokio::test]
|
||||
async fn subtitle_queue_lists_a_movie_gap() {
|
||||
let fixture = stub_application().await;
|
||||
arr_db::subtitles::record_attempt(
|
||||
&fixture.pool,
|
||||
fixture.media_file_id,
|
||||
"en",
|
||||
arr_db::SubtitleState::Failed,
|
||||
Some("429 from opensubtitles"),
|
||||
)
|
||||
.await
|
||||
.expect("record failed attempt");
|
||||
|
||||
let body = queue(&fixture.base).await;
|
||||
let movies = body["movies"].as_array().expect("movies");
|
||||
assert_eq!(movies.len(), 1, "{movies:?}");
|
||||
assert_eq!(movies[0]["movie_id"], 1);
|
||||
assert_eq!(movies[0]["media_file_id"], fixture.media_file_id);
|
||||
let gaps = movies[0]["gaps"].as_array().expect("gaps");
|
||||
assert_eq!(gaps.len(), 1, "{gaps:?}");
|
||||
assert_eq!(gaps[0]["language"], "en");
|
||||
assert_eq!(gaps[0]["reason"], "failed");
|
||||
assert_eq!(gaps[0]["detail"], "429 from opensubtitles");
|
||||
}
|
||||
|
||||
/// #202, §15: a subtitle whose sync `alass` rejected is flagged even
|
||||
/// though the language it answers is satisfied.
|
||||
#[tokio::test]
|
||||
async fn subtitle_queue_surfaces_a_sync_rejected_file() {
|
||||
let fixture = stub_application().await;
|
||||
arr_db::subtitles::mark_satisfied(&fixture.pool, fixture.media_file_id, "pt-PT")
|
||||
.await
|
||||
.expect("mark satisfied");
|
||||
arr_db::subtitles::record_file(
|
||||
&fixture.pool,
|
||||
&arr_db::NewSubtitleFile::fetched(
|
||||
fixture.media_file_id,
|
||||
"pt-PT",
|
||||
"opensubtitles",
|
||||
"1",
|
||||
&fixture.video.with_extension("pt-PT.srt").to_string_lossy(),
|
||||
)
|
||||
.sync(arr_db::SubtitleSync::Rejected),
|
||||
)
|
||||
.await
|
||||
.expect("record rejected sync");
|
||||
|
||||
let body = queue(&fixture.base).await;
|
||||
let movies = body["movies"].as_array().expect("movies");
|
||||
assert_eq!(movies.len(), 1, "{movies:?}");
|
||||
let gaps = movies[0]["gaps"].as_array().expect("gaps");
|
||||
assert_eq!(gaps.len(), 1, "{gaps:?}");
|
||||
assert_eq!(gaps[0]["language"], "pt-PT");
|
||||
assert_eq!(gaps[0]["reason"], "sync_rejected");
|
||||
assert!(gaps[0]["detail"].is_null());
|
||||
}
|
||||
|
||||
async fn seed_episode(
|
||||
pool: &sqlx::SqlitePool,
|
||||
episode_id: i64,
|
||||
season_number: i64,
|
||||
episode_number: i64,
|
||||
) -> i64 {
|
||||
sqlx::query(
|
||||
"INSERT INTO seasons (id, series_id, number) VALUES (?, 1, ?)
|
||||
ON CONFLICT (series_id, number) DO NOTHING",
|
||||
)
|
||||
.bind(season_number)
|
||||
.bind(season_number)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("season");
|
||||
sqlx::query("INSERT INTO episodes (id, season_id, number, title) VALUES (?, ?, ?, 'Ep')")
|
||||
.bind(episode_id)
|
||||
.bind(season_number)
|
||||
.bind(episode_number)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("episode");
|
||||
let media_file_id = 100 + episode_id;
|
||||
sqlx::query(
|
||||
"INSERT INTO media_files (id, owner_kind, owner_id, path, size) VALUES (?, 'episode', ?, ?, 100)",
|
||||
)
|
||||
.bind(media_file_id)
|
||||
.bind(episode_id)
|
||||
.bind(format!("S{season_number:02}E{episode_number:02}.mkv"))
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("media file");
|
||||
media_file_id
|
||||
}
|
||||
|
||||
/// #202, same restraint as §9.5's TV attention lanes: when every episode
|
||||
/// in a season carries the identical gap, the queue collapses them into
|
||||
/// one season row instead of flooding it one row per episode.
|
||||
#[tokio::test]
|
||||
async fn subtitle_queue_collapses_a_season_when_every_episode_shares_the_gap() {
|
||||
let fixture = stub_application().await;
|
||||
sqlx::query(
|
||||
"INSERT INTO series (id, tmdb_id, title, root_id)
|
||||
SELECT 1, 9999, 'Bluey', id FROM roots WHERE kind = 'tv' LIMIT 1",
|
||||
)
|
||||
.execute(&fixture.pool)
|
||||
.await
|
||||
.expect("series");
|
||||
let first = seed_episode(&fixture.pool, 1, 1, 1).await;
|
||||
let second = seed_episode(&fixture.pool, 2, 1, 2).await;
|
||||
for media_file_id in [first, second] {
|
||||
arr_db::subtitles::record_attempt(
|
||||
&fixture.pool,
|
||||
media_file_id,
|
||||
"pt-PT",
|
||||
arr_db::SubtitleState::Capped,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("record capped attempt");
|
||||
}
|
||||
|
||||
let body = queue(&fixture.base).await;
|
||||
let series = body["series"].as_array().expect("series");
|
||||
assert_eq!(series.len(), 1, "{series:?}");
|
||||
assert!(series[0]["episodes"]
|
||||
.as_array()
|
||||
.expect("episodes")
|
||||
.is_empty());
|
||||
let seasons = series[0]["seasons"].as_array().expect("seasons");
|
||||
assert_eq!(seasons.len(), 1, "{seasons:?}");
|
||||
assert_eq!(seasons[0]["season_number"], 1);
|
||||
let mut media_file_ids: Vec<i64> = seasons[0]["media_file_ids"]
|
||||
.as_array()
|
||||
.expect("media_file_ids")
|
||||
.iter()
|
||||
.map(|id| id.as_i64().expect("id"))
|
||||
.collect();
|
||||
media_file_ids.sort_unstable();
|
||||
assert_eq!(media_file_ids, vec![first, second]);
|
||||
let gaps = seasons[0]["gaps"].as_array().expect("gaps");
|
||||
assert_eq!(gaps.len(), 1, "{gaps:?}");
|
||||
assert_eq!(gaps[0]["language"], "pt-PT");
|
||||
assert_eq!(gaps[0]["reason"], "capped");
|
||||
}
|
||||
|
||||
/// #202: episodes whose gaps differ stay listed individually — the
|
||||
/// season only collapses when the failure is uniform across it.
|
||||
#[tokio::test]
|
||||
async fn subtitle_queue_keeps_episodes_separate_when_gaps_differ() {
|
||||
let fixture = stub_application().await;
|
||||
sqlx::query(
|
||||
"INSERT INTO series (id, tmdb_id, title, root_id)
|
||||
SELECT 1, 9999, 'Bluey', id FROM roots WHERE kind = 'tv' LIMIT 1",
|
||||
)
|
||||
.execute(&fixture.pool)
|
||||
.await
|
||||
.expect("series");
|
||||
let first = seed_episode(&fixture.pool, 1, 1, 1).await;
|
||||
let second = seed_episode(&fixture.pool, 2, 1, 2).await;
|
||||
arr_db::subtitles::record_attempt(
|
||||
&fixture.pool,
|
||||
first,
|
||||
"pt-PT",
|
||||
arr_db::SubtitleState::Capped,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("record capped attempt");
|
||||
arr_db::subtitles::record_attempt(
|
||||
&fixture.pool,
|
||||
second,
|
||||
"pt-PT",
|
||||
arr_db::SubtitleState::Unavailable,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("record unavailable attempt");
|
||||
|
||||
let body = queue(&fixture.base).await;
|
||||
let series = body["series"].as_array().expect("series");
|
||||
assert_eq!(series.len(), 1, "{series:?}");
|
||||
assert!(series[0]["seasons"].as_array().expect("seasons").is_empty());
|
||||
let episodes = series[0]["episodes"].as_array().expect("episodes");
|
||||
assert_eq!(episodes.len(), 2, "{episodes:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,6 +199,19 @@
|
||||
</p>
|
||||
<ul class="deck-rows" id="rows-decision"></ul>
|
||||
</section>
|
||||
|
||||
<section class="deck-group" id="queue-subtitles" hidden aria-labelledby="label-subtitles">
|
||||
<header class="deck-head">
|
||||
<h3 class="deck-label" id="label-subtitles">missing subtitles</h3>
|
||||
<span class="deck-count readout" id="count-subtitles"></span>
|
||||
</header>
|
||||
<p class="queue-note readout dim">
|
||||
a wanted language with no subtitle, and why — no candidate, a translation or provider
|
||||
failure, the daily budget, or a sync `alass` flagged. open the title to search,
|
||||
translate or retry by hand.
|
||||
</p>
|
||||
<ul class="deck-rows" id="rows-subtitles"></ul>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!--
|
||||
|
||||
+126
-14
@@ -29,7 +29,12 @@ import {
|
||||
attemptsLabel,
|
||||
attentionTotal,
|
||||
fetchAttention,
|
||||
fetchSubtitleQueue,
|
||||
type MovieSubtitleGaps,
|
||||
type SeriesAttention,
|
||||
type SeriesSubtitleGaps,
|
||||
type SubtitleQueue,
|
||||
subtitleQueueTotal,
|
||||
} from "./queues";
|
||||
import {
|
||||
type ActionOutcome,
|
||||
@@ -284,7 +289,7 @@ function main() {
|
||||
const movieDetail = movieMain(views);
|
||||
const library = libraryMain(movieDetail, seriesDetail, views);
|
||||
const goHome = () => library.open();
|
||||
const queues = queuesMain(movieDetail, views, goHome);
|
||||
const queues = queuesMain(movieDetail, seriesDetail, views, goHome);
|
||||
const settings = settingsMain(views, goHome);
|
||||
views.push(tvDeck, seriesDetail, movieDetail, library, queues, settings);
|
||||
const search = searchMain(movieDetail, seriesDetail, views, goHome);
|
||||
@@ -4601,14 +4606,19 @@ interface QueueGroup {
|
||||
rows: HTMLUListElement;
|
||||
}
|
||||
|
||||
function queuesMain(movieDetail: MovieView, views: HideableView[], goHome: () => void): QueuesView {
|
||||
function queuesMain(
|
||||
movieDetail: MovieView,
|
||||
seriesDetail: SeriesView,
|
||||
views: HideableView[],
|
||||
goHome: () => void,
|
||||
): QueuesView {
|
||||
const view = must<HTMLElement>("#queues");
|
||||
const deck = must<HTMLElement>("#deck");
|
||||
const nav = must<HTMLButtonElement>("#nav-queues");
|
||||
const badge = must<HTMLElement>("#queues-count");
|
||||
const summary = must<HTMLElement>("#queues-summary");
|
||||
const status = must<HTMLElement>("#queues-status");
|
||||
const groups: { noPt: QueueGroup; decision: QueueGroup } = {
|
||||
const groups: { noPt: QueueGroup; decision: QueueGroup; subtitles: QueueGroup } = {
|
||||
noPt: {
|
||||
section: must<HTMLElement>("#queue-no-pt"),
|
||||
count: must<HTMLElement>("#count-no-pt"),
|
||||
@@ -4619,7 +4629,13 @@ function queuesMain(movieDetail: MovieView, views: HideableView[], goHome: () =>
|
||||
count: must<HTMLElement>("#count-decision"),
|
||||
rows: must<HTMLUListElement>("#rows-decision"),
|
||||
},
|
||||
subtitles: {
|
||||
section: must<HTMLElement>("#queue-subtitles"),
|
||||
count: must<HTMLElement>("#count-subtitles"),
|
||||
rows: must<HTMLUListElement>("#rows-subtitles"),
|
||||
},
|
||||
};
|
||||
const allGroups = [groups.noPt, groups.decision, groups.subtitles];
|
||||
|
||||
let roots: Root[] = [];
|
||||
// guards a stale fetch from painting over a newer view
|
||||
@@ -4636,7 +4652,7 @@ function queuesMain(movieDetail: MovieView, views: HideableView[], goHome: () =>
|
||||
}
|
||||
|
||||
function clearGroups() {
|
||||
for (const group of [groups.noPt, groups.decision]) {
|
||||
for (const group of allGroups) {
|
||||
group.section.hidden = true;
|
||||
group.rows.replaceChildren();
|
||||
}
|
||||
@@ -4664,7 +4680,7 @@ function queuesMain(movieDetail: MovieView, views: HideableView[], goHome: () =>
|
||||
|
||||
function paintCounts() {
|
||||
let total = 0;
|
||||
for (const group of [groups.noPt, groups.decision]) {
|
||||
for (const group of allGroups) {
|
||||
const entries = entryCount(group);
|
||||
group.count.textContent = String(entries);
|
||||
total += entries;
|
||||
@@ -4678,6 +4694,16 @@ function queuesMain(movieDetail: MovieView, views: HideableView[], goHome: () =>
|
||||
void movieDetail.open(movie.id, origin, view, { kind: "queues" });
|
||||
};
|
||||
|
||||
const openMovie = (movieId: number, origin: HTMLElement) => {
|
||||
navigate({ kind: "movie", movieId });
|
||||
void movieDetail.open(movieId, origin, view, { kind: "queues" });
|
||||
};
|
||||
|
||||
const openSeries = (seriesId: number, origin: HTMLElement) => {
|
||||
navigate({ kind: "series", seriesId });
|
||||
void seriesDetail.open(seriesId, origin, view, { kind: "queues" });
|
||||
};
|
||||
|
||||
/** Drops an emptied item without a refetch: the override IS written. */
|
||||
function settle(item: HTMLLIElement, group: QueueGroup, text: string) {
|
||||
item.remove();
|
||||
@@ -4686,11 +4712,11 @@ function queuesMain(movieDetail: MovieView, views: HideableView[], goHome: () =>
|
||||
setStatus(text);
|
||||
}
|
||||
|
||||
function render(queues: AttentionQueues) {
|
||||
function render(queues: AttentionQueues, subtitles: SubtitleQueue | null) {
|
||||
clearGroups();
|
||||
// both sections always render, even empty — this is the one surface
|
||||
// every section always renders, even empty — this is the one surface
|
||||
// whose good news is an absence, and the operator visits to see it
|
||||
for (const group of [groups.noPt, groups.decision]) {
|
||||
for (const group of allGroups) {
|
||||
group.section.hidden = false;
|
||||
}
|
||||
for (const movie of queues.no_pt_source) {
|
||||
@@ -4712,19 +4738,30 @@ function queuesMain(movieDetail: MovieView, views: HideableView[], goHome: () =>
|
||||
for (const series of queues.tv_needs_decision) {
|
||||
groups.decision.rows.append(seriesAttentionRow(series));
|
||||
}
|
||||
for (const group of [groups.noPt, groups.decision]) {
|
||||
if (subtitles !== null) {
|
||||
for (const movie of subtitles.movies) {
|
||||
groups.subtitles.rows.append(movieSubtitleGapsRow(movie, openMovie));
|
||||
}
|
||||
for (const series of subtitles.series) {
|
||||
groups.subtitles.rows.append(seriesSubtitleGapsRow(series, openSeries));
|
||||
}
|
||||
}
|
||||
for (const group of allGroups) {
|
||||
emptyLine(group);
|
||||
}
|
||||
paintCounts();
|
||||
setStatus(attentionTotal(queues) === 0 ? "nothing needs attention" : null);
|
||||
const nothingWaiting =
|
||||
attentionTotal(queues) === 0 && (subtitles === null || subtitleQueueTotal(subtitles) === 0);
|
||||
setStatus(nothingWaiting ? "nothing needs attention" : null);
|
||||
}
|
||||
|
||||
async function load() {
|
||||
sequence += 1;
|
||||
const ticket = sequence;
|
||||
setStatus("reading queues…");
|
||||
const [outcome, fetchedRoots] = await Promise.all([
|
||||
const [outcome, subtitlesOutcome, fetchedRoots] = await Promise.all([
|
||||
fetchAttention(),
|
||||
fetchSubtitleQueue(),
|
||||
allRoots().catch(() => roots),
|
||||
]);
|
||||
if (ticket !== sequence) {
|
||||
@@ -4736,7 +4773,10 @@ function queuesMain(movieDetail: MovieView, views: HideableView[], goHome: () =>
|
||||
setStatus(`queues unavailable — ${outcome.detail}`, "fault");
|
||||
return;
|
||||
}
|
||||
render(outcome.queues);
|
||||
render(outcome.queues, subtitlesOutcome.kind === "results" ? subtitlesOutcome.queue : null);
|
||||
if (subtitlesOutcome.kind === "error") {
|
||||
setStatus(`subtitle queue unavailable — ${subtitlesOutcome.detail}`, "fault");
|
||||
}
|
||||
}
|
||||
|
||||
function open() {
|
||||
@@ -4785,11 +4825,13 @@ function queuesMain(movieDetail: MovieView, views: HideableView[], goHome: () =>
|
||||
);
|
||||
|
||||
async function refreshBadge() {
|
||||
const outcome = await fetchAttention();
|
||||
const [outcome, subtitlesOutcome] = await Promise.all([fetchAttention(), fetchSubtitleQueue()]);
|
||||
if (outcome.kind === "error") {
|
||||
return;
|
||||
}
|
||||
paintBadge(attentionTotal(outcome.queues));
|
||||
const subtitlesTotal =
|
||||
subtitlesOutcome.kind === "results" ? subtitleQueueTotal(subtitlesOutcome.queue) : 0;
|
||||
paintBadge(attentionTotal(outcome.queues) + subtitlesTotal);
|
||||
}
|
||||
|
||||
return { hide, open, refreshBadge };
|
||||
@@ -4890,6 +4932,76 @@ function noPtRow(
|
||||
return item;
|
||||
}
|
||||
|
||||
/**
|
||||
* One missing-subtitles entry for a movie: its title, plus one chip per gap
|
||||
* — language and why (#202). The row opens the movie page, where the
|
||||
* subtitle section already offers manual search, translate and retry
|
||||
* (#203, §9.3).
|
||||
*/
|
||||
function movieSubtitleGapsRow(
|
||||
movie: MovieSubtitleGaps,
|
||||
open: (movieId: number, origin: HTMLElement) => void,
|
||||
): HTMLLIElement {
|
||||
const item = document.createElement("li");
|
||||
const line = document.createElement("button");
|
||||
line.type = "button";
|
||||
line.className = "row row-tmdb queue-line";
|
||||
const chips = document.createElement("span");
|
||||
chips.className = "row-chips";
|
||||
for (const gap of movie.gaps) {
|
||||
chips.append(chip(missingChipLabel(gap)));
|
||||
}
|
||||
const affordance = document.createElement("span");
|
||||
affordance.className = "row-add readout";
|
||||
affordance.textContent = "open";
|
||||
chips.append(affordance);
|
||||
line.append(rowTitle(movie.title, movie.year), chips);
|
||||
line.addEventListener("click", () => {
|
||||
open(movie.movie_id, line);
|
||||
});
|
||||
item.append(line);
|
||||
return item;
|
||||
}
|
||||
|
||||
/**
|
||||
* One missing-subtitles entry for a series: one chip per episode gap,
|
||||
* tagged `SxxEyy`, plus one per season row a uniform gap collapsed into
|
||||
* (§9.5's restraint). Opens the series page, same reasoning as the movie row.
|
||||
*/
|
||||
function seriesSubtitleGapsRow(
|
||||
series: SeriesSubtitleGaps,
|
||||
open: (seriesId: number, origin: HTMLElement) => void,
|
||||
): HTMLLIElement {
|
||||
const item = document.createElement("li");
|
||||
const line = document.createElement("button");
|
||||
line.type = "button";
|
||||
line.className = "row row-tmdb queue-line";
|
||||
const chips = document.createElement("span");
|
||||
chips.className = "row-chips";
|
||||
for (const episode of series.episodes) {
|
||||
const tag = episodeTag(episode.season_number, episode.episode_number);
|
||||
for (const gap of episode.gaps) {
|
||||
chips.append(chip(`${tag} ${missingChipLabel(gap)}`));
|
||||
}
|
||||
}
|
||||
for (const season of series.seasons) {
|
||||
const tag = seasonTag(season.season_number);
|
||||
for (const gap of season.gaps) {
|
||||
chips.append(chip(`${tag} ${missingChipLabel(gap)}`));
|
||||
}
|
||||
}
|
||||
const affordance = document.createElement("span");
|
||||
affordance.className = "row-add readout";
|
||||
affordance.textContent = "open";
|
||||
chips.append(affordance);
|
||||
line.append(rowTitle(series.title, series.year), chips);
|
||||
line.addEventListener("click", () => {
|
||||
open(series.series_id, line);
|
||||
});
|
||||
item.append(line);
|
||||
return item;
|
||||
}
|
||||
|
||||
function renderManual(intake: HTMLElement, kind: "magnet" | "torrent_url", raw: string) {
|
||||
const parsed = parseManualInput(kind, raw);
|
||||
intake.replaceChildren();
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
import { type ActionOutcome, queueSearch } from "./releases";
|
||||
import type { LibraryMovie } from "./search";
|
||||
import type { MissingSubtitle } from "./subtitles";
|
||||
|
||||
/** A queue entry: the movie plus its §6.2 search history. */
|
||||
export interface AttentionMovie extends LibraryMovie {
|
||||
@@ -114,3 +115,80 @@ async function errorDetail(response: Response): Promise<string> {
|
||||
return `http ${response.status}`;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- missing-subtitles queue (§15, issue #202) ------------------------- */
|
||||
|
||||
/**
|
||||
* A wanted language a media file lacks, or a subtitle it has but `alass`
|
||||
* flagged. Same shape as `MissingSubtitle` (#201) — `reason` carries one more
|
||||
* word here, `sync_rejected`, for the flagged case.
|
||||
*/
|
||||
export type SubtitleGap = MissingSubtitle;
|
||||
|
||||
/** A movie with at least one subtitle gap. */
|
||||
export interface MovieSubtitleGaps {
|
||||
movie_id: number;
|
||||
tmdb_id: number;
|
||||
title: string;
|
||||
year: number | null;
|
||||
poster_path: string | null;
|
||||
media_file_id: number;
|
||||
gaps: SubtitleGap[];
|
||||
}
|
||||
|
||||
/** One episode's gaps, named `SxxEyy` for the operator like `QueuedEpisode`. */
|
||||
export interface EpisodeSubtitleGaps {
|
||||
episode_id: number;
|
||||
media_file_id: number;
|
||||
season_number: number;
|
||||
episode_number: number;
|
||||
gaps: SubtitleGap[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A season collapsed into one row because every one of its episodes carries
|
||||
* the identical gap — the restraint §9.5 already applies to the TV
|
||||
* attention queues.
|
||||
*/
|
||||
export interface SeasonSubtitleGaps {
|
||||
season_number: number;
|
||||
media_file_ids: number[];
|
||||
gaps: SubtitleGap[];
|
||||
}
|
||||
|
||||
/** A series with at least one subtitle gap. */
|
||||
export interface SeriesSubtitleGaps {
|
||||
series_id: number;
|
||||
tmdb_id: number;
|
||||
title: string;
|
||||
year: number | null;
|
||||
poster_path: string | null;
|
||||
episodes: EpisodeSubtitleGaps[];
|
||||
seasons: SeasonSubtitleGaps[];
|
||||
}
|
||||
|
||||
export interface SubtitleQueue {
|
||||
movies: MovieSubtitleGaps[];
|
||||
series: SeriesSubtitleGaps[];
|
||||
}
|
||||
|
||||
export type SubtitleQueueOutcome =
|
||||
| { kind: "results"; queue: SubtitleQueue }
|
||||
| { kind: "error"; detail: string };
|
||||
|
||||
export async function fetchSubtitleQueue(): Promise<SubtitleQueueOutcome> {
|
||||
try {
|
||||
const response = await fetch("/api/queues/subtitles");
|
||||
if (!response.ok) {
|
||||
return { kind: "error", detail: await errorDetail(response) };
|
||||
}
|
||||
return { kind: "results", queue: (await response.json()) as SubtitleQueue };
|
||||
} catch {
|
||||
return { kind: "error", detail: "daemon unreachable" };
|
||||
}
|
||||
}
|
||||
|
||||
/** Entries across both lanes — the queue section's own count. */
|
||||
export function subtitleQueueTotal(queue: SubtitleQueue): number {
|
||||
return queue.movies.length + queue.series.length;
|
||||
}
|
||||
|
||||
@@ -120,6 +120,9 @@ const REASON_LABELS: Record<string, string> = {
|
||||
no_candidates: "no candidates",
|
||||
capped: "capped",
|
||||
failed: "failed",
|
||||
// #202: not a missing language — a subtitle exists but `alass` rejected
|
||||
// its sync (§15) and the file is flagged.
|
||||
sync_rejected: "sync flagged",
|
||||
};
|
||||
|
||||
/** How long a `failed` detail rides in the chip text before it is title-only. */
|
||||
|
||||
Reference in New Issue
Block a user