From 25f38eefb1db45c27b0def30ce354affd6080104 Mon Sep 17 00:00:00 2001 From: FrenchGithubUser Date: Wed, 26 Nov 2025 23:15:58 +0100 Subject: [PATCH] feat: revamp the tagging system (keep track of who adds tags and when, search for tags, etc.) --- backend/api/src/api_doc.rs | 4 + backend/api/src/handlers/mod.rs | 1 + backend/api/src/handlers/search/mod.rs | 4 + .../search/search_title_group_tags.rs | 40 ++++ .../handlers/title_group_tags/apply_tag.rs | 40 ++++ .../handlers/title_group_tags/create_tag.rs | 32 +++ .../api/src/handlers/title_group_tags/mod.rs | 12 ++ .../handlers/title_group_tags/remove_tag.rs | 40 ++++ backend/api/src/routes.rs | 3 +- .../tests/fixtures/with_test_title_group.sql | 5 +- backend/common/src/error/mod.rs | 3 + ...1eb56bb118ec343305ce54714deaca65f33ba.json | 15 ++ ...d6e9914639ce29d4eccf18346318820fc036.json} | 35 ++-- ...f349331024ff2522e39d22a097d1a0450a376.json | 16 ++ ...5de34b5e8dd5a763c8fb4b288e7a24711168.json} | 5 +- ...becdee013c49ba2985d63910b036831b718e.json} | 4 +- ...e82ee389113ad743ae5e8405de7947b6e8f3.json} | 4 +- ...5a90a8685fe2ef9948b8572e524c304f88791.json | 15 ++ ...da87186f244880a1823fd422f3935e45dd57b.json | 22 +++ ...5d64d2ca0c8c6a8220af548b4898bdd319bb.json} | 34 ++-- ...8a8f57dfd20b17095604cfc86ba3c15fcfa8f.json | 34 ++++ ...5139e9066df3dcdded560c95cf36282b1f37d.json | 153 ++++++++++++++ ...a82929e76891ad628d44244afde6e35005165.json | 47 +++++ .../migrations/20250312215600_initdb.sql | 85 +++++++- .../storage/migrations/fixtures/fixtures.sql | 51 ++--- backend/storage/src/models/mod.rs | 1 + backend/storage/src/models/title_group.rs | 7 +- backend/storage/src/models/title_group_tag.rs | 26 +++ backend/storage/src/models/torrent.rs | 2 +- backend/storage/src/repositories/mod.rs | 1 + .../repositories/title_group_repository.rs | 177 ++++++++++++----- .../title_group_tag_repository.rs | 140 +++++++++++++ .../src/repositories/torrent_repository.rs | 4 +- .../torrent_request_repository.rs | 6 +- frontend/src/App.vue | 2 +- frontend/src/api-schema/schema.d.ts | 186 +++++++++++++++++- .../title_group/CreateOrEditTitleGroup.vue | 4 +- .../title_group/TitleGroupSidebar.vue | 24 ++- .../title_group/TitleGroupTagSearchBar.vue | 69 +++++++ frontend/src/i18n/en.json | 6 +- .../src/services/api/titleGroupTagService.ts | 22 +++ frontend/src/views/TitleGroupView.vue | 1 + .../tests/fixtures/with_test_title_group.sql | 6 +- 43 files changed, 1223 insertions(+), 165 deletions(-) create mode 100644 backend/api/src/handlers/search/search_title_group_tags.rs create mode 100644 backend/api/src/handlers/title_group_tags/apply_tag.rs create mode 100644 backend/api/src/handlers/title_group_tags/create_tag.rs create mode 100644 backend/api/src/handlers/title_group_tags/mod.rs create mode 100644 backend/api/src/handlers/title_group_tags/remove_tag.rs create mode 100644 backend/storage/.sqlx/query-0932918cefa7877026926792fda1eb56bb118ec343305ce54714deaca65f33ba.json rename backend/storage/.sqlx/{query-5f77d48e7072420c7ffded38cf73477cb1f8c18d3ea38097c5afbf8cb65febdc.json => query-34cebdbd9891696a3e03e1342728d6e9914639ce29d4eccf18346318820fc036.json} (88%) create mode 100644 backend/storage/.sqlx/query-3767e89d844ddc486bb0fa998e5f349331024ff2522e39d22a097d1a0450a376.json rename backend/storage/.sqlx/{query-77241c2d307c8438ce4e9d9c7ccb91256538b4bb4ac29f2f24e27f8c76528419.json => query-385572ee8919dd3a9d735d29f3035de34b5e8dd5a763c8fb4b288e7a24711168.json} (92%) rename backend/storage/.sqlx/{query-2e5b50eb82d388982654d096878d0e787f2353784d5f9cc8ff515178f598c950.json => query-3a104da12b9d05a2af0eff0085dbbecdee013c49ba2985d63910b036831b718e.json} (53%) rename backend/storage/.sqlx/{query-1e2e5b5c74695a72660829775378c04e7126e680dcf6cf9ad77746dfbc3c6c52.json => query-3add63fe8f17ca2696f8c1c8633de82ee389113ad743ae5e8405de7947b6e8f3.json} (78%) create mode 100644 backend/storage/.sqlx/query-7115e0aefdb908aca7046d7a0b75a90a8685fe2ef9948b8572e524c304f88791.json create mode 100644 backend/storage/.sqlx/query-712a10208493bb34c4740d135f1da87186f244880a1823fd422f3935e45dd57b.json rename backend/storage/.sqlx/{query-2912c2e0c16b5dd6d8e6d0a58450f5f394ed490d19c9783405109726ee86e18b.json => query-794430240cbe19b0d54bf70460b85d64d2ca0c8c6a8220af548b4898bdd319bb.json} (87%) create mode 100644 backend/storage/.sqlx/query-9a1cbde7336ef3cf344ec2dfd228a8f57dfd20b17095604cfc86ba3c15fcfa8f.json create mode 100644 backend/storage/.sqlx/query-e3730b114f60276f08a725edf645139e9066df3dcdded560c95cf36282b1f37d.json create mode 100644 backend/storage/.sqlx/query-f4a861b5d1c3c1d52f04f024462a82929e76891ad628d44244afde6e35005165.json create mode 100644 backend/storage/src/models/title_group_tag.rs create mode 100644 backend/storage/src/repositories/title_group_tag_repository.rs create mode 100644 frontend/src/components/title_group/TitleGroupTagSearchBar.vue create mode 100644 frontend/src/services/api/titleGroupTagService.ts diff --git a/backend/api/src/api_doc.rs b/backend/api/src/api_doc.rs index f0ab9fff..68b1f8f8 100644 --- a/backend/api/src/api_doc.rs +++ b/backend/api/src/api_doc.rs @@ -57,6 +57,10 @@ use crate::handlers::{ crate::handlers::title_groups::edit_title_group::exec, crate::handlers::title_groups::get_title_group::exec, crate::handlers::title_groups::get_title_group_info_lite::exec, + crate::handlers::title_group_tags::create_tag::exec, + crate::handlers::title_group_tags::apply_tag::exec, + crate::handlers::title_group_tags::remove_tag::exec, + crate::handlers::search::search_title_group_tags::exec, crate::handlers::search::search_torrents::exec, crate::handlers::search::search_title_group_info_lite::exec, crate::handlers::search::search_torrent_requests::exec, diff --git a/backend/api/src/handlers/mod.rs b/backend/api/src/handlers/mod.rs index f48e1263..96655ed1 100644 --- a/backend/api/src/handlers/mod.rs +++ b/backend/api/src/handlers/mod.rs @@ -16,6 +16,7 @@ pub mod series; pub mod staff_pms; pub mod subscriptions; pub mod title_group_bookmarks; +pub mod title_group_tags; pub mod title_groups; pub mod torrent_requests; pub mod torrents; diff --git a/backend/api/src/handlers/search/mod.rs b/backend/api/src/handlers/search/mod.rs index 2f6a4f4c..49cff705 100644 --- a/backend/api/src/handlers/search/mod.rs +++ b/backend/api/src/handlers/search/mod.rs @@ -4,6 +4,7 @@ pub mod search_collages_lite; pub mod search_forum; pub mod search_series; pub mod search_title_group_info_lite; +pub mod search_title_group_tags; pub mod search_torrent_requests; pub mod search_torrents; @@ -15,6 +16,9 @@ pub fn config(cfg: &mut ServiceConfig) { resource("/title-groups/lite") .route(get().to(self::search_title_group_info_lite::exec::)), ); + cfg.service( + resource("/title-group-tags").route(get().to(self::search_title_group_tags::exec::)), + ); cfg.service(resource("/torrents/lite").route(get().to(self::search_torrents::exec::))); cfg.service(resource("/artists/lite").route(get().to(self::search_artists_lite::exec::))); cfg.service( diff --git a/backend/api/src/handlers/search/search_title_group_tags.rs b/backend/api/src/handlers/search/search_title_group_tags.rs new file mode 100644 index 00000000..b1a322a7 --- /dev/null +++ b/backend/api/src/handlers/search/search_title_group_tags.rs @@ -0,0 +1,40 @@ +use crate::Arcadia; +use actix_web::{ + web::{Data, Query}, + HttpResponse, +}; +use arcadia_common::error::Result; +use arcadia_storage::{ + models::title_group_tag::TitleGroupTagSearchResult, redis::RedisPoolInterface, +}; +use serde::Deserialize; +use utoipa::{IntoParams, ToSchema}; + +#[derive(Debug, Deserialize, IntoParams, ToSchema)] +pub struct SearchTagsQuery { + pub name: String, +} + +#[utoipa::path( + get, + operation_id = "Search title group tags", + tag = "Search", + path = "/api/search/title-group-tags", + params( + ("name" = String, Query, description = "Search query (searches in tag name and synonyms)") + ), + security( + ("http" = ["Bearer"]) + ), + responses( + (status = 200, description = "List of matching tags with their names and synonyms", body=Vec), + ) +)] +pub async fn exec( + query: Query, + arc: Data>, +) -> Result { + let results = arc.pool.search_title_group_tags(&query.name).await?; + + Ok(HttpResponse::Ok().json(results)) +} diff --git a/backend/api/src/handlers/title_group_tags/apply_tag.rs b/backend/api/src/handlers/title_group_tags/apply_tag.rs new file mode 100644 index 00000000..5da5e3c5 --- /dev/null +++ b/backend/api/src/handlers/title_group_tags/apply_tag.rs @@ -0,0 +1,40 @@ +use crate::{middlewares::auth_middleware::Authdata, Arcadia}; +use actix_web::{ + web::{Data, Json}, + HttpResponse, +}; +use arcadia_common::error::Result; +use arcadia_storage::redis::RedisPoolInterface; +use serde::Deserialize; +use serde_json::json; +use utoipa::ToSchema; + +#[derive(Debug, Deserialize, ToSchema)] +pub struct AppliedTitleGroupTag { + pub title_group_id: i32, + pub tag_id: i32, +} + +#[utoipa::path( + post, + operation_id = "Apply tag to title group", + tag = "Title Group Tag", + path = "/api/title-group-tags/apply", + security( + ("http" = ["Bearer"]) + ), + responses( + (status = 200, description = "Successfully applied the tag to the title group"), + ) +)] +pub async fn exec( + request: Json, + arc: Data>, + user: Authdata, +) -> Result { + arc.pool + .apply_tag_to_title_group(request.title_group_id, request.tag_id, user.sub) + .await?; + + Ok(HttpResponse::Ok().json(json!({"result": "success"}))) +} diff --git a/backend/api/src/handlers/title_group_tags/create_tag.rs b/backend/api/src/handlers/title_group_tags/create_tag.rs new file mode 100644 index 00000000..57dfc58e --- /dev/null +++ b/backend/api/src/handlers/title_group_tags/create_tag.rs @@ -0,0 +1,32 @@ +use crate::{middlewares::auth_middleware::Authdata, Arcadia}; +use actix_web::{ + web::{Data, Json}, + HttpResponse, +}; +use arcadia_common::error::Result; +use arcadia_storage::{ + models::title_group_tag::{TitleGroupTag, UserCreatedTitleGroupTag}, + redis::RedisPoolInterface, +}; + +#[utoipa::path( + post, + operation_id = "Create title group tag", + tag = "Title Group Tag", + path = "/api/title-group-tags", + security( + ("http" = ["Bearer"]) + ), + responses( + (status = 201, description = "Successfully created the title group tag", body=TitleGroupTag), + ) +)] +pub async fn exec( + tag: Json, + arc: Data>, + user: Authdata, +) -> Result { + let created_tag = arc.pool.create_title_group_tag(&tag, user.sub).await?; + + Ok(HttpResponse::Created().json(created_tag)) +} diff --git a/backend/api/src/handlers/title_group_tags/mod.rs b/backend/api/src/handlers/title_group_tags/mod.rs new file mode 100644 index 00000000..a78bb3cf --- /dev/null +++ b/backend/api/src/handlers/title_group_tags/mod.rs @@ -0,0 +1,12 @@ +pub mod apply_tag; +pub mod create_tag; +pub mod remove_tag; + +use actix_web::web::{delete, post, resource, ServiceConfig}; +use arcadia_storage::redis::RedisPoolInterface; + +pub fn config(cfg: &mut ServiceConfig) { + cfg.service(resource("").route(post().to(self::create_tag::exec::))); + cfg.service(resource("/apply").route(post().to(self::apply_tag::exec::))); + cfg.service(resource("/remove").route(delete().to(self::remove_tag::exec::))); +} diff --git a/backend/api/src/handlers/title_group_tags/remove_tag.rs b/backend/api/src/handlers/title_group_tags/remove_tag.rs new file mode 100644 index 00000000..81720ea5 --- /dev/null +++ b/backend/api/src/handlers/title_group_tags/remove_tag.rs @@ -0,0 +1,40 @@ +use crate::{middlewares::auth_middleware::Authdata, Arcadia}; +use actix_web::{ + web::{Data, Json}, + HttpResponse, +}; +use arcadia_common::error::Result; +use arcadia_storage::redis::RedisPoolInterface; +use serde::Deserialize; +use serde_json::json; +use utoipa::ToSchema; + +#[derive(Debug, Deserialize, ToSchema)] +pub struct RemoveTagRequest { + pub title_group_id: i32, + pub tag_name: String, +} + +#[utoipa::path( + delete, + operation_id = "Remove tag from title group", + tag = "Title Group Tag", + path = "/api/title-group-tags/remove", + security( + ("http" = ["Bearer"]) + ), + responses( + (status = 200, description = "Successfully removed the tag from the title group"), + ) +)] +pub async fn exec( + request: Json, + arc: Data>, + _: Authdata, +) -> Result { + arc.pool + .remove_tag_from_title_group(request.title_group_id, &request.tag_name) + .await?; + + Ok(HttpResponse::Ok().json(json!({"result": "success"}))) +} diff --git a/backend/api/src/routes.rs b/backend/api/src/routes.rs index bda8d436..939cec84 100644 --- a/backend/api/src/routes.rs +++ b/backend/api/src/routes.rs @@ -3,7 +3,6 @@ use actix_web_httpauth::middleware::HttpAuthentication; use arcadia_storage::redis::RedisPoolInterface; use crate::handlers::affiliated_artists::config as AffiliatedArtistsConfig; -// use crate::handlers::announces::config as AnnouncesConfig; use crate::handlers::artists::config as ArtistsConfig; use crate::handlers::auth::config as AuthConfig; use crate::handlers::collages::config as CollagesConfig; @@ -21,6 +20,7 @@ use crate::handlers::series::config as SeriesConfig; use crate::handlers::staff_pms::config as StaffPmsConfig; use crate::handlers::subscriptions::config as SubscriptionsConfig; use crate::handlers::title_group_bookmarks::config as BookmarksConfig; +use crate::handlers::title_group_tags::config as TitleGroupTagsConfig; use crate::handlers::title_groups::config as TitleGroupsConfig; use crate::handlers::torrent_requests::config as TorrentRequestsConfig; use crate::handlers::torrents::config as TorrentsConfig; @@ -41,6 +41,7 @@ pub fn init(cfg: &mut web::ServiceConfig) { .service(scope("/user-applications").configure(UserApplicationsConfig::)) .service(scope("/title-group-bookmarks").configure(BookmarksConfig::)) .service(scope("/title-groups").configure(TitleGroupsConfig::)) + .service(scope("/title-group-tags").configure(TitleGroupTagsConfig::)) .service(scope("/edition-groups").configure(EditionGroupsConfig::)) .service(scope("/search").configure(SearchConfig::)) .service(scope("/torrents").configure(TorrentsConfig::)) diff --git a/backend/api/tests/fixtures/with_test_title_group.sql b/backend/api/tests/fixtures/with_test_title_group.sql index 714df166..6b7fde4f 100644 --- a/backend/api/tests/fixtures/with_test_title_group.sql +++ b/backend/api/tests/fixtures/with_test_title_group.sql @@ -12,7 +12,6 @@ INSERT INTO original_language, original_release_date, tagline, - tags, country_from, covers, external_links, @@ -39,7 +38,6 @@ B - P.S. I Love You', 'English', '1962-01-01 00:00:00', NULL, - '{rock,pop}', 'UK', '{https://ia903406.us.archive.org/16/items/mbid-20e0bad7-bfbf-4f18-b0b3-8549dfcef6f3/mbid-20e0bad7-bfbf-4f18-b0b3-8549dfcef6f3-2190513301.jpg}', '{https://musicbrainz.org/release-group/5db85281-934d-36e5-865c-1922ad82a948,https://www.discogs.com/master/1154826-The-Beatles-Love-Me-Do}', @@ -63,7 +61,6 @@ B - P.S. I Love You', 'English', '1999-01-01 00:00:00+00', NULL, - '{simulation,game}', 'UK', '{https://example.com/rollercoaster.jpg}', '{https://en.wikipedia.org/wiki/RollerCoaster_Tycoon}', @@ -73,4 +70,4 @@ B - P.S. I Love You', '[]'::JSONB, NULL, '{}' - ); \ No newline at end of file + ); diff --git a/backend/common/src/error/mod.rs b/backend/common/src/error/mod.rs index 2acc9509..9b7ef8a4 100644 --- a/backend/common/src/error/mod.rs +++ b/backend/common/src/error/mod.rs @@ -66,6 +66,9 @@ pub enum Error { #[error("could not create title group")] CouldNotCreateTitleGroup(#[source] sqlx::Error), + #[error("could not create title group tag")] + CouldNotCreateTitleGroupTag(#[source] sqlx::Error), + #[error("could not create torrent")] CouldNotCreateTorrent(#[source] sqlx::Error), diff --git a/backend/storage/.sqlx/query-0932918cefa7877026926792fda1eb56bb118ec343305ce54714deaca65f33ba.json b/backend/storage/.sqlx/query-0932918cefa7877026926792fda1eb56bb118ec343305ce54714deaca65f33ba.json new file mode 100644 index 00000000..3e18dc23 --- /dev/null +++ b/backend/storage/.sqlx/query-0932918cefa7877026926792fda1eb56bb118ec343305ce54714deaca65f33ba.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO title_group_applied_tags (title_group_id, tag_id)\n VALUES ($1, $2)\n ON CONFLICT DO NOTHING\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int4", + "Int4" + ] + }, + "nullable": [] + }, + "hash": "0932918cefa7877026926792fda1eb56bb118ec343305ce54714deaca65f33ba" +} diff --git a/backend/storage/.sqlx/query-5f77d48e7072420c7ffded38cf73477cb1f8c18d3ea38097c5afbf8cb65febdc.json b/backend/storage/.sqlx/query-34cebdbd9891696a3e03e1342728d6e9914639ce29d4eccf18346318820fc036.json similarity index 88% rename from backend/storage/.sqlx/query-5f77d48e7072420c7ffded38cf73477cb1f8c18d3ea38097c5afbf8cb65febdc.json rename to backend/storage/.sqlx/query-34cebdbd9891696a3e03e1342728d6e9914639ce29d4eccf18346318820fc036.json index ce73a949..8aa234f6 100644 --- a/backend/storage/.sqlx/query-5f77d48e7072420c7ffded38cf73477cb1f8c18d3ea38097c5afbf8cb65febdc.json +++ b/backend/storage/.sqlx/query-34cebdbd9891696a3e03e1342728d6e9914639ce29d4eccf18346318820fc036.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n UPDATE title_groups\n SET\n master_group_id = $2,\n name = $3,\n name_aliases = $4,\n description = $5,\n platform = $6,\n original_language = $7,\n original_release_date = $8,\n tagline = $9,\n country_from = $10,\n covers = $11,\n external_links = $12,\n embedded_links = $13,\n category = $14,\n content_type = $15,\n tags = $16,\n screenshots = $17,\n updated_at = NOW()\n WHERE id = $1\n RETURNING\n id, master_group_id, name, name_aliases AS \"name_aliases!: _\",\n created_at, updated_at, created_by_id, description,\n platform AS \"platform: _\", original_language AS \"original_language: _\", original_release_date,\n tagline, tags AS \"tags!: _\", country_from, covers AS \"covers!: _\",\n external_links AS \"external_links!: _\", embedded_links,\n category AS \"category: _\", content_type AS \"content_type: _\",\n public_ratings, screenshots AS \"screenshots!: _\", series_id\n ", + "query": "\n UPDATE title_groups\n SET\n master_group_id = $2,\n name = $3,\n name_aliases = $4,\n description = $5,\n platform = $6,\n original_language = $7,\n original_release_date = $8,\n tagline = $9,\n country_from = $10,\n covers = $11,\n external_links = $12,\n embedded_links = $13,\n category = $14,\n content_type = $15,\n screenshots = $16,\n updated_at = NOW()\n WHERE id = $1\n RETURNING\n id, master_group_id, name, name_aliases AS \"name_aliases!: _\",\n created_at, updated_at, created_by_id, description,\n platform AS \"platform: _\", original_language AS \"original_language: _\", original_release_date,\n tagline, country_from, covers AS \"covers!: _\",\n external_links AS \"external_links!: _\", embedded_links,\n category AS \"category: _\", content_type AS \"content_type: _\",\n public_ratings, screenshots AS \"screenshots!: _\", series_id,\n COALESCE(\n ARRAY(\n SELECT t.name\n FROM title_group_applied_tags tat\n JOIN title_group_tags t ON t.id = tat.tag_id\n WHERE tat.title_group_id = title_groups.id\n ),\n ARRAY[]::text[]\n ) AS \"tags!: _\"\n ", "describe": { "columns": [ { @@ -135,31 +135,26 @@ }, { "ordinal": 12, - "name": "tags!: _", - "type_info": "VarcharArray" - }, - { - "ordinal": 13, "name": "country_from", "type_info": "Text" }, { - "ordinal": 14, + "ordinal": 13, "name": "covers!: _", "type_info": "TextArray" }, { - "ordinal": 15, + "ordinal": 14, "name": "external_links!: _", "type_info": "TextArray" }, { - "ordinal": 16, + "ordinal": 15, "name": "embedded_links", "type_info": "Jsonb" }, { - "ordinal": 17, + "ordinal": 16, "name": "category: _", "type_info": { "Custom": { @@ -193,7 +188,7 @@ } }, { - "ordinal": 18, + "ordinal": 17, "name": "content_type: _", "type_info": { "Custom": { @@ -214,19 +209,24 @@ } }, { - "ordinal": 19, + "ordinal": 18, "name": "public_ratings", "type_info": "Jsonb" }, { - "ordinal": 20, + "ordinal": 19, "name": "screenshots!: _", "type_info": "TextArray" }, { - "ordinal": 21, + "ordinal": 20, "name": "series_id", "type_info": "Int8" + }, + { + "ordinal": 21, + "name": "tags!: _", + "type_info": "VarcharArray" } ], "parameters": { @@ -361,7 +361,6 @@ } } }, - "VarcharArray", "TextArray" ] }, @@ -378,7 +377,6 @@ true, false, true, - false, true, false, false, @@ -387,8 +385,9 @@ false, false, false, - true + true, + null ] }, - "hash": "5f77d48e7072420c7ffded38cf73477cb1f8c18d3ea38097c5afbf8cb65febdc" + "hash": "34cebdbd9891696a3e03e1342728d6e9914639ce29d4eccf18346318820fc036" } diff --git a/backend/storage/.sqlx/query-3767e89d844ddc486bb0fa998e5f349331024ff2522e39d22a097d1a0450a376.json b/backend/storage/.sqlx/query-3767e89d844ddc486bb0fa998e5f349331024ff2522e39d22a097d1a0450a376.json new file mode 100644 index 00000000..2449b341 --- /dev/null +++ b/backend/storage/.sqlx/query-3767e89d844ddc486bb0fa998e5f349331024ff2522e39d22a097d1a0450a376.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO title_group_applied_tags (title_group_id, tag_id, created_by_id)\n VALUES ($1, $2, $3)\n ON CONFLICT DO NOTHING\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int4", + "Int4", + "Int4" + ] + }, + "nullable": [] + }, + "hash": "3767e89d844ddc486bb0fa998e5f349331024ff2522e39d22a097d1a0450a376" +} diff --git a/backend/storage/.sqlx/query-77241c2d307c8438ce4e9d9c7ccb91256538b4bb4ac29f2f24e27f8c76528419.json b/backend/storage/.sqlx/query-385572ee8919dd3a9d735d29f3035de34b5e8dd5a763c8fb4b288e7a24711168.json similarity index 92% rename from backend/storage/.sqlx/query-77241c2d307c8438ce4e9d9c7ccb91256538b4bb4ac29f2f24e27f8c76528419.json rename to backend/storage/.sqlx/query-385572ee8919dd3a9d735d29f3035de34b5e8dd5a763c8fb4b288e7a24711168.json index 1d01aa48..26a84bf7 100644 --- a/backend/storage/.sqlx/query-77241c2d307c8438ce4e9d9c7ccb91256538b4bb4ac29f2f24e27f8c76528419.json +++ b/backend/storage/.sqlx/query-385572ee8919dd3a9d735d29f3035de34b5e8dd5a763c8fb4b288e7a24711168.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT COALESCE(json_agg(data), '[]'::json) as data FROM (\n SELECT json_build_object(\n 'torrent_request', tr,\n 'title_group', json_build_object(\n 'id', tg.id,\n 'name', tg.name,\n 'content_type', tg.content_type,\n 'original_release_date', tg.original_release_date,\n 'covers', tg.covers,\n 'edition_groups', '[]',\n 'platform', tg.platform\n ),\n 'bounty', json_build_object(\n 'upload', (\n SELECT COALESCE(SUM(trv.bounty_upload), 0)\n FROM torrent_request_votes trv\n WHERE trv.torrent_request_id = tr.id\n ),\n 'bonus_points', (\n SELECT COALESCE(SUM(trv.bounty_bonus_points), 0)\n FROM torrent_request_votes trv\n WHERE trv.torrent_request_id = tr.id\n )\n ),\n 'user_votes_amount', (\n SELECT COALESCE(COUNT(DISTINCT trv2.created_by_id), 0)\n FROM torrent_request_votes trv2\n WHERE trv2.torrent_request_id = tr.id\n ),\n 'affiliated_artists', COALESCE((\n SELECT json_agg(\n json_build_object(\n 'id', aa.id,\n 'title_group_id', aa.title_group_id,\n 'artist_id', aa.artist_id,\n 'roles', aa.roles,\n 'nickname', aa.nickname,\n 'created_at', aa.created_at,\n 'created_by_id', aa.created_by_id,\n 'artist', json_build_object(\n 'id', a.id,\n 'name', a.name,\n 'created_at', a.created_at,\n 'created_by_id', a.created_by_id,\n 'description', a.description,\n 'pictures', a.pictures,\n 'title_groups_amount', a.title_groups_amount,\n 'edition_groups_amount', a.edition_groups_amount,\n 'torrents_amount', a.torrents_amount,\n 'seeders_amount', a.seeders_amount,\n 'leechers_amount', a.leechers_amount,\n 'snatches_amount', a.snatches_amount\n )\n )\n )\n FROM affiliated_artists aa\n JOIN artists a ON a.id = aa.artist_id\n WHERE aa.title_group_id = tg.id\n ), '[]'::json),\n 'series', COALESCE((\n SELECT json_build_object('id', s.id, 'name', s.name)\n FROM series s\n WHERE s.id = tg.series_id\n ), '{}'::json)\n ) as data\n FROM torrent_requests tr\n JOIN title_groups tg ON tr.title_group_id = tg.id\n WHERE ($1::TEXT IS NULL OR tg.name ILIKE '%' || $1 || '%' OR $1 = ANY(tg.name_aliases))\n AND ($2::VARCHAR[] IS NULL OR tg.tags && $2::VARCHAR[])\n ORDER BY tr.created_at DESC\n LIMIT $3 OFFSET $4\n ) sub\n ", + "query": "\n SELECT COALESCE(json_agg(data), '[]'::json) as data FROM (\n SELECT json_build_object(\n 'torrent_request', tr,\n 'title_group', json_build_object(\n 'id', tg.id,\n 'name', tg.name,\n 'content_type', tg.content_type,\n 'original_release_date', tg.original_release_date,\n 'covers', tg.covers,\n 'edition_groups', '[]',\n 'platform', tg.platform\n ),\n 'bounty', json_build_object(\n 'upload', (\n SELECT COALESCE(SUM(trv.bounty_upload), 0)\n FROM torrent_request_votes trv\n WHERE trv.torrent_request_id = tr.id\n ),\n 'bonus_points', (\n SELECT COALESCE(SUM(trv.bounty_bonus_points), 0)\n FROM torrent_request_votes trv\n WHERE trv.torrent_request_id = tr.id\n )\n ),\n 'user_votes_amount', (\n SELECT COALESCE(COUNT(DISTINCT trv2.created_by_id), 0)\n FROM torrent_request_votes trv2\n WHERE trv2.torrent_request_id = tr.id\n ),\n 'affiliated_artists', COALESCE((\n SELECT json_agg(\n json_build_object(\n 'id', aa.id,\n 'title_group_id', aa.title_group_id,\n 'artist_id', aa.artist_id,\n 'roles', aa.roles,\n 'nickname', aa.nickname,\n 'created_at', aa.created_at,\n 'created_by_id', aa.created_by_id,\n 'artist', json_build_object(\n 'id', a.id,\n 'name', a.name,\n 'created_at', a.created_at,\n 'created_by_id', a.created_by_id,\n 'description', a.description,\n 'pictures', a.pictures,\n 'title_groups_amount', a.title_groups_amount,\n 'edition_groups_amount', a.edition_groups_amount,\n 'torrents_amount', a.torrents_amount,\n 'seeders_amount', a.seeders_amount,\n 'leechers_amount', a.leechers_amount,\n 'snatches_amount', a.snatches_amount\n )\n )\n )\n FROM affiliated_artists aa\n JOIN artists a ON a.id = aa.artist_id\n WHERE aa.title_group_id = tg.id\n ), '[]'::json),\n 'series', COALESCE((\n SELECT json_build_object('id', s.id, 'name', s.name)\n FROM series s\n WHERE s.id = tg.series_id\n ), '{}'::json)\n ) as data\n FROM torrent_requests tr\n JOIN title_groups tg ON tr.title_group_id = tg.id\n WHERE ($1::TEXT IS NULL OR tg.name ILIKE '%' || $1 || '%' OR $1 = ANY(tg.name_aliases))\n ORDER BY tr.created_at DESC\n LIMIT $2 OFFSET $3\n ) sub\n ", "describe": { "columns": [ { @@ -12,7 +12,6 @@ "parameters": { "Left": [ "Text", - "VarcharArray", "Int8", "Int8" ] @@ -21,5 +20,5 @@ null ] }, - "hash": "77241c2d307c8438ce4e9d9c7ccb91256538b4bb4ac29f2f24e27f8c76528419" + "hash": "385572ee8919dd3a9d735d29f3035de34b5e8dd5a763c8fb4b288e7a24711168" } diff --git a/backend/storage/.sqlx/query-2e5b50eb82d388982654d096878d0e787f2353784d5f9cc8ff515178f598c950.json b/backend/storage/.sqlx/query-3a104da12b9d05a2af0eff0085dbbecdee013c49ba2985d63910b036831b718e.json similarity index 53% rename from backend/storage/.sqlx/query-2e5b50eb82d388982654d096878d0e787f2353784d5f9cc8ff515178f598c950.json rename to backend/storage/.sqlx/query-3a104da12b9d05a2af0eff0085dbbecdee013c49ba2985d63910b036831b718e.json index 43a6f0f9..9d2ff0a7 100644 --- a/backend/storage/.sqlx/query-2e5b50eb82d388982654d096878d0e787f2353784d5f9cc8ff515178f598c950.json +++ b/backend/storage/.sqlx/query-3a104da12b9d05a2af0eff0085dbbecdee013c49ba2985d63910b036831b718e.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT title_group_id AS \"id!\", title_group_name AS \"name!\", title_group_covers AS \"covers!\",\n title_group_category AS \"category!: _\", title_group_content_type AS \"content_type!: _\", title_group_tags AS \"tags!\",\n title_group_original_release_date AS \"original_release_date!\", title_group_platform AS \"platform!: _\",\n '[]'::jsonb AS \"edition_groups!: _\",\n '[]'::jsonb AS \"affiliated_artists!: _\"\n\n FROM title_group_hierarchy_lite tgh\n\n WHERE ($4::BOOLEAN IS NULL OR tgh.torrent_staff_checked = $4)\n AND ($5::BOOLEAN IS NULL OR tgh.torrent_reported = $5)\n AND (\n $7::INT IS NULL OR\n -- don't return torrents created as anonymous\n -- unless the requesting user is the uploader\n (tgh.torrent_created_by_id = $7 AND (\n tgh.torrent_created_by_id = $8 OR\n NOT tgh.torrent_uploaded_as_anonymous)\n )\n )\n AND (\n $9::BIGINT IS NULL OR\n EXISTS (SELECT 1 FROM affiliated_artists aa WHERE aa.title_group_id = tgh.title_group_id AND aa.artist_id = $9)\n )\n -- name filter (partial match) or external link match or series name match\n AND (\n $10::TEXT IS NULL OR\n tgh.title_group_name ILIKE '%' || $10 || '%' ESCAPE '\\' OR\n tgh.title_group_series_name ILIKE '%' || $10 || '%' ESCAPE '\\'\n )\n AND ($11::TEXT IS NULL OR $11 = ANY(tgh.title_group_external_links))\n AND ($12::BOOLEAN IS TRUE OR tgh.torrent_id IS NOT NULL)\n\n GROUP BY title_group_id, title_group_name, title_group_covers, title_group_category,\n title_group_content_type, title_group_tags, title_group_original_release_date, title_group_platform\n\n ORDER BY\n CASE WHEN $1 = 'title_group_original_release_date' AND $6 = 'asc' THEN title_group_original_release_date END ASC,\n CASE WHEN $1 = 'title_group_original_release_date' AND $6 = 'desc' THEN title_group_original_release_date END DESC,\n CASE WHEN $1 = 'torrent_size' AND $6 = 'asc' THEN MAX(torrent_size) END ASC,\n CASE WHEN $1 = 'torrent_size' AND $6 = 'desc' THEN MAX(torrent_size) END DESC,\n CASE WHEN $1 = 'torrent_created_at' AND $6 = 'asc' THEN MAX(torrent_created_at) END ASC,\n CASE WHEN $1 = 'torrent_created_at' AND $6 = 'desc' THEN MAX(torrent_created_at) END DESC,\n title_group_original_release_date ASC\n\n LIMIT $2 OFFSET $3\n ", + "query": "\n SELECT title_group_id AS \"id!\", title_group_name AS \"name!\", title_group_covers AS \"covers!\",\n title_group_category AS \"category!: _\", title_group_content_type AS \"content_type!: _\", title_group_tag_names AS \"tags!\",\n title_group_original_release_date AS \"original_release_date!\", title_group_platform AS \"platform!: _\",\n '[]'::jsonb AS \"edition_groups!: _\",\n '[]'::jsonb AS \"affiliated_artists!: _\"\n\n FROM title_group_hierarchy_lite tgh\n\n WHERE ($4::BOOLEAN IS NULL OR tgh.torrent_staff_checked = $4)\n AND ($5::BOOLEAN IS NULL OR tgh.torrent_reported = $5)\n AND (\n $7::INT IS NULL OR\n -- don't return torrents created as anonymous\n -- unless the requesting user is the uploader\n (tgh.torrent_created_by_id = $7 AND (\n tgh.torrent_created_by_id = $8 OR\n NOT tgh.torrent_uploaded_as_anonymous)\n )\n )\n AND (\n $9::BIGINT IS NULL OR\n EXISTS (SELECT 1 FROM affiliated_artists aa WHERE aa.title_group_id = tgh.title_group_id AND aa.artist_id = $9)\n )\n -- name filter (partial match) or external link match or series name match\n AND (\n $10::TEXT IS NULL OR\n tgh.title_group_name ILIKE '%' || $10 || '%' ESCAPE '\\' OR\n tgh.title_group_series_name ILIKE '%' || $10 || '%' ESCAPE '\\'\n )\n AND ($11::TEXT IS NULL OR $11 = ANY(tgh.title_group_external_links))\n AND ($12::BOOLEAN IS TRUE OR tgh.torrent_id IS NOT NULL)\n\n GROUP BY title_group_id, title_group_name, title_group_covers, title_group_category,\n title_group_content_type, title_group_tag_names, title_group_original_release_date, title_group_platform\n\n ORDER BY\n CASE WHEN $1 = 'title_group_original_release_date' AND $6 = 'asc' THEN title_group_original_release_date END ASC,\n CASE WHEN $1 = 'title_group_original_release_date' AND $6 = 'desc' THEN title_group_original_release_date END DESC,\n CASE WHEN $1 = 'torrent_size' AND $6 = 'asc' THEN MAX(torrent_size) END ASC,\n CASE WHEN $1 = 'torrent_size' AND $6 = 'desc' THEN MAX(torrent_size) END DESC,\n CASE WHEN $1 = 'torrent_created_at' AND $6 = 'asc' THEN MAX(torrent_created_at) END ASC,\n CASE WHEN $1 = 'torrent_created_at' AND $6 = 'desc' THEN MAX(torrent_created_at) END DESC,\n title_group_original_release_date ASC\n\n LIMIT $2 OFFSET $3\n ", "describe": { "columns": [ { @@ -140,5 +140,5 @@ null ] }, - "hash": "2e5b50eb82d388982654d096878d0e787f2353784d5f9cc8ff515178f598c950" + "hash": "3a104da12b9d05a2af0eff0085dbbecdee013c49ba2985d63910b036831b718e" } diff --git a/backend/storage/.sqlx/query-1e2e5b5c74695a72660829775378c04e7126e680dcf6cf9ad77746dfbc3c6c52.json b/backend/storage/.sqlx/query-3add63fe8f17ca2696f8c1c8633de82ee389113ad743ae5e8405de7947b6e8f3.json similarity index 78% rename from backend/storage/.sqlx/query-1e2e5b5c74695a72660829775378c04e7126e680dcf6cf9ad77746dfbc3c6c52.json rename to backend/storage/.sqlx/query-3add63fe8f17ca2696f8c1c8633de82ee389113ad743ae5e8405de7947b6e8f3.json index adccf718..f8a2c37f 100644 --- a/backend/storage/.sqlx/query-1e2e5b5c74695a72660829775378c04e7126e680dcf6cf9ad77746dfbc3c6c52.json +++ b/backend/storage/.sqlx/query-3add63fe8f17ca2696f8c1c8633de82ee389113ad743ae5e8405de7947b6e8f3.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "WITH torrent_data AS (\n SELECT\n t.edition_group_id,\n jsonb_agg(\n -- Handle anonymity: show creator info only if requesting user is the uploader or if not anonymous\n CASE\n WHEN t.uploaded_as_anonymous AND t.created_by_id != $1 THEN\n (to_jsonb(t) - 'created_by_id' - 'display_created_by_id' - 'display_created_by') ||\n jsonb_build_object('created_by_id', NULL, 'created_by', NULL, 'uploaded_as_anonymous', true)\n ELSE\n (to_jsonb(t) - 'display_created_by_id' - 'display_created_by') ||\n jsonb_build_object('created_by', to_jsonb(u))\n END\n ORDER BY t.size DESC\n ) AS torrents\n FROM torrents_and_reports t\n LEFT JOIN users u ON u.id = t.created_by_id\n GROUP BY t.edition_group_id\n ),\n torrent_request_with_bounty AS (\n SELECT\n tr.*,\n u.username,\n u.warned,\n u.banned,\n COALESCE(SUM(trv.bounty_upload), 0) AS total_upload_bounty,\n COALESCE(SUM(trv.bounty_bonus_points), 0) AS total_bonus_bounty,\n COUNT(DISTINCT trv.created_by_id) AS user_votes_amount\n FROM torrent_requests tr\n LEFT JOIN torrent_request_votes trv ON tr.id = trv.torrent_request_id\n LEFT JOIN users u ON u.id = tr.created_by_id -- Join with users table\n GROUP BY\n tr.id,\n tr.title_group_id,\n tr.created_at,\n tr.updated_at,\n tr.created_by_id,\n tr.filled_by_user_id,\n tr.filled_by_torrent_id,\n tr.filled_at,\n tr.edition_name,\n tr.release_group,\n tr.description,\n tr.languages,\n tr.container,\n tr.audio_codec,\n tr.audio_channels,\n tr.audio_bitrate_sampling,\n tr.video_codec,\n tr.features,\n tr.subtitle_languages,\n tr.video_resolution,\n u.username,\n u.warned,\n u.banned\n ),\n torrent_request_data AS (\n SELECT\n trb.title_group_id,\n jsonb_agg(\n jsonb_build_object(\n 'torrent_request', to_jsonb(trb),\n 'created_by', jsonb_build_object(\n 'id', trb.created_by_id,\n 'username', trb.username,\n 'warned', trb.warned,\n 'banned', trb.banned\n ),\n 'bounty', jsonb_build_object(\n 'upload', trb.total_upload_bounty,\n 'bonus_points', trb.total_bonus_bounty\n ),\n 'user_votes_amount', trb.user_votes_amount\n )\n ORDER BY trb.id\n ) AS torrent_requests\n FROM torrent_request_with_bounty trb\n GROUP BY trb.title_group_id\n ),\n edition_data AS (\n SELECT\n eg.title_group_id,\n jsonb_agg(\n to_jsonb(eg) || jsonb_build_object('torrents', COALESCE(td.torrents, '[]'::jsonb))\n ORDER BY eg.release_date\n ) AS edition_groups\n FROM edition_groups eg\n LEFT JOIN torrent_data td ON td.edition_group_id = eg.id\n GROUP BY eg.title_group_id\n ),\n artist_data AS (\n SELECT\n aa.title_group_id,\n jsonb_agg(\n to_jsonb(aa) || jsonb_build_object('artist', to_jsonb(a))\n ) AS affiliated_artists\n FROM affiliated_artists aa\n JOIN artists a ON a.id = aa.artist_id\n GROUP BY aa.title_group_id\n ),\n entity_data AS (\n SELECT\n ae.title_group_id,\n jsonb_agg(\n to_jsonb(ae) || jsonb_build_object('entity', to_jsonb(e))\n ) AS affiliated_entities\n FROM affiliated_entities ae\n JOIN entities e ON e.id = ae.entity_id\n GROUP BY ae.title_group_id\n ),\n comment_data AS (\n SELECT\n c.title_group_id,\n jsonb_agg(\n to_jsonb(c) || jsonb_build_object('created_by', jsonb_build_object('id', u.id, 'username', u.username, 'avatar', u.avatar, 'warned', u.warned, 'banned', u.banned))\n ORDER BY c.created_at\n ) AS title_group_comments\n FROM title_group_comments c\n LEFT JOIN users u ON u.id = c.created_by_id\n GROUP BY c.title_group_id\n ),\n series_data AS (\n SELECT\n tg.id AS title_group_id,\n jsonb_build_object('name', s.name, 'id', s.id) AS series\n FROM title_groups tg\n LEFT JOIN series s ON s.id = tg.series_id\n ),\n subscription_data AS (\n SELECT\n id,\n EXISTS(\n SELECT 1\n FROM subscriptions_title_group_torrents tgs\n WHERE tgs.title_group_id = tg.id\n AND tgs.user_id = $1\n ) AS is_subscribed\n FROM title_groups tg\n ),\n same_master_group AS (\n SELECT\n jsonb_agg(jsonb_build_object('id', tg_inner.id, 'name', tg_inner.name, 'content_type', tg_inner.content_type, 'platform', tg_inner.platform)) AS in_same_master_group\n FROM title_groups tg_main\n JOIN title_groups tg_inner ON tg_inner.master_group_id = tg_main.master_group_id AND tg_inner.id != tg_main.id\n WHERE tg_main.id = $2 AND tg_main.master_group_id IS NOT NULL\n GROUP BY tg_main.master_group_id\n ),\n collage_metrics AS (\n SELECT\n collage_id,\n COUNT(id) AS entries_amount,\n MAX(created_at) AS last_entry_at\n FROM collage_entry\n GROUP BY collage_id\n ),\n collage_data AS (\n SELECT\n ce.title_group_id,\n jsonb_agg(\n jsonb_build_object(\n 'id', c.id,\n 'created_at', c.created_at,\n 'created_by_id', c.created_by_id,\n 'created_by', jsonb_build_object(\n 'id', u.id,\n 'username', u.username,\n 'warned', u.warned,\n 'banned', u.banned\n ),\n 'name', c.name,\n 'cover', c.cover,\n 'description', c.description,\n 'tags', c.tags,\n 'category', c.category,\n 'collage_type', c.collage_type,\n 'entries_amount', cm.entries_amount,\n 'last_entry_at', cm.last_entry_at\n )\n ORDER BY c.created_at\n ) AS collages\n FROM collage_entry ce\n JOIN collage c ON c.id = ce.collage_id\n JOIN users u ON u.id = c.created_by_id\n LEFT JOIN collage_metrics cm ON cm.collage_id = c.id\n WHERE ce.title_group_id = $2\n GROUP BY ce.title_group_id\n )\n SELECT\n jsonb_build_object(\n 'title_group', to_jsonb(tg),\n 'series', COALESCE(sd.series, '{}'::jsonb),\n 'edition_groups', COALESCE(ed.edition_groups, '[]'::jsonb),\n 'affiliated_artists', COALESCE(ad.affiliated_artists, '[]'::jsonb),\n 'affiliated_entities', COALESCE(aed.affiliated_entities, '[]'::jsonb),\n 'title_group_comments', COALESCE(cd.title_group_comments, '[]'::jsonb),\n 'torrent_requests', COALESCE(trd.torrent_requests, '[]'::jsonb),\n 'is_subscribed', COALESCE(sud.is_subscribed, false),\n 'in_same_master_group', COALESCE(smg.in_same_master_group, '[]'::jsonb),\n 'collages', COALESCE(cod.collages, '[]'::jsonb)\n ) AS title_group_data\n FROM title_groups tg\n LEFT JOIN edition_data ed ON ed.title_group_id = tg.id\n LEFT JOIN artist_data ad ON ad.title_group_id = tg.id\n LEFT JOIN entity_data aed ON aed.title_group_id = tg.id\n LEFT JOIN comment_data cd ON cd.title_group_id = tg.id\n LEFT JOIN series_data sd ON sd.title_group_id = tg.id\n LEFT JOIN torrent_request_data trd ON trd.title_group_id = tg.id\n LEFT JOIN subscription_data sud ON sud.id = tg.id\n LEFT JOIN same_master_group smg ON TRUE -- Only one row will be returned from same_master_group when master_group_id is set\n LEFT JOIN collage_data cod ON cod.title_group_id = tg.id\n WHERE tg.id = $2;", + "query": "WITH torrent_data AS (\n SELECT\n t.edition_group_id,\n jsonb_agg(\n -- Handle anonymity: show creator info only if requesting user is the uploader or if not anonymous\n CASE\n WHEN t.uploaded_as_anonymous AND t.created_by_id != $1 THEN\n (to_jsonb(t) - 'created_by_id' - 'display_created_by_id' - 'display_created_by') ||\n jsonb_build_object('created_by_id', NULL, 'created_by', NULL, 'uploaded_as_anonymous', true)\n ELSE\n (to_jsonb(t) - 'display_created_by_id' - 'display_created_by') ||\n jsonb_build_object('created_by', to_jsonb(u))\n END\n ORDER BY t.size DESC\n ) AS torrents\n FROM torrents_and_reports t\n LEFT JOIN users u ON u.id = t.created_by_id\n GROUP BY t.edition_group_id\n ),\n torrent_request_with_bounty AS (\n SELECT\n tr.*,\n u.username,\n u.warned,\n u.banned,\n COALESCE(SUM(trv.bounty_upload), 0) AS total_upload_bounty,\n COALESCE(SUM(trv.bounty_bonus_points), 0) AS total_bonus_bounty,\n COUNT(DISTINCT trv.created_by_id) AS user_votes_amount\n FROM torrent_requests tr\n LEFT JOIN torrent_request_votes trv ON tr.id = trv.torrent_request_id\n LEFT JOIN users u ON u.id = tr.created_by_id -- Join with users table\n GROUP BY\n tr.id,\n tr.title_group_id,\n tr.created_at,\n tr.updated_at,\n tr.created_by_id,\n tr.filled_by_user_id,\n tr.filled_by_torrent_id,\n tr.filled_at,\n tr.edition_name,\n tr.release_group,\n tr.description,\n tr.languages,\n tr.container,\n tr.audio_codec,\n tr.audio_channels,\n tr.audio_bitrate_sampling,\n tr.video_codec,\n tr.features,\n tr.subtitle_languages,\n tr.video_resolution,\n u.username,\n u.warned,\n u.banned\n ),\n torrent_request_data AS (\n SELECT\n trb.title_group_id,\n jsonb_agg(\n jsonb_build_object(\n 'torrent_request', to_jsonb(trb),\n 'created_by', jsonb_build_object(\n 'id', trb.created_by_id,\n 'username', trb.username,\n 'warned', trb.warned,\n 'banned', trb.banned\n ),\n 'bounty', jsonb_build_object(\n 'upload', trb.total_upload_bounty,\n 'bonus_points', trb.total_bonus_bounty\n ),\n 'user_votes_amount', trb.user_votes_amount\n )\n ORDER BY trb.id\n ) AS torrent_requests\n FROM torrent_request_with_bounty trb\n GROUP BY trb.title_group_id\n ),\n edition_data AS (\n SELECT\n eg.title_group_id,\n jsonb_agg(\n to_jsonb(eg) || jsonb_build_object('torrents', COALESCE(td.torrents, '[]'::jsonb))\n ORDER BY eg.release_date\n ) AS edition_groups\n FROM edition_groups eg\n LEFT JOIN torrent_data td ON td.edition_group_id = eg.id\n GROUP BY eg.title_group_id\n ),\n artist_data AS (\n SELECT\n aa.title_group_id,\n jsonb_agg(\n to_jsonb(aa) || jsonb_build_object('artist', to_jsonb(a))\n ) AS affiliated_artists\n FROM affiliated_artists aa\n JOIN artists a ON a.id = aa.artist_id\n GROUP BY aa.title_group_id\n ),\n entity_data AS (\n SELECT\n ae.title_group_id,\n jsonb_agg(\n to_jsonb(ae) || jsonb_build_object('entity', to_jsonb(e))\n ) AS affiliated_entities\n FROM affiliated_entities ae\n JOIN entities e ON e.id = ae.entity_id\n GROUP BY ae.title_group_id\n ),\n comment_data AS (\n SELECT\n c.title_group_id,\n jsonb_agg(\n to_jsonb(c) || jsonb_build_object('created_by', jsonb_build_object('id', u.id, 'username', u.username, 'avatar', u.avatar, 'warned', u.warned, 'banned', u.banned))\n ORDER BY c.created_at\n ) AS title_group_comments\n FROM title_group_comments c\n LEFT JOIN users u ON u.id = c.created_by_id\n GROUP BY c.title_group_id\n ),\n series_data AS (\n SELECT\n tg.id AS title_group_id,\n jsonb_build_object('name', s.name, 'id', s.id) AS series\n FROM title_groups tg\n LEFT JOIN series s ON s.id = tg.series_id\n ),\n subscription_data AS (\n SELECT\n id,\n EXISTS(\n SELECT 1\n FROM subscriptions_title_group_torrents tgs\n WHERE tgs.title_group_id = tg.id\n AND tgs.user_id = $1\n ) AS is_subscribed\n FROM title_groups tg\n ),\n same_master_group AS (\n SELECT\n jsonb_agg(jsonb_build_object('id', tg_inner.id, 'name', tg_inner.name, 'content_type', tg_inner.content_type, 'platform', tg_inner.platform)) AS in_same_master_group\n FROM title_groups tg_main\n JOIN title_groups tg_inner ON tg_inner.master_group_id = tg_main.master_group_id AND tg_inner.id != tg_main.id\n WHERE tg_main.id = $2 AND tg_main.master_group_id IS NOT NULL\n GROUP BY tg_main.master_group_id\n ),\n collage_metrics AS (\n SELECT\n collage_id,\n COUNT(id) AS entries_amount,\n MAX(created_at) AS last_entry_at\n FROM collage_entry\n GROUP BY collage_id\n ),\n collage_data AS (\n SELECT\n ce.title_group_id,\n jsonb_agg(\n jsonb_build_object(\n 'id', c.id,\n 'created_at', c.created_at,\n 'created_by_id', c.created_by_id,\n 'created_by', jsonb_build_object(\n 'id', u.id,\n 'username', u.username,\n 'warned', u.warned,\n 'banned', u.banned\n ),\n 'name', c.name,\n 'cover', c.cover,\n 'description', c.description,\n 'tags', c.tags,\n 'category', c.category,\n 'collage_type', c.collage_type,\n 'entries_amount', cm.entries_amount,\n 'last_entry_at', cm.last_entry_at\n )\n ORDER BY c.created_at\n ) AS collages\n FROM collage_entry ce\n JOIN collage c ON c.id = ce.collage_id\n JOIN users u ON u.id = c.created_by_id\n LEFT JOIN collage_metrics cm ON cm.collage_id = c.id\n WHERE ce.title_group_id = $2\n GROUP BY ce.title_group_id\n ),\n title_group_tags AS (\n SELECT\n tg.id AS title_group_id,\n COALESCE(\n ARRAY(\n SELECT t.name\n FROM title_group_applied_tags tat\n JOIN title_group_tags t ON t.id = tat.tag_id\n WHERE tat.title_group_id = tg.id\n ),\n ARRAY[]::text[]\n ) AS tags\n FROM title_groups tg\n )\n SELECT\n jsonb_build_object(\n 'title_group', to_jsonb(tg) || jsonb_build_object('tags', COALESCE(td.tags, ARRAY[]::text[])),\n 'series', COALESCE(sd.series, '{}'::jsonb),\n 'edition_groups', COALESCE(ed.edition_groups, '[]'::jsonb),\n 'affiliated_artists', COALESCE(ad.affiliated_artists, '[]'::jsonb),\n 'affiliated_entities', COALESCE(aed.affiliated_entities, '[]'::jsonb),\n 'title_group_comments', COALESCE(cd.title_group_comments, '[]'::jsonb),\n 'torrent_requests', COALESCE(trd.torrent_requests, '[]'::jsonb),\n 'is_subscribed', COALESCE(sud.is_subscribed, false),\n 'in_same_master_group', COALESCE(smg.in_same_master_group, '[]'::jsonb),\n 'collages', COALESCE(cod.collages, '[]'::jsonb)\n ) AS title_group_data\n FROM title_groups tg\n LEFT JOIN title_group_tags td ON td.title_group_id = tg.id\n LEFT JOIN edition_data ed ON ed.title_group_id = tg.id\n LEFT JOIN artist_data ad ON ad.title_group_id = tg.id\n LEFT JOIN entity_data aed ON aed.title_group_id = tg.id\n LEFT JOIN comment_data cd ON cd.title_group_id = tg.id\n LEFT JOIN series_data sd ON sd.title_group_id = tg.id\n LEFT JOIN torrent_request_data trd ON trd.title_group_id = tg.id\n LEFT JOIN subscription_data sud ON sud.id = tg.id\n LEFT JOIN same_master_group smg ON TRUE -- Only one row will be returned from same_master_group when master_group_id is set\n LEFT JOIN collage_data cod ON cod.title_group_id = tg.id\n WHERE tg.id = $2;", "describe": { "columns": [ { @@ -19,5 +19,5 @@ null ] }, - "hash": "1e2e5b5c74695a72660829775378c04e7126e680dcf6cf9ad77746dfbc3c6c52" + "hash": "3add63fe8f17ca2696f8c1c8633de82ee389113ad743ae5e8405de7947b6e8f3" } diff --git a/backend/storage/.sqlx/query-7115e0aefdb908aca7046d7a0b75a90a8685fe2ef9948b8572e524c304f88791.json b/backend/storage/.sqlx/query-7115e0aefdb908aca7046d7a0b75a90a8685fe2ef9948b8572e524c304f88791.json new file mode 100644 index 00000000..09c96b97 --- /dev/null +++ b/backend/storage/.sqlx/query-7115e0aefdb908aca7046d7a0b75a90a8685fe2ef9948b8572e524c304f88791.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM title_group_applied_tags\n WHERE title_group_id = $1 AND tag_id = $2\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int4", + "Int4" + ] + }, + "nullable": [] + }, + "hash": "7115e0aefdb908aca7046d7a0b75a90a8685fe2ef9948b8572e524c304f88791" +} diff --git a/backend/storage/.sqlx/query-712a10208493bb34c4740d135f1da87186f244880a1823fd422f3935e45dd57b.json b/backend/storage/.sqlx/query-712a10208493bb34c4740d135f1da87186f244880a1823fd422f3935e45dd57b.json new file mode 100644 index 00000000..e84cf2c6 --- /dev/null +++ b/backend/storage/.sqlx/query-712a10208493bb34c4740d135f1da87186f244880a1823fd422f3935e45dd57b.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id FROM title_group_tags WHERE name = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "712a10208493bb34c4740d135f1da87186f244880a1823fd422f3935e45dd57b" +} diff --git a/backend/storage/.sqlx/query-2912c2e0c16b5dd6d8e6d0a58450f5f394ed490d19c9783405109726ee86e18b.json b/backend/storage/.sqlx/query-794430240cbe19b0d54bf70460b85d64d2ca0c8c6a8220af548b4898bdd319bb.json similarity index 87% rename from backend/storage/.sqlx/query-2912c2e0c16b5dd6d8e6d0a58450f5f394ed490d19c9783405109726ee86e18b.json rename to backend/storage/.sqlx/query-794430240cbe19b0d54bf70460b85d64d2ca0c8c6a8220af548b4898bdd319bb.json index fedc6307..1026ae52 100644 --- a/backend/storage/.sqlx/query-2912c2e0c16b5dd6d8e6d0a58450f5f394ed490d19c9783405109726ee86e18b.json +++ b/backend/storage/.sqlx/query-794430240cbe19b0d54bf70460b85d64d2ca0c8c6a8220af548b4898bdd319bb.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n id, master_group_id, name, name_aliases AS \"name_aliases!: _\",\n created_at, updated_at, created_by_id, description,\n platform AS \"platform: _\", original_language AS \"original_language: _\", original_release_date,\n tagline, tags AS \"tags!: _\", country_from, covers AS \"covers!: _\",\n external_links AS \"external_links!: _\", embedded_links,\n category AS \"category: _\", content_type AS \"content_type: _\",\n public_ratings, screenshots AS \"screenshots!: _\", series_id\n FROM title_groups\n WHERE id = $1\n ", + "query": "\n SELECT\n id, master_group_id, name, name_aliases AS \"name_aliases!: _\",\n created_at, updated_at, created_by_id, description,\n platform AS \"platform: _\", original_language AS \"original_language: _\", original_release_date,\n tagline, country_from, covers AS \"covers!: _\",\n external_links AS \"external_links!: _\", embedded_links,\n category AS \"category: _\", content_type AS \"content_type: _\",\n public_ratings, screenshots AS \"screenshots!: _\", series_id,\n COALESCE(\n ARRAY(\n SELECT t.name\n FROM title_group_applied_tags tat\n JOIN title_group_tags t ON t.id = tat.tag_id\n WHERE tat.title_group_id = title_groups.id\n ),\n ARRAY[]::text[]\n ) AS \"tags!: _\"\n FROM title_groups\n WHERE id = $1\n ", "describe": { "columns": [ { @@ -135,31 +135,26 @@ }, { "ordinal": 12, - "name": "tags!: _", - "type_info": "VarcharArray" - }, - { - "ordinal": 13, "name": "country_from", "type_info": "Text" }, { - "ordinal": 14, + "ordinal": 13, "name": "covers!: _", "type_info": "TextArray" }, { - "ordinal": 15, + "ordinal": 14, "name": "external_links!: _", "type_info": "TextArray" }, { - "ordinal": 16, + "ordinal": 15, "name": "embedded_links", "type_info": "Jsonb" }, { - "ordinal": 17, + "ordinal": 16, "name": "category: _", "type_info": { "Custom": { @@ -193,7 +188,7 @@ } }, { - "ordinal": 18, + "ordinal": 17, "name": "content_type: _", "type_info": { "Custom": { @@ -214,19 +209,24 @@ } }, { - "ordinal": 19, + "ordinal": 18, "name": "public_ratings", "type_info": "Jsonb" }, { - "ordinal": 20, + "ordinal": 19, "name": "screenshots!: _", "type_info": "TextArray" }, { - "ordinal": 21, + "ordinal": 20, "name": "series_id", "type_info": "Int8" + }, + { + "ordinal": 21, + "name": "tags!: _", + "type_info": "VarcharArray" } ], "parameters": { @@ -247,7 +247,6 @@ true, false, true, - false, true, false, false, @@ -256,8 +255,9 @@ false, false, false, - true + true, + null ] }, - "hash": "2912c2e0c16b5dd6d8e6d0a58450f5f394ed490d19c9783405109726ee86e18b" + "hash": "794430240cbe19b0d54bf70460b85d64d2ca0c8c6a8220af548b4898bdd319bb" } diff --git a/backend/storage/.sqlx/query-9a1cbde7336ef3cf344ec2dfd228a8f57dfd20b17095604cfc86ba3c15fcfa8f.json b/backend/storage/.sqlx/query-9a1cbde7336ef3cf344ec2dfd228a8f57dfd20b17095604cfc86ba3c15fcfa8f.json new file mode 100644 index 00000000..75d9ae3b --- /dev/null +++ b/backend/storage/.sqlx/query-9a1cbde7336ef3cf344ec2dfd228a8f57dfd20b17095604cfc86ba3c15fcfa8f.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n name,\n synonyms as \"synonyms!: Vec\",\n id\n FROM title_group_tags\n WHERE\n name ILIKE '%' || $1 || '%'\n OR EXISTS (\n SELECT 1\n FROM unnest(synonyms) AS synonym\n WHERE synonym ILIKE '%' || $1 || '%'\n )\n ORDER BY name\n LIMIT 10\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "synonyms!: Vec", + "type_info": "VarcharArray" + }, + { + "ordinal": 2, + "name": "id", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + true, + false + ] + }, + "hash": "9a1cbde7336ef3cf344ec2dfd228a8f57dfd20b17095604cfc86ba3c15fcfa8f" +} diff --git a/backend/storage/.sqlx/query-e3730b114f60276f08a725edf645139e9066df3dcdded560c95cf36282b1f37d.json b/backend/storage/.sqlx/query-e3730b114f60276f08a725edf645139e9066df3dcdded560c95cf36282b1f37d.json new file mode 100644 index 00000000..cc03d73b --- /dev/null +++ b/backend/storage/.sqlx/query-e3730b114f60276f08a725edf645139e9066df3dcdded560c95cf36282b1f37d.json @@ -0,0 +1,153 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO title_groups (\n master_group_id,\n name,\n name_aliases,\n created_by_id,\n description,\n original_language,\n country_from,\n covers,\n external_links,\n embedded_links,\n category,\n content_type,\n original_release_date,\n tagline,\n platform,\n screenshots,\n public_ratings\n )\n VALUES (\n $1, $2, $3, $4, $5, $6::language_enum,\n $7, $8, $9, $10, $11::title_group_category_enum,\n $12::content_type_enum, $13, $14, $15, $16, $17\n )\n RETURNING id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Int4", + "Text", + "TextArray", + "Int4", + "Text", + { + "Custom": { + "name": "language_enum", + "kind": { + "Enum": [ + "Albanian", + "Arabic", + "Belarusian", + "Bengali", + "Bosnian", + "Bulgarian", + "Cantonese", + "Catalan", + "Chinese", + "Croatian", + "Czech", + "Danish", + "Dutch", + "English", + "Estonian", + "Finnish", + "French", + "German", + "Greek", + "Hebrew", + "Hindi", + "Hungarian", + "Icelandic", + "Indonesian", + "Italian", + "Japanese", + "Kannada", + "Korean", + "Macedonian", + "Malayalam", + "Mandarin", + "Nepali", + "Norwegian", + "Persian", + "Polish", + "Portuguese", + "Romanian", + "Russian", + "Serbian", + "Spanish", + "Swedish", + "Tamil", + "Tagalog", + "Telugu", + "Thai", + "Turkish", + "Ukrainian", + "Vietnamese", + "Wolof", + "Other" + ] + } + } + }, + "Text", + "TextArray", + "TextArray", + "Jsonb", + { + "Custom": { + "name": "title_group_category_enum", + "kind": { + "Enum": [ + "Ep", + "Album", + "Single", + "Soundtrack", + "Anthology", + "Compilation", + "Remix", + "Bootleg", + "Mixtape", + "ConcertRecording", + "DjMix", + "FeatureFilm", + "ShortFilm", + "Game", + "Program", + "Illustrated", + "Periodical", + "Book", + "Article", + "Manual", + "Other" + ] + } + } + }, + { + "Custom": { + "name": "content_type_enum", + "kind": { + "Enum": [ + "movie", + "video", + "tv_show", + "music", + "podcast", + "software", + "book", + "collection" + ] + } + } + }, + "Timestamptz", + "Text", + { + "Custom": { + "name": "platform_enum", + "kind": { + "Enum": [ + "Linux", + "MacOS", + "Windows", + "Xbox" + ] + } + } + }, + "TextArray", + "Jsonb" + ] + }, + "nullable": [ + false + ] + }, + "hash": "e3730b114f60276f08a725edf645139e9066df3dcdded560c95cf36282b1f37d" +} diff --git a/backend/storage/.sqlx/query-f4a861b5d1c3c1d52f04f024462a82929e76891ad628d44244afde6e35005165.json b/backend/storage/.sqlx/query-f4a861b5d1c3c1d52f04f024462a82929e76891ad628d44244afde6e35005165.json new file mode 100644 index 00000000..50b1444a --- /dev/null +++ b/backend/storage/.sqlx/query-f4a861b5d1c3c1d52f04f024462a82929e76891ad628d44244afde6e35005165.json @@ -0,0 +1,47 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO title_group_tags (name, created_by_id)\n VALUES ($1, $2)\n ON CONFLICT (name) DO NOTHING\n RETURNING\n id,\n name,\n synonyms as \"synonyms!: Vec\",\n created_at,\n created_by_id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "synonyms!: Vec", + "type_info": "VarcharArray" + }, + { + "ordinal": 3, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "created_by_id", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Varchar", + "Int4" + ] + }, + "nullable": [ + false, + false, + true, + false, + false + ] + }, + "hash": "f4a861b5d1c3c1d52f04f024462a82929e76891ad628d44244afde6e35005165" +} diff --git a/backend/storage/migrations/20250312215600_initdb.sql b/backend/storage/migrations/20250312215600_initdb.sql index 556d57f7..eaf3f3cf 100644 --- a/backend/storage/migrations/20250312215600_initdb.sql +++ b/backend/storage/migrations/20250312215600_initdb.sql @@ -263,7 +263,6 @@ CREATE TABLE title_groups ( original_language language_enum, original_release_date TIMESTAMP WITH TIME ZONE NOT NULL, tagline TEXT, - tags VARCHAR(50) [] NOT NULL, country_from TEXT, covers TEXT [] NOT NULL, external_links TEXT [] NOT NULL, @@ -281,12 +280,60 @@ CREATE TABLE title_groups ( SET NULL ); CREATE TABLE similar_title_groups ( - group_1_id BIGINT NOT NULL, - group_2_id BIGINT NOT NULL, + group_1_id INT NOT NULL, + group_2_id INT NOT NULL, PRIMARY KEY (group_1_id, group_2_id), FOREIGN KEY (group_1_id) REFERENCES title_groups(id) ON DELETE CASCADE, FOREIGN KEY (group_2_id) REFERENCES title_groups(id) ON DELETE CASCADE ); +CREATE TABLE title_group_tags ( + id SERIAL PRIMARY KEY, + name VARCHAR(40) NOT NULL, + synonyms VARCHAR(40)[] DEFAULT '{}', + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + created_by_id INT NOT NULL, + FOREIGN KEY (created_by_id) REFERENCES users(id) ON DELETE CASCADE, + UNIQUE (name) +); + +CREATE OR REPLACE FUNCTION enforce_unique_title_group_tag_synonyms() +RETURNS TRIGGER AS $$ +DECLARE + existing VARCHAR(40); + conflict_tag_name VARCHAR(40); +BEGIN + -- Loop through each synonym in the new row + FOREACH existing IN ARRAY NEW.synonyms LOOP + -- Check if this synonym exists in any other row (or if it's an existing tag name) + SELECT name INTO conflict_tag_name + FROM title_group_tags + WHERE id <> NEW.id + AND (existing = ANY(synonyms) OR existing = name) + LIMIT 1; + + IF conflict_tag_name IS NOT NULL THEN + RAISE EXCEPTION 'Synonym "%" already exists in title_group_tag "%" ', existing, conflict_tag_name; + END IF; + END LOOP; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_unique_synonyms +BEFORE INSERT OR UPDATE ON title_group_tags +FOR EACH ROW +EXECUTE FUNCTION enforce_unique_title_group_tag_synonyms(); + +CREATE TABLE title_group_applied_tags ( + title_group_id INT NOT NULL, + tag_id INT NOT NULL, + created_by_id INT NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + PRIMARY KEY (title_group_id, tag_id), + FOREIGN KEY (title_group_id) REFERENCES title_groups(id) ON DELETE CASCADE, + FOREIGN KEY (tag_id) REFERENCES title_group_tags(id) ON DELETE CASCADE +); CREATE TYPE artist_role_enum AS ENUM ( 'main', 'guest', @@ -1003,7 +1050,7 @@ CREATE VIEW get_title_groups_and_edition_group_and_torrents_lite AS 'covers', tgr.covers, 'category', tgr.category, 'content_type', tgr.content_type, - 'tags', tgr.tags, + -- 'tags', tgr.tags, 'original_release_date', tgr.original_release_date, 'platform', tgr.platform ) || jsonb_build_object( @@ -1015,7 +1062,7 @@ CREATE VIEW get_title_groups_and_edition_group_and_torrents_lite AS LEFT JOIN edition_groups_with_torrents egwt ON tgr.id = egwt.title_group_id LEFT JOIN affiliated_artists_data aad ON tgr.id = aad.title_group_id GROUP BY - tgr.id, tgr.name, tgr.covers, tgr.category, tgr.content_type, tgr.tags, tgr.original_release_date, tgr.platform, aad.affiliated_artists + tgr.id, tgr.name, tgr.covers, tgr.category, tgr.content_type, tgr.original_release_date, tgr.platform, aad.affiliated_artists ORDER BY tgr.original_release_date DESC, tgr.id ASC; @@ -1028,10 +1075,11 @@ SELECT title_groups.covers AS title_group_covers, title_groups.category AS title_group_category, title_groups.content_type AS title_group_content_type, - title_groups.tags AS title_group_tags, title_groups.platform AS title_group_platform, title_groups.original_release_date AS title_group_original_release_date, title_groups.external_links AS title_group_external_links, + tg_tags.tag_ids AS title_group_tag_ids, + tg_tags.tag_names AS title_group_tag_names, series.id AS title_group_series_id, series.name AS title_group_series_name, @@ -1081,6 +1129,26 @@ SELECT WHERE tr.reported_torrent_id = torrents.id )) AS torrent_reported FROM title_groups +LEFT JOIN LATERAL ( + SELECT + COALESCE( + ARRAY( + SELECT tat.tag_id + FROM title_group_applied_tags tat + WHERE tat.title_group_id = title_groups.id + ), + ARRAY[]::int[] + ) AS tag_ids, + COALESCE( + ARRAY( + SELECT t.name + FROM title_group_applied_tags tat + JOIN title_group_tags t ON t.id = tat.tag_id + WHERE tat.title_group_id = title_groups.id + ), + ARRAY[]::text[] + ) AS tag_names +) tg_tags ON TRUE LEFT JOIN edition_groups ON edition_groups.title_group_id = title_groups.id LEFT JOIN torrents ON torrents.edition_group_id = edition_groups.id LEFT JOIN series ON series.id = title_groups.series_id; @@ -1118,3 +1186,8 @@ create trigger refresh_materialized_view_title_group_hierarchy_lite after insert or update or delete or truncate on series for each statement execute procedure refresh_materialized_view_title_group_hierarchy_lite(); + +create trigger refresh_materialized_view_title_group_hierarchy_lite +after insert or update or delete or truncate +on title_group_applied_tags for each statement +execute procedure refresh_materialized_view_title_group_hierarchy_lite(); diff --git a/backend/storage/migrations/fixtures/fixtures.sql b/backend/storage/migrations/fixtures/fixtures.sql index d7f5f19a..a8a6ebb7 100644 --- a/backend/storage/migrations/fixtures/fixtures.sql +++ b/backend/storage/migrations/fixtures/fixtures.sql @@ -65,36 +65,36 @@ INSERT INTO public.series VALUES (3, 'Pimp my ride', 'Xzibit and the good people INSERT INTO public.title_groups VALUES (1, NULL, 'Love Me Do / P.S. I Love You', '{""}', '2025-03-30 16:35:06.418293+00', '2025-03-30 16:35:06.418293+00', 1, 'Tracklist A - Love Me Do -B - P.S. I Love You', NULL, 'English', '1962-01-01 00:00:00+00', NULL, '{rock,pop}', 'UK', '{https://ia903406.us.archive.org/16/items/mbid-20e0bad7-bfbf-4f18-b0b3-8549dfcef6f3/mbid-20e0bad7-bfbf-4f18-b0b3-8549dfcef6f3-2190513301.jpg}', '{https://musicbrainz.org/release-group/5db85281-934d-36e5-865c-1922ad82a948,https://www.discogs.com/master/1154826-The-Beatles-Love-Me-Do,https://redacted.sh/torrents.php?id=1814965,https://orpheus.network/torrents.php?id=907423}', '{}', 'Single', 'music', '[]', '{}', NULL); -INSERT INTO public.title_groups VALUES (8, NULL, 'Season 1', '{""}', '2025-03-31 18:35:58.583204+00', '2025-03-31 18:35:58.583204+00', 1, 'Season 1 of the series', NULL, 'English', '2004-03-04 00:00:00+00', NULL, '{reality}', 'USA', '{https://thetvdb.com/banners/seasons/26075-1.jpg}', '{https://thetvdb.com/series/pimp-my-ride}', '{}', NULL, 'tv_show', '[]', '{}', 3); +B - P.S. I Love You', NULL, 'English', '1962-01-01 00:00:00+00', NULL, 'UK', '{https://ia903406.us.archive.org/16/items/mbid-20e0bad7-bfbf-4f18-b0b3-8549dfcef6f3/mbid-20e0bad7-bfbf-4f18-b0b3-8549dfcef6f3-2190513301.jpg}', '{https://musicbrainz.org/release-group/5db85281-934d-36e5-865c-1922ad82a948,https://www.discogs.com/master/1154826-The-Beatles-Love-Me-Do,https://redacted.sh/torrents.php?id=1814965,https://orpheus.network/torrents.php?id=907423}', '{}', 'Single', 'music', '[]', '{}', NULL); +INSERT INTO public.title_groups VALUES (8, NULL, 'Season 1', '{""}', '2025-03-31 18:35:58.583204+00', '2025-03-31 18:35:58.583204+00', 1, 'Season 1 of the series', NULL, 'English', '2004-03-04 00:00:00+00', NULL, 'USA', '{https://thetvdb.com/banners/seasons/26075-1.jpg}', '{https://thetvdb.com/series/pimp-my-ride}', '{}', NULL, 'tv_show', '[]', '{}', 3); INSERT INTO public.title_groups VALUES (13, 1, ' Rollercoaster Tycoon (Original Game Soundtrack)', '{}', '2025-04-20 18:50:39.453999+00', '2025-04-20 18:50:39.453999+00', 1, 'Tracklist 01. Title Theme (From \u201cRollercoaster Tycoon 1\u201d) (02:07) -02. Title Theme (From \u201cRollercoaster Tycoon 2\u201d) (01:43)', NULL, 'English', '2022-01-01 00:00:00+00', NULL, '{video.game,soundtrack}', 'UK', '{https://m.media-amazon.com/images/I/61WavFxog8L._UXNaN_FMjpg_QL85_.jpg}', '{https://en.wikipedia.org/wiki/RollerCoaster_Tycoon_(video_game)}', '{}', 'Soundtrack', 'music', '[]', '{}', NULL); -INSERT INTO public.title_groups VALUES (2, NULL, 'The Gold Rush', '{""}', '2025-03-31 10:22:23.744818+00', '2025-03-31 10:22:23.744818+00', 1, 'A gold prospector in Alaska struggles to survive the elements and win the heart of a dance hall girl.', NULL, 'English', '1925-08-15 00:00:00+00', NULL, '{adventure,comedy,drama}', 'USA', '{https://image.tmdb.org/t/p/w1280/eQRFo1qwRREYwj47Yoe1PisgOle.jpg}', '{https://www.themoviedb.org/movie/962-the-gold-rush,https://www.imdb.com/title/tt0015864/}', '{"Clips": {"Charlie Chaplin Eating His Shoe": "https://www.youtube.com/embed/u65lvwfTPtM"}, "Trailers": {"Official trailer": "https://www.youtube.com/embed/kDlEvaKBkhU", "100th Anniversary 4K Restoration Trailer": "https://www.youtube.com/embed/N9-VZlxIIAs"}}', 'FeatureFilm', 'movie', '[]', '{}', NULL); -INSERT INTO public.title_groups VALUES (3, NULL, 'Les Misérables', '{""}', '2025-03-31 11:02:49.707845+00', '2025-03-31 11:02:49.707845+00', 1, 'Les Misérables (/leɪ ˌmɪzəˈrɑːb(əl), -blə/,[4] French: [le mizeʁabl]) is a French epic historical novel by Victor Hugo, first published on 31 March 1862, that is considered one of the greatest novels of the 19th century. Les Misérables has been popularized through numerous adaptations for film, television, and the stage, including a musical. ', NULL, 'French', '1862-03-31 00:16:08+00', NULL, '{drama,historical}', 'France', '{https://francetoday.com/wp-content/uploads/2022/03/51JEItnoKFL.jpg}', '{https://openlibrary.org/works/OL1063588W/Les_Mis%C3%A9rables}', '{}', 'Book', 'book', '[]', '{}', NULL); -INSERT INTO public.title_groups VALUES (4, NULL, 'Skyman No. 1', '{""}', '2025-03-31 12:02:07.049479+00', '2025-03-31 12:02:07.049479+00', 1, 'Cover by Ogden Whitney. Stories and art by Boody Rogers, Gardner Fox, Ogden Whitney, Paul Dean, Fred Schwab and Mart Bailey. Columbia Comics launches a solo book featuring aviator hero Skyman, one of the stars of Big Shot Comics. Orphaned by a plane crash and raised by his uncle to be superhuman, Allen Turner uses his experimental aircraft The Wing to fight crime as Skyman. Sparky Watts encounters The World''s Strongest Puppy, in a typically wacky Boody Rogers tale. In a story that illustrates its times, Skyman battles saboteur Red Signet and his gang, who are trying to force isolationist America into joining World War II. Plus a Face story, and an ad for the never-produced Skyman daily comic strip. Introducing the Skyman; The Paralyzing Ray; The Skyman Encounters Kidnappers; Sparky Watts; Jibby Jones; Mortimer the Monk; Saboteurs; The Red Signet; The Face: The Orphan Asylum; War Games. 64 pages, Full Color. Cover price $0.10. ', NULL, 'English', '1940-05-01 00:00:00+00', NULL, '{superhero}', 'USA', '{https://media.mycomicshop.com/n_iv/600/837159.jpg}', '{https://en.wikipedia.org/wiki/Skyman_(Columbia_Comics)}', '{}', 'Illustrated', 'book', '[]', '{}', 1); +02. Title Theme (From \u201cRollercoaster Tycoon 2\u201d) (01:43)', NULL, 'English', '2022-01-01 00:00:00+00', NULL, 'UK', '{https://m.media-amazon.com/images/I/61WavFxog8L._UXNaN_FMjpg_QL85_.jpg}', '{https://en.wikipedia.org/wiki/RollerCoaster_Tycoon_(video_game)}', '{}', 'Soundtrack', 'music', '[]', '{}', NULL); +INSERT INTO public.title_groups VALUES (2, NULL, 'The Gold Rush', '{""}', '2025-03-31 10:22:23.744818+00', '2025-03-31 10:22:23.744818+00', 1, 'A gold prospector in Alaska struggles to survive the elements and win the heart of a dance hall girl.', NULL, 'English', '1925-08-15 00:00:00+00', NULL, 'USA', '{https://image.tmdb.org/t/p/w1280/eQRFo1qwRREYwj47Yoe1PisgOle.jpg}', '{https://www.themoviedb.org/movie/962-the-gold-rush,https://www.imdb.com/title/tt0015864/}', '{"Clips": {"Charlie Chaplin Eating His Shoe": "https://www.youtube.com/embed/u65lvwfTPtM"}, "Trailers": {"Official trailer": "https://www.youtube.com/embed/kDlEvaKBkhU", "100th Anniversary 4K Restoration Trailer": "https://www.youtube.com/embed/N9-VZlxIIAs"}}', 'FeatureFilm', 'movie', '[]', '{}', NULL); +INSERT INTO public.title_groups VALUES (3, NULL, 'Les Misérables', '{""}', '2025-03-31 11:02:49.707845+00', '2025-03-31 11:02:49.707845+00', 1, 'Les Misérables (/leɪ ˌmɪzəˈrɑːb(əl), -blə/,[4] French: [le mizeʁabl]) is a French epic historical novel by Victor Hugo, first published on 31 March 1862, that is considered one of the greatest novels of the 19th century. Les Misérables has been popularized through numerous adaptations for film, television, and the stage, including a musical. ', NULL, 'French', '1862-03-31 00:16:08+00', NULL, 'France', '{https://francetoday.com/wp-content/uploads/2022/03/51JEItnoKFL.jpg}', '{https://openlibrary.org/works/OL1063588W/Les_Mis%C3%A9rables}', '{}', 'Book', 'book', '[]', '{}', NULL); +INSERT INTO public.title_groups VALUES (4, NULL, 'Skyman No. 1', '{""}', '2025-03-31 12:02:07.049479+00', '2025-03-31 12:02:07.049479+00', 1, 'Cover by Ogden Whitney. Stories and art by Boody Rogers, Gardner Fox, Ogden Whitney, Paul Dean, Fred Schwab and Mart Bailey. Columbia Comics launches a solo book featuring aviator hero Skyman, one of the stars of Big Shot Comics. Orphaned by a plane crash and raised by his uncle to be superhuman, Allen Turner uses his experimental aircraft The Wing to fight crime as Skyman. Sparky Watts encounters The World''s Strongest Puppy, in a typically wacky Boody Rogers tale. In a story that illustrates its times, Skyman battles saboteur Red Signet and his gang, who are trying to force isolationist America into joining World War II. Plus a Face story, and an ad for the never-produced Skyman daily comic strip. Introducing the Skyman; The Paralyzing Ray; The Skyman Encounters Kidnappers; Sparky Watts; Jibby Jones; Mortimer the Monk; Saboteurs; The Red Signet; The Face: The Orphan Asylum; War Games. 64 pages, Full Color. Cover price $0.10. ', NULL, 'English', '1940-05-01 00:00:00+00', NULL, 'USA', '{https://media.mycomicshop.com/n_iv/600/837159.jpg}', '{https://en.wikipedia.org/wiki/Skyman_(Columbia_Comics)}', '{}', 'Illustrated', 'book', '[]', '{}', 1); INSERT INTO public.title_groups VALUES (10, 1, 'RollerCoaster Tycoon', '{}', '2025-04-19 11:01:55.160763+00', '2025-04-19 11:01:55.160763+00', 1, 'The premise of the game is to complete a series of preset scenarios by successfully building and maintaining amusement parks through business ownership as a theme park entrepreneur. Players can choose from dozens of roller coaster types and can also build log flumes, carousels, bumper cars, haunted houses, go-karts, Ferris wheels, and swinging ships, among other rides. The player may hire handymen to sweep paths, empty garbage cans, water flowers and mow lawns; mechanics to inspect and fix rides; security guards to prevent vandalism within the park; and entertainers to entertain the guests. The geography and landscaping of the park can be modified, allowing the player to lower/raise terrain and add water to improve the park''s attractiveness, as well as to allow rides to fit into their surroundings more easily. Players must also balance the needs of the visitors by strategically placing food stalls, concession stands, bathrooms, and information kiosks. -The player also has the option of building their own roller coaster designs as well as other rides by laying out individual track pieces, choosing the direction, height, and steepness, and adding such elements as zero-g rolls, corkscrews, vertical loops, and even on-ride photos, using a tile-based construction system. ', 'Windows', 'English', '1999-03-22 00:00:00+00', NULL, '{strategy,simulation}', 'UK', '{https://cdn.mobygames.com/covers/8040635-rollercoaster-tycoon-front-cover.jpg}', '{https://en.wikipedia.org/wiki/RollerCoaster_Tycoon_(video_game),https://store.steampowered.com/app/285310/RollerCoaster_Tycoon_Deluxe/}', '{}', 'Game', 'software', '[]', '{https://oyster.ignimgs.com/mediawiki/apis.ign.com/rollercoaster-tycoon-classic/3/36/Rollercoaster-tycoon-classic-tips-and-tricks-4.jpg,https://www.mmoga.de/images/screenshots/_p/1061986/ee570adbbea126df79911d16f21bfe6d_rollercoaster-tycoon-deluxe.jpg,https://i.ytimg.com/vi/rB_vsNKm1A0/hqdefault.jpg,https://i.ytimg.com/vi/0mqmIdDhnSQ/maxresdefault.jpg,https://www.mobygames.com/images/promo/l/165996-rollercoaster-tycoon-screenshot.gif}', NULL); -INSERT INTO public.title_groups VALUES (5, NULL, 'Skyman No. 2', '{""}', '2025-03-31 12:15:20.060021+00', '2025-03-31 12:15:20.060021+00', 1, 'Cover by Ogden Whitney. Stories and art by Gardner Fox, Ogden Whitney, Fred Schwab and Frank Tinsley. A solo book featuring aviator hero Skyman, one of the stars of Columbia''s Big Shot Comics. Skyman''s attacks on Axis military bases makes him their number one target. Aviator hero Yankee Doodle shows a group of kids how to fly, in a semi-educational strip - but Rusty''s redheaded, beautiful older sister doesn''t think that''s such a great idea. Skyman investigates a deadly green fog that is attacking fishermen at sea. Skyman; Mike the Mascot; Yankee Doodle; Jibby Jones. 68 pages, Full Color. Cover price $0.10. ', NULL, 'English', '1940-05-01 00:00:00+00', NULL, '{adventure}', 'USA', '{https://media.mycomicshop.com/n_iv/600/1050423.jpg}', '{""}', '{}', 'Illustrated', 'book', '[]', '{}', 1); +The player also has the option of building their own roller coaster designs as well as other rides by laying out individual track pieces, choosing the direction, height, and steepness, and adding such elements as zero-g rolls, corkscrews, vertical loops, and even on-ride photos, using a tile-based construction system. ', 'Windows', 'English', '1999-03-22 00:00:00+00', NULL, 'UK', '{https://cdn.mobygames.com/covers/8040635-rollercoaster-tycoon-front-cover.jpg}', '{https://en.wikipedia.org/wiki/RollerCoaster_Tycoon_(video_game),https://store.steampowered.com/app/285310/RollerCoaster_Tycoon_Deluxe/}', '{}', 'Game', 'software', '[]', '{https://oyster.ignimgs.com/mediawiki/apis.ign.com/rollercoaster-tycoon-classic/3/36/Rollercoaster-tycoon-classic-tips-and-tricks-4.jpg,https://www.mmoga.de/images/screenshots/_p/1061986/ee570adbbea126df79911d16f21bfe6d_rollercoaster-tycoon-deluxe.jpg,https://i.ytimg.com/vi/rB_vsNKm1A0/hqdefault.jpg,https://i.ytimg.com/vi/0mqmIdDhnSQ/maxresdefault.jpg,https://www.mobygames.com/images/promo/l/165996-rollercoaster-tycoon-screenshot.gif}', NULL); +INSERT INTO public.title_groups VALUES (5, NULL, 'Skyman No. 2', '{""}', '2025-03-31 12:15:20.060021+00', '2025-03-31 12:15:20.060021+00', 1, 'Cover by Ogden Whitney. Stories and art by Gardner Fox, Ogden Whitney, Fred Schwab and Frank Tinsley. A solo book featuring aviator hero Skyman, one of the stars of Columbia''s Big Shot Comics. Skyman''s attacks on Axis military bases makes him their number one target. Aviator hero Yankee Doodle shows a group of kids how to fly, in a semi-educational strip - but Rusty''s redheaded, beautiful older sister doesn''t think that''s such a great idea. Skyman investigates a deadly green fog that is attacking fishermen at sea. Skyman; Mike the Mascot; Yankee Doodle; Jibby Jones. 68 pages, Full Color. Cover price $0.10. ', NULL, 'English', '1940-05-01 00:00:00+00', NULL, 'USA', '{https://media.mycomicshop.com/n_iv/600/1050423.jpg}', '{""}', '{}', 'Illustrated', 'book', '[]', '{}', 1); INSERT INTO public.title_groups VALUES (6, NULL, 'Casefile True Crime', '{""}', '2025-03-31 13:38:28.755554+00', '2025-03-31 13:38:28.755554+00', 1, ' Casefile is an award-winning true-crime podcast that presents unforgettable stories in a professionally produced audio format. What started in 2016 as a one-person side project has grown to include an entire team based across multiple continents. Our episodes delve deep into the circumstances, investigations and trials of both solved and unsolved cases from all over the world. Casefile has hundreds of millions of downloads and consistently ranks highly across podcasting charts. Discover why everyone from Rolling Stone to Time magazine is calling it a must-listen experience. -', NULL, 'English', '2016-01-09 00:00:00+00', NULL, '{crime}', 'USA', '{https://casefilepodcast.com/wp-content/uploads/2020/07/casefile_icon_web.jpg}', '{https://en.wikipedia.org/wiki/Casefile}', '{}', 'Other', 'collection', '[]', '{}', 2); -INSERT INTO public.title_groups VALUES (9, NULL, 'Season 2', '{""}', '2025-04-02 10:37:47.156346+00', '2025-04-02 10:37:47.156346+00', 1, '', NULL, 'English', '2004-10-24 00:00:00+00', NULL, '{reality}', 'USA', '{https://artworks.thetvdb.com/banners/seasons/26075-2.jpg}', '{https://thetvdb.com/series/pimp-my-ride/seasons/official/2}', '{}', 'Other', 'tv_show', '[]', '{}', 3); +', NULL, 'English', '2016-01-09 00:00:00+00', NULL, 'USA', '{https://casefilepodcast.com/wp-content/uploads/2020/07/casefile_icon_web.jpg}', '{https://en.wikipedia.org/wiki/Casefile}', '{}', 'Other', 'collection', '[]', '{}', 2); +INSERT INTO public.title_groups VALUES (9, NULL, 'Season 2', '{""}', '2025-04-02 10:37:47.156346+00', '2025-04-02 10:37:47.156346+00', 1, '', NULL, 'English', '2004-10-24 00:00:00+00', NULL, 'USA', '{https://artworks.thetvdb.com/banners/seasons/26075-2.jpg}', '{https://thetvdb.com/series/pimp-my-ride/seasons/official/2}', '{}', 'Other', 'tv_show', '[]', '{}', 3); INSERT INTO public.title_groups VALUES (11, 1, 'RollerCoaster Tycoon', '{}', '2025-04-19 11:48:29.123003+00', '2025-04-19 11:48:29.123003+00', 1, 'The premise of the game is to complete a series of preset scenarios by successfully building and maintaining amusement parks through business ownership as a theme park entrepreneur. Players can choose from dozens of roller coaster types and can also build log flumes, carousels, bumper cars, haunted houses, go-karts, Ferris wheels, and swinging ships, among other rides. The player may hire handymen to sweep paths, empty garbage cans, water flowers and mow lawns; mechanics to inspect and fix rides; security guards to prevent vandalism within the park; and entertainers to entertain the guests. The geography and landscaping of the park can be modified, allowing the player to lower/raise terrain and add water to improve the park''s attractiveness, as well as to allow rides to fit into their surroundings more easily. Players must also balance the needs of the visitors by strategically placing food stalls, concession stands, bathrooms, and information kiosks. -The player also has the option of building their own roller coaster designs as well as other rides by laying out individual track pieces, choosing the direction, height, and steepness, and adding such elements as zero-g rolls, corkscrews, vertical loops, and even on-ride photos, using a tile-based construction system. ', 'Linux', 'English', '1999-03-22 00:00:00+00', NULL, '{strategy,simulation}', 'UK', '{https://cdn.mobygames.com/covers/8040635-rollercoaster-tycoon-front-cover.jpg}', '{https://en.wikipedia.org/wiki/RollerCoaster_Tycoon_(video_game)}', '{}', 'Game', 'software', '[]', '{}', NULL); +The player also has the option of building their own roller coaster designs as well as other rides by laying out individual track pieces, choosing the direction, height, and steepness, and adding such elements as zero-g rolls, corkscrews, vertical loops, and even on-ride photos, using a tile-based construction system. ', 'Linux', 'English', '1999-03-22 00:00:00+00', NULL, 'UK', '{https://cdn.mobygames.com/covers/8040635-rollercoaster-tycoon-front-cover.jpg}', '{https://en.wikipedia.org/wiki/RollerCoaster_Tycoon_(video_game)}', '{}', 'Game', 'software', '[]', '{}', NULL); INSERT INTO public.title_groups VALUES (12, 1, 'RollerCoaster Tycoon', '{}', '2025-04-19 13:59:38.181373+00', '2025-04-19 13:59:38.181373+00', 1, 'The premise of the game is to complete a series of preset scenarios by successfully building and maintaining amusement parks through business ownership as a theme park entrepreneur. Players can choose from dozens of roller coaster types and can also build log flumes, carousels, bumper cars, haunted houses, go-karts, Ferris wheels, and swinging ships, among other rides. The player may hire handymen to sweep paths, empty garbage cans, water flowers and mow lawns; mechanics to inspect and fix rides; security guards to prevent vandalism within the park; and entertainers to entertain the guests. The geography and landscaping of the park can be modified, allowing the player to lower/raise terrain and add water to improve the park''s attractiveness, as well as to allow rides to fit into their surroundings more easily. Players must also balance the needs of the visitors by strategically placing food stalls, concession stands, bathrooms, and information kiosks. -The player also has the option of building their own roller coaster designs as well as other rides by laying out individual track pieces, choosing the direction, height, and steepness, and adding such elements as zero-g rolls, corkscrews, vertical loops, and even on-ride photos, using a tile-based construction system. ', 'Xbox', 'English', '2003-03-25 00:00:00+00', NULL, '{strategy,simulation}', 'UK', '{https://www.thevideogamecompany.com/cdn/shop/products/roller-coaster-tycoon-microsoft-xbox-742725246973-cover-art.jpg}', '{https://en.wikipedia.org/wiki/RollerCoaster_Tycoon_(video_game)}', '{}', 'Game', 'software', '[]', '{}', NULL); +The player also has the option of building their own roller coaster designs as well as other rides by laying out individual track pieces, choosing the direction, height, and steepness, and adding such elements as zero-g rolls, corkscrews, vertical loops, and even on-ride photos, using a tile-based construction system. ', 'Xbox', 'English', '2003-03-25 00:00:00+00', NULL, 'UK', '{https://www.thevideogamecompany.com/cdn/shop/products/roller-coaster-tycoon-microsoft-xbox-742725246973-cover-art.jpg}', '{https://en.wikipedia.org/wiki/RollerCoaster_Tycoon_(video_game)}', '{}', 'Game', 'software', '[]', '{}', NULL); -- @@ -1249,12 +1249,6 @@ Track 3: Initial Single version "The first version of ''Love Me Do'' was available only on initial pressings of the 45 while the one included on the album ''Please Please Me'' has always been the version with Andy White on drums. The single came out on Friday 5 October 1962." (Mark Lewisohn - Author of The Complete Beatles Chronicle).', '{"cue": 1, "log": 1, "flac": 3}', false, '{"files": [{"name": "01 Love Me Do.flac", "size": 11268945}, {"name": "02 P.S. I Love You.flac", "size": 9505300}, {"name": "03 Love Me Do (original single version).flac", "size": 11238335}, {"name": "Love Me Do (Single).cue", "size": 848}, {"name": "Love Me Do (Single).log", "size": 3852}], "parent_folder": "The Beatles - Love Me Do (Single) (1992) [FLAC]"}', NULL, '', false, 'FLAC', 32017280, NULL, 'flac', NULL, 'Lossless', NULL, NULL, '{Cue}', '{}', NULL, NULL, NULL, '{}'); --- --- Data for Name: notifications; Type: TABLE DATA; Schema: public; Owner: arcadia --- - - - -- -- Data for Name: peers; Type: TABLE DATA; Schema: public; Owner: arcadia -- @@ -1290,13 +1284,6 @@ Track 3: Initial Single version -- - --- --- Data for Name: subscriptions; Type: TABLE DATA; Schema: public; Owner: arcadia --- - - - -- -- Data for Name: title_group_comments; Type: TABLE DATA; Schema: public; Owner: arcadia -- @@ -1508,12 +1495,6 @@ SELECT pg_catalog.setval('public.invitations_id_seq', 1, false); SELECT pg_catalog.setval('public.master_groups_id_seq', 1, true); --- --- Name: notifications_id_seq; Type: SEQUENCE SET; Schema: public; Owner: arcadia --- - -SELECT pg_catalog.setval('public.notifications_id_seq', 1, false); - -- -- Name: series_id_seq; Type: SEQUENCE SET; Schema: public; Owner: arcadia -- @@ -1534,14 +1515,6 @@ SELECT pg_catalog.setval('public.staff_pm_messages_id_seq', 1, false); SELECT pg_catalog.setval('public.staff_pms_id_seq', 1, false); - --- --- Name: subscriptions_id_seq; Type: SEQUENCE SET; Schema: public; Owner: arcadia --- - -SELECT pg_catalog.setval('public.subscriptions_id_seq', 1, false); - - -- -- Name: title_group_comments_id_seq; Type: SEQUENCE SET; Schema: public; Owner: arcadia -- diff --git a/backend/storage/src/models/mod.rs b/backend/storage/src/models/mod.rs index 58981f75..656e1ac3 100644 --- a/backend/storage/src/models/mod.rs +++ b/backend/storage/src/models/mod.rs @@ -17,6 +17,7 @@ pub mod subscription; pub mod title_group; pub mod title_group_bookmark; pub mod title_group_comment; +pub mod title_group_tag; pub mod torrent; pub mod torrent_activity; pub mod torrent_report; diff --git a/backend/storage/src/models/title_group.rs b/backend/storage/src/models/title_group.rs index 878ffae8..e3a0dfa3 100644 --- a/backend/storage/src/models/title_group.rs +++ b/backend/storage/src/models/title_group.rs @@ -16,7 +16,7 @@ use crate::models::{ torrent_request::TorrentRequestHierarchyLite, }; -#[derive(Debug, Serialize, Deserialize, sqlx::Type, ToSchema)] +#[derive(Debug, Serialize, Deserialize, sqlx::Type, ToSchema, Clone)] #[sqlx(type_name = "content_type_enum")] pub enum ContentType { #[sqlx(rename = "movie")] @@ -47,7 +47,7 @@ pub enum ContentType { Collection, } -#[derive(Debug, Serialize, Deserialize, sqlx::Type, ToSchema)] +#[derive(Debug, Serialize, Deserialize, sqlx::Type, ToSchema, Clone)] #[sqlx(type_name = "platform_enum")] pub enum Platform { Windows, @@ -57,7 +57,7 @@ pub enum Platform { } // this is not to store the genre, but the format -#[derive(Debug, Serialize, Deserialize, sqlx::Type, ToSchema)] +#[derive(Debug, Serialize, Deserialize, sqlx::Type, ToSchema, Clone)] #[sqlx(type_name = "title_group_category_enum")] pub enum TitleGroupCategory { //music @@ -240,7 +240,6 @@ pub struct EditedTitleGroup { pub embedded_links: Value, pub category: Option, pub content_type: ContentType, - pub tags: Vec, pub screenshots: Vec, } diff --git a/backend/storage/src/models/title_group_tag.rs b/backend/storage/src/models/title_group_tag.rs new file mode 100644 index 00000000..9d421089 --- /dev/null +++ b/backend/storage/src/models/title_group_tag.rs @@ -0,0 +1,26 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::prelude::FromRow; +use utoipa::ToSchema; + +#[derive(Debug, Deserialize, Serialize, FromRow, ToSchema)] +pub struct TitleGroupTag { + pub id: i32, + pub name: String, + pub synonyms: Vec, + #[schema(value_type = String, format = DateTime)] + pub created_at: DateTime, + pub created_by_id: i32, +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct UserCreatedTitleGroupTag { + pub name: String, +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct TitleGroupTagSearchResult { + pub name: String, + pub synonyms: Vec, + pub id: i32, +} diff --git a/backend/storage/src/models/torrent.rs b/backend/storage/src/models/torrent.rs index 7cfbf81c..c52f73ab 100644 --- a/backend/storage/src/models/torrent.rs +++ b/backend/storage/src/models/torrent.rs @@ -150,7 +150,7 @@ pub enum VideoCodec { UHD100, } -#[derive(Debug, Deserialize, Serialize, sqlx::Type, ToSchema, EnumString)] +#[derive(Debug, Deserialize, Serialize, sqlx::Type, ToSchema, EnumString, Clone)] #[sqlx(type_name = "language_enum")] pub enum Language { Albanian, diff --git a/backend/storage/src/repositories/mod.rs b/backend/storage/src/repositories/mod.rs index a00f99d1..3834ae4b 100644 --- a/backend/storage/src/repositories/mod.rs +++ b/backend/storage/src/repositories/mod.rs @@ -16,6 +16,7 @@ pub mod subscriptions_repository; pub mod title_group_bookmark_repository; pub mod title_group_comment_repository; pub mod title_group_repository; +pub mod title_group_tag_repository; pub mod torrent_report_repository; pub mod torrent_repository; pub mod torrent_request_comment_repository; diff --git a/backend/storage/src/repositories/title_group_repository.rs b/backend/storage/src/repositories/title_group_repository.rs index ef94a2f6..da9e3f62 100644 --- a/backend/storage/src/repositories/title_group_repository.rs +++ b/backend/storage/src/repositories/title_group_repository.rs @@ -1,7 +1,12 @@ use crate::{ connection_pool::ConnectionPool, - models::title_group::{ - ContentType, EditedTitleGroup, PublicRating, TitleGroup, UserCreatedTitleGroup, + models::{ + title_group::{ + ContentType, EditedTitleGroup, Platform, PublicRating, TitleGroup, TitleGroupCategory, + UserCreatedTitleGroup, + }, + title_group_tag::UserCreatedTitleGroupTag, + torrent::Language, }, }; use arcadia_common::error::{Error, Result}; @@ -9,55 +14,92 @@ use serde_json::{json, Value}; use std::borrow::Borrow; impl ConnectionPool { - fn sanitize_title_group_tags(tags: Vec) -> Vec { - tags.into_iter() - .map(|s| { - s.trim() - .to_lowercase() - .split_whitespace() - .collect::>() - .join(".") - }) - .collect() - } - pub async fn create_title_group( &self, title_group_form: &UserCreatedTitleGroup, public_ratings: &Vec, user_id: i32, ) -> Result { - let create_title_group_query = r#" - INSERT INTO title_groups (master_group_id,name,name_aliases,created_by_id,description,original_language,country_from,covers,external_links,embedded_links,category,content_type,original_release_date,tags,tagline,platform,screenshots,public_ratings) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::title_group_category_enum, $12::content_type_enum, $13, $14, $15, $16, $17, $18) - RETURNING *; - "#; + let created_title_group_id: i32 = sqlx::query_scalar!( + r#" + INSERT INTO title_groups ( + master_group_id, + name, + name_aliases, + created_by_id, + description, + original_language, + country_from, + covers, + external_links, + embedded_links, + category, + content_type, + original_release_date, + tagline, + platform, + screenshots, + public_ratings + ) + VALUES ( + $1, $2, $3, $4, $5, $6::language_enum, + $7, $8, $9, $10, $11::title_group_category_enum, + $12::content_type_enum, $13, $14, $15, $16, $17 + ) + RETURNING id + "#, + title_group_form.master_group_id, + &title_group_form.name, + &title_group_form.name_aliases, + user_id, + &title_group_form.description, + title_group_form.original_language.clone() as Option, + title_group_form.country_from, + &title_group_form.covers, + &title_group_form.external_links, + &title_group_form.embedded_links, + title_group_form.category.clone() as Option, + title_group_form.content_type.clone() as ContentType, + title_group_form.original_release_date, + title_group_form.tagline, + title_group_form.platform.clone() as Option, + &title_group_form.screenshots, + json!(public_ratings) + ) + .fetch_one(self.borrow()) + .await + .map_err(Error::CouldNotCreateTitleGroup)?; - let created_title_group = sqlx::query_as::<_, TitleGroup>(create_title_group_query) - .bind(title_group_form.master_group_id) - .bind(&title_group_form.name) - .bind(&title_group_form.name_aliases) - .bind(user_id) - .bind(&title_group_form.description) - .bind(&title_group_form.original_language) - .bind(&title_group_form.country_from) - .bind(&title_group_form.covers) - .bind(&title_group_form.external_links) - .bind(&title_group_form.embedded_links) - .bind(&title_group_form.category) - .bind(&title_group_form.content_type) - .bind(title_group_form.original_release_date) - .bind(Self::sanitize_title_group_tags( - title_group_form.tags.clone(), - )) - .bind(&title_group_form.tagline) - .bind(&title_group_form.platform) - .bind(&title_group_form.screenshots) - .bind(json!(public_ratings)) - // .bind(&title_group_form.public_ratings) - .fetch_one(self.borrow()) - .await - .map_err(Error::CouldNotCreateTitleGroup)?; + // ensure tags exist + let mut tag_ids = Vec::new(); + for tag_name in title_group_form.tags.iter() { + let tag = Self::create_title_group_tag( + self, + &UserCreatedTitleGroupTag { + name: tag_name.clone(), + }, + user_id, + ) + .await?; + tag_ids.push(tag.id); + } + + // link tags to title group + for tag_id in tag_ids { + sqlx::query!( + r#" + INSERT INTO title_group_applied_tags (title_group_id, tag_id) + VALUES ($1, $2) + ON CONFLICT DO NOTHING + "#, + created_title_group_id, + tag_id + ) + .execute(self.borrow()) + .await?; + } + + let created_title_group = Self::find_title_group(self, created_title_group_id).await?; Ok(created_title_group) } @@ -253,10 +295,24 @@ impl ConnectionPool { LEFT JOIN collage_metrics cm ON cm.collage_id = c.id WHERE ce.title_group_id = $2 GROUP BY ce.title_group_id + ), + title_group_tags AS ( + SELECT + tg.id AS title_group_id, + COALESCE( + ARRAY( + SELECT t.name + FROM title_group_applied_tags tat + JOIN title_group_tags t ON t.id = tat.tag_id + WHERE tat.title_group_id = tg.id + ), + ARRAY[]::text[] + ) AS tags + FROM title_groups tg ) SELECT jsonb_build_object( - 'title_group', to_jsonb(tg), + 'title_group', to_jsonb(tg) || jsonb_build_object('tags', COALESCE(td.tags, ARRAY[]::text[])), 'series', COALESCE(sd.series, '{}'::jsonb), 'edition_groups', COALESCE(ed.edition_groups, '[]'::jsonb), 'affiliated_artists', COALESCE(ad.affiliated_artists, '[]'::jsonb), @@ -268,6 +324,7 @@ impl ConnectionPool { 'collages', COALESCE(cod.collages, '[]'::jsonb) ) AS title_group_data FROM title_groups tg + LEFT JOIN title_group_tags td ON td.title_group_id = tg.id LEFT JOIN edition_data ed ON ed.title_group_id = tg.id LEFT JOIN artist_data ad ON ad.title_group_id = tg.id LEFT JOIN entity_data aed ON aed.title_group_id = tg.id @@ -347,10 +404,19 @@ impl ConnectionPool { id, master_group_id, name, name_aliases AS "name_aliases!: _", created_at, updated_at, created_by_id, description, platform AS "platform: _", original_language AS "original_language: _", original_release_date, - tagline, tags AS "tags!: _", country_from, covers AS "covers!: _", + tagline, country_from, covers AS "covers!: _", external_links AS "external_links!: _", embedded_links, category AS "category: _", content_type AS "content_type: _", - public_ratings, screenshots AS "screenshots!: _", series_id + public_ratings, screenshots AS "screenshots!: _", series_id, + COALESCE( + ARRAY( + SELECT t.name + FROM title_group_applied_tags tat + JOIN title_group_tags t ON t.id = tat.tag_id + WHERE tat.title_group_id = title_groups.id + ), + ARRAY[]::text[] + ) AS "tags!: _" FROM title_groups WHERE id = $1 "#, @@ -387,18 +453,26 @@ impl ConnectionPool { embedded_links = $13, category = $14, content_type = $15, - tags = $16, - screenshots = $17, + screenshots = $16, updated_at = NOW() WHERE id = $1 RETURNING id, master_group_id, name, name_aliases AS "name_aliases!: _", created_at, updated_at, created_by_id, description, platform AS "platform: _", original_language AS "original_language: _", original_release_date, - tagline, tags AS "tags!: _", country_from, covers AS "covers!: _", + tagline, country_from, covers AS "covers!: _", external_links AS "external_links!: _", embedded_links, category AS "category: _", content_type AS "content_type: _", - public_ratings, screenshots AS "screenshots!: _", series_id + public_ratings, screenshots AS "screenshots!: _", series_id, + COALESCE( + ARRAY( + SELECT t.name + FROM title_group_applied_tags tat + JOIN title_group_tags t ON t.id = tat.tag_id + WHERE tat.title_group_id = title_groups.id + ), + ARRAY[]::text[] + ) AS "tags!: _" "#, title_group_id, edited_title_group.master_group_id, @@ -415,7 +489,6 @@ impl ConnectionPool { edited_title_group.embedded_links, edited_title_group.category as _, edited_title_group.content_type as _, - edited_title_group.tags as _, edited_title_group.screenshots as _ ) .fetch_one(self.borrow()) diff --git a/backend/storage/src/repositories/title_group_tag_repository.rs b/backend/storage/src/repositories/title_group_tag_repository.rs new file mode 100644 index 00000000..34903cdc --- /dev/null +++ b/backend/storage/src/repositories/title_group_tag_repository.rs @@ -0,0 +1,140 @@ +use crate::{ + connection_pool::ConnectionPool, + models::title_group_tag::{TitleGroupTag, TitleGroupTagSearchResult, UserCreatedTitleGroupTag}, +}; +use arcadia_common::error::{Error, Result}; +use sqlx::PgPool; +use std::borrow::Borrow; + +impl ConnectionPool { + fn sanitize_tag_name(name: &str) -> String { + name.trim() + .to_lowercase() + .split_whitespace() + .collect::>() + .join(".") + } + + pub async fn create_title_group_tag( + &self, + tag: &UserCreatedTitleGroupTag, + user_id: i32, + ) -> Result { + let sanitized_name = Self::sanitize_tag_name(&tag.name); + + let created_tag = sqlx::query_as!( + TitleGroupTag, + r#" + INSERT INTO title_group_tags (name, created_by_id) + VALUES ($1, $2) + ON CONFLICT (name) DO NOTHING + RETURNING + id, + name, + synonyms as "synonyms!: Vec", + created_at, + created_by_id + "#, + sanitized_name, + user_id + ) + .fetch_one(self.borrow()) + .await + .map_err(Error::CouldNotCreateTitleGroupTag)?; + + Ok(created_tag) + } + + async fn find_tag_id_by_name(&self, tag_name: &str) -> Result> { + let sanitized_name = Self::sanitize_tag_name(tag_name); + + let tag_id = sqlx::query_scalar!( + r#" + SELECT id FROM title_group_tags WHERE name = $1 + "#, + sanitized_name + ) + .fetch_optional(self.borrow()) + .await?; + + Ok(tag_id) + } + + pub async fn apply_tag_to_title_group( + &self, + title_group_id: i32, + tag_id: i32, + user_id: i32, + ) -> Result<()> { + sqlx::query!( + r#" + INSERT INTO title_group_applied_tags (title_group_id, tag_id, created_by_id) + VALUES ($1, $2, $3) + ON CONFLICT DO NOTHING + "#, + title_group_id, + tag_id, + user_id + ) + .execute(self.borrow()) + .await?; + + Ok(()) + } + + pub async fn remove_tag_from_title_group( + &self, + title_group_id: i32, + tag_name: &str, + ) -> Result<()> { + let tag_id = self.find_tag_id_by_name(tag_name).await?; + + let tag_id = + tag_id.ok_or_else(|| Error::BadRequest(format!("Tag '{}' not found", tag_name)))?; + + sqlx::query!( + r#" + DELETE FROM title_group_applied_tags + WHERE title_group_id = $1 AND tag_id = $2 + "#, + title_group_id, + tag_id + ) + .execute(self.borrow()) + .await?; + + Ok(()) + } + + pub async fn search_title_group_tags( + &self, + query: &str, + ) -> Result> { + let search_pattern = format!("%{}%", query); + + let results = sqlx::query_as!( + TitleGroupTagSearchResult, + r#" + SELECT + name, + synonyms as "synonyms!: Vec", + id + FROM title_group_tags + WHERE + name ILIKE '%' || $1 || '%' + OR EXISTS ( + SELECT 1 + FROM unnest(synonyms) AS synonym + WHERE synonym ILIKE '%' || $1 || '%' + ) + ORDER BY name + LIMIT 10 + "#, + search_pattern + ) + .fetch_all(>::borrow(self)) + .await?; + + Ok(results) + } +} diff --git a/backend/storage/src/repositories/torrent_repository.rs b/backend/storage/src/repositories/torrent_repository.rs index c455765d..d331f03a 100644 --- a/backend/storage/src/repositories/torrent_repository.rs +++ b/backend/storage/src/repositories/torrent_repository.rs @@ -424,7 +424,7 @@ impl ConnectionPool { TitleGroupHierarchyLite, r#" SELECT title_group_id AS "id!", title_group_name AS "name!", title_group_covers AS "covers!", - title_group_category AS "category!: _", title_group_content_type AS "content_type!: _", title_group_tags AS "tags!", + title_group_category AS "category!: _", title_group_content_type AS "content_type!: _", title_group_tag_names AS "tags!", title_group_original_release_date AS "original_release_date!", title_group_platform AS "platform!: _", '[]'::jsonb AS "edition_groups!: _", '[]'::jsonb AS "affiliated_artists!: _" @@ -456,7 +456,7 @@ impl ConnectionPool { AND ($12::BOOLEAN IS TRUE OR tgh.torrent_id IS NOT NULL) GROUP BY title_group_id, title_group_name, title_group_covers, title_group_category, - title_group_content_type, title_group_tags, title_group_original_release_date, title_group_platform + title_group_content_type, title_group_tag_names, title_group_original_release_date, title_group_platform ORDER BY CASE WHEN $1 = 'title_group_original_release_date' AND $6 = 'asc' THEN title_group_original_release_date END ASC, diff --git a/backend/storage/src/repositories/torrent_request_repository.rs b/backend/storage/src/repositories/torrent_request_repository.rs index b9b999d0..252979e3 100644 --- a/backend/storage/src/repositories/torrent_request_repository.rs +++ b/backend/storage/src/repositories/torrent_request_repository.rs @@ -213,7 +213,7 @@ impl ConnectionPool { pub async fn search_torrent_requests( &self, title_group_name: Option<&str>, - tags: Option<&[String]>, + _tags: Option<&[String]>, page: i64, page_size: i64, ) -> Result { @@ -288,13 +288,11 @@ impl ConnectionPool { FROM torrent_requests tr JOIN title_groups tg ON tr.title_group_id = tg.id WHERE ($1::TEXT IS NULL OR tg.name ILIKE '%' || $1 || '%' OR $1 = ANY(tg.name_aliases)) - AND ($2::VARCHAR[] IS NULL OR tg.tags && $2::VARCHAR[]) ORDER BY tr.created_at DESC - LIMIT $3 OFFSET $4 + LIMIT $2 OFFSET $3 ) sub "#, title_group_name, - tags, page_size, offset ) diff --git a/frontend/src/App.vue b/frontend/src/App.vue index cfd5eb15..bced39e0 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -92,7 +92,7 @@ const getAppReady = async (forceGetUser: boolean = false) => { } } else { // no token is present - if (import.meta.env.VITE_ENABLE_CUSTOM_FRONT_PAGE) { + if (import.meta.env.VITE_ENABLE_CUSTOM_FRONT_PAGE === 'true') { window.location.href = '/home/index.html' } else { router.push('/login') diff --git a/frontend/src/api-schema/schema.d.ts b/frontend/src/api-schema/schema.d.ts index 9a2d31f3..a8f8850a 100644 --- a/frontend/src/api-schema/schema.d.ts +++ b/frontend/src/api-schema/schema.d.ts @@ -489,6 +489,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/search/title-group-tags": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["Search title group tags"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/search/title-groups/lite": { parameters: { query?: never; @@ -649,6 +665,54 @@ export interface paths { patch?: never; trace?: never; }; + "/api/title-group-tags": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["Create title group tag"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/title-group-tags/apply": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["Apply tag to title group"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/title-group-tags/remove": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete: operations["Remove tag from title group"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/title-groups": { parameters: { query?: never; @@ -975,6 +1039,12 @@ export interface components { /** Format: int32 */ title_group_id: number; }; + AppliedTitleGroupTag: { + /** Format: int32 */ + tag_id: number; + /** Format: int32 */ + title_group_id: number; + }; Artist: { /** Format: date-time */ created_at: string; @@ -1205,7 +1275,6 @@ export interface components { platform?: null | components["schemas"]["Platform"]; screenshots: string[]; tagline?: string | null; - tags: string[]; }; EditedTorrent: { /** Format: int32 */ @@ -1874,6 +1943,11 @@ export interface components { password_verify: string; username: string; }; + RemoveTagRequest: { + tag_name: string; + /** Format: int32 */ + title_group_id: number; + }; SearchCollagesLiteQuery: { name: string; /** Format: int32 */ @@ -2117,6 +2191,22 @@ export interface components { original_release_date: string; platform?: null | components["schemas"]["Platform"]; }; + TitleGroupTag: { + /** Format: date-time */ + created_at: string; + /** Format: int32 */ + created_by_id: number; + /** Format: int32 */ + id: number; + name: string; + synonyms: string[]; + }; + TitleGroupTagSearchResult: { + /** Format: int32 */ + id: number; + name: string; + synonyms: string[]; + }; Torrent: { /** Format: int32 */ audio_bitrate?: number | null; @@ -2712,6 +2802,9 @@ export interface components { /** Format: int32 */ title_group_id: number; }; + UserCreatedTitleGroupTag: { + name: string; + }; UserCreatedTorrentReport: { description: string; /** Format: int32 */ @@ -3625,6 +3718,29 @@ export interface operations { }; }; }; + "Search title group tags": { + parameters: { + query: { + /** @description Search query (searches in tag name and synonyms) */ + name: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description List of matching tags with their names and synonyms */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TitleGroupTagSearchResult"][]; + }; + }; + }; + }; "Search title group info": { parameters: { query: { @@ -3910,6 +4026,74 @@ export interface operations { }; }; }; + "Create title group tag": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UserCreatedTitleGroupTag"]; + }; + }; + responses: { + /** @description Successfully created the title group tag */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TitleGroupTag"]; + }; + }; + }; + }; + "Apply tag to title group": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AppliedTitleGroupTag"]; + }; + }; + responses: { + /** @description Successfully applied the tag to the title group */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + "Remove tag from title group": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["RemoveTagRequest"]; + }; + }; + responses: { + /** @description Successfully removed the tag from the title group */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; "Get title group": { parameters: { query: { diff --git a/frontend/src/components/title_group/CreateOrEditTitleGroup.vue b/frontend/src/components/title_group/CreateOrEditTitleGroup.vue index 7fe08ce8..2ace94c0 100644 --- a/frontend/src/components/title_group/CreateOrEditTitleGroup.vue +++ b/frontend/src/components/title_group/CreateOrEditTitleGroup.vue @@ -68,7 +68,7 @@ -
+
{{ $form.tags.error?.message }} @@ -303,7 +303,7 @@ const resolver = ({ values }: FormResolverOptions) => { errors.category = [{ message: t('error.select_category') }] } //TODO config: the minimum amount of tags required should be taken from the global config file - if (titleGroupForm.value.tags.length === 0) { + if (titleGroupForm.value.tags.length === 0 && !props.editMode) { // somehow isn't displayed in the form and doesn't prevent submitting errors.tags = [{ message: t('error.enter_at_least_x_tags', [1]) }] } diff --git a/frontend/src/components/title_group/TitleGroupSidebar.vue b/frontend/src/components/title_group/TitleGroupSidebar.vue index d41b442e..3b0a42f9 100644 --- a/frontend/src/components/title_group/TitleGroupSidebar.vue +++ b/frontend/src/components/title_group/TitleGroupSidebar.vue @@ -52,6 +52,11 @@
{{ tag }}
+
+
+ +
+
@@ -68,14 +73,19 @@ import type { SeriesLite } from '@/services/api/seriesService' import type { AffiliatedArtistHierarchy } from '@/services/api/artistService' import type { AffiliatedEntityHierarchy } from '@/services/api/entityService' import ImagePreview from '../ImagePreview.vue' +import TitleGroupTagSearchBar from './TitleGroupTagSearchBar.vue' +import { applyTitleGroupTag, type TitleGroupTagSearchResult } from '@/services/api/titleGroupTagService' +import { ref } from 'vue' +import { nextTick } from 'vue' const { t } = useI18n() const emit = defineEmits<{ editAffiliatedArtistsClicked: [] + tagApplied: [string] }>() -defineProps<{ +const props = defineProps<{ title_group: TitleGroup inSameMasterGroup?: TitleGroupLite[] series: SeriesLite @@ -83,6 +93,18 @@ defineProps<{ affiliatedEntities?: AffiliatedEntityHierarchy[] editAffiliationBtns?: boolean }>() + +const showTagSearchBar = ref(true) + +const applyTag = async (tag: TitleGroupTagSearchResult) => { + applyTitleGroupTag({ tag_id: tag.id, title_group_id: props.title_group.id }).then(async () => { + emit('tagApplied', tag.name) + // reload the search bar + showTagSearchBar.value = false + await nextTick() + showTagSearchBar.value = true + }) +} diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json index 7ecb661c..1d13f07e 100644 --- a/frontend/src/i18n/en.json +++ b/frontend/src/i18n/en.json @@ -59,7 +59,9 @@ "prev": "Prev", "next": "Next", "first": "First", - "time": "Time" + "time": "Time", + "synonym": "Synonym | Synonyms", + "new": "New" }, "auth": { "remember_me": "Remember me" @@ -197,6 +199,8 @@ "existing_title_group": "Existing title group", "existing_title_group_explanation": "The title group already exists. Make sure that the torrent you're trying to upload isn't already part of it, and use the 'add format' button.", "go_to_title_group": "Go to the title group", + "add_tag": "Add tag", + "tag_will_be_created": "This tag will be created", "content_type": { "content_type": "Content type", "music": "Music", diff --git a/frontend/src/services/api/titleGroupTagService.ts b/frontend/src/services/api/titleGroupTagService.ts new file mode 100644 index 00000000..d0e07073 --- /dev/null +++ b/frontend/src/services/api/titleGroupTagService.ts @@ -0,0 +1,22 @@ +import type { components } from '@/api-schema/schema' +import api from './api.ts' + +export type TitleGroupTagSearchResult = components['schemas']['TitleGroupTagSearchResult'] + +export type UserCreatedTitleGroupTag = components['schemas']['UserCreatedTitleGroupTag'] + +export type TitleGroupTag = components['schemas']['TitleGroupTag'] + +export type AppliedTitleGroupTag = components['schemas']['AppliedTitleGroupTag'] + +export const searchTitleGroupTag = async (name: string): Promise => { + return (await api.get('/search/title-group-tags', { params: { name } })).data +} + +export const createTitleGroupTag = async (titleGroupTag: UserCreatedTitleGroupTag): Promise => { + return (await api.post('/title-group-tags', titleGroupTag)).data +} + +export const applyTitleGroupTag = async (titleGroupTag: AppliedTitleGroupTag) => { + return (await api.post('/title-group-tags/apply', titleGroupTag)).data +} diff --git a/frontend/src/views/TitleGroupView.vue b/frontend/src/views/TitleGroupView.vue index ccc23952..68e93389 100644 --- a/frontend/src/views/TitleGroupView.vue +++ b/frontend/src/views/TitleGroupView.vue @@ -121,6 +121,7 @@ :series="titleGroupAndAssociatedData.series" editAffiliationBtns @edit-affiliated-artists-clicked="editAffiliatedArtistsDialogVisible = true" + @tag-applied="titleGroupAndAssociatedData.title_group.tags.push($event)" />
diff --git a/tracker/arcadia_tracker/tests/fixtures/with_test_title_group.sql b/tracker/arcadia_tracker/tests/fixtures/with_test_title_group.sql index 395dce7d..9fa7acab 100644 --- a/tracker/arcadia_tracker/tests/fixtures/with_test_title_group.sql +++ b/tracker/arcadia_tracker/tests/fixtures/with_test_title_group.sql @@ -1,5 +1,3 @@ -INSERT INTO title_groups (id, master_group_id, name, name_aliases, created_at, updated_at, created_by_id, description, platform, original_language, original_release_date, tagline, tags, country_from, covers, external_links, embedded_links, category, content_type, public_ratings, series_id, screenshots) VALUES (1, NULL, 'Love Me Do / P.S. I Love You', '{""}', '2025-03-30 16:35:06.418293', '2025-03-30 16:35:06.418293', 1, 'Tracklist +INSERT INTO title_groups (id, master_group_id, name, name_aliases, created_at, updated_at, created_by_id, description, platform, original_language, original_release_date, tagline, country_from, covers, external_links, embedded_links, category, content_type, public_ratings, series_id, screenshots) VALUES (1, NULL, 'Love Me Do / P.S. I Love You', '{""}', '2025-03-30 16:35:06.418293', '2025-03-30 16:35:06.418293', 1, 'Tracklist A - Love Me Do -B - P.S. I Love You', NULL, 'English', '1962-01-01 00:00:00', NULL, '{rock,pop}', 'UK', '{https://ia903406.us.archive.org/16/items/mbid-20e0bad7-bfbf-4f18-b0b3-8549dfcef6f3/mbid-20e0bad7-bfbf-4f18-b0b3-8549dfcef6f3-2190513301.jpg}', '{https://musicbrainz.org/release-group/5db85281-934d-36e5-865c-1922ad82a948,https://www.discogs.com/master/1154826-The-Beatles-Love-Me-Do}', '{}'::jsonb, 'Single', 'music', '[]', NULL, '{}'); - - +B - P.S. I Love You', NULL, 'English', '1962-01-01 00:00:00', NULL, 'UK', '{https://ia903406.us.archive.org/16/items/mbid-20e0bad7-bfbf-4f18-b0b3-8549dfcef6f3/mbid-20e0bad7-bfbf-4f18-b0b3-8549dfcef6f3-2190513301.jpg}', '{https://musicbrainz.org/release-group/5db85281-934d-36e5-865c-1922ad82a948,https://www.discogs.com/master/1154826-The-Beatles-Love-Me-Do}', '{}'::jsonb, 'Single', 'music', '[]', NULL, '{}');